diff --git a/.agents/skills/add-integration/SKILL.md b/.agents/skills/add-integration/SKILL.md index ee7d85e8f0e..65f45f7007d 100644 --- a/.agents/skills/add-integration/SKILL.md +++ b/.agents/skills/add-integration/SKILL.md @@ -578,29 +578,54 @@ tools: { #### 3. Create Internal API Route -Create `apps/sim/app/api/tools/{service}/{action}/route.ts`: +Create `apps/sim/app/api/tools/{service}/{action}/route.ts`. Internal tool routes are HTTP boundaries and follow the same contract policy as public routes — define the request/response shape in `apps/sim/lib/api/contracts/{service}-tools.ts` (or an existing `internal-tools.ts` / `communication-tools.ts` aggregate) and validate with canonical helpers from `@/lib/api/server`. Never write a route-local Zod schema. ```typescript +// apps/sim/lib/api/contracts/{service}-tools.ts +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' + +export const {service}UploadBodySchema = z.object({ + accessToken: z.string(), + file: FileInputSchema.optional().nullable(), + fileContent: z.string().optional().nullable(), + // ... other params +}) + +export const {service}UploadResponseSchema = z.object({ + success: z.boolean(), + output: z.object({ id: z.string(), url: z.string() }).optional(), + error: z.string().optional(), +}) + +export const {service}UploadContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/{service}/upload', + body: {service}UploadBodySchema, + response: { mode: 'json', schema: {service}UploadResponseSchema }, +}) + +export type {Service}UploadBody = z.input +export type {Service}UploadResponse = z.output +``` + +```typescript +// apps/sim/app/api/tools/{service}/upload/route.ts import { createLogger } from '@sim/logger' import { NextResponse, type NextRequest } from 'next/server' -import { z } from 'zod' +import { {service}UploadContract } from '@/lib/api/contracts/{service}-tools' +import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' -import { FileInputSchema, type RawFileInput } from '@/lib/uploads/utils/file-schemas' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { type RawFileInput } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' const logger = createLogger('{Service}UploadAPI') -const RequestSchema = z.object({ - accessToken: z.string(), - file: FileInputSchema.optional().nullable(), - // Legacy field for backwards compatibility - fileContent: z.string().optional().nullable(), - // ... other params -}) - -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) @@ -608,8 +633,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = RequestSchema.parse(body) + let data + try { + ;({ body: data } = await parseRequest({service}UploadContract, request)) + } catch (error) { + return validationErrorResponseFromError(error) + } let fileBuffer: Buffer let fileName: string @@ -624,22 +653,20 @@ export async function POST(request: NextRequest) { fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) fileName = userFile.name } else if (data.fileContent) { - // Legacy: base64 string (backwards compatibility) fileBuffer = Buffer.from(data.fileContent, 'base64') fileName = 'file' } else { return NextResponse.json({ success: false, error: 'File required' }, { status: 400 }) } - // Now call external API with fileBuffer const response = await fetch('https://api.{service}.com/upload', { method: 'POST', headers: { Authorization: `Bearer ${data.accessToken}` }, - body: new Uint8Array(fileBuffer), // Convert Buffer for fetch + body: new Uint8Array(fileBuffer), }) // ... handle response -} +}) ``` #### 4. Update Tool to Use Internal Route diff --git a/.agents/skills/cleanup/SKILL.md b/.agents/skills/cleanup/SKILL.md index 54438a9b813..3df93f3f6ed 100644 --- a/.agents/skills/cleanup/SKILL.md +++ b/.agents/skills/cleanup/SKILL.md @@ -23,3 +23,8 @@ Run each of these skills in order on the specified scope, passing through the sc 6. `/emcn-design-review $ARGUMENTS` After all skills have run, output a summary of what was found and fixed (or proposed) across all six passes. + +## Boundary Audit Guidance + +- When removing route-local Zod schemas, replacing raw `fetch(` calls in hooks, or removing `as unknown as X` casts, do not introduce `// boundary-raw-fetch: ` or `// double-cast-allowed: ` annotations to silence the audit. Fix the underlying call instead — adopt a contract from `@/lib/api/contracts/**` and use `requestJson(contract, ...)` from `@/lib/api/client/request`, or refine the type so the double cast is unnecessary. +- Annotations are reserved for legitimate exceptions only: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, external-origin requests, and double casts where no narrower type is available. Each annotation requires a non-empty reason; empty reasons fail `bun run check:api-validation:strict`. diff --git a/.cursor/rules/sim-architecture.mdc b/.cursor/rules/sim-architecture.mdc index 6ebd0581b1d..dd8d44c39f0 100644 --- a/.cursor/rules/sim-architecture.mdc +++ b/.cursor/rules/sim-architecture.mdc @@ -54,3 +54,16 @@ feature/ - **Create `utils.ts` when** 2+ files need the same helper - **Check existing sources** before duplicating (`lib/` has many utilities) - **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use) + +## API Contracts + +Boundary HTTP request and response shapes for all routes under `apps/sim/app/api/**` live in `apps/sim/lib/api/contracts/` (one file per resource family). Routes and clients consume the same contract — routes never define route-local boundary Zod schemas, and clients never define ad-hoc wire types. Domain validators that are not HTTP boundaries (tools, blocks, triggers, connectors, realtime handlers, internal helpers) may still use Zod directly; the contract rule is boundary-only. + +- Each contract is built with `defineRouteContract({ method, path, params?, query?, body?, headers?, response: { mode: 'json', schema } })` and exports both schemas and named TypeScript type aliases (e.g., `export type CreateFolderBody = z.input`). +- Shared identifier schemas live in `apps/sim/lib/api/contracts/primitives.ts`. +- Routes validate via canonical helpers in `apps/sim/lib/api/server/validation.ts` (`parseRequest`, `validateJsonBody`, `validateSchema`, `validationErrorResponse`, `getValidationErrorMessage`, `isZodError`). Routes never `import { z } from 'zod'` and never use `instanceof z.ZodError`. +- Clients call `requestJson(contract, ...)` from `apps/sim/lib/api/client/request.ts`; hooks import named type aliases from contracts, never `z.input/z.output`. +- Routes under `apps/sim/app/api/v1/**` use `apps/sim/app/api/v1/middleware.ts` for shared auth, rate-limit, and workspace access. Compose contract validation inside that middleware. +- `bun run check:api-validation` enforces this policy and must pass on PRs. + +`bun run check:api-validation:strict` is the strict CI gate and additionally fails on annotations with empty reasons. Two per-line opt-out forms are recognized: `// boundary-raw-fetch: ` (placed immediately above a legitimate raw `fetch(` call in `apps/sim/hooks/queries/**` or `apps/sim/hooks/selectors/**` for stream/binary/multipart/signed-URL/OAuth-redirect/external-origin cases) and `// double-cast-allowed: ` (placed immediately above an `as unknown as X` cast outside test files). The reason must be non-empty. Whole-file allowlists for routes that legitimately import Zod for non-boundary reasons go through `INDIRECT_ZOD_ROUTES` in `scripts/check-api-validation-contracts.ts`, not per-line annotations. diff --git a/.cursor/rules/sim-queries.mdc b/.cursor/rules/sim-queries.mdc index e12db98522e..b5bf4f0bd8c 100644 --- a/.cursor/rules/sim-queries.mdc +++ b/.cursor/rules/sim-queries.mdc @@ -37,12 +37,18 @@ Never use inline query keys — always use the factory. - Every `queryFn` must destructure and forward `signal` for request cancellation - Every query must have an explicit `staleTime` - Use `keepPreviousData` only on variable-key queries (where params change), never on static keys +- Same-origin JSON calls must go through `requestJson(contract, ...)` from `@/lib/api/client/request` against the contract in `@/lib/api/contracts/**` ```typescript -async function fetchEntities(workspaceId: string, signal?: AbortSignal) { - const response = await fetch(`/api/entities?workspaceId=${workspaceId}`, { signal }) - if (!response.ok) throw new Error('Failed to fetch entities') - return response.json() +import { requestJson } from '@/lib/api/client/request' +import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities' + +async function fetchEntities(workspaceId: string, signal?: AbortSignal): Promise { + const data = await requestJson(listEntitiesContract, { + query: { workspaceId }, + signal, + }) + return data.entities } export function useEntityList(workspaceId?: string, options?: { enabled?: boolean }) { @@ -51,7 +57,7 @@ export function useEntityList(workspaceId?: string, options?: { enabled?: boolea queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal), enabled: Boolean(workspaceId) && (options?.enabled ?? true), staleTime: 60 * 1000, - placeholderData: keepPreviousData, // OK: workspaceId varies + placeholderData: keepPreviousData, }) } ``` @@ -118,6 +124,12 @@ const handler = useCallback(() => { }, [data]) ``` +## Boundary Types + +- Hooks must import named type aliases from `@/lib/api/contracts/**` (e.g., `import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities'`). Never write `z.input<...>` or `z.output<...>` in hooks. +- Hooks must not `import { z } from 'zod'`. Boundary types come from contract aliases; non-boundary helpers can stay in plain TypeScript. +- For non-contract endpoints (multipart uploads, binary downloads, streaming responses, signed-URL flows, OAuth redirects, external origins), it is OK to keep raw `fetch`. Each legitimate raw `fetch(` call inside `apps/sim/hooks/queries/**` or `apps/sim/hooks/selectors/**` must be preceded by a `// boundary-raw-fetch: ` annotation on the immediately preceding line (up to three non-empty preceding comment lines are tolerated). The reason must be non-empty — empty reasons fail strict mode. The audit script `scripts/check-api-validation-contracts.ts` (`bun run check:api-validation` / `bun run check:api-validation:strict`) enforces this. + ## Naming - **Keys**: `entityKeys` diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 19c31f028a8..8ad0e00f777 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -106,6 +106,9 @@ jobs: - name: Enforce monorepo boundaries run: bun run check:boundaries + - name: API contract boundary audit + run: bun run check:api-validation:strict + - name: Verify realtime prune graph run: bun run check:realtime-prune diff --git a/AGENTS.md b/AGENTS.md index 025ad5a3883..faea2dca57f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ You are a professional software engineer. All code must follow best practices: a ## Global Standards +- **Linting / Audit**: `bun run check:api-validation` must pass on PRs. Do not introduce route-local boundary Zod schemas, direct route Zod imports, or ad-hoc client wire types — see "API Contracts" and "API Route Pattern" below - **Logging**: Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log` - **Comments**: Use TSDoc for documentation. No `====` separators. No non-TSDoc comments - **Styling**: Never update global styles. Keep all styling local to components @@ -115,6 +116,101 @@ export function Component({ requiredProp, optionalProp = false }: ComponentProps Extract when: 50+ lines, used in 2+ files, or has own state/logic. Keep inline when: < 10 lines, single use, purely presentational. +## API Contracts + +Boundary HTTP request and response shapes for all routes under `apps/sim/app/api/**` live in `apps/sim/lib/api/contracts/**` (one file per resource family — `folders.ts`, `chats.ts`, `knowledge.ts`, etc.). Routes never define route-local boundary Zod schemas, and clients never define ad-hoc wire types — both sides consume the same contract. + +- Each contract is built with `defineRouteContract({ method, path, params?, query?, body?, headers?, response: { mode: 'json', schema } })` from `@/lib/api/contracts` +- Contracts export named schemas (e.g., `createFolderBodySchema`) AND named TypeScript type aliases (e.g., `export type CreateFolderBody = z.input`) +- Clients (hooks, utilities, components) import the named type aliases from the contract file. They must never write `z.input<...>` / `z.output<...>` themselves +- Shared identifier schemas live in `apps/sim/lib/api/contracts/primitives.ts` (e.g., `workspaceIdSchema`, `workflowIdSchema`). Reuse these instead of redefining string-based ID schemas +- Audit script: `bun run check:api-validation` enforces boundary policy and prints ratchet metrics for route Zod imports, route-local schema constructors, route `ZodError` references, client hook Zod imports, and related counters. It must pass on PRs. `bun run check:api-validation:strict` is the strict CI gate and additionally fails on annotations with empty reasons + +Domain validators that are not HTTP boundaries — tools, blocks, triggers, connectors, realtime handlers, and internal helpers — may still use Zod directly. The contract rule is boundary-only. + +### Boundary annotations + +A small number of legitimate exceptions to the boundary rules are tolerated when annotated. The audit script recognizes two annotation forms: + +- `// boundary-raw-fetch: ` — placed on the line directly above a raw `fetch(` call inside `apps/sim/hooks/queries/**` or `apps/sim/hooks/selectors/**`. Use only for documented exceptions: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests +- `// double-cast-allowed: ` — placed on the line directly above an `as unknown as X` cast outside test files + +Placement rule: the annotation must immediately precede the call or cast. Up to three non-empty preceding comment lines are tolerated, so additional context comments above the annotation are fine. The reason must be non-empty after trimming — annotations with empty reasons fail strict mode (`annotationsMissingReason`). + +Whole-file allowlists for routes (legitimate non-boundary or auth-handled routes that legitimately import Zod for non-boundary reasons) go through `INDIRECT_ZOD_ROUTES` in `scripts/check-api-validation-contracts.ts`, not per-line annotations. + +Examples: + +```ts +// boundary-raw-fetch: streaming SSE chunks must be processed as they arrive +const response = await fetch(`/api/copilot/chat/stream?chatId=${chatId}`, { signal }) +``` + +```ts +// double-cast-allowed: legacy provider type lacks the discriminator field we need +const provider = config as unknown as LegacyProvider +``` + +## API Route Pattern + +Routes never `import { z } from 'zod'` and never define route-local boundary schemas. They consume the contract from `@/lib/api/contracts/**` and validate with canonical helpers from `@/lib/api/server`: + +- `parseRequest(contract, request, context)` — fully contract-bound routes; parses params, query, body, and headers in one call +- `validateJsonBody(request, schema)` — when the body schema comes from a contract but you need to assemble query/headers manually +- `validateSchema(schema, data)` — for ad-hoc validation against a contract schema or primitive +- `validationErrorResponse(error)` and `getValidationErrorMessage(error, fallback)` — produce 400 responses from a `ZodError` +- `validationErrorResponseFromError(error)` — when handling unknown caught errors that may or may not be a `ZodError` +- `isZodError(error)` — type guard. Routes never use `instanceof z.ZodError` + +### Fully contract-bound route (`parseRequest`) + +```typescript +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { createFolderContract } from '@/lib/api/contracts/folders' +import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('FoldersAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const { body } = await parseRequest(createFolderContract, request) + logger.info('Creating folder', { workspaceId: body.workspaceId }) + return NextResponse.json({ ok: true }) + } catch (error) { + return validationErrorResponseFromError(error) + } +}) +``` + +### Partial validation (`validateJsonBody`) + +```typescript +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { updateFolderBodySchema } from '@/lib/api/contracts/folders' +import { isZodError, validateJsonBody, validationErrorResponse } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const PATCH = withRouteHandler(async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) => { + const { id } = await params + try { + const body = await validateJsonBody(request, updateFolderBodySchema) + return NextResponse.json({ id, ...body }) + } catch (error) { + if (isZodError(error)) return validationErrorResponse(error) + throw error + } +}) +``` + +Routes under `apps/sim/app/api/v1/**` use the shared middleware in `apps/sim/app/api/v1/middleware.ts` for auth, rate-limit, and workspace access. Compose contract validation inside that middleware — never reimplement auth/rate-limit per-route. + ## Hooks ```typescript @@ -160,6 +256,38 @@ Use `devtools` middleware. Use `persist` only when data should survive reload wi All React Query hooks live in `hooks/queries/`. All server state must go through React Query — never use `useState` + `fetch` in components for data fetching or mutations. +### Client Boundary + +Hooks consume contracts the same way routes do. Every same-origin JSON call must go through `requestJson(contract, ...)` from `@/lib/api/client/request` instead of raw `fetch`: + +- Hooks import named type aliases from `@/lib/api/contracts/**`. Never write `z.input<...>` / `z.output<...>` in hooks, and never `import { z } from 'zod'` in client code +- `requestJson` parses params, query, body, and headers against the contract on the way out and validates the JSON response on the way back. Hooks always forward `signal` for cancellation +- Documented exceptions for raw `fetch`: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests. Mark each raw `fetch` with a TSDoc comment explaining which exception applies + +```typescript +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities' + +async function fetchEntities(workspaceId: string, signal?: AbortSignal): Promise { + const data = await requestJson(listEntitiesContract, { + query: { workspaceId }, + signal, + }) + return data.entities +} + +export function useEntityList(workspaceId?: string) { + return useQuery({ + queryKey: entityKeys.list(workspaceId), + queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal), + enabled: Boolean(workspaceId), + staleTime: 60 * 1000, + placeholderData: keepPreviousData, + }) +} +``` + ### Query Key Factory Every file must have a hierarchical key factory with an `all` root key and intermediate plural keys for prefix invalidation: diff --git a/CLAUDE.md b/CLAUDE.md index bc4797c8314..16ba37e1fd9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,7 @@ You are a professional software engineer. All code must follow best practices: a ## Global Standards +- **Linting / Audit**: `bun run check:api-validation` must pass on PRs. Do not introduce route-local boundary Zod schemas, direct route Zod imports, or ad-hoc client wire types — see "API Contracts" and "API Route Pattern" below - **Logging**: Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`. Inside API routes wrapped with `withRouteHandler`, loggers automatically include the request ID — no manual `withMetadata({ requestId })` needed - **API Route Handlers**: All API route handlers (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`) must be wrapped with `withRouteHandler` from `@/lib/core/utils/with-route-handler`. This provides request ID tracking, automatic error logging for 4xx/5xx responses, and unhandled error catching. See "API Route Pattern" section below - **Comments**: Use TSDoc for documentation. No `====` separators. No non-TSDoc comments @@ -94,39 +95,111 @@ export function Component({ requiredProp, optionalProp = false }: ComponentProps Extract when: 50+ lines, used in 2+ files, or has own state/logic. Keep inline when: < 10 lines, single use, purely presentational. +## API Contracts + +Boundary HTTP request and response shapes for all routes under `apps/sim/app/api/**` live in `apps/sim/lib/api/contracts/**` (one file per resource family — `folders.ts`, `chats.ts`, `knowledge.ts`, etc.). Routes never define route-local boundary Zod schemas, and clients never define ad-hoc wire types — both sides consume the same contract. + +- Each contract is built with `defineRouteContract({ method, path, params?, query?, body?, headers?, response: { mode: 'json', schema } })` from `@/lib/api/contracts` +- Contracts export named schemas (e.g., `createFolderBodySchema`) AND named TypeScript type aliases (e.g., `export type CreateFolderBody = z.input`) +- Clients (hooks, utilities, components) import the named type aliases from the contract file. They must never write `z.input<...>` / `z.output<...>` themselves +- Shared identifier schemas live in `apps/sim/lib/api/contracts/primitives.ts` (e.g., `workspaceIdSchema`, `workflowIdSchema`). Reuse these instead of redefining string-based ID schemas +- Audit script: `bun run check:api-validation` enforces boundary policy and prints ratchet metrics for route Zod imports, route-local schema constructors, route `ZodError` references, client hook Zod imports, and related counters. It must pass on PRs. `bun run check:api-validation:strict` is the strict CI gate and additionally fails on annotations with empty reasons + +Domain validators that are not HTTP boundaries — tools, blocks, triggers, connectors, realtime handlers, and internal helpers — may still use Zod directly. The contract rule is boundary-only. + +### Boundary annotations + +A small number of legitimate exceptions to the boundary rules are tolerated when annotated. The audit script recognizes two annotation forms: + +- `// boundary-raw-fetch: ` — placed on the line directly above a raw `fetch(` call inside `apps/sim/hooks/queries/**` or `apps/sim/hooks/selectors/**`. Use only for documented exceptions: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests +- `// double-cast-allowed: ` — placed on the line directly above an `as unknown as X` cast outside test files + +Placement rule: the annotation must immediately precede the call or cast. Up to three non-empty preceding comment lines are tolerated, so additional context comments above the annotation are fine. The reason must be non-empty after trimming — annotations with empty reasons fail strict mode (`annotationsMissingReason`). + +Whole-file allowlists for routes (legitimate non-boundary or auth-handled routes that legitimately import Zod for non-boundary reasons) go through `INDIRECT_ZOD_ROUTES` in `scripts/check-api-validation-contracts.ts`, not per-line annotations. + +Examples: + +```ts +// boundary-raw-fetch: streaming SSE chunks must be processed as they arrive +const response = await fetch(`/api/copilot/chat/stream?chatId=${chatId}`, { signal }) +``` + +```ts +// double-cast-allowed: legacy provider type lacks the discriminator field we need +const provider = config as unknown as LegacyProvider +``` + ## API Route Pattern Every API route handler must be wrapped with `withRouteHandler`. This sets up `AsyncLocalStorage`-based request context so all loggers in the request lifecycle automatically include the request ID. +Routes never `import { z } from 'zod'` and never define route-local boundary schemas. They consume the contract from `@/lib/api/contracts/**` and validate with canonical helpers from `@/lib/api/server`: + +- `parseRequest(contract, request, context)` — fully contract-bound routes; parses params, query, body, and headers in one call +- `validateJsonBody(request, schema)` — when the body schema comes from a contract but you need to assemble query/headers manually +- `validateSchema(schema, data)` — for ad-hoc validation against a contract schema or primitive +- `validationErrorResponse(error)` and `getValidationErrorMessage(error, fallback)` — produce 400 responses from a `ZodError` +- `validationErrorResponseFromError(error)` — when handling unknown caught errors that may or may not be a `ZodError` +- `isZodError(error)` — type guard. Routes never use `instanceof z.ZodError` + +### Fully contract-bound route (`parseRequest`) + ```typescript import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { createFolderContract } from '@/lib/api/contracts/folders' +import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -const logger = createLogger('MyAPI') +const logger = createLogger('FoldersAPI') -// Simple route -export const GET = withRouteHandler(async (request: NextRequest) => { - logger.info('Handling request') // automatically includes {requestId=...} - return NextResponse.json({ ok: true }) +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const { body } = await parseRequest(createFolderContract, request) + logger.info('Creating folder', { workspaceId: body.workspaceId }) + return NextResponse.json({ ok: true }) + } catch (error) { + return validationErrorResponseFromError(error) + } }) +``` + +### Partial validation (`validateJsonBody`) + +```typescript +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { updateFolderBodySchema } from '@/lib/api/contracts/folders' +import { isZodError, validateJsonBody, validationErrorResponse } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -// Route with params -export const DELETE = withRouteHandler(async ( +export const PATCH = withRouteHandler(async ( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) => { const { id } = await params - return NextResponse.json({ deleted: id }) + try { + const body = await validateJsonBody(request, updateFolderBodySchema) + return NextResponse.json({ id, ...body }) + } catch (error) { + if (isZodError(error)) return validationErrorResponse(error) + throw error + } }) +``` -// Composing with other middleware (withRouteHandler wraps the outermost layer) +### Composing with other middleware + +```typescript export const POST = withRouteHandler(withAdminAuth(async (request) => { return NextResponse.json({ ok: true }) })) ``` +Routes under `apps/sim/app/api/v1/**` use the shared middleware in `apps/sim/app/api/v1/middleware.ts` for auth, rate-limit, and workspace access. Compose contract validation inside that middleware — never reimplement auth/rate-limit per-route. + Never export a bare `async function GET/POST/...` — always use `export const METHOD = withRouteHandler(...)`. ## Hooks @@ -174,6 +247,38 @@ Use `devtools` middleware. Use `persist` only when data should survive reload wi All React Query hooks live in `hooks/queries/`. All server state must go through React Query — never use `useState` + `fetch` in components for data fetching or mutations. +### Client Boundary + +Hooks consume contracts the same way routes do. Every same-origin JSON call must go through `requestJson(contract, ...)` from `@/lib/api/client/request` instead of raw `fetch`: + +- Hooks import named type aliases from `@/lib/api/contracts/**`. Never write `z.input<...>` / `z.output<...>` in hooks, and never `import { z } from 'zod'` in client code +- `requestJson` parses params, query, body, and headers against the contract on the way out and validates the JSON response on the way back. Hooks always forward `signal` for cancellation +- Documented exceptions for raw `fetch`: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests. Mark each raw `fetch` with a TSDoc comment explaining which exception applies + +```typescript +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities' + +async function fetchEntities(workspaceId: string, signal?: AbortSignal): Promise { + const data = await requestJson(listEntitiesContract, { + query: { workspaceId }, + signal, + }) + return data.entities +} + +export function useEntityList(workspaceId?: string) { + return useQuery({ + queryKey: entityKeys.list(workspaceId), + queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal), + enabled: Boolean(workspaceId), + staleTime: 60 * 1000, + placeholderData: keepPreviousData, + }) +} +``` + ### Query Key Factory Every file must have a hierarchical key factory with an `all` root key and intermediate plural keys for prefix invalidation: diff --git a/README.md b/README.md index 57c0192825e..989452870fd 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ See the [environment variables reference](https://docs.sim.ai/self-hosting/envir - **Runtime**: [Bun](https://bun.sh/) - **Database**: PostgreSQL with [Drizzle ORM](https://orm.drizzle.team) - **Authentication**: [Better Auth](https://better-auth.com) +- **Schema Validation**: [Zod](https://zod.dev) - **UI**: [Shadcn](https://ui.shadcn.com/), [Tailwind CSS](https://tailwindcss.com) - **Streaming Markdown**: [Streamdown](https://github.com/vercel/streamdown) - **State Management**: [Zustand](https://zustand-demo.pmnd.rs/), [TanStack Query](https://tanstack.com/query) diff --git a/apps/realtime/package.json b/apps/realtime/package.json index 8451a633a35..a7ec69192bd 100644 --- a/apps/realtime/package.json +++ b/apps/realtime/package.json @@ -35,7 +35,7 @@ "postgres": "^3.4.5", "redis": "5.10.0", "socket.io": "^4.8.1", - "zod": "^3.24.2" + "zod": "4.3.6" }, "devDependencies": { "@sim/testing": "workspace:*", diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index 9e2b4d1ddbe..279b56a9b03 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -1765,7 +1765,7 @@ async function handleVariableOperationTx( ...currentVariables, [payload.id]: { id: payload.id, - workflowId: payload.workflowId, + workflowId, name: payload.name, type: payload.type, value: payload.value || '', diff --git a/apps/realtime/src/env.ts b/apps/realtime/src/env.ts index f08ff5ddd29..083126a60d7 100644 --- a/apps/realtime/src/env.ts +++ b/apps/realtime/src/env.ts @@ -19,7 +19,7 @@ const EnvSchema = z.object({ function parseEnv() { const parsed = EnvSchema.safeParse(process.env) if (!parsed.success) { - const formatted = parsed.error.format() + const formatted = z.treeifyError(parsed.error) throw new Error(`Invalid realtime server environment: ${JSON.stringify(formatted, null, 2)}`) } return parsed.data diff --git a/apps/realtime/src/handlers/operations.ts b/apps/realtime/src/handlers/operations.ts index 6635d157b3a..5b57aa6bb57 100644 --- a/apps/realtime/src/handlers/operations.ts +++ b/apps/realtime/src/handlers/operations.ts @@ -582,11 +582,11 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager socket.emit('operation-error', { type: 'VALIDATION_ERROR', message: 'Invalid operation data', - errors: error.errors, + errors: error.issues, operation: data.operation, target: data.target, }) - logger.warn(`Validation error for operation from ${session.userId}:`, error.errors) + logger.warn(`Validation error for operation from ${session.userId}:`, error.issues) } else if (error instanceof Error) { if (error.message.includes('not found')) { socket.emit('operation-error', { diff --git a/apps/sim/AGENTS.md b/apps/sim/AGENTS.md index ac75315fffa..107100d0220 100644 --- a/apps/sim/AGENTS.md +++ b/apps/sim/AGENTS.md @@ -69,6 +69,131 @@ feature/ - Use Tailwind classes and `cn()` for conditional classes; avoid inline styles unless CSS variables are the intended mechanism. - Keep styling local to the component; do not modify global styles for feature work. +## API Contracts + +Boundary HTTP request and response shapes for all routes under `apps/sim/app/api/**` live in `apps/sim/lib/api/contracts/**` (one file per resource family). Routes never define route-local boundary Zod schemas, and clients never define ad-hoc wire types — both sides consume the same contract. + +- Each contract is built with `defineRouteContract({ method, path, params?, query?, body?, headers?, response: { mode: 'json', schema } })` from `@/lib/api/contracts`. +- Contracts export named schemas AND named TypeScript type aliases (e.g., `export type CreateFolderBody = z.input`). Clients import the named aliases — never `z.input<...>` / `z.output<...>` in hooks. +- Shared identifier schemas live in `apps/sim/lib/api/contracts/primitives.ts` (e.g., `workspaceIdSchema`, `workflowIdSchema`). +- Audit script: `bun run check:api-validation` enforces boundary policy and prints ratchet metrics for route Zod imports, route-local schema constructors, route `ZodError` references, client hook Zod imports, and related counters. It must pass on PRs. `bun run check:api-validation:strict` is the strict CI gate and additionally fails on annotations with empty reasons. +- Domain validators that are not HTTP boundaries — tools, blocks, triggers, connectors, realtime handlers, and internal helpers — may still use Zod directly. The contract rule is boundary-only. + +### Boundary annotations + +A small number of legitimate exceptions to the boundary rules are tolerated when annotated. The audit script recognizes two annotation forms: + +- `// boundary-raw-fetch: ` — placed on the line directly above a raw `fetch(` call inside `apps/sim/hooks/queries/**` or `apps/sim/hooks/selectors/**`. Use only for documented exceptions: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests. +- `// double-cast-allowed: ` — placed on the line directly above an `as unknown as X` cast outside test files. + +Placement rule: the annotation must immediately precede the call or cast. Up to three non-empty preceding comment lines are tolerated, so additional context comments above the annotation are fine. The reason must be non-empty after trimming — annotations with empty reasons fail strict mode (`annotationsMissingReason`). + +Whole-file allowlists for routes (legitimate non-boundary or auth-handled routes that legitimately import Zod for non-boundary reasons) go through `INDIRECT_ZOD_ROUTES` in `scripts/check-api-validation-contracts.ts`, not per-line annotations. + +Examples: + +```ts +// boundary-raw-fetch: streaming SSE chunks must be processed as they arrive +const response = await fetch(`/api/copilot/chat/stream?chatId=${chatId}`, { signal }) +``` + +```ts +// double-cast-allowed: legacy provider type lacks the discriminator field we need +const provider = config as unknown as LegacyProvider +``` + +## API Route Pattern + +Routes never `import { z } from 'zod'` and never define route-local boundary schemas. They consume the contract from `@/lib/api/contracts/**` and validate with canonical helpers from `@/lib/api/server`: + +- `parseRequest(contract, request, context)` — fully contract-bound routes; parses params, query, body, and headers in one call. +- `validateJsonBody(request, schema)` — when the body schema comes from a contract but you need to assemble query/headers manually. +- `validateSchema(schema, data)` — for ad-hoc validation against a contract schema or primitive. +- `validationErrorResponse(error)` and `getValidationErrorMessage(error, fallback)` — produce 400 responses from a `ZodError`. +- `validationErrorResponseFromError(error)` — when handling unknown caught errors that may or may not be a `ZodError`. +- `isZodError(error)` — type guard. Routes never use `instanceof z.ZodError`. + +### Fully contract-bound route (`parseRequest`) + +```typescript +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { createFolderContract } from '@/lib/api/contracts/folders' +import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('FoldersAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const { body } = await parseRequest(createFolderContract, request) + logger.info('Creating folder', { workspaceId: body.workspaceId }) + return NextResponse.json({ ok: true }) + } catch (error) { + return validationErrorResponseFromError(error) + } +}) +``` + +### Partial validation (`validateJsonBody`) + +```typescript +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { updateFolderBodySchema } from '@/lib/api/contracts/folders' +import { isZodError, validateJsonBody, validationErrorResponse } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const PATCH = withRouteHandler(async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) => { + const { id } = await params + try { + const body = await validateJsonBody(request, updateFolderBodySchema) + return NextResponse.json({ id, ...body }) + } catch (error) { + if (isZodError(error)) return validationErrorResponse(error) + throw error + } +}) +``` + +Routes under `apps/sim/app/api/v1/**` use the shared middleware in `apps/sim/app/api/v1/middleware.ts` for auth, rate-limit, and workspace access. Compose contract validation inside that middleware — never reimplement auth/rate-limit per-route. + +## React Query Client Boundary + +Hooks in `apps/sim/hooks/queries/**` consume contracts the same way routes do. Every same-origin JSON call must go through `requestJson(contract, ...)` from `@/lib/api/client/request` instead of raw `fetch`: + +- Hooks import named type aliases from `@/lib/api/contracts/**`. Never write `z.input<...>` / `z.output<...>` in hooks, and never `import { z } from 'zod'` in client code. +- `requestJson` parses params, query, body, and headers against the contract on the way out and validates the JSON response on the way back. Hooks always forward `signal` for cancellation. +- Documented exceptions for raw `fetch`: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests. Mark each raw `fetch` with a TSDoc comment explaining which exception applies. + +```typescript +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities' + +async function fetchEntities(workspaceId: string, signal?: AbortSignal): Promise { + const data = await requestJson(listEntitiesContract, { + query: { workspaceId }, + signal, + }) + return data.entities +} + +export function useEntityList(workspaceId?: string) { + return useQuery({ + queryKey: entityKeys.list(workspaceId), + queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal), + enabled: Boolean(workspaceId), + staleTime: 60 * 1000, + placeholderData: keepPreviousData, + }) +} +``` + ## Testing - Use Vitest. diff --git a/apps/sim/app/(landing)/components/contact/consts.ts b/apps/sim/app/(landing)/components/contact/consts.ts index 242d06ecaf0..8f86ecfe4df 100644 --- a/apps/sim/app/(landing)/components/contact/consts.ts +++ b/apps/sim/app/(landing)/components/contact/consts.ts @@ -45,7 +45,7 @@ export const contactRequestSchema = z.object({ .optional() .transform((value) => (value && value.length > 0 ? value : undefined)), topic: z.enum(CONTACT_TOPIC_VALUES, { - errorMap: () => ({ message: 'Please select a topic' }), + error: 'Please select a topic', }), subject: z .string() diff --git a/apps/sim/app/(landing)/components/contact/contact-form.tsx b/apps/sim/app/(landing)/components/contact/contact-form.tsx index 879f28133bc..5d9f254b78b 100644 --- a/apps/sim/app/(landing)/components/contact/contact-form.tsx +++ b/apps/sim/app/(landing)/components/contact/contact-form.tsx @@ -5,8 +5,9 @@ import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' import { toError } from '@sim/utils/errors' import { useMutation } from '@tanstack/react-query' import Link from 'next/link' -import { Combobox, type ComboboxOption, Input, Textarea } from '@/components/emcn' +import { Combobox, Input, Textarea } from '@/components/emcn' import { Check } from '@/components/emcn/icons' +import { flattenFieldErrors } from '@/lib/api/contracts/primitives' import { getEnv } from '@/lib/core/config/env' import { captureClientEvent } from '@/lib/posthog/client' import { @@ -130,15 +131,7 @@ export function ContactForm() { }) if (!parsed.success) { - const fieldErrors = parsed.error.flatten().fieldErrors - setErrors({ - name: fieldErrors.name?.[0], - email: fieldErrors.email?.[0], - company: fieldErrors.company?.[0], - topic: fieldErrors.topic?.[0], - subject: fieldErrors.subject?.[0], - message: fieldErrors.message?.[0], - }) + setErrors(flattenFieldErrors(parsed.error)) setIsSubmitting(false) return } @@ -271,7 +264,7 @@ export function ContactForm() { labelClassName={LANDING_LABEL} > updateField('topic', value as ContactRequestPayload['topic'])} diff --git a/apps/sim/app/(landing)/components/demo-request/consts.ts b/apps/sim/app/(landing)/components/demo-request/consts.ts index 5272a0efd3d..581c4183664 100644 --- a/apps/sim/app/(landing)/components/demo-request/consts.ts +++ b/apps/sim/app/(landing)/components/demo-request/consts.ts @@ -56,7 +56,7 @@ export const demoRequestSchema = z.object({ .optional() .transform((value) => (value && value.length > 0 ? value : undefined)), companySize: z.enum(DEMO_REQUEST_COMPANY_SIZE_VALUES, { - errorMap: () => ({ message: 'Please select company size' }), + error: 'Please select company size', }), details: z.string().trim().min(1, 'Details are required').max(2000), }) diff --git a/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx b/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx index 2de9238b9f4..13916c19525 100644 --- a/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx +++ b/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx @@ -14,6 +14,7 @@ import { Textarea, } from '@/components/emcn' import { Check } from '@/components/emcn/icons' +import { flattenFieldErrors } from '@/lib/api/contracts/primitives' import { captureClientEvent } from '@/lib/posthog/client' import { DEMO_REQUEST_COMPANY_SIZE_OPTIONS, @@ -129,15 +130,7 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP }) if (!parsed.success) { - const fieldErrors = parsed.error.flatten().fieldErrors - setErrors({ - firstName: fieldErrors.firstName?.[0], - lastName: fieldErrors.lastName?.[0], - companyEmail: fieldErrors.companyEmail?.[0], - phoneNumber: fieldErrors.phoneNumber?.[0], - companySize: fieldErrors.companySize?.[0], - details: fieldErrors.details?.[0], - }) + setErrors(flattenFieldErrors(parsed.error)) return } diff --git a/apps/sim/app/academy/(catalog)/certificate/[certificateNumber]/page.tsx b/apps/sim/app/academy/(catalog)/certificate/[certificateNumber]/page.tsx index 3f70f454973..f5fc7b9ce10 100644 --- a/apps/sim/app/academy/(catalog)/certificate/[certificateNumber]/page.tsx +++ b/apps/sim/app/academy/(catalog)/certificate/[certificateNumber]/page.tsx @@ -6,6 +6,7 @@ import { CheckCircle2, GraduationCap, XCircle } from 'lucide-react' import type { Metadata } from 'next' import { notFound } from 'next/navigation' import type { AcademyCertificate } from '@/lib/academy/types' +import { academyCertificateMetadataSchema } from '@/lib/api/contracts/academy' interface CertificatePageProps { params: Promise<{ certificateNumber: string }> @@ -28,7 +29,11 @@ const fetchCertificate = cache( .from(academyCertificate) .where(eq(academyCertificate.certificateNumber, certificateNumber)) .limit(1) - return (row as unknown as AcademyCertificate) ?? null + if (!row) return null + return { + ...row, + metadata: academyCertificateMetadataSchema.nullable().parse(row.metadata), + } } ) diff --git a/apps/sim/app/api/a2a/agents/[agentId]/route.ts b/apps/sim/app/api/a2a/agents/[agentId]/route.ts index d3d6c19ff7f..52e8cdeaeac 100644 --- a/apps/sim/app/api/a2a/agents/[agentId]/route.ts +++ b/apps/sim/app/api/a2a/agents/[agentId]/route.ts @@ -5,6 +5,12 @@ import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { generateAgentCard, generateSkillsFromWorkflow } from '@/lib/a2a/agent-card' import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types' +import { + a2aAgentParamsSchema, + publishA2AAgentBodySchema, + updateA2AAgentBodySchema, +} from '@/lib/api/contracts/a2a-agents' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { getRedisClient } from '@/lib/core/config/redis' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -25,7 +31,7 @@ interface RouteParams { */ export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise }) => { - const { agentId } = await params + const { agentId } = a2aAgentParamsSchema.parse(await params) try { const [agent] = await db @@ -88,7 +94,7 @@ export const GET = withRouteHandler( */ export const PUT = withRouteHandler( async (request: NextRequest, { params }: { params: Promise }) => { - const { agentId } = await params + const { agentId } = a2aAgentParamsSchema.parse(await params) try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -111,18 +117,18 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const body = await request.json() - - if ( - body.skillTags !== undefined && - (!Array.isArray(body.skillTags) || - !body.skillTags.every((tag: unknown): tag is string => typeof tag === 'string')) - ) { + const parseResult = await validateJsonBody(request, updateA2AAgentBodySchema) + if (!parseResult.success) { return NextResponse.json( - { error: 'skillTags must be an array of strings' }, + { + error: parseResult.error + ? getValidationErrorMessage(parseResult.error) + : 'Invalid request body', + }, { status: 400 } ) } + const body = parseResult.data let skills = body.skills ?? existingAgent.skills if (body.skillTags !== undefined) { @@ -163,7 +169,7 @@ export const PUT = withRouteHandler( */ export const DELETE = withRouteHandler( async (request: NextRequest, { params }: { params: Promise }) => { - const { agentId } = await params + const { agentId } = a2aAgentParamsSchema.parse(await params) try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -214,7 +220,7 @@ export const DELETE = withRouteHandler( */ export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise }) => { - const { agentId } = await params + const { agentId } = a2aAgentParamsSchema.parse(await params) try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -241,8 +247,18 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const body = await request.json() - const action = body.action as 'publish' | 'unpublish' | 'refresh' + const actionResult = await validateJsonBody(request, publishA2AAgentBodySchema) + if (!actionResult.success) { + return NextResponse.json( + { + error: actionResult.error + ? getValidationErrorMessage(actionResult.error) + : 'Invalid action', + }, + { status: 400 } + ) + } + const { action } = actionResult.data if (action === 'publish') { const [wf] = await db diff --git a/apps/sim/app/api/a2a/agents/route.ts b/apps/sim/app/api/a2a/agents/route.ts index 082b17f42ca..833e38dcd91 100644 --- a/apps/sim/app/api/a2a/agents/route.ts +++ b/apps/sim/app/api/a2a/agents/route.ts @@ -13,6 +13,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { generateSkillsFromWorkflow } from '@/lib/a2a/agent-card' import { A2A_DEFAULT_CAPABILITIES } from '@/lib/a2a/constants' import { sanitizeAgentName } from '@/lib/a2a/utils' +import { createA2AAgentBodySchema, listA2AAgentsQuerySchema } from '@/lib/api/contracts/a2a-agents' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' @@ -34,12 +36,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const workspaceId = searchParams.get('workspaceId') + const queryResult = listA2AAgentsQuerySchema.safeParse({ + workspaceId: request.nextUrl.searchParams.get('workspaceId'), + }) - if (!workspaceId) { - return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) + if (!queryResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(queryResult.error) }, + { status: 400 } + ) } + const { workspaceId } = queryResult.data const workspaceAccess = await checkWorkspaceAccess(workspaceId, auth.userId) if (!workspaceAccess.exists) { @@ -97,17 +104,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { workspaceId, workflowId, name, description, capabilities, authentication, skillTags } = - body - - if (!workspaceId || !workflowId) { + const parseResult = await validateJsonBody(request, createA2AAgentBodySchema) + if (!parseResult.success) { return NextResponse.json( - { error: 'workspaceId and workflowId are required' }, + { + error: parseResult.error + ? getValidationErrorMessage(parseResult.error) + : 'Invalid request body', + }, { status: 400 } ) } + const { workspaceId, workflowId, name, description, capabilities, authentication, skillTags } = + parseResult.data + const workspaceAccess = await checkWorkspaceAccess(workspaceId, auth.userId) if (!workspaceAccess.exists) { return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) diff --git a/apps/sim/app/api/a2a/serve/[agentId]/route.ts b/apps/sim/app/api/a2a/serve/[agentId]/route.ts index 2a304445a14..a8954abeb3a 100644 --- a/apps/sim/app/api/a2a/serve/[agentId]/route.ts +++ b/apps/sim/app/api/a2a/serve/[agentId]/route.ts @@ -13,6 +13,17 @@ import { isTerminalState, parseWorkflowSSEChunk, } from '@/lib/a2a/utils' +import { + type A2AJsonRpcId, + type A2AMessageSendParams, + type A2APushNotificationSetParams, + type A2ATaskIdParams, + a2aJsonRpcRequestSchema, + a2aMessageSendParamsSchema, + a2aPushNotificationSetParamsSchema, + a2aServeAgentParamsSchema, + a2aTaskIdParamsSchema, +} from '@/lib/api/contracts/a2a-agents' import { type AuthResult, AuthType, checkHybridAuth } from '@/lib/auth/hybrid' import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis' import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' @@ -33,10 +44,6 @@ import { extractAgentContent, formatTaskResponse, generateTaskId, - isJSONRPCRequest, - type MessageSendParams, - type PushNotificationSetParams, - type TaskIdParams, } from '@/app/api/a2a/serve/[agentId]/utils' import { getBrandConfig } from '@/ee/whitelabeling' @@ -74,7 +81,7 @@ function hasCallerAccessToTask( */ export const GET = withRouteHandler( async (_request: NextRequest, { params }: { params: Promise }) => { - const { agentId } = await params + const { agentId } = a2aServeAgentParamsSchema.parse(await params) const redis = getRedisClient() const cacheKey = `a2a:agent:${agentId}:card` @@ -185,7 +192,7 @@ export const GET = withRouteHandler( */ export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise }) => { - const { agentId } = await params + const { agentId } = a2aServeAgentParamsSchema.parse(await params) try { const [agent] = await db @@ -263,15 +270,26 @@ export const POST = withRouteHandler( ) } - const body = await request.json() + let rawBody: unknown + try { + rawBody = await request.json() + } catch { + return NextResponse.json( + createError(null, A2A_ERROR_CODES.PARSE_ERROR, 'Invalid JSON body'), + { status: 400 } + ) + } - if (!isJSONRPCRequest(body)) { + const bodyResult = a2aJsonRpcRequestSchema.safeParse(rawBody) + + if (!bodyResult.success) { return NextResponse.json( createError(null, A2A_ERROR_CODES.INVALID_REQUEST, 'Invalid JSON-RPC request'), { status: 400 } ) } + const body = bodyResult.data const { id, method, params: rpcParams } = body const requestApiKey = request.headers.get('X-API-Key') const apiKey = authenticatedAuthType === AuthType.API_KEY ? requestApiKey : null @@ -299,65 +317,124 @@ export const POST = withRouteHandler( logger.info(`A2A request: ${method} for agent ${agentId}`) switch (method) { - case A2A_METHODS.MESSAGE_SEND: + case A2A_METHODS.MESSAGE_SEND: { + const paramsValidation = a2aMessageSendParamsSchema.safeParse(rpcParams) + if (!paramsValidation.success) { + return NextResponse.json( + createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Message is required'), + { status: 400 } + ) + } + return handleMessageSend( id, agent, - rpcParams as MessageSendParams, + paramsValidation.data, apiKey, executionUserId, callerFingerprint ) + } + + case A2A_METHODS.MESSAGE_STREAM: { + const paramsValidation = a2aMessageSendParamsSchema.safeParse(rpcParams) + if (!paramsValidation.success) { + return NextResponse.json( + createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Message is required'), + { status: 400 } + ) + } - case A2A_METHODS.MESSAGE_STREAM: return handleMessageStream( request, id, agent, - rpcParams as MessageSendParams, + paramsValidation.data, apiKey, executionUserId, callerFingerprint ) + } + + case A2A_METHODS.TASKS_GET: { + const paramsValidation = a2aTaskIdParamsSchema.safeParse(rpcParams) + if (!paramsValidation.success) { + return NextResponse.json( + createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'), + { status: 400 } + ) + } + return handleTaskGet(id, agent.id, paramsValidation.data, callerFingerprint) + } - case A2A_METHODS.TASKS_GET: - return handleTaskGet(id, agent.id, rpcParams as TaskIdParams, callerFingerprint) + case A2A_METHODS.TASKS_CANCEL: { + const paramsValidation = a2aTaskIdParamsSchema.safeParse(rpcParams) + if (!paramsValidation.success) { + return NextResponse.json( + createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'), + { status: 400 } + ) + } + return handleTaskCancel(id, agent.id, paramsValidation.data, callerFingerprint) + } - case A2A_METHODS.TASKS_CANCEL: - return handleTaskCancel(id, agent.id, rpcParams as TaskIdParams, callerFingerprint) + case A2A_METHODS.TASKS_RESUBSCRIBE: { + const paramsValidation = a2aTaskIdParamsSchema.safeParse(rpcParams) + if (!paramsValidation.success) { + return NextResponse.json( + createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'), + { status: 400 } + ) + } - case A2A_METHODS.TASKS_RESUBSCRIBE: return handleTaskResubscribe( request, id, agent.id, - rpcParams as TaskIdParams, + paramsValidation.data, callerFingerprint ) + } - case A2A_METHODS.PUSH_NOTIFICATION_SET: - return handlePushNotificationSet( - id, - agent.id, - rpcParams as PushNotificationSetParams, - callerFingerprint - ) + case A2A_METHODS.PUSH_NOTIFICATION_SET: { + const paramsValidation = a2aPushNotificationSetParamsSchema.safeParse(rpcParams) + if (!paramsValidation.success) { + return NextResponse.json( + createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Invalid push notification params'), + { status: 400 } + ) + } - case A2A_METHODS.PUSH_NOTIFICATION_GET: - return handlePushNotificationGet( - id, - agent.id, - rpcParams as TaskIdParams, - callerFingerprint - ) + return handlePushNotificationSet(id, agent.id, paramsValidation.data, callerFingerprint) + } + + case A2A_METHODS.PUSH_NOTIFICATION_GET: { + const paramsValidation = a2aTaskIdParamsSchema.safeParse(rpcParams) + if (!paramsValidation.success) { + return NextResponse.json( + createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'), + { status: 400 } + ) + } + return handlePushNotificationGet(id, agent.id, paramsValidation.data, callerFingerprint) + } + + case A2A_METHODS.PUSH_NOTIFICATION_DELETE: { + const paramsValidation = a2aTaskIdParamsSchema.safeParse(rpcParams) + if (!paramsValidation.success) { + return NextResponse.json( + createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'), + { status: 400 } + ) + } - case A2A_METHODS.PUSH_NOTIFICATION_DELETE: return handlePushNotificationDelete( id, agent.id, - rpcParams as TaskIdParams, + paramsValidation.data, callerFingerprint ) + } default: return NextResponse.json( @@ -392,25 +469,18 @@ async function getTaskForAgent(taskId: string, agentId: string, callerFingerprin * Handle message/send - Send a message (v0.3) */ async function handleMessageSend( - id: string | number, + id: A2AJsonRpcId, agent: { id: string name: string workflowId: string workspaceId: string }, - params: MessageSendParams, + params: A2AMessageSendParams, apiKey?: string | null, executionUserId?: string, callerFingerprint?: string ): Promise { - if (!params?.message) { - return NextResponse.json( - createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Message is required'), - { status: 400 } - ) - } - const message = params.message const taskId = message.taskId || generateTaskId() const contextId = message.contextId || generateId() @@ -621,25 +691,18 @@ async function handleMessageSend( */ async function handleMessageStream( _request: NextRequest, - id: string | number, + id: A2AJsonRpcId, agent: { id: string name: string workflowId: string workspaceId: string }, - params: MessageSendParams, + params: A2AMessageSendParams, apiKey?: string | null, executionUserId?: string, callerFingerprint?: string ): Promise { - if (!params?.message) { - return NextResponse.json( - createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Message is required'), - { status: 400 } - ) - } - const message = params.message const contextId = message.contextId || generateId() const taskId = message.taskId || generateTaskId() @@ -1057,18 +1120,11 @@ async function handleMessageStream( * Handle tasks/get - Query task status */ async function handleTaskGet( - id: string | number, + id: A2AJsonRpcId, agentId: string, - params: TaskIdParams, + params: A2ATaskIdParams, callerFingerprint?: string ): Promise { - if (!params?.id) { - return NextResponse.json( - createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'), - { status: 400 } - ) - } - const historyLength = params.historyLength !== undefined && params.historyLength >= 0 ? params.historyLength @@ -1099,18 +1155,11 @@ async function handleTaskGet( * Handle tasks/cancel - Cancel a running task */ async function handleTaskCancel( - id: string | number, + id: A2AJsonRpcId, agentId: string, - params: TaskIdParams, + params: A2ATaskIdParams, callerFingerprint?: string ): Promise { - if (!params?.id) { - return NextResponse.json( - createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'), - { status: 400 } - ) - } - const task = await getTaskForAgent(params.id, agentId, callerFingerprint) if (!task) { @@ -1174,18 +1223,11 @@ async function handleTaskCancel( */ async function handleTaskResubscribe( request: NextRequest, - id: string | number, + id: A2AJsonRpcId, agentId: string, - params: TaskIdParams, + params: A2ATaskIdParams, callerFingerprint?: string ): Promise { - if (!params?.id) { - return NextResponse.json( - createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'), - { status: 400 } - ) - } - const task = await getTaskForAgent(params.id, agentId, callerFingerprint) if (!task) { @@ -1382,25 +1424,11 @@ async function handleTaskResubscribe( * Handle tasks/pushNotificationConfig/set - Set webhook for task updates */ async function handlePushNotificationSet( - id: string | number, + id: A2AJsonRpcId, agentId: string, - params: PushNotificationSetParams, + params: A2APushNotificationSetParams, callerFingerprint?: string ): Promise { - if (!params?.id) { - return NextResponse.json( - createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'), - { status: 400 } - ) - } - - if (!params?.pushNotificationConfig?.url) { - return NextResponse.json( - createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Push notification URL is required'), - { status: 400 } - ) - } - const urlValidation = await validateUrlWithDNS( params.pushNotificationConfig.url, 'Push notification URL' @@ -1462,18 +1490,11 @@ async function handlePushNotificationSet( * Handle tasks/pushNotificationConfig/get - Get webhook config for a task */ async function handlePushNotificationGet( - id: string | number, + id: A2AJsonRpcId, agentId: string, - params: TaskIdParams, + params: A2ATaskIdParams, callerFingerprint?: string ): Promise { - if (!params?.id) { - return NextResponse.json( - createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'), - { status: 400 } - ) - } - const task = await getTaskForAgent(params.id, agentId, callerFingerprint) if (!task) { @@ -1507,18 +1528,11 @@ async function handlePushNotificationGet( * Handle tasks/pushNotificationConfig/delete - Delete webhook config for a task */ async function handlePushNotificationDelete( - id: string | number, + id: A2AJsonRpcId, agentId: string, - params: TaskIdParams, + params: A2ATaskIdParams, callerFingerprint?: string ): Promise { - if (!params?.id) { - return NextResponse.json( - createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'), - { status: 400 } - ) - } - const task = await getTaskForAgent(params.id, agentId, callerFingerprint) if (!task) { diff --git a/apps/sim/app/api/academy/certificates/route.ts b/apps/sim/app/api/academy/certificates/route.ts index ba18d585260..af8e4da6259 100644 --- a/apps/sim/app/api/academy/certificates/route.ts +++ b/apps/sim/app/api/academy/certificates/route.ts @@ -4,9 +4,10 @@ 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 { z } from 'zod' import { getCourseById } from '@/lib/academy/content' import type { CertificateMetadata } from '@/lib/academy/types' +import { issueAcademyCertificateBodySchema } from '@/lib/api/contracts' +import { validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' @@ -21,11 +22,6 @@ const CERT_RATE_LIMIT: TokenBucketConfig = { refillIntervalMs: 60 * 60_000, // 1 per hour refill } -const IssueCertificateSchema = z.object({ - courseId: z.string(), - completedLessonIds: z.array(z.string()), -}) - /** * POST /api/academy/certificates * Issues a certificate for the given course after verifying all lessons are completed. @@ -48,10 +44,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } const body = await req.json() - const parsed = IssueCertificateSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) - } + const parsed = validateSchema(issueAcademyCertificateBodySchema, body) + if (!parsed.success) return parsed.response const { courseId, completedLessonIds } = parsed.data diff --git a/apps/sim/app/api/admin/mothership/route.ts b/apps/sim/app/api/admin/mothership/route.ts index d34efca9e50..34f0c814ac4 100644 --- a/apps/sim/app/api/admin/mothership/route.ts +++ b/apps/sim/app/api/admin/mothership/route.ts @@ -2,6 +2,8 @@ import { db } from '@sim/db' import { user } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { adminMothershipQuerySchema } from '@/lib/api/contracts/mothership-tasks' +import { searchParamsToObject, validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -58,12 +60,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } const { searchParams } = new URL(req.url) - const environment = searchParams.get('env') || 'dev' - const endpoint = searchParams.get('endpoint') - - if (!endpoint) { - return NextResponse.json({ error: 'endpoint query param required' }, { status: 400 }) - } + const queryValidation = validateSchema( + adminMothershipQuerySchema, + searchParamsToObject(searchParams) + ) + if (!queryValidation.success) return queryValidation.response + const { env: environment, endpoint } = queryValidation.data if (!isValidEndpoint(endpoint)) { return NextResponse.json({ error: 'invalid endpoint' }, { status: 400 }) @@ -113,12 +115,12 @@ export const GET = withRouteHandler(async (req: NextRequest) => { } const { searchParams } = new URL(req.url) - const environment = searchParams.get('env') || 'dev' - const endpoint = searchParams.get('endpoint') - - if (!endpoint) { - return NextResponse.json({ error: 'endpoint query param required' }, { status: 400 }) - } + const queryValidation = validateSchema( + adminMothershipQuerySchema, + searchParamsToObject(searchParams) + ) + if (!queryValidation.success) return queryValidation.response + const { env: environment, endpoint } = queryValidation.data if (!isValidEndpoint(endpoint)) { return NextResponse.json({ error: 'invalid endpoint' }, { status: 400 }) diff --git a/apps/sim/app/api/audit-logs/route.ts b/apps/sim/app/api/audit-logs/route.ts index 547d57b118a..168a77ff5c3 100644 --- a/apps/sim/app/api/audit-logs/route.ts +++ b/apps/sim/app/api/audit-logs/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' +import { auditLogsQuerySchema } from '@/lib/api/contracts/audit-logs' +import { getValidationErrorMessage, validateSchema } 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' @@ -29,25 +31,34 @@ export const GET = withRouteHandler(async (request: Request) => { const { orgMemberIds } = authResult.context const { searchParams } = new URL(request.url) - const search = searchParams.get('search')?.trim() || undefined - const startDate = searchParams.get('startDate') || undefined - const endDate = searchParams.get('endDate') || undefined - const includeDeparted = searchParams.get('includeDeparted') === 'true' - const limit = Math.min(Math.max(Number(searchParams.get('limit')) || 50, 1), 100) - const cursor = searchParams.get('cursor') || undefined - - if (startDate && Number.isNaN(Date.parse(startDate))) { - return NextResponse.json({ error: 'Invalid startDate format' }, { status: 400 }) - } - if (endDate && Number.isNaN(Date.parse(endDate))) { - return NextResponse.json({ error: 'Invalid endDate format' }, { status: 400 }) + const parsedQuery = validateSchema( + auditLogsQuerySchema, + Object.fromEntries(searchParams.entries()) + ) + if (!parsedQuery.success) { + return NextResponse.json( + { error: getValidationErrorMessage(parsedQuery.error, 'Invalid query parameters') }, + { status: 400 } + ) } + const { + search, + action, + resourceType, + actorId, + startDate, + endDate, + includeDeparted, + limit, + cursor, + } = parsedQuery.data + const scopeCondition = await buildOrgScopeCondition(orgMemberIds, includeDeparted) const filterConditions = buildFilterConditions({ - action: searchParams.get('action') || undefined, - resourceType: searchParams.get('resourceType') || undefined, - actorId: searchParams.get('actorId') || undefined, + action, + resourceType, + actorId, search, startDate, endDate, diff --git a/apps/sim/app/api/auth/[...all]/route.ts b/apps/sim/app/api/auth/[...all]/route.ts index 22cc107c708..b09ce7e7e67 100644 --- a/apps/sim/app/api/auth/[...all]/route.ts +++ b/apps/sim/app/api/auth/[...all]/route.ts @@ -10,13 +10,17 @@ export const dynamic = 'force-dynamic' const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler) const SAFE_ORGANIZATION_POST_PATHS = new Set(['organization/check-slug', 'organization/set-active']) +function getAuthPath(request: NextRequest): string { + const pathname = request.nextUrl?.pathname ?? new URL(request.url).pathname + return pathname.replace('/api/auth/', '') +} + function isBlockedOrganizationMutationPath(path: string): boolean { return path.startsWith('organization/') && !SAFE_ORGANIZATION_POST_PATHS.has(path) } export const GET = withRouteHandler(async (request: NextRequest) => { - const url = new URL(request.url) - const path = url.pathname.replace('/api/auth/', '') + const path = getAuthPath(request) if (path === 'get-session' && isAuthDisabled) { await ensureAnonymousUserExists() @@ -27,8 +31,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) export const POST = withRouteHandler(async (request: NextRequest) => { - const url = new URL(request.url) - const path = url.pathname.replace('/api/auth/', '') + const path = getAuthPath(request) if (isBlockedOrganizationMutationPath(path)) { return NextResponse.json( diff --git a/apps/sim/app/api/auth/accounts/route.ts b/apps/sim/app/api/auth/accounts/route.ts index 25d0f97490c..016384aa9c7 100644 --- a/apps/sim/app/api/auth/accounts/route.ts +++ b/apps/sim/app/api/auth/accounts/route.ts @@ -3,6 +3,7 @@ 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' @@ -16,7 +17,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const { searchParams } = new URL(request.url) - const provider = searchParams.get('provider') + const { provider } = connectedAccountsQuerySchema.parse({ + provider: searchParams.get('provider') || undefined, + }) const whereConditions = [eq(account.userId, session.user.id)] diff --git a/apps/sim/app/api/auth/forget-password/route.ts b/apps/sim/app/api/auth/forget-password/route.ts index 2bf7be8782a..2d0a97bc04b 100644 --- a/apps/sim/app/api/auth/forget-password/route.ts +++ b/apps/sim/app/api/auth/forget-password/route.ts @@ -4,46 +4,31 @@ import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { forgetPasswordBodySchema } from '@/lib/api/contracts' +import { getValidationErrorMessage } from '@/lib/api/server' import { auth } from '@/lib/auth' -import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' const logger = createLogger('ForgetPasswordAPI') -const forgetPasswordSchema = z.object({ - email: z - .string({ required_error: 'Email is required' }) - .email('Please provide a valid email address'), - redirectTo: z - .string() - .optional() - .or(z.literal('')) - .transform((val) => (val === '' || val === undefined ? undefined : val)) - .refine( - (val) => val === undefined || (z.string().url().safeParse(val).success && isSameOrigin(val)), - { - message: 'Redirect URL must be a valid same-origin URL', - } - ), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const body = await request.json() - const validationResult = forgetPasswordSchema.safeParse(body) + const validationResult = forgetPasswordBodySchema.safeParse(body) if (!validationResult.success) { - const firstError = validationResult.error.errors[0] - const errorMessage = firstError?.message || 'Invalid request data' - logger.warn('Invalid forget password request data', { - errors: validationResult.error.format(), + errors: validationResult.error.issues, }) - return NextResponse.json({ message: errorMessage }, { status: 400 }) + return NextResponse.json( + { + message: getValidationErrorMessage(validationResult.error, 'Invalid request data'), + }, + { status: 400 } + ) } const { email, redirectTo } = validationResult.data diff --git a/apps/sim/app/api/auth/oauth/connections/route.ts b/apps/sim/app/api/auth/oauth/connections/route.ts index d36ad0f248a..11a98dfbfeb 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { jwtDecode } from 'jwt-decode' 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' @@ -46,7 +47,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const userEmail = userRecord.length > 0 ? userRecord[0]?.email : null // Process accounts to determine connections - const connections: any[] = [] + const connections: OAuthConnection[] = [] for (const acc of accounts) { const { baseProvider, featureType } = parseProvider(acc.providerId as OAuthProvider) diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index db2db24fc79..cb81a810b1b 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -4,7 +4,8 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { oauthCredentialsQuerySchema } from '@/lib/api/contracts/credentials' +import { getValidationErrorMessage } 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' @@ -19,22 +20,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('OAuthCredentialsAPI') -const credentialsQuerySchema = z - .object({ - provider: z.string().nullish(), - workflowId: z.string().uuid('Workflow ID must be a valid UUID').nullish(), - workspaceId: z.string().uuid('Workspace ID must be a valid UUID').nullish(), - credentialId: z - .string() - .min(1, 'Credential ID must not be empty') - .max(255, 'Credential ID is too long') - .nullish(), - }) - .refine((data) => data.provider || data.credentialId, { - message: 'Provider or credentialId is required', - path: ['provider'], - }) - function toCredentialResponse( id: string, displayName: string, @@ -79,31 +64,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => { credentialId: searchParams.get('credentialId'), } - const parseResult = credentialsQuerySchema.safeParse(rawQuery) + const parseResult = oauthCredentialsQuerySchema.safeParse(rawQuery) if (!parseResult.success) { - const refinementError = parseResult.error.errors.find((err) => err.code === 'custom') + const refinementError = parseResult.error.issues.find((err) => err.code === 'custom') if (refinementError) { logger.warn(`[${requestId}] Invalid query parameters: ${refinementError.message}`) - return NextResponse.json( - { - error: refinementError.message, - }, - { status: 400 } - ) + return NextResponse.json({ error: refinementError.message }, { status: 400 }) } - const firstError = parseResult.error.errors[0] - const errorMessage = firstError?.message || 'Validation failed' - logger.warn(`[${requestId}] Invalid query parameters`, { - errors: parseResult.error.errors, + errors: parseResult.error.issues, }) return NextResponse.json( - { - error: errorMessage, - }, + { error: getValidationErrorMessage(parseResult.error, 'Validation failed') }, { status: 400 } ) } diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts index 51767bd482e..3e26ede2739 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts @@ -4,7 +4,8 @@ import { account, credentialSet, credentialSetMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, like, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { disconnectOAuthBodySchema } from '@/lib/api/contracts/oauth-connections' +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' @@ -14,12 +15,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('OAuthDisconnectAPI') -const disconnectSchema = z.object({ - provider: z.string({ required_error: 'Provider is required' }).min(1, 'Provider is required'), - providerId: z.string().optional(), - accountId: z.string().optional(), -}) - /** * Disconnect an OAuth provider for the current user */ @@ -35,20 +30,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const rawBody = await request.json() - const parseResult = disconnectSchema.safeParse(rawBody) + const parseResult = disconnectOAuthBodySchema.safeParse(rawBody) if (!parseResult.success) { - const firstError = parseResult.error.errors[0] - const errorMessage = firstError?.message || 'Validation failed' - logger.warn(`[${requestId}] Invalid disconnect request`, { - errors: parseResult.error.errors, + errors: parseResult.error.issues, }) return NextResponse.json( - { - error: errorMessage, - }, + { error: getValidationErrorMessage(parseResult.error, 'Validation failed') }, { status: 400 } ) } 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 f238ccd25f9..2f3730a2f45 100644 --- a/apps/sim/app/api/auth/oauth/microsoft/file/route.ts +++ b/apps/sim/app/api/auth/oauth/microsoft/file/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { microsoftFileQuerySchema } from '@/lib/api/contracts/selectors/microsoft' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -14,14 +16,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const fileId = searchParams.get('fileId') - const workflowId = searchParams.get('workflowId') || undefined + const parsedQuery = validateSchema(microsoftFileQuerySchema, { + credentialId: searchParams.get('credentialId') ?? undefined, + fileId: searchParams.get('fileId') ?? undefined, + workflowId: searchParams.get('workflowId') ?? undefined, + }) - if (!credentialId || !fileId) { - return NextResponse.json({ error: 'Credential ID and File ID are required' }, { status: 400 }) + if (!parsedQuery.success) { + return NextResponse.json( + { error: getValidationErrorMessage(parsedQuery.error) }, + { status: 400 } + ) } + const { credentialId, fileId, workflowId } = parsedQuery.data + const fileIdValidation = validateMicrosoftGraphId(fileId, 'fileId') if (!fileIdValidation.isValid) { logger.warn(`[${requestId}] Invalid file ID: ${fileIdValidation.error}`) 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 8b4d5cfc894..f52bf733e54 100644 --- a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts +++ b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { microsoftFilesQuerySchema } from '@/lib/api/contracts/selectors/microsoft' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -20,16 +22,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { // Get the credential ID from the query params const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const query = searchParams.get('query') || '' - const driveId = searchParams.get('driveId') || undefined - const workflowId = searchParams.get('workflowId') || undefined - - if (!credentialId) { - logger.warn(`[${requestId}] Missing credential ID`) - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + const parsedQuery = validateSchema(microsoftFilesQuerySchema, { + credentialId: searchParams.get('credentialId') ?? undefined, + query: searchParams.get('query') ?? undefined, + driveId: searchParams.get('driveId') ?? undefined, + workflowId: searchParams.get('workflowId') ?? undefined, + }) + + if (!parsedQuery.success) { + logger.warn(`[${requestId}] Invalid query parameters`) + return NextResponse.json( + { error: getValidationErrorMessage(parsedQuery.error) }, + { status: 400 } + ) } + const { credentialId, driveId, workflowId } = parsedQuery.data + const query = parsedQuery.data.query ?? '' + const authz = await authorizeCredentialUse(request, { credentialId, workflowId, diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index 3d6004b6577..2382361cdb0 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -1,6 +1,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + oauthTokenGetQuerySchema, + oauthTokenPostQuerySchema, + oauthTokenRequestBodySchema, +} from '@/lib/api/contracts/oauth-connections' +import { getValidationErrorMessage } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' @@ -19,29 +24,6 @@ const logger = createLogger('OAuthTokenAPI') const SALESFORCE_INSTANCE_URL_REGEX = /__sf_instance__:([^\s]+)/ -const tokenRequestSchema = z - .object({ - credentialId: z.string().min(1).optional(), - credentialAccountUserId: z.string().min(1).optional(), - providerId: z.string().min(1).optional(), - workflowId: z.string().min(1).nullish(), - scopes: z.array(z.string()).optional(), - impersonateEmail: z.string().email().optional(), - }) - .refine( - (data) => data.credentialId || (data.credentialAccountUserId && data.providerId), - 'Either credentialId or (credentialAccountUserId + providerId) is required' - ) - -const tokenQuerySchema = z.object({ - credentialId: z - .string({ - required_error: 'Credential ID is required', - invalid_type_error: 'Credential ID is required', - }) - .min(1, 'Credential ID is required'), -}) - /** * Get an access token for a specific credential * Supports both session-based authentication (for client-side requests) @@ -54,20 +36,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const rawBody = await request.json() - const parseResult = tokenRequestSchema.safeParse(rawBody) + const parseResult = oauthTokenRequestBodySchema.safeParse(rawBody) if (!parseResult.success) { - const firstError = parseResult.error.errors[0] - const errorMessage = firstError?.message || 'Validation failed' - logger.warn(`[${requestId}] Invalid token request`, { - errors: parseResult.error.errors, + errors: parseResult.error.issues, }) return NextResponse.json( - { - error: errorMessage, - }, + { error: getValidationErrorMessage(parseResult.error, 'Validation failed') }, { status: 400 } ) } @@ -126,7 +103,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) } - const callerUserId = new URL(request.url).searchParams.get('userId') || undefined + const callerUserId = oauthTokenPostQuerySchema.parse({ + userId: new URL(request.url).searchParams.get('userId') || undefined, + }).userId const resolved = await resolveOAuthAccountId(credentialId) if (resolved?.credentialType === 'service_account' && resolved.credentialId) { @@ -219,20 +198,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { credentialId: searchParams.get('credentialId'), } - const parseResult = tokenQuerySchema.safeParse(rawQuery) + const parseResult = oauthTokenGetQuerySchema.safeParse(rawQuery) if (!parseResult.success) { - const firstError = parseResult.error.errors[0] - const errorMessage = firstError?.message || 'Validation failed' - logger.warn(`[${requestId}] Invalid query parameters`, { - errors: parseResult.error.errors, + errors: parseResult.error.issues, }) return NextResponse.json( - { - error: errorMessage, - }, + { error: getValidationErrorMessage(parseResult.error, 'Validation failed') }, { status: 400 } ) } 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 b1240aacb28..924e5c68fcf 100644 --- a/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts +++ b/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts @@ -3,6 +3,8 @@ import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { wealthboxOAuthItemContract } from '@/lib/api/contracts/selectors/wealthbox' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -13,6 +15,15 @@ export const dynamic = 'force-dynamic' const logger = createLogger('WealthboxItemAPI') +interface WealthboxItem { + id: string + name: string + type: string + content: string + createdAt: string + updatedAt: string +} + /** * Get a single item (note, contact, task) from Wealthbox */ @@ -20,25 +31,16 @@ 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 parameters from query - const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const itemId = searchParams.get('itemId') - const type = searchParams.get('type') || 'contact' - - if (!credentialId || !itemId) { - logger.warn(`[${requestId}] Missing required parameters`, { credentialId, itemId }) - return NextResponse.json({ error: 'Credential ID and Item ID are required' }, { status: 400 }) - } + const parsed = await parseRequest(wealthboxOAuthItemContract, request, {}) + if (!parsed.success) return parsed.response + const { credentialId, itemId, type } = parsed.data.query const typeValidation = validateEnum(type, ['contact'] as const, 'type') if (!typeValidation.isValid) { @@ -134,26 +136,31 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } - const data = await response.json() + const data = (await response.json()) as Record + const meta = + data.meta && typeof data.meta === 'object' ? (data.meta as Record) : null + const totalCount = meta?.total_count ?? 'unknown' logger.info(`[${requestId}] Wealthbox API response structure`, { type, dataKeys: Object.keys(data || {}), hasContacts: !!data.contacts, - totalCount: data.meta?.total_count, + totalCount, }) - let items: any[] = [] + let items: WealthboxItem[] = [] if (type === 'contact') { if (data?.id) { + const firstName = typeof data.first_name === 'string' ? data.first_name : '' + const lastName = typeof data.last_name === 'string' ? data.last_name : '' const item = { id: data.id?.toString() || '', - name: `${data.first_name || ''} ${data.last_name || ''}`.trim() || `Contact ${data.id}`, + name: `${firstName} ${lastName}`.trim() || `Contact ${data.id}`, type: 'contact', - content: data.background_info || '', - createdAt: data.created_at, - updatedAt: data.updated_at, + content: typeof data.background_info === 'string' ? data.background_info : '', + createdAt: typeof data.created_at === 'string' ? data.created_at : '', + updatedAt: typeof data.updated_at === 'string' ? data.updated_at : '', } items = [item] } else { @@ -163,7 +170,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } logger.info( - `[${requestId}] Successfully fetched ${items.length} ${type}s from Wealthbox (total: ${data.meta?.total_count || 'unknown'})` + `[${requestId}] Successfully fetched ${items.length} ${type}s from Wealthbox (total: ${totalCount})` ) return NextResponse.json({ item: items[0] }, { status: 200 }) 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 a5b7885b406..102e8f16c08 100644 --- a/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts +++ b/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts @@ -3,6 +3,8 @@ import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { wealthboxOAuthItemsContract } from '@/lib/api/contracts/selectors/wealthbox' +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' @@ -12,6 +14,15 @@ export const dynamic = 'force-dynamic' const logger = createLogger('WealthboxItemsAPI') +interface WealthboxItem { + id: string + name: string + type: string + content: string + createdAt: string + updatedAt: string +} + /** * Get items (notes, contacts, tasks) from Wealthbox */ @@ -19,34 +30,17 @@ 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 parameters from query - const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const type = searchParams.get('type') || 'contact' - const query = searchParams.get('query') || '' - - if (!credentialId) { - logger.warn(`[${requestId}] Missing credential ID`) - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) - } - - // Validate item type - only handle contacts now - if (type !== 'contact') { - logger.warn(`[${requestId}] Invalid item type: ${type}`) - return NextResponse.json( - { error: 'Invalid item type. Only contact is supported.' }, - { status: 400 } - ) - } + const parsed = await parseRequest(wealthboxOAuthItemsContract, request, {}) + if (!parsed.success) return parsed.response + const { credentialId, type } = parsed.data.query + const query = parsed.data.query.query ?? '' const resolved = await resolveOAuthAccountId(credentialId) if (!resolved) { @@ -89,13 +83,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } - // Use correct endpoints based on documentation - only for contacts const endpoints = { contact: 'contacts', } const endpoint = endpoints[type as keyof typeof endpoints] - // Build URL - using correct API base URL const url = new URL(`https://api.crmworkspace.com/v1/${endpoint}`) logger.info(`[${requestId}] Fetching ${type}s from Wealthbox`, { @@ -104,7 +96,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { hasQuery: !!query.trim(), }) - // Make request to Wealthbox API const response = await fetch(url.toString(), { headers: { Authorization: `Bearer ${accessToken}`, @@ -128,7 +119,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } - const data = await response.json() + const data = (await response.json()) as { contacts?: Array> } & Record< + string, + unknown + > logger.info(`[${requestId}] Wealthbox API response structure`, { type, @@ -138,8 +132,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { dataStructure: typeof data === 'object' ? Object.keys(data) : 'not an object', }) - // Transform the response based on type and correct response format - let items: any[] = [] + let items: WealthboxItem[] = [] if (type === 'contact') { const contacts = data.contacts || [] @@ -151,17 +144,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ items: [] }, { status: 200 }) } - items = contacts.map((item: any) => ({ - id: item.id?.toString() || '', - name: `${item.first_name || ''} ${item.last_name || ''}`.trim() || `Contact ${item.id}`, - type: 'contact', - content: item.background_information || '', - createdAt: item.created_at, - updatedAt: item.updated_at, - })) + items = contacts.map((item) => { + const firstName = typeof item.first_name === 'string' ? item.first_name : '' + const lastName = typeof item.last_name === 'string' ? item.last_name : '' + return { + id: item.id?.toString() || '', + name: `${firstName} ${lastName}`.trim() || `Contact ${item.id ?? ''}`, + type: 'contact', + content: + typeof item.background_information === 'string' ? item.background_information : '', + createdAt: typeof item.created_at === 'string' ? item.created_at : '', + updatedAt: typeof item.updated_at === 'string' ? item.updated_at : '', + } + }) } - // Apply client-side filtering if query is provided if (query.trim()) { const searchTerm = query.trim().toLowerCase() items = items.filter( diff --git a/apps/sim/app/api/auth/oauth2/authorize-params/route.ts b/apps/sim/app/api/auth/oauth2/authorize-params/route.ts index 9111ca826e9..042b17dada0 100644 --- a/apps/sim/app/api/auth/oauth2/authorize-params/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize-params/route.ts @@ -3,6 +3,8 @@ import { verification } from '@sim/db/schema' import { and, eq, gt } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { oauthAuthorizeParamsQuerySchema } from '@/lib/api/contracts/oauth-connections' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -17,10 +19,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const consentCode = request.nextUrl.searchParams.get('consent_code') - if (!consentCode) { - return NextResponse.json({ error: 'consent_code is required' }, { status: 400 }) + const parsedQuery = oauthAuthorizeParamsQuerySchema.safeParse({ + consent_code: request.nextUrl.searchParams.get('consent_code') || undefined, + }) + if (!parsedQuery.success) { + return NextResponse.json( + { error: getValidationErrorMessage(parsedQuery.error) }, + { status: 400 } + ) } + const consentCode = parsedQuery.data.consent_code const [record] = await db .select({ value: verification.value }) 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 c12c52ea4ba..a3ab5ac06df 100644 --- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts @@ -2,6 +2,10 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' import { type NextRequest, NextResponse } from 'next/server' +import { + shopifyCallbackQuerySchema, + shopifyShopDomainSchema, +} from '@/lib/api/contracts/oauth-connections' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -11,8 +15,6 @@ const logger = createLogger('ShopifyCallback') export const dynamic = 'force-dynamic' -const SHOP_DOMAIN_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/ - /** * 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 @@ -50,9 +52,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const { searchParams } = request.nextUrl - const code = searchParams.get('code') - const state = searchParams.get('state') - const shop = searchParams.get('shop') + const { code, state, shop } = shopifyCallbackQuerySchema.parse({ + code: searchParams.get('code') || undefined, + state: searchParams.get('state') || undefined, + shop: searchParams.get('shop') || undefined, + }) const storedState = request.cookies.get('shopify_oauth_state')?.value const storedShop = request.cookies.get('shopify_shop_domain')?.value @@ -86,7 +90,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_shop`) } - if (!SHOP_DOMAIN_REGEX.test(shopDomain)) { + if (!shopifyShopDomainSchema.safeParse(shopDomain).success) { logger.error('Invalid shop domain format:', { shopDomain }) return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_shop`) } 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 3d66dfa3107..dfa59fa77c6 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -3,6 +3,7 @@ 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 { shopifyStoreCookieSchema } from '@/lib/api/contracts/oauth-connections' import { getSession } from '@/lib/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' @@ -24,14 +25,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`) } - const accessToken = request.cookies.get('shopify_pending_token')?.value - const shopDomain = request.cookies.get('shopify_pending_shop')?.value - const scope = request.cookies.get('shopify_pending_scope')?.value + const parsedCookies = shopifyStoreCookieSchema.safeParse({ + accessToken: request.cookies.get('shopify_pending_token')?.value, + shopDomain: request.cookies.get('shopify_pending_shop')?.value, + scope: request.cookies.get('shopify_pending_scope')?.value || undefined, + returnUrl: request.cookies.get('shopify_return_url')?.value || undefined, + }) - if (!accessToken || !shopDomain) { + if (!parsedCookies.success) { logger.error('Missing token or shop domain in cookies') return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_missing_data`) } + const { accessToken, shopDomain, scope, returnUrl } = parsedCookies.data const shopResponse = await fetch(`https://${shopDomain}/admin/api/2024-10/shop.json`, { headers: { @@ -113,8 +118,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } } - const returnUrl = request.cookies.get('shopify_return_url')?.value - const redirectUrl = returnUrl && isSameOrigin(returnUrl) ? returnUrl : `${baseUrl}/workspace` const finalUrl = new URL(redirectUrl) finalUrl.searchParams.set('shopify_connected', 'true') diff --git a/apps/sim/app/api/auth/reset-password/route.ts b/apps/sim/app/api/auth/reset-password/route.ts index 637ffe65392..0f976b31a8d 100644 --- a/apps/sim/app/api/auth/reset-password/route.ts +++ b/apps/sim/app/api/auth/reset-password/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { resetPasswordBodySchema } from '@/lib/api/contracts' import { auth } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -8,29 +8,17 @@ export const dynamic = 'force-dynamic' const logger = createLogger('PasswordResetAPI') -const resetPasswordSchema = z.object({ - token: z.string({ required_error: 'Token is required' }).min(1, 'Token is required'), - newPassword: z - .string({ required_error: 'Password is required' }) - .min(8, 'Password must be at least 8 characters long') - .max(100, 'Password must not exceed 100 characters') - .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') - .regex(/[a-z]/, 'Password must contain at least one lowercase letter') - .regex(/[0-9]/, 'Password must contain at least one number') - .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const body = await request.json() - const validationResult = resetPasswordSchema.safeParse(body) + const validationResult = resetPasswordBodySchema.safeParse(body) if (!validationResult.success) { - const errorMessage = validationResult.error.errors.map((e) => e.message).join(' ') + const errorMessage = validationResult.error.issues.map((e) => e.message).join(' ') logger.warn('Invalid password reset request data', { - errors: validationResult.error.format(), + errors: validationResult.error.issues, }) return NextResponse.json({ message: errorMessage }, { status: 400 }) } diff --git a/apps/sim/app/api/auth/shopify/authorize/route.ts b/apps/sim/app/api/auth/shopify/authorize/route.ts index c32dd313521..6bb1a94ffd9 100644 --- a/apps/sim/app/api/auth/shopify/authorize/route.ts +++ b/apps/sim/app/api/auth/shopify/authorize/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' +import { shopifyAuthorizeQuerySchema } from '@/lib/api/contracts/oauth-connections' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -28,8 +29,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Shopify client ID not configured' }, { status: 500 }) } - const shopDomain = request.nextUrl.searchParams.get('shop') - const returnUrl = request.nextUrl.searchParams.get('returnUrl') + const query = shopifyAuthorizeQuerySchema.parse({ + shop: request.nextUrl.searchParams.get('shop') || undefined, + returnUrl: request.nextUrl.searchParams.get('returnUrl') || undefined, + }) + const { shop: shopDomain, returnUrl } = query if (!shopDomain) { const safeReturnUrl = diff --git a/apps/sim/app/api/auth/sso/providers/route.ts b/apps/sim/app/api/auth/sso/providers/route.ts index 0305d6b1a72..fa33b2a8f3c 100644 --- a/apps/sim/app/api/auth/sso/providers/route.ts +++ b/apps/sim/app/api/auth/sso/providers/route.ts @@ -2,6 +2,7 @@ import { db, member, ssoProvider } from '@sim/db' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { ssoProvidersQuerySchema } from '@/lib/api/contracts/auth' import { getSession } from '@/lib/auth' import { REDACTED_MARKER } from '@/lib/core/security/redaction' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -12,7 +13,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() const { searchParams } = new URL(request.url) - const organizationId = searchParams.get('organizationId') + const query = ssoProvidersQuerySchema.parse({ + organizationId: searchParams.get('organizationId') || undefined, + }) + const { organizationId } = query let providers if (session?.user?.id) { diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts index 41e075696aa..37f5c92b03b 100644 --- a/apps/sim/app/api/auth/sso/register/route.ts +++ b/apps/sim/app/api/auth/sso/register/route.ts @@ -2,7 +2,8 @@ import { db, member, ssoProvider } from '@sim/db' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { ssoRegistrationBodySchema } from '@/lib/api/contracts/auth' +import { getValidationErrorMessage } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth' import { hasSSOAccess } from '@/lib/billing' import { env } from '@/lib/core/config/env' @@ -16,66 +17,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('SSORegisterRoute') -const mappingSchema = z - .object({ - id: z.string().default('sub'), - email: z.string().default('email'), - name: z.string().default('name'), - image: z.string().default('picture'), - }) - .default({ - id: 'sub', - email: 'email', - name: 'name', - image: 'picture', - }) - -const ssoRegistrationSchema = z.discriminatedUnion('providerType', [ - z.object({ - providerType: z.literal('oidc').default('oidc'), - providerId: z.string().min(1, 'Provider ID is required'), - issuer: z.string().url('Issuer must be a valid URL'), - domain: z.string().min(1, 'Domain is required'), - orgId: z.string().optional(), - mapping: mappingSchema, - clientId: z.string().min(1, 'Client ID is required for OIDC'), - clientSecret: z.string().min(1, 'Client Secret is required for OIDC'), - scopes: z - .union([ - z.string().transform((s) => - s - .split(',') - .map((s) => s.trim()) - .filter((s) => s !== '') - ), - z.array(z.string()), - ]) - .default(['openid', 'profile', 'email']), - pkce: z.boolean().default(true), - authorizationEndpoint: z.string().url().optional(), - tokenEndpoint: z.string().url().optional(), - userInfoEndpoint: z.string().url().optional(), - jwksEndpoint: z.string().url().optional(), - }), - z.object({ - providerType: z.literal('saml'), - providerId: z.string().min(1, 'Provider ID is required'), - issuer: z.string().url('Issuer must be a valid URL'), - domain: z.string().min(1, 'Domain is required'), - orgId: z.string().optional(), - mapping: mappingSchema, - entryPoint: z.string().url('Entry point must be a valid URL for SAML'), - cert: z.string().min(1, 'Certificate is required for SAML'), - callbackUrl: z.string().url().optional(), - audience: z.string().optional(), - wantAssertionsSigned: z.boolean().optional(), - signatureAlgorithm: z.string().optional(), - digestAlgorithm: z.string().optional(), - identifierFormat: z.string().optional(), - idpMetadata: z.string().optional(), - }), -]) - export const POST = withRouteHandler(async (request: NextRequest) => { try { if (!env.SSO_ENABLED) { @@ -94,20 +35,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const rawBody = await request.json() - const parseResult = ssoRegistrationSchema.safeParse(rawBody) + const parseResult = ssoRegistrationBodySchema.safeParse(rawBody) if (!parseResult.success) { - const firstError = parseResult.error.errors[0] - const errorMessage = firstError?.message || 'Validation failed' - logger.warn('Invalid SSO registration request', { - errors: parseResult.error.errors, + errors: parseResult.error.issues, }) return NextResponse.json( - { - error: errorMessage, - }, + { error: getValidationErrorMessage(parseResult.error, 'Validation failed') }, { status: 400 } ) } diff --git a/apps/sim/app/api/auth/trello/authorize/route.ts b/apps/sim/app/api/auth/trello/authorize/route.ts index e1ef13aec38..48d2f10f2a6 100644 --- a/apps/sim/app/api/auth/trello/authorize/route.ts +++ b/apps/sim/app/api/auth/trello/authorize/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { authorizeTrelloContract } from '@/lib/api/contracts/oauth-connections' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -10,13 +12,16 @@ const logger = createLogger('TrelloAuthorize') export const dynamic = 'force-dynamic' -export const GET = withRouteHandler(async () => { +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(authorizeTrelloContract, request, {}) + if (!parsed.success) return parsed.response + const apiKey = env.TRELLO_API_KEY if (!apiKey) { diff --git a/apps/sim/app/api/auth/trello/callback/route.ts b/apps/sim/app/api/auth/trello/callback/route.ts index 53e05e65f6d..6f0aecd12f5 100644 --- a/apps/sim/app/api/auth/trello/callback/route.ts +++ b/apps/sim/app/api/auth/trello/callback/route.ts @@ -1,10 +1,15 @@ -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { trelloCallbackContract } from '@/lib/api/contracts/oauth-connections' +import { parseRequest } from '@/lib/api/server' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' -export const GET = withRouteHandler(async () => { +export const GET = withRouteHandler(async (request: NextRequest) => { + const parsed = await parseRequest(trelloCallbackContract, request, {}) + if (!parsed.success) return parsed.response + const baseUrl = getBaseUrl() return new NextResponse( diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index abf8c7603a0..d62237e7015 100644 --- a/apps/sim/app/api/auth/trello/store/route.ts +++ b/apps/sim/app/api/auth/trello/store/route.ts @@ -3,6 +3,8 @@ 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 { storeTrelloTokenContract } from '@/lib/api/contracts/oauth-connections' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -22,12 +24,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } - const body = (await request.json().catch(() => null)) as { token?: string } | null - const token = typeof body?.token === 'string' ? body.token : '' - - if (!token) { - return NextResponse.json({ success: false, error: 'Token required' }, { status: 400 }) - } + const parsed = await parseRequest(storeTrelloTokenContract, request, {}) + if (!parsed.success) return parsed.response + const { token } = parsed.data.body const apiKey = env.TRELLO_API_KEY if (!apiKey) { diff --git a/apps/sim/app/api/billing/credits/route.ts b/apps/sim/app/api/billing/credits/route.ts index 7f7e5390221..354434cbc32 100644 --- a/apps/sim/app/api/billing/credits/route.ts +++ b/apps/sim/app/api/billing/credits/route.ts @@ -1,7 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { purchaseCreditsBodySchema } from '@/lib/api/contracts/subscription' import { getSession } from '@/lib/auth' import { getCreditBalance } from '@/lib/billing/credits/balance' import { purchaseCredits } from '@/lib/billing/credits/purchase' @@ -9,11 +9,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CreditsAPI') -const PurchaseSchema = z.object({ - amount: z.number().min(10).max(1000), - requestId: z.string().uuid(), -}) - export const GET = withRouteHandler(async () => { const session = await getSession() if (!session?.user?.id) { @@ -40,7 +35,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const body = await request.json() - const validation = PurchaseSchema.safeParse(body) + const validation = purchaseCreditsBodySchema.safeParse(body) if (!validation.success) { return NextResponse.json( diff --git a/apps/sim/app/api/billing/portal/route.ts b/apps/sim/app/api/billing/portal/route.ts index fd9ad19e82f..dc3ae3430ba 100644 --- a/apps/sim/app/api/billing/portal/route.ts +++ b/apps/sim/app/api/billing/portal/route.ts @@ -3,6 +3,7 @@ import { subscription as subscriptionTable, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { billingPortalBodySchema } from '@/lib/api/contracts/subscription' import { getSession } from '@/lib/auth' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { requireStripeClient } from '@/lib/billing/stripe-client' @@ -21,10 +22,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const body = await request.json().catch(() => ({})) - const context: 'user' | 'organization' = - body?.context === 'organization' ? 'organization' : 'user' - const organizationId: string | undefined = body?.organizationId || undefined - const returnUrl: string = body?.returnUrl || `${getBaseUrl()}/workspace?billing=updated` + const parsedBody = billingPortalBodySchema.safeParse(body) + if (!parsedBody.success) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + const context = parsedBody.data.context + const organizationId = parsedBody.data.organizationId + const returnUrl = parsedBody.data.returnUrl || `${getBaseUrl()}/workspace?billing=updated` const stripe = requireStripeClient() diff --git a/apps/sim/app/api/billing/route.ts b/apps/sim/app/api/billing/route.ts index ee0152a6d46..1b9411647e8 100644 --- a/apps/sim/app/api/billing/route.ts +++ b/apps/sim/app/api/billing/route.ts @@ -3,6 +3,7 @@ import { member } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { billingQuerySchema } from '@/lib/api/contracts/subscription' import { getSession } from '@/lib/auth' import { getEffectiveBillingStatus } from '@/lib/billing/core/access' import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing' @@ -23,18 +24,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const { searchParams } = new URL(request.url) - const context = searchParams.get('context') || 'user' - const contextId = searchParams.get('id') - const includeOrg = searchParams.get('includeOrg') === 'true' + const parsedQuery = billingQuerySchema.safeParse({ + context: searchParams.get('context') || undefined, + id: searchParams.get('id') || undefined, + includeOrg: searchParams.get('includeOrg') === 'true', + }) - // Validate context parameter - if (!['user', 'organization'].includes(context)) { + if (!parsedQuery.success) { return NextResponse.json( { error: 'Invalid context. Must be "user" or "organization"' }, { status: 400 } ) } + const { context, id: contextId, includeOrg } = parsedQuery.data + // For organization context, require contextId if (context === 'organization' && !contextId) { return NextResponse.json( diff --git a/apps/sim/app/api/billing/switch-plan/route.ts b/apps/sim/app/api/billing/switch-plan/route.ts index 2cc3594bffc..9b9e05a21ec 100644 --- a/apps/sim/app/api/billing/switch-plan/route.ts +++ b/apps/sim/app/api/billing/switch-plan/route.ts @@ -4,7 +4,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { billingSwitchPlanBodySchema } from '@/lib/api/contracts/subscription' +import { validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getEffectiveBillingStatus } from '@/lib/billing/core/access' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' @@ -24,11 +25,6 @@ import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('SwitchPlan') -const switchPlanSchema = z.object({ - targetPlanName: z.string(), - interval: z.enum(['month', 'year']).optional(), -}) - /** * POST /api/billing/switch-plan * @@ -53,12 +49,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const body = await request.json() - const parsed = switchPlanSchema.safeParse(body) + const parsed = validateSchema(billingSwitchPlanBodySchema, body, 'Invalid request') if (!parsed.success) { - return NextResponse.json( - { error: 'Invalid request', details: parsed.error.flatten() }, - { status: 400 } - ) + return parsed.response } const { targetPlanName, interval } = parsed.data diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 4a3bbc46a92..d5531adc732 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { billingUpdateCostBodySchema } from '@/lib/api/contracts/subscription' +import { validateSchema } from '@/lib/api/server' import { recordUsage } from '@/lib/billing/core/usage-log' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import { BillingRouteOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' @@ -17,18 +18,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('BillingUpdateCostAPI') -const UpdateCostSchema = z.object({ - userId: z.string().min(1, 'User ID is required'), - cost: z.number().min(0, 'Cost must be a non-negative number'), - 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'), - idempotencyKey: z.string().min(1).optional(), -}) - /** * POST /api/billing/update-cost * Update user cost with a pre-calculated cost value (internal API key auth required) @@ -92,7 +81,7 @@ async function updateCostInner( } const body = await req.json() - const validation = UpdateCostSchema.safeParse(body) + const validation = validateSchema(billingUpdateCostBodySchema, body) if (!validation.success) { logger.warn(`[${requestId}] Invalid request body`, { diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts index fa8da3f97c5..acd6652bf5d 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts @@ -167,18 +167,33 @@ vi.mock('zod', () => { this.errors = issues } } - const mockStringReturnValue = { - email: vi.fn().mockReturnThis(), - length: vi.fn().mockReturnThis(), - } - return { - z: { - object: vi.fn().mockReturnValue({ - parse: mockZodParse, - }), - string: vi.fn().mockReturnValue(mockStringReturnValue), - ZodError, + const chainable: Record = {} + const proxy: Record = new Proxy(chainable, { + get(target, prop) { + if (prop === 'parse') return mockZodParse + if (prop === 'safeParse') { + return (data: unknown) => ({ success: true, data }) + } + if (prop === 'then') return undefined + if (typeof prop === 'symbol') return Reflect.get(target, prop) + if (!(prop in target)) { + target[prop as string] = vi.fn().mockReturnValue(proxy) + } + return target[prop as string] }, + }) + const makeChain = vi.fn(() => proxy) + return { + z: new Proxy( + { ZodError }, + { + get(target, prop) { + if (prop === 'ZodError') return ZodError + if (typeof prop === 'symbol') return Reflect.get(target, prop) + return makeChain + }, + } + ), } }) diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index 9010f9af464..f399d05b455 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -5,8 +5,12 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, gt, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { z } from 'zod' import { renderOTPEmail } from '@/components/emails' +import { + chatEmailOtpRequestBodySchema, + chatEmailOtpVerifyBodySchema, +} from '@/lib/api/contracts/chats' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { getRedisClient } from '@/lib/core/config/redis' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' @@ -215,15 +219,6 @@ async function deleteOTP(email: string, chatId: string): Promise { } } -const otpRequestSchema = z.object({ - email: z.string().email('Invalid email address'), -}) - -const otpVerifySchema = z.object({ - email: z.string().email('Invalid email address'), - otp: z.string().length(6, 'OTP must be 6 digits'), -}) - export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ identifier: string }> }) => { const { identifier } = await params @@ -248,7 +243,14 @@ export const POST = withRouteHandler( } const body = await request.json() - const { email } = otpRequestSchema.parse(body) + const validation = validateSchema(chatEmailOtpRequestBodySchema, body) + if (!validation.success) { + return addCorsHeaders( + createErrorResponse(getValidationErrorMessage(validation.error, 'Invalid request'), 400), + request + ) + } + const { email } = validation.data const deploymentResult = await db .select({ @@ -334,12 +336,6 @@ export const POST = withRouteHandler( logger.info(`[${requestId}] OTP sent to ${email} for chat ${deployment.id}`) return addCorsHeaders(createSuccessResponse({ message: 'Verification code sent' }), request) } catch (error: any) { - if (error instanceof z.ZodError) { - return addCorsHeaders( - createErrorResponse(error.errors[0]?.message || 'Invalid request', 400), - request - ) - } logger.error(`[${requestId}] Error processing OTP request:`, error) return addCorsHeaders( createErrorResponse(error.message || 'Failed to process request', 500), @@ -356,7 +352,14 @@ export const PUT = withRouteHandler( try { const body = await request.json() - const { email, otp } = otpVerifySchema.parse(body) + const validation = validateSchema(chatEmailOtpVerifyBodySchema, body) + if (!validation.success) { + return addCorsHeaders( + createErrorResponse(getValidationErrorMessage(validation.error, 'Invalid request'), 400), + request + ) + } + const { email, otp } = validation.data const deploymentResult = await db .select({ @@ -429,12 +432,6 @@ export const PUT = withRouteHandler( return response } catch (error: any) { - if (error instanceof z.ZodError) { - return addCorsHeaders( - createErrorResponse(error.errors[0]?.message || 'Invalid request', 400), - request - ) - } logger.error(`[${requestId}] Error verifying OTP:`, error) return addCorsHeaders( createErrorResponse(error.message || 'Failed to process request', 500), diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 959f8d27e78..2f74fba5abe 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -4,7 +4,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { deployedChatPostBodySchema } from '@/lib/api/contracts/chats' +import { validateSchema } from '@/lib/api/server' import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -36,22 +37,6 @@ function toChatConfigResponse(deployment: ChatConfigSource) { } } -const chatFileSchema = z.object({ - name: z.string().min(1, 'File name is required'), - type: z.string().min(1, 'File type is required'), - size: z.number().positive('File size must be positive'), - data: z.string().min(1, 'File data is required'), - lastModified: z.number().optional(), -}) - -const chatPostBodySchema = z.object({ - input: z.string().optional(), - password: z.string().optional(), - email: z.string().email('Invalid email format').optional().or(z.literal('')), - conversationId: z.string().optional(), - files: z.array(chatFileSchema).optional().default([]), -}) - export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -64,10 +49,10 @@ export const POST = withRouteHandler( let parsedBody try { const rawBody = await request.json() - const validation = chatPostBodySchema.safeParse(rawBody) + const validation = validateSchema(deployedChatPostBodySchema, rawBody) if (!validation.success) { - const errorMessage = validation.error.errors + const errorMessage = validation.error.issues .map((err) => `${err.path.join('.')}: ${err.message}`) .join(', ') logger.warn(`[${requestId}] Validation error: ${errorMessage}`) diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 4f937d75258..9b1c7e96247 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -4,7 +4,8 @@ import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { z } from 'zod' +import { chatIdParamsSchema, updateChatBodySchema } from '@/lib/api/contracts/chats' +import { getValidationErrorMessage, isZodError } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { isDev } from '@/lib/core/config/feature-flags' import { encryptSecret } from '@/lib/core/security/encryption' @@ -19,41 +20,16 @@ export const dynamic = 'force-dynamic' const logger = createLogger('ChatDetailAPI') -const chatUpdateSchema = z.object({ - workflowId: z.string().min(1, 'Workflow ID is required').optional(), - identifier: z - .string() - .min(1, 'Identifier is required') - .regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens') - .optional(), - title: z.string().min(1, 'Title is required').optional(), - description: z.string().optional(), - customizations: z - .object({ - primaryColor: z.string(), - welcomeMessage: z.string(), - imageUrl: z.string().optional(), - }) - .optional(), - authType: z.enum(['public', 'password', 'email', 'sso']).optional(), - password: z.string().optional(), - allowedEmails: z.array(z.string()).optional(), - outputConfigs: z - .array( - z.object({ - blockId: z.string(), - path: z.string(), - }) - ) - .optional(), -}) +function getErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback +} /** * GET endpoint to fetch a specific chat deployment by ID */ export const GET = withRouteHandler( async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id } = await params + const { id } = chatIdParamsSchema.parse(await params) const chatId = id try { @@ -82,9 +58,9 @@ export const GET = withRouteHandler( } return createSuccessResponse(result) - } catch (error: any) { + } catch (error) { logger.error('Error fetching chat deployment:', error) - return createErrorResponse(error.message || 'Failed to fetch chat deployment', 500) + return createErrorResponse(getErrorMessage(error, 'Failed to fetch chat deployment'), 500) } } ) @@ -94,7 +70,7 @@ export const GET = withRouteHandler( */ export const PATCH = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id } = await params + const { id } = chatIdParamsSchema.parse(await params) const chatId = id try { @@ -107,7 +83,7 @@ export const PATCH = withRouteHandler( const body = await request.json() try { - const validatedData = chatUpdateSchema.parse(body) + const validatedData = updateChatBodySchema.parse(body) const { hasAccess, @@ -175,7 +151,7 @@ export const PATCH = withRouteHandler( logger.info('Keeping existing password') } - const updateData: any = { + const updateData: Record = { updatedAt: new Date(), } @@ -210,14 +186,19 @@ export const PATCH = withRouteHandler( updateData.outputConfigs = outputConfigs } + const emailCount = Array.isArray(updateData.allowedEmails) + ? updateData.allowedEmails.length + : undefined + const outputConfigsCount = Array.isArray(updateData.outputConfigs) + ? updateData.outputConfigs.length + : undefined + logger.info('Updating chat deployment with values:', { chatId, authType: updateData.authType, hasPassword: updateData.password !== undefined, - emailCount: updateData.allowedEmails?.length, - outputConfigsCount: updateData.outputConfigs - ? updateData.outputConfigs.length - : undefined, + emailCount, + outputConfigsCount, }) await db.update(chat).set(updateData).where(eq(chat.id, chatId)) @@ -255,15 +236,18 @@ export const PATCH = withRouteHandler( message: 'Chat deployment updated successfully', }) } catch (validationError) { - if (validationError instanceof z.ZodError) { - const errorMessage = validationError.errors[0]?.message || 'Invalid request data' - return createErrorResponse(errorMessage, 400, 'VALIDATION_ERROR') + if (isZodError(validationError)) { + return createErrorResponse( + getValidationErrorMessage(validationError), + 400, + 'VALIDATION_ERROR' + ) } throw validationError } - } catch (error: any) { + } catch (error) { logger.error('Error updating chat deployment:', error) - return createErrorResponse(error.message || 'Failed to update chat deployment', 500) + return createErrorResponse(getErrorMessage(error, 'Failed to update chat deployment'), 500) } } ) @@ -273,7 +257,7 @@ export const PATCH = withRouteHandler( */ export const DELETE = withRouteHandler( async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id } = await params + const { id } = chatIdParamsSchema.parse(await params) const chatId = id try { @@ -305,9 +289,9 @@ export const DELETE = withRouteHandler( return createSuccessResponse({ message: 'Chat deployment deleted successfully', }) - } catch (error: any) { + } catch (error) { logger.error('Error deleting chat deployment:', error) - return createErrorResponse(error.message || 'Failed to delete chat deployment', 500) + return createErrorResponse(getErrorMessage(error, 'Failed to delete chat deployment'), 500) } } ) diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index c0171a024b5..ff61436d390 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -3,7 +3,8 @@ import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { z } from 'zod' +import { createChatBodySchema } from '@/lib/api/contracts/chats' +import { getValidationErrorMessage, isZodError } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performChatDeploy } from '@/lib/workflows/orchestration' @@ -12,32 +13,9 @@ import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/ const logger = createLogger('ChatAPI') -const chatSchema = z.object({ - workflowId: z.string().min(1, 'Workflow ID is required'), - identifier: z - .string() - .min(1, 'Identifier is required') - .regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'), - title: z.string().min(1, 'Title is required'), - description: z.string().optional(), - customizations: z.object({ - primaryColor: z.string(), - welcomeMessage: z.string(), - imageUrl: z.string().optional(), - }), - authType: z.enum(['public', 'password', 'email', 'sso']).default('public'), - password: z.string().optional(), - allowedEmails: z.array(z.string()).optional().default([]), - outputConfigs: z - .array( - z.object({ - blockId: z.string(), - path: z.string(), - }) - ) - .optional() - .default([]), -}) +function getErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback +} export const GET = withRouteHandler(async (_request: NextRequest) => { try { @@ -54,9 +32,9 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { .where(and(eq(chat.userId, session.user.id), isNull(chat.archivedAt))) return createSuccessResponse({ deployments }) - } catch (error: any) { + } catch (error) { logger.error('Error fetching chat deployments:', error) - return createErrorResponse(error.message || 'Failed to fetch chat deployments', 500) + return createErrorResponse(getErrorMessage(error, 'Failed to fetch chat deployments'), 500) } }) @@ -71,7 +49,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const body = await request.json() try { - const validatedData = chatSchema.parse(body) + const validatedData = createChatBodySchema.parse(body) // Extract validated data const { @@ -142,18 +120,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return createSuccessResponse({ id: result.chatId, + chatId: result.chatId, chatUrl: result.chatUrl, message: 'Chat deployment created successfully', }) } catch (validationError) { - if (validationError instanceof z.ZodError) { - const errorMessage = validationError.errors[0]?.message || 'Invalid request data' - return createErrorResponse(errorMessage, 400, 'VALIDATION_ERROR') + if (isZodError(validationError)) { + return createErrorResponse( + getValidationErrorMessage(validationError), + 400, + 'VALIDATION_ERROR' + ) } throw validationError } - } catch (error: any) { + } catch (error) { logger.error('Error creating chat deployment:', error) - return createErrorResponse(error.message || 'Failed to create chat deployment', 500) + return createErrorResponse(getErrorMessage(error, 'Failed to create chat deployment'), 500) } }) diff --git a/apps/sim/app/api/chat/validate/route.ts b/apps/sim/app/api/chat/validate/route.ts index 59dd09df902..9dec93f3d0c 100644 --- a/apps/sim/app/api/chat/validate/route.ts +++ b/apps/sim/app/api/chat/validate/route.ts @@ -3,20 +3,13 @@ import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { z } from 'zod' +import { identifierValidationQuerySchema } from '@/lib/api/contracts/chats' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('ChatValidateAPI') -const validateQuerySchema = z.object({ - identifier: z - .string() - .min(1, 'Identifier is required') - .regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens') - .max(100, 'Identifier must be 100 characters or less'), -}) - /** * GET endpoint to validate chat identifier availability */ @@ -25,10 +18,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { searchParams } = new URL(request.url) const identifier = searchParams.get('identifier') - const validation = validateQuerySchema.safeParse({ identifier }) + const validation = validateSchema(identifierValidationQuerySchema, { identifier }) if (!validation.success) { - const errorMessage = validation.error.errors[0]?.message || 'Invalid identifier' + const errorMessage = getValidationErrorMessage(validation.error, 'Invalid identifier') logger.warn(`Validation error: ${errorMessage}`) if (identifier && !/^[a-z0-9-]+$/.test(identifier)) { diff --git a/apps/sim/app/api/contact/route.ts b/apps/sim/app/api/contact/route.ts index 23963a24728..3a5d316ba45 100644 --- a/apps/sim/app/api/contact/route.ts +++ b/apps/sim/app/api/contact/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { renderHelpConfirmationEmail } from '@/components/emails' +import { getValidationErrorMessage } from '@/lib/api/server' import { env } from '@/lib/core/config/env' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' @@ -57,15 +58,16 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - const body = (await req.json()) as Record + const body = await req.json() + const bodyRecord = body && typeof body === 'object' ? (body as Record) : {} - const honeypot = body?.website + const honeypot = bodyRecord.website if (typeof honeypot === 'string' && honeypot.trim().length > 0) { logger.warn(`[${requestId}] Honeypot triggered, discarding`, { ip }) return NextResponse.json(SUCCESS_RESPONSE, { status: 201 }) } - const captchaUnavailable = body?.captchaUnavailable === true + const captchaUnavailable = bodyRecord.captchaUnavailable === true if (captchaUnavailable) { const nocaptchaKey = `public:contact:nocaptcha:${ip}` @@ -83,7 +85,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } if (isTurnstileConfigured() && !captchaUnavailable) { - const token = typeof body?.captchaToken === 'string' ? body.captchaToken : null + const token = typeof bodyRecord.captchaToken === 'string' ? bodyRecord.captchaToken : null const verification = await verifyTurnstileToken({ token, remoteIp: ip, @@ -122,9 +124,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (!validationResult.success) { logger.warn(`[${requestId}] Invalid contact request data`, { - errors: validationResult.error.format(), + issues: validationResult.error.issues, }) - return NextResponse.json({ error: 'Invalid request data' }, { status: 400 }) + return NextResponse.json( + { error: getValidationErrorMessage(validationResult.error, 'Invalid request data') }, + { status: 400 } + ) } const { name, email, company, topic, subject, message } = validationResult.data diff --git a/apps/sim/app/api/copilot/api-keys/generate/route.ts b/apps/sim/app/api/copilot/api-keys/generate/route.ts index 802ad0956c0..5430d554cb2 100644 --- a/apps/sim/app/api/copilot/api-keys/generate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/generate/route.ts @@ -1,5 +1,5 @@ import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { generateCopilotApiKeyBodySchema } from '@/lib/api/contracts' import { getSession } from '@/lib/auth' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -7,10 +7,6 @@ import { fetchGo } from '@/lib/copilot/request/go/fetch' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -const GenerateApiKeySchema = z.object({ - name: z.string().min(1, 'Name is required').max(255, 'Name is too long'), -}) - export const POST = withRouteHandler(async (req: NextRequest) => { try { const session = await getSession() @@ -21,13 +17,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const userId = session.user.id const body = await req.json().catch(() => ({})) - const validationResult = GenerateApiKeySchema.safeParse(body) + const validationResult = generateCopilotApiKeyBodySchema.safeParse(body) if (!validationResult.success) { return NextResponse.json( { error: 'Invalid request body', - details: validationResult.error.errors, + details: validationResult.error.issues, }, { status: 400 } ) diff --git a/apps/sim/app/api/copilot/api-keys/route.test.ts b/apps/sim/app/api/copilot/api-keys/route.test.ts index d23035a0a48..d021c8aec82 100644 --- a/apps/sim/app/api/copilot/api-keys/route.test.ts +++ b/apps/sim/app/api/copilot/api-keys/route.test.ts @@ -14,6 +14,8 @@ const { mockFetch } = vi.hoisted(() => ({ vi.mock('@/lib/copilot/constants', () => ({ SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com', SIM_AGENT_API_URL: 'https://agent.sim.example.com', + COPILOT_MODES: ['ask', 'build', 'plan'] as const, + COPILOT_REQUEST_MODES: ['ask', 'build', 'plan', 'agent'] as const, })) vi.mock('@/lib/core/config/env', () => createEnvMock({ COPILOT_API_KEY: 'test-api-key' })) diff --git a/apps/sim/app/api/copilot/api-keys/route.ts b/apps/sim/app/api/copilot/api-keys/route.ts index 9048e8a2c82..8b2d7bf55bf 100644 --- a/apps/sim/app/api/copilot/api-keys/route.ts +++ b/apps/sim/app/api/copilot/api-keys/route.ts @@ -1,4 +1,5 @@ import { type NextRequest, NextResponse } from 'next/server' +import { deleteCopilotApiKeyQuerySchema } from '@/lib/api/contracts' import { getSession } from '@/lib/auth' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -66,11 +67,13 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { } const userId = session.user.id - const url = new URL(request.url) - const id = url.searchParams.get('id') - if (!id) { + const queryResult = deleteCopilotApiKeyQuerySchema.safeParse( + Object.fromEntries(new URL(request.url).searchParams) + ) + if (!queryResult.success) { return NextResponse.json({ error: 'id is required' }, { status: 400 }) } + const { id } = queryResult.data const res = await fetchGo(`${SIM_AGENT_API_URL}/api/validate-key/delete`, { method: 'POST', diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.ts b/apps/sim/app/api/copilot/api-keys/validate/route.ts index 5ce6aa82430..7a8178955f8 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.ts @@ -3,7 +3,8 @@ import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { validateCopilotApiKeyBodySchema } from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { CopilotValidateOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -14,10 +15,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotApiKeysValidate') -const ValidateApiKeySchema = z.object({ - userId: z.string().min(1, 'userId is required'), -}) - // Incoming-from-Go: extracts traceparent so this handler's work shows // up as a child of the Go-side `sim.validate_api_key` span in the same // trace. If there's no traceparent (manual curl / browser), the helper @@ -43,15 +40,15 @@ export const POST = withRouteHandler((req: NextRequest) => } const body = await req.json().catch(() => null) - const validationResult = ValidateApiKeySchema.safeParse(body) + const validationResult = validateSchema(validateCopilotApiKeyBodySchema, body) if (!validationResult.success) { - logger.warn('Invalid validation request', { errors: validationResult.error.errors }) + logger.warn('Invalid validation request', { errors: validationResult.error.issues }) span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.InvalidBody) span.setAttribute(TraceAttr.HttpStatusCode, 400) return NextResponse.json( { error: 'userId is required', - details: validationResult.error.errors, + details: validationResult.error.issues, }, { status: 400 } ) diff --git a/apps/sim/app/api/copilot/auto-allowed-tools/route.ts b/apps/sim/app/api/copilot/auto-allowed-tools/route.ts index 3e9fb835a6b..ebbbf262a4b 100644 --- a/apps/sim/app/api/copilot/auto-allowed-tools/route.ts +++ b/apps/sim/app/api/copilot/auto-allowed-tools/route.ts @@ -1,5 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { + copilotToolPreferenceBodySchema, + copilotToolPreferenceQuerySchema, +} from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -69,19 +74,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = session.user.id - const body = await request.json() + const bodyResult = validateSchema( + copilotToolPreferenceBodySchema, + await request.json().catch(() => null) + ) - if (!body.toolId || typeof body.toolId !== 'string') { + if (!bodyResult.success) { return NextResponse.json({ error: 'toolId must be a string' }, { status: 400 }) } + const { toolId } = bodyResult.data const res = await fetchGo(`${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed`, { method: 'POST', headers: copilotHeaders(), - body: JSON.stringify({ userId, toolId: body.toolId }), + body: JSON.stringify({ userId, toolId }), spanName: 'sim → go /api/tool-preferences/auto-allowed', operation: 'add_auto_allowed_tool', - attributes: { [TraceAttr.UserId]: userId, [TraceAttr.ToolId]: body.toolId }, + attributes: { [TraceAttr.UserId]: userId, [TraceAttr.ToolId]: toolId }, }) if (!res.ok) { @@ -112,12 +121,15 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { } const userId = session.user.id - const { searchParams } = new URL(request.url) - const toolId = searchParams.get('toolId') + const queryResult = validateSchema( + copilotToolPreferenceQuerySchema, + Object.fromEntries(new URL(request.url).searchParams) + ) - if (!toolId) { + if (!queryResult.success) { return NextResponse.json({ error: 'toolId query parameter is required' }, { status: 400 }) } + const { toolId } = queryResult.data const res = await fetchGo( `${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed?userId=${encodeURIComponent(userId)}&toolId=${encodeURIComponent(toolId)}`, diff --git a/apps/sim/app/api/copilot/chat/abort/route.ts b/apps/sim/app/api/copilot/chat/abort/route.ts index 17a0ec10d2f..c61d2a823e1 100644 --- a/apps/sim/app/api/copilot/chat/abort/route.ts +++ b/apps/sim/app/api/copilot/chat/abort/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { copilotChatAbortBodySchema } from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { CopilotAbortOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' @@ -38,8 +40,16 @@ export const POST = withRouteHandler((request: NextRequest) => }) return {} }) - const streamId = typeof body.streamId === 'string' ? body.streamId : '' - let chatId = typeof body.chatId === 'string' ? body.chatId : '' + const validation = validateSchema(copilotChatAbortBodySchema, body) + if (!validation.success) { + rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.MissingStreamId) + return NextResponse.json( + { error: 'Invalid request body', details: validation.error.issues }, + { status: 400 } + ) + } + const { streamId, chatId: parsedChatId } = validation.data + let chatId = parsedChatId if (!streamId) { rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.MissingStreamId) diff --git a/apps/sim/app/api/copilot/chat/delete/route.test.ts b/apps/sim/app/api/copilot/chat/delete/route.test.ts index 4d1ef809e78..785cc66a63d 100644 --- a/apps/sim/app/api/copilot/chat/delete/route.test.ts +++ b/apps/sim/app/api/copilot/chat/delete/route.test.ts @@ -77,19 +77,19 @@ describe('Copilot Chat Delete API Route', () => { expect(dbChainMockFns.where).toHaveBeenCalled() }) - it('should return 500 for invalid request body - missing chatId', async () => { + it('should return 400 for invalid request body - missing chatId', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const req = createMockRequest('DELETE', {}) const response = await DELETE(req) - expect(response.status).toBe(500) + expect(response.status).toBe(400) const responseData = await response.json() - expect(responseData.error).toBe('Failed to delete chat') + expect(responseData.error).toBe('Validation error') }) - it('should return 500 for invalid request body - chatId is not a string', async () => { + it('should return 400 for invalid request body - chatId is not a string', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const req = createMockRequest('DELETE', { @@ -98,9 +98,9 @@ describe('Copilot Chat Delete API Route', () => { const response = await DELETE(req) - expect(response.status).toBe(500) + expect(response.status).toBe(400) const responseData = await response.json() - expect(responseData.error).toBe('Failed to delete chat') + expect(responseData.error).toBe('Validation error') }) it('should handle database errors gracefully', async () => { diff --git a/apps/sim/app/api/copilot/chat/delete/route.ts b/apps/sim/app/api/copilot/chat/delete/route.ts index 519d038b6a2..40b259ce4c1 100644 --- a/apps/sim/app/api/copilot/chat/delete/route.ts +++ b/apps/sim/app/api/copilot/chat/delete/route.ts @@ -3,7 +3,8 @@ import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { deleteCopilotChatBodySchema } from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' import { taskPubSub } from '@/lib/copilot/tasks' @@ -11,10 +12,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('DeleteChatAPI') -const DeleteChatSchema = z.object({ - chatId: z.string(), -}) - export const DELETE = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -23,7 +20,9 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { } const body = await request.json() - const parsed = DeleteChatSchema.parse(body) + const validation = validateSchema(deleteCopilotChatBodySchema, body) + if (!validation.success) return validation.response + const parsed = validation.data const chat = await getAccessibleCopilotChat(parsed.chatId, session.user.id) if (!chat) { diff --git a/apps/sim/app/api/copilot/chat/rename/route.ts b/apps/sim/app/api/copilot/chat/rename/route.ts index 49d8a616bfe..e559dc4ba0a 100644 --- a/apps/sim/app/api/copilot/chat/rename/route.ts +++ b/apps/sim/app/api/copilot/chat/rename/route.ts @@ -3,7 +3,8 @@ import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { renameCopilotChatBodySchema } from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' import { taskPubSub } from '@/lib/copilot/tasks' @@ -11,11 +12,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('RenameChatAPI') -const RenameChatSchema = z.object({ - chatId: z.string().min(1), - title: z.string().min(1).max(200), -}) - export const PATCH = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -24,7 +20,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest) => { } const body = await request.json() - const { chatId, title } = RenameChatSchema.parse(body) + const validation = validateSchema(renameCopilotChatBodySchema, body, 'Invalid request data') + if (!validation.success) return validation.response + const { chatId, title } = validation.data const chat = await getAccessibleCopilotChat(chatId, session.user.id) if (!chat) { @@ -54,12 +52,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { success: false, error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error('Error renaming chat:', error) return NextResponse.json({ success: false, error: 'Failed to rename chat' }, { status: 500 }) } diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index 6ed05e3f109..728a6d2d9eb 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -3,7 +3,12 @@ import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + addCopilotChatResourceBodySchema, + removeCopilotChatResourceBodySchema, + reorderCopilotChatResourcesBodySchema, +} from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, @@ -26,32 +31,6 @@ const VALID_RESOURCE_TYPES = new Set([ ]) const GENERIC_TITLES = new Set(['Table', 'File', 'Workflow', 'Knowledge Base', 'Folder', 'Log']) -const AddResourceSchema = z.object({ - chatId: z.string(), - resource: z.object({ - type: z.enum(['table', 'file', 'workflow', 'knowledgebase', 'folder', 'log']), - id: z.string(), - title: z.string(), - }), -}) - -const RemoveResourceSchema = z.object({ - chatId: z.string(), - resourceType: z.enum(['table', 'file', 'workflow', 'knowledgebase', 'folder', 'log']), - resourceId: z.string(), -}) - -const ReorderResourcesSchema = z.object({ - chatId: z.string(), - resources: z.array( - z.object({ - type: z.enum(['table', 'file', 'workflow', 'knowledgebase', 'folder', 'log']), - id: z.string(), - title: z.string(), - }) - ), -}) - export const POST = withRouteHandler(async (req: NextRequest) => { try { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() @@ -60,7 +39,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } const body = await req.json() - const { chatId, resource } = AddResourceSchema.parse(body) + const validation = validateSchema(addCopilotChatResourceBodySchema, body) + if (!validation.success) { + return createBadRequestResponse(validation.error.issues.map((e) => e.message).join(', ')) + } + const { chatId, resource } = validation.data // Ephemeral UI tab (client does not POST this; guard for old clients / bugs). if (resource.id === 'streaming-file') { @@ -101,15 +84,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { await db .update(copilotChats) .set({ resources: sql`${JSON.stringify(merged)}::jsonb`, updatedAt: new Date() }) - .where(eq(copilotChats.id, chatId)) + .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) logger.info('Added resource to chat', { chatId, resource }) return NextResponse.json({ success: true, resources: merged }) } catch (error) { - if (error instanceof z.ZodError) { - return createBadRequestResponse(error.errors.map((e) => e.message).join(', ')) - } logger.error('Error adding chat resource:', error) return createInternalServerErrorResponse('Failed to add resource') } @@ -123,7 +103,11 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => { } const body = await req.json() - const { chatId, resources: newOrder } = ReorderResourcesSchema.parse(body) + const validation = validateSchema(reorderCopilotChatResourcesBodySchema, body) + if (!validation.success) { + return createBadRequestResponse(validation.error.issues.map((e) => e.message).join(', ')) + } + const { chatId, resources: newOrder } = validation.data const [chat] = await db .select({ resources: copilotChats.resources }) @@ -146,15 +130,12 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => { await db .update(copilotChats) .set({ resources: sql`${JSON.stringify(newOrder)}::jsonb`, updatedAt: new Date() }) - .where(eq(copilotChats.id, chatId)) + .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) logger.info('Reordered resources for chat', { chatId, count: newOrder.length }) return NextResponse.json({ success: true, resources: newOrder }) } catch (error) { - if (error instanceof z.ZodError) { - return createBadRequestResponse(error.errors.map((e) => e.message).join(', ')) - } logger.error('Error reordering chat resources:', error) return createInternalServerErrorResponse('Failed to reorder resources') } @@ -168,7 +149,11 @@ export const DELETE = withRouteHandler(async (req: NextRequest) => { } const body = await req.json() - const { chatId, resourceType, resourceId } = RemoveResourceSchema.parse(body) + const validation = validateSchema(removeCopilotChatResourceBodySchema, body) + if (!validation.success) { + return createBadRequestResponse(validation.error.issues.map((e) => e.message).join(', ')) + } + const { chatId, resourceType, resourceId } = validation.data const [updated] = await db .update(copilotChats) @@ -193,9 +178,6 @@ export const DELETE = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ success: true, resources: merged }) } catch (error) { - if (error instanceof z.ZodError) { - return createBadRequestResponse(error.errors.map((e) => e.message).join(', ')) - } logger.error('Error removing chat resource:', error) return createInternalServerErrorResponse('Failed to remove resource') } diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index d353e98eacb..c2deedb14ad 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -1,2 +1,18 @@ -export { handleUnifiedChatPost as POST, maxDuration } from '@/lib/copilot/chat/post' -export { GET } from './queries' +import type { NextRequest, NextResponse } from 'next/server' +import { copilotChatGetQuerySchema } from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' +import { handleUnifiedChatPost, maxDuration } from '@/lib/copilot/chat/post' +import { GET as getChat } from '@/app/api/copilot/chat/queries' + +export { maxDuration } + +export const POST = handleUnifiedChatPost + +export function GET(request: NextRequest) { + const queryValidation = validateSchema( + copilotChatGetQuerySchema, + Object.fromEntries(new URL(request.url).searchParams) + ) + if (!queryValidation.success) return queryValidation.response as NextResponse + return getChat(request) +} diff --git a/apps/sim/app/api/copilot/chat/stop/route.ts b/apps/sim/app/api/copilot/chat/stop/route.ts index 5feed89a58e..b549eb69d10 100644 --- a/apps/sim/app/api/copilot/chat/stop/route.ts +++ b/apps/sim/app/api/copilot/chat/stop/route.ts @@ -4,7 +4,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { copilotChatStopBodySchema } from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message' import { CopilotStopOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' @@ -16,56 +17,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotChatStopAPI') -const StoredToolCallSchema = z - .object({ - id: z.string().optional(), - name: z.string().optional(), - state: z.string().optional(), - params: z.record(z.unknown()).optional(), - result: z - .object({ - success: z.boolean(), - output: z.unknown().optional(), - error: z.string().optional(), - }) - .optional(), - display: z - .object({ - text: z.string().optional(), - title: z.string().optional(), - phaseLabel: z.string().optional(), - }) - .optional(), - calledBy: z.string().optional(), - durationMs: z.number().optional(), - error: z.string().optional(), - }) - .nullable() - -const ContentBlockSchema = z.object({ - type: z.string(), - lane: z.enum(['main', 'subagent']).optional(), - content: z.string().optional(), - channel: z.enum(['assistant', 'thinking']).optional(), - phase: z.enum(['call', 'args_delta', 'result']).optional(), - kind: z.enum(['subagent', 'structured_result', 'subagent_result']).optional(), - lifecycle: z.enum(['start', 'end']).optional(), - status: z.enum(['complete', 'error', 'cancelled']).optional(), - toolCall: StoredToolCallSchema.optional(), - timestamp: z.number().optional(), - endedAt: z.number().optional(), -}) - -const StopSchema = z.object({ - chatId: z.string(), - streamId: z.string(), - content: z.string(), - contentBlocks: z.array(ContentBlockSchema).optional(), - // Optional for older clients; when present, flows into msg.requestId - // so the UI's copy-request-ID button survives a stopped turn. - requestId: z.string().optional(), -}) - // POST /api/copilot/chat/stop — persists partial assistant content // when the user stops mid-stream. Lock release is handled by the // aborted server stream unwinding, not this handler. @@ -78,9 +29,12 @@ export const POST = withRouteHandler((req: NextRequest) => return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { chatId, streamId, content, contentBlocks, requestId } = StopSchema.parse( - await req.json() - ) + const validation = validateSchema(copilotChatStopBodySchema, await req.json()) + if (!validation.success) { + span.setAttribute(TraceAttr.CopilotStopOutcome, CopilotStopOutcome.ValidationError) + return validation.response + } + const { chatId, streamId, content, contentBlocks, requestId } = validation.data span.setAttributes({ [TraceAttr.ChatId]: chatId, [TraceAttr.StreamId]: streamId, @@ -166,13 +120,6 @@ export const POST = withRouteHandler((req: NextRequest) => ) return NextResponse.json({ success: true }) } catch (error) { - if (error instanceof z.ZodError) { - span.setAttribute(TraceAttr.CopilotStopOutcome, CopilotStopOutcome.ValidationError) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error('Error stopping chat stream:', error) span.setAttribute(TraceAttr.CopilotStopOutcome, CopilotStopOutcome.InternalError) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index 3d7ab03b438..f0ca5e7a7b0 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -2,6 +2,8 @@ import { context as otelContext, trace } from '@opentelemetry/api' import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' import { type NextRequest, NextResponse } from 'next/server' +import { copilotChatStreamQuerySchema } from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' import { MothershipStreamV1CompletionStatus, @@ -119,10 +121,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const url = new URL(request.url) - const streamId = url.searchParams.get('streamId') || '' - const afterCursor = url.searchParams.get('after') || '' - const batchMode = url.searchParams.get('batch') === 'true' + const queryValidation = validateSchema( + copilotChatStreamQuerySchema, + Object.fromEntries(new URL(request.url).searchParams) + ) + if (!queryValidation.success) return queryValidation.response + const { streamId, after: afterCursor, batch: batchMode } = queryValidation.data if (!streamId) { return NextResponse.json({ error: 'streamId is required' }, { status: 400 }) diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts index a2f45487a61..501d1e54555 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts @@ -98,9 +98,9 @@ describe('Copilot Chat Update Messages API Route', () => { const response = await POST(req) - expect(response.status).toBe(500) + expect(response.status).toBe(400) const responseData = await response.json() - expect(responseData.error).toBe('Failed to update chat messages') + expect(responseData.error).toBe('Validation error') }) it('should return 400 for invalid request body - missing messages', async () => { @@ -112,9 +112,9 @@ describe('Copilot Chat Update Messages API Route', () => { const response = await POST(req) - expect(response.status).toBe(500) + expect(response.status).toBe(400) const responseData = await response.json() - expect(responseData.error).toBe('Failed to update chat messages') + expect(responseData.error).toBe('Validation error') }) it('should return 400 for invalid message structure - missing required fields', async () => { @@ -131,9 +131,9 @@ describe('Copilot Chat Update Messages API Route', () => { const response = await POST(req) - expect(response.status).toBe(500) + expect(response.status).toBe(400) const responseData = await response.json() - expect(responseData.error).toBe('Failed to update chat messages') + expect(responseData.error).toBe('Validation error') }) it('should return 400 for invalid message role', async () => { @@ -153,9 +153,9 @@ describe('Copilot Chat Update Messages API Route', () => { const response = await POST(req) - expect(response.status).toBe(500) + expect(response.status).toBe(400) const responseData = await response.json() - expect(responseData.error).toBe('Failed to update chat messages') + expect(responseData.error).toBe('Validation error') }) it('should return 404 when chat is not found', async () => { diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.ts b/apps/sim/app/api/copilot/chat/update-messages/route.ts index 17dafad187d..208447eea44 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.ts @@ -3,10 +3,10 @@ import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateCopilotMessagesBodySchema } from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message' -import { COPILOT_MODES } from '@/lib/copilot/constants' import { authenticateCopilotRequestSessionOnly, createInternalServerErrorResponse, @@ -18,44 +18,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotChatUpdateAPI') -const UpdateMessagesSchema = z.object({ - chatId: z.string(), - messages: z.array( - z - .object({ - id: z.string(), - role: z.enum(['user', 'assistant', 'system']), - content: z.string(), - timestamp: z.string(), - toolCalls: z.array(z.any()).optional(), - contentBlocks: z.array(z.any()).optional(), - fileAttachments: z - .array( - z.object({ - id: z.string(), - key: z.string(), - filename: z.string(), - media_type: z.string(), - size: z.number(), - }) - ) - .optional(), - contexts: z.array(z.any()).optional(), - citations: z.array(z.any()).optional(), - errorType: z.string().optional(), - }) - .passthrough() // Preserve any additional fields for future compatibility - ), - planArtifact: z.string().nullable().optional(), - config: z - .object({ - mode: z.enum(COPILOT_MODES).optional(), - model: z.string().optional(), - }) - .nullable() - .optional(), -}) - export const POST = withRouteHandler(async (req: NextRequest) => { const tracker = createRequestTracker() @@ -79,7 +41,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) } - const { chatId, messages, planArtifact, config } = UpdateMessagesSchema.parse(body) + const validation = validateSchema(updateCopilotMessagesBodySchema, body) + if (!validation.success) return validation.response + const { chatId, messages, planArtifact, config } = validation.data const normalizedMessages: PersistedMessage[] = messages.map((message) => normalizeMessage(message as Record) ) diff --git a/apps/sim/app/api/copilot/chats/route.ts b/apps/sim/app/api/copilot/chats/route.ts index 07b6974ed45..f0bf4e25940 100644 --- a/apps/sim/app/api/copilot/chats/route.ts +++ b/apps/sim/app/api/copilot/chats/route.ts @@ -4,7 +4,8 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq, isNull, or, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { createWorkflowCopilotChatBodySchema } from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' import { authenticateCopilotRequestSessionOnly, @@ -18,11 +19,6 @@ import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotChatsListAPI') -const CreateWorkflowCopilotChatSchema = z.object({ - workspaceId: z.string().min(1), - workflowId: z.string().min(1), -}) - const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6' export const GET = withRouteHandler(async (_request: NextRequest) => { @@ -97,7 +93,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const body = await request.json() - const { workspaceId, workflowId } = CreateWorkflowCopilotChatSchema.parse(body) + const validation = validateSchema(createWorkflowCopilotChatBodySchema, body) + if (!validation.success) { + return createBadRequestResponse('workspaceId and workflowId are required') + } + const { workspaceId, workflowId } = validation.data await assertActiveWorkspaceAccess(workspaceId, userId) @@ -133,9 +133,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, id: result.chatId }) } catch (error) { - if (error instanceof z.ZodError) { - return createBadRequestResponse('workspaceId and workflowId are required') - } logger.error('Error creating workflow copilot chat:', error) return createInternalServerErrorResponse('Failed to create chat') } diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts index 0730fe748fb..a05d3df1131 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts @@ -137,7 +137,7 @@ describe('Copilot Checkpoints Revert API Route', () => { expect(responseData).toEqual({ error: 'Unauthorized' }) }) - it('should return 500 for invalid request body - missing checkpointId', async () => { + it('should return 400 for invalid request body - missing checkpointId', async () => { setAuthenticated() const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { @@ -148,12 +148,12 @@ describe('Copilot Checkpoints Revert API Route', () => { const response = await POST(req) - expect(response.status).toBe(500) + expect(response.status).toBe(400) const responseData = await response.json() - expect(responseData.error).toBe('Failed to revert to checkpoint') + expect(typeof responseData.error).toBe('string') }) - it('should return 500 for empty checkpointId', async () => { + it('should return 400 for empty checkpointId', async () => { setAuthenticated() const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { @@ -164,9 +164,9 @@ describe('Copilot Checkpoints Revert API Route', () => { const response = await POST(req) - expect(response.status).toBe(500) + expect(response.status).toBe(400) const responseData = await response.json() - expect(responseData.error).toBe('Failed to revert to checkpoint') + expect(typeof responseData.error).toBe('string') }) it('should return 404 when checkpoint is not found', async () => { diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.ts index b5c050d13d7..604edeaec2b 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.ts @@ -4,7 +4,8 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { revertCopilotCheckpointBodySchema } from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' import { authenticateCopilotRequestSessionOnly, @@ -19,10 +20,6 @@ import { isUuidV4 } from '@/executor/constants' const logger = createLogger('CheckpointRevertAPI') -const RevertCheckpointSchema = z.object({ - checkpointId: z.string().min(1), -}) - /** * POST /api/copilot/checkpoints/revert * Revert workflow to a specific checkpoint state @@ -37,7 +34,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const body = await request.json() - const { checkpointId } = RevertCheckpointSchema.parse(body) + const validation = validateSchema(revertCopilotCheckpointBodySchema, body) + if (!validation.success) return validation.response + const { checkpointId } = validation.data logger.info(`[${tracker.requestId}] Reverting to checkpoint ${checkpointId}`) diff --git a/apps/sim/app/api/copilot/checkpoints/route.test.ts b/apps/sim/app/api/copilot/checkpoints/route.test.ts index e3da1f258f4..1001f87701e 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.test.ts @@ -105,7 +105,7 @@ describe('Copilot Checkpoints API Route', () => { expect(responseData).toEqual({ error: 'Unauthorized' }) }) - it('should return 500 for invalid request body', async () => { + it('should return 400 for invalid request body', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const req = createMockRequest('POST', { @@ -114,9 +114,9 @@ describe('Copilot Checkpoints API Route', () => { const response = await POST(req) - expect(response.status).toBe(500) + expect(response.status).toBe(400) const responseData = await response.json() - expect(responseData.error).toBe('Failed to create checkpoint') + expect(typeof responseData.error).toBe('string') }) it('should return 400 when chat not found or unauthorized', async () => { diff --git a/apps/sim/app/api/copilot/checkpoints/route.ts b/apps/sim/app/api/copilot/checkpoints/route.ts index 0e00dbf1c3a..1aae11501ae 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.ts @@ -4,7 +4,11 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + createCopilotCheckpointBodySchema, + listCopilotCheckpointsQuerySchema, +} from '@/lib/api/contracts/copilot' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' import { authenticateCopilotRequestSessionOnly, @@ -17,13 +21,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('WorkflowCheckpointsAPI') -const CreateCheckpointSchema = z.object({ - workflowId: z.string(), - chatId: z.string(), - messageId: z.string().optional(), // ID of the user message that triggered this checkpoint - workflowState: z.string(), // JSON stringified workflow state -}) - /** * POST /api/copilot/checkpoints * Create a new checkpoint with JSON workflow state @@ -38,7 +35,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } const body = await req.json() - const { workflowId, chatId, messageId, workflowState } = CreateCheckpointSchema.parse(body) + const validation = validateSchema(createCopilotCheckpointBodySchema, body) + if (!validation.success) { + return createBadRequestResponse( + getValidationErrorMessage(validation.error, 'Invalid checkpoint payload') + ) + } + const { workflowId, chatId, messageId, workflowState } = validation.data logger.info(`[${tracker.requestId}] Creating workflow checkpoint`, { userId, @@ -133,12 +136,15 @@ export const GET = withRouteHandler(async (req: NextRequest) => { return createUnauthorizedResponse() } - const { searchParams } = new URL(req.url) - const chatId = searchParams.get('chatId') + const queryResult = validateSchema( + listCopilotCheckpointsQuerySchema, + Object.fromEntries(new URL(req.url).searchParams) + ) - if (!chatId) { - return createBadRequestResponse('chatId is required') + if (!queryResult.success) { + return createBadRequestResponse(getValidationErrorMessage(queryResult.error)) } + const { chatId } = queryResult.data logger.info(`[${tracker.requestId}] Fetching workflow checkpoints for chat`, { userId, diff --git a/apps/sim/app/api/copilot/confirm/route.ts b/apps/sim/app/api/copilot/confirm/route.ts index f1fea4c4388..26be417655f 100644 --- a/apps/sim/app/api/copilot/confirm/route.ts +++ b/apps/sim/app/api/copilot/confirm/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { copilotConfirmBodySchema } from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' import { ASYNC_TOOL_CONFIRMATION_STATUS, ASYNC_TOOL_STATUS, @@ -31,22 +32,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotConfirmAPI') -// Schema for confirmation request -const ConfirmationSchema = z.object({ - toolCallId: z.string().min(1, 'Tool call ID is required'), - status: z.enum( - Object.values(ASYNC_TOOL_CONFIRMATION_STATUS) as [ - AsyncConfirmationStatus, - ...AsyncConfirmationStatus[], - ], - { - errorMap: () => ({ message: 'Invalid notification status' }), - } - ), - message: z.string().optional(), - data: z.unknown().optional(), -}) - /** * Persist terminal durable tool status, then publish a wakeup event. * @@ -139,7 +124,14 @@ export const POST = withRouteHandler((req: NextRequest) => { } const body = await req.json() - const { toolCallId, status, message, data } = ConfirmationSchema.parse(body) + const validation = validateSchema(copilotConfirmBodySchema, body) + if (!validation.success) { + span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.ValidationError) + return createBadRequestResponse( + `Invalid request data: ${validation.error.issues.map((e) => e.message).join(', ')}` + ) + } + const { toolCallId, status, message, data } = validation.data span.setAttributes({ [TraceAttr.ToolCallId]: toolCallId, [TraceAttr.ToolConfirmationStatus]: status, @@ -202,17 +194,6 @@ export const POST = withRouteHandler((req: NextRequest) => { } catch (error) { const duration = tracker.getDuration() - if (error instanceof z.ZodError) { - logger.error(`[${tracker.requestId}] Request validation error:`, { - duration, - errors: error.errors, - }) - span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.ValidationError) - return createBadRequestResponse( - `Invalid request data: ${error.errors.map((e) => e.message).join(', ')}` - ) - } - logger.error(`[${tracker.requestId}] Unexpected error:`, { duration, error: error instanceof Error ? error.message : 'Unknown error', diff --git a/apps/sim/app/api/copilot/credentials/route.ts b/apps/sim/app/api/copilot/credentials/route.ts index 4d570157d60..38f0d002920 100644 --- a/apps/sim/app/api/copilot/credentials/route.ts +++ b/apps/sim/app/api/copilot/credentials/route.ts @@ -1,4 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' +import { copilotCredentialsQuerySchema } from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http' import { routeExecution } from '@/lib/copilot/tools/server/router' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -8,7 +10,13 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' * Returns connected OAuth credentials for the authenticated user. * Used by the copilot store for credential masking. */ -export const GET = withRouteHandler(async (_req: NextRequest) => { +export const GET = withRouteHandler(async (req: NextRequest) => { + const queryValidation = validateSchema( + copilotCredentialsQuerySchema, + Object.fromEntries(new URL(req.url).searchParams) + ) + if (!queryValidation.success) return queryValidation.response + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) diff --git a/apps/sim/app/api/copilot/feedback/route.ts b/apps/sim/app/api/copilot/feedback/route.ts index 66e124c2402..8d059d989ad 100644 --- a/apps/sim/app/api/copilot/feedback/route.ts +++ b/apps/sim/app/api/copilot/feedback/route.ts @@ -3,7 +3,8 @@ import { copilotFeedback } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { submitCopilotFeedbackBodySchema } from '@/lib/api/contracts' +import { isZodError } from '@/lib/api/server' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, @@ -16,16 +17,6 @@ import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CopilotFeedbackAPI') -// Schema for feedback submission -const FeedbackSchema = z.object({ - chatId: z.string().uuid('Chat ID must be a valid UUID'), - userQuery: z.string().min(1, 'User query is required'), - agentResponse: z.string().min(1, 'Agent response is required'), - isPositiveFeedback: z.boolean(), - feedback: z.string().optional(), - workflowYaml: z.string().optional(), // Optional workflow YAML when edit/build workflow tools were used -}) - /** * POST /api/copilot/feedback * Submit feedback for a copilot interaction @@ -44,7 +35,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const body = await req.json() const { chatId, userQuery, agentResponse, isPositiveFeedback, feedback, workflowYaml } = - FeedbackSchema.parse(body) + submitCopilotFeedbackBodySchema.parse(body) logger.info(`[${tracker.requestId}] Processing copilot feedback submission`, { userId: authenticatedUserId, @@ -96,13 +87,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } catch (error) { const duration = tracker.getDuration() - if (error instanceof z.ZodError) { + if (isZodError(error)) { logger.error(`[${tracker.requestId}] Validation error:`, { duration, - errors: error.errors, + errors: error.issues, }) return createBadRequestResponse( - `Invalid request data: ${error.errors.map((e) => e.message).join(', ')}` + `Invalid request data: ${error.issues.map((e) => e.message).join(', ')}` ) } diff --git a/apps/sim/app/api/copilot/models/route.ts b/apps/sim/app/api/copilot/models/route.ts index a9f3a36ec6c..90a0a5fe76e 100644 --- a/apps/sim/app/api/copilot/models/route.ts +++ b/apps/sim/app/api/copilot/models/route.ts @@ -1,9 +1,13 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { copilotModelsQuerySchema } from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { fetchGo } from '@/lib/copilot/request/go/fetch' import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http' +import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' interface AvailableModel { id: string @@ -11,9 +15,6 @@ interface AvailableModel { provider: string } -import { env } from '@/lib/core/config/env' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - const logger = createLogger('CopilotModelsAPI') interface RawAvailableModel { @@ -32,7 +33,13 @@ function isRawAvailableModel(item: unknown): item is RawAvailableModel { ) } -export const GET = withRouteHandler(async (_req: NextRequest) => { +export const GET = withRouteHandler(async (req: NextRequest) => { + const queryValidation = validateSchema( + copilotModelsQuerySchema, + Object.fromEntries(new URL(req.url).searchParams) + ) + if (!queryValidation.success) return queryValidation.response + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) diff --git a/apps/sim/app/api/copilot/stats/route.test.ts b/apps/sim/app/api/copilot/stats/route.test.ts index 28ddffda9c7..44251a9ea9f 100644 --- a/apps/sim/app/api/copilot/stats/route.test.ts +++ b/apps/sim/app/api/copilot/stats/route.test.ts @@ -16,6 +16,8 @@ vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) vi.mock('@/lib/copilot/constants', () => ({ SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com', SIM_AGENT_API_URL: 'https://agent.sim.example.com', + COPILOT_MODES: ['ask', 'build', 'plan'] as const, + COPILOT_REQUEST_MODES: ['ask', 'build', 'plan', 'agent'] as const, })) vi.mock('@/lib/core/config/env', () => createEnvMock({ COPILOT_API_KEY: 'test-api-key' })) diff --git a/apps/sim/app/api/copilot/stats/route.ts b/apps/sim/app/api/copilot/stats/route.ts index a42e318d42a..a1a2e26c234 100644 --- a/apps/sim/app/api/copilot/stats/route.ts +++ b/apps/sim/app/api/copilot/stats/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { copilotStatsBodySchema } from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { fetchGo } from '@/lib/copilot/request/go/fetch' import { @@ -12,12 +13,6 @@ import { import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -const BodySchema = z.object({ - messageId: z.string(), - diffCreated: z.boolean(), - diffAccepted: z.boolean(), -}) - export const POST = withRouteHandler(async (req: NextRequest) => { const tracker = createRequestTracker() try { @@ -27,12 +22,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } const json = await req.json().catch(() => ({})) - const parsed = BodySchema.safeParse(json) + const parsed = validateSchema(copilotStatsBodySchema, json) if (!parsed.success) { return createBadRequestResponse('Invalid request body for copilot stats') } - const { messageId, diffCreated, diffAccepted } = parsed.data as any + const { messageId, diffCreated, diffAccepted } = parsed.data // Build outgoing payload for Sim Agent with only required fields const payload: Record = { diff --git a/apps/sim/app/api/copilot/training/examples/route.ts b/apps/sim/app/api/copilot/training/examples/route.ts index 7f68cf812c4..01f014dd857 100644 --- a/apps/sim/app/api/copilot/training/examples/route.ts +++ b/apps/sim/app/api/copilot/training/examples/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { copilotTrainingExampleBodySchema } from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/copilot/request/http' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,13 +11,6 @@ const logger = createLogger('CopilotTrainingExamplesAPI') export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -const TrainingExampleSchema = z.object({ - json: z.string().min(1, 'JSON string is required'), - title: z.string().min(1, 'Title is required'), - tags: z.array(z.string()).optional(), - metadata: z.record(z.unknown()).optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = checkInternalApiKey(request) if (!auth.success) { @@ -38,17 +32,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const body = await request.json() - const validationResult = TrainingExampleSchema.safeParse(body) + const validationResult = validateSchema( + copilotTrainingExampleBodySchema, + body, + 'Invalid training example format' + ) if (!validationResult.success) { - logger.warn('Invalid training example format', { errors: validationResult.error.errors }) - return NextResponse.json( - { - error: 'Invalid training example format', - details: validationResult.error.errors, - }, - { status: 400 } - ) + logger.warn('Invalid training example format', { errors: validationResult.error.issues }) + return validationResult.response } const validatedData = validationResult.data diff --git a/apps/sim/app/api/copilot/training/route.ts b/apps/sim/app/api/copilot/training/route.ts index 637928b23a9..44d9b13cba9 100644 --- a/apps/sim/app/api/copilot/training/route.ts +++ b/apps/sim/app/api/copilot/training/route.ts @@ -1,28 +1,13 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { copilotTrainingDataBodySchema } from '@/lib/api/contracts/copilot' +import { validateSchema } from '@/lib/api/server' import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/copilot/request/http' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotTrainingAPI') -const WorkflowStateSchema = z.record(z.unknown()) - -const OperationSchema = z.object({ - operation_type: z.string(), - block_id: z.string(), - params: z.record(z.unknown()).optional(), -}) - -const TrainingDataSchema = z.object({ - title: z.string().min(1, 'Title is required'), - prompt: z.string().min(1, 'Prompt is required'), - input: WorkflowStateSchema, - output: WorkflowStateSchema, - operations: z.array(OperationSchema), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = checkInternalApiKey(request) if (!auth.success) { @@ -46,17 +31,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const body = await request.json() - const validationResult = TrainingDataSchema.safeParse(body) + const validationResult = validateSchema( + copilotTrainingDataBodySchema, + body, + 'Invalid training data format' + ) if (!validationResult.success) { - logger.warn('Invalid training data format', { errors: validationResult.error.errors }) - return NextResponse.json( - { - error: 'Invalid training data format', - details: validationResult.error.errors, - }, - { status: 400 } - ) + logger.warn('Invalid training data format', { errors: validationResult.error.issues }) + return validationResult.response } const { title, prompt, input, output, operations } = validationResult.data diff --git a/apps/sim/app/api/creators/[id]/route.ts b/apps/sim/app/api/creators/[id]/route.ts index d1e6508caf6..9b6867cb55c 100644 --- a/apps/sim/app/api/creators/[id]/route.ts +++ b/apps/sim/app/api/creators/[id]/route.ts @@ -3,30 +3,26 @@ import { member, templateCreators } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + creatorProfileParamsSchema, + updateCreatorProfileBodySchema, +} from '@/lib/api/contracts/creator-profile' +import { isZodError, validationErrorResponse } 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' const logger = createLogger('CreatorProfileByIdAPI') -const CreatorProfileDetailsSchema = z.object({ - about: z.string().max(2000, 'Max 2000 characters').optional(), - xUrl: z.string().url().optional().or(z.literal('')), - linkedinUrl: z.string().url().optional().or(z.literal('')), - websiteUrl: z.string().url().optional().or(z.literal('')), - contactEmail: z.string().email().optional().or(z.literal('')), -}) - -const UpdateCreatorProfileSchema = z.object({ - name: z.string().min(1, 'Name is required').max(100, 'Max 100 characters').optional(), - profileImageUrl: z.string().optional().or(z.literal('')), - details: CreatorProfileDetailsSchema.optional(), - verified: z.boolean().optional(), // Verification status (super users only) -}) +type CreatorProfileRow = typeof templateCreators.$inferSelect +type CreatorProfileUpdate = Partial< + Pick +> & { + updatedAt: Date +} // Helper to check if user has permission to manage profile -async function hasPermission(userId: string, profile: any): Promise { +async function hasPermission(userId: string, profile: CreatorProfileRow): Promise { if (profile.referenceType === 'user') { return profile.referenceId === userId } @@ -49,9 +45,13 @@ async function hasPermission(userId: string, profile: any): Promise { // GET /api/creators/[id] - Get a specific creator profile export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const paramsResult = creatorProfileParamsSchema.safeParse(await params) + if (!paramsResult.success) { + return NextResponse.json({ error: 'Invalid route parameters' }, { status: 400 }) + } + const { id } = paramsResult.data try { const profile = await db @@ -67,7 +67,7 @@ export const GET = withRouteHandler( logger.info(`[${requestId}] Retrieved creator profile: ${id}`) return NextResponse.json({ data: profile[0] }) - } catch (error: any) { + } catch (error) { logger.error(`[${requestId}] Error fetching creator profile: ${id}`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } @@ -78,7 +78,11 @@ export const GET = withRouteHandler( export const PUT = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const paramsResult = creatorProfileParamsSchema.safeParse(await params) + if (!paramsResult.success) { + return NextResponse.json({ error: 'Invalid route parameters' }, { status: 400 }) + } + const { id } = paramsResult.data try { const session = await getSession() @@ -88,7 +92,7 @@ export const PUT = withRouteHandler( } const body = await request.json() - const data = UpdateCreatorProfileSchema.parse(body) + const data = updateCreatorProfileBodySchema.parse(body) // Check if profile exists const existing = await db @@ -97,7 +101,8 @@ export const PUT = withRouteHandler( .where(eq(templateCreators.id, id)) .limit(1) - if (existing.length === 0) { + const existingProfile = existing[0] + if (!existingProfile) { logger.warn(`[${requestId}] Profile not found for update: ${id}`) return NextResponse.json({ error: 'Profile not found' }, { status: 404 }) } @@ -122,14 +127,14 @@ export const PUT = withRouteHandler( data.name !== undefined || data.profileImageUrl !== undefined || data.details !== undefined if (hasNonVerifiedUpdates) { - const canEdit = await hasPermission(session.user.id, existing[0]) + const canEdit = await hasPermission(session.user.id, existingProfile) if (!canEdit) { logger.warn(`[${requestId}] User denied permission to update profile: ${id}`) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } } - const updateData: any = { + const updateData: CreatorProfileUpdate = { updatedAt: new Date(), } @@ -147,15 +152,12 @@ export const PUT = withRouteHandler( logger.info(`[${requestId}] Successfully updated creator profile: ${id}`) return NextResponse.json({ data: updated[0] }) - } catch (error: any) { - if (error instanceof z.ZodError) { + } catch (error) { + if (isZodError(error)) { logger.warn(`[${requestId}] Invalid update data for profile: ${id}`, { - errors: error.errors, + errors: error.issues, }) - return NextResponse.json( - { error: 'Invalid update data', details: error.errors }, - { status: 400 } - ) + return validationErrorResponse(error, 'Invalid update data') } logger.error(`[${requestId}] Error updating creator profile: ${id}`, error) @@ -166,9 +168,13 @@ export const PUT = withRouteHandler( // DELETE /api/creators/[id] - Delete a creator profile 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 + const paramsResult = creatorProfileParamsSchema.safeParse(await params) + if (!paramsResult.success) { + return NextResponse.json({ error: 'Invalid route parameters' }, { status: 400 }) + } + const { id } = paramsResult.data try { const session = await getSession() @@ -184,13 +190,14 @@ export const DELETE = withRouteHandler( .where(eq(templateCreators.id, id)) .limit(1) - if (existing.length === 0) { + const existingProfile = existing[0] + if (!existingProfile) { logger.warn(`[${requestId}] Profile not found for delete: ${id}`) return NextResponse.json({ error: 'Profile not found' }, { status: 404 }) } // Check permissions - const canDelete = await hasPermission(session.user.id, existing[0]) + const canDelete = await hasPermission(session.user.id, existingProfile) if (!canDelete) { logger.warn(`[${requestId}] User denied permission to delete profile: ${id}`) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) @@ -200,7 +207,7 @@ export const DELETE = withRouteHandler( logger.info(`[${requestId}] Successfully deleted creator profile: ${id}`) return NextResponse.json({ success: true }) - } catch (error: any) { + } catch (error) { logger.error(`[${requestId}] Error deleting creator profile: ${id}`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/creators/route.ts b/apps/sim/app/api/creators/route.ts index ecb5b8adf18..ccab4ae4344 100644 --- a/apps/sim/app/api/creators/route.ts +++ b/apps/sim/app/api/creators/route.ts @@ -4,35 +4,27 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + type CreatorProfileDetails, + createCreatorProfileBodySchema, + listCreatorProfilesQuerySchema, +} from '@/lib/api/contracts/creator-profile' +import { isZodError, validationErrorResponse } 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 type { CreatorProfileDetails } from '@/app/_types/creator-profile' const logger = createLogger('CreatorProfilesAPI') -const CreatorProfileDetailsSchema = z.object({ - about: z.string().max(2000, 'Max 2000 characters').optional(), - xUrl: z.string().url().optional().or(z.literal('')), - linkedinUrl: z.string().url().optional().or(z.literal('')), - websiteUrl: z.string().url().optional().or(z.literal('')), - contactEmail: z.string().email().optional().or(z.literal('')), -}) - -const CreateCreatorProfileSchema = z.object({ - referenceType: z.enum(['user', 'organization']), - referenceId: z.string().min(1, 'Reference ID is required'), - name: z.string().min(1, 'Name is required').max(100, 'Max 100 characters'), - profileImageUrl: z.string().min(1, 'Profile image is required'), - details: CreatorProfileDetailsSchema.optional(), -}) - // GET /api/creators - Get creator profiles for current user export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() - const { searchParams } = new URL(request.url) - const userId = searchParams.get('userId') + const queryResult = listCreatorProfilesQuerySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!queryResult.success) { + return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 }) + } try { const session = await getSession() @@ -41,6 +33,27 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const requestedUserId = queryResult.data.userId + if (requestedUserId && requestedUserId !== session.user.id) { + return NextResponse.json({ profiles: [] }) + } + + if (requestedUserId) { + const profiles = await db + .select() + .from(templateCreators) + .where( + and( + eq(templateCreators.referenceType, 'user'), + eq(templateCreators.referenceId, requestedUserId) + ) + ) + + logger.info(`[${requestId}] Retrieved ${profiles.length} creator profiles`) + + return NextResponse.json({ profiles }) + } + // Get user's organizations where they're admin or owner const userOrgs = await db .select({ organizationId: member.organizationId }) @@ -76,7 +89,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Retrieved ${profiles.length} creator profiles`) return NextResponse.json({ profiles }) - } catch (error: any) { + } catch (error) { logger.error(`[${requestId}] Error fetching creator profiles`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } @@ -94,7 +107,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const body = await request.json() - const data = CreateCreatorProfileSchema.parse(body) + const data = createCreatorProfileBodySchema.parse(body) // Validate permissions if (data.referenceType === 'user') { @@ -165,6 +178,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { name: data.name, profileImageUrl: data.profileImageUrl || null, details: Object.keys(details).length > 0 ? details : null, + verified: false, createdBy: session.user.id, createdAt: now, updatedAt: now, @@ -175,13 +189,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Successfully created creator profile: ${profileId}`) return NextResponse.json({ data: newProfile }, { status: 201 }) - } catch (error: any) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid profile data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid profile data', details: error.errors }, - { status: 400 } - ) + } catch (error) { + if (isZodError(error)) { + logger.warn(`[${requestId}] Invalid profile data`, { errors: error.issues }) + return validationErrorResponse(error, 'Invalid profile data') } logger.error(`[${requestId}] Error creating creator profile`, error) diff --git a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts index 287a78b0d23..cdd1fc4b9f5 100644 --- a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts @@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails' +import { credentialSetInvitationParamsSchema } from '@/lib/api/contracts/credential-sets' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -58,7 +59,7 @@ export const POST = withRouteHandler( ) } - const { id, invitationId } = await params + const { id, invitationId } = credentialSetInvitationParamsSchema.parse(await params) try { const result = await getCredentialSetWithAccess(id, session.user.id) diff --git a/apps/sim/app/api/credential-sets/[id]/invite/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/route.ts index d0f4c0ca00c..e808a91cae8 100644 --- a/apps/sim/app/api/credential-sets/[id]/invite/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/invite/route.ts @@ -5,8 +5,12 @@ 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 { z } from 'zod' import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails' +import { + cancelCredentialSetInvitationQuerySchema, + createCredentialSetInvitationBodySchema, +} from '@/lib/api/contracts/credential-sets' +import { getValidationErrorMessage, isZodError } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -15,10 +19,6 @@ import { sendEmail } from '@/lib/messaging/email/mailer' const logger = createLogger('CredentialSetInvite') -const createInviteSchema = z.object({ - email: z.string().email().optional(), -}) - async function getCredentialSetWithAccess(credentialSetId: string, userId: string) { const [set] = await db .select({ @@ -108,7 +108,7 @@ export const POST = withRouteHandler( } const body = await req.json() - const { email } = createInviteSchema.parse(body) + const { email } = createCredentialSetInvitationBodySchema.parse(body) const token = generateId() const expiresAt = new Date() @@ -207,8 +207,8 @@ export const POST = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + if (isZodError(error)) { + return NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }) } logger.error('Error creating invitation', error) return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 }) @@ -235,12 +235,19 @@ export const DELETE = withRouteHandler( const { id } = await params const { searchParams } = new URL(req.url) - const invitationId = searchParams.get('invitationId') + const validation = cancelCredentialSetInvitationQuerySchema.safeParse({ + invitationId: searchParams.get('invitationId') ?? '', + }) - if (!invitationId) { - return NextResponse.json({ error: 'invitationId is required' }, { status: 400 }) + if (!validation.success) { + return NextResponse.json( + { error: getValidationErrorMessage(validation.error) }, + { status: 400 } + ) } + const { invitationId } = validation.data + try { const result = await getCredentialSetWithAccess(id, session.user.id) diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts index 64d72281259..9b94debf958 100644 --- a/apps/sim/app/api/credential-sets/[id]/members/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/members/route.ts @@ -5,6 +5,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { removeCredentialSetMemberQuerySchema } from '@/lib/api/contracts/credential-sets' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -138,12 +140,19 @@ export const DELETE = withRouteHandler( const { id } = await params const { searchParams } = new URL(req.url) - const memberId = searchParams.get('memberId') + const validation = removeCredentialSetMemberQuerySchema.safeParse({ + memberId: searchParams.get('memberId') ?? '', + }) - if (!memberId) { - return NextResponse.json({ error: 'memberId is required' }, { status: 400 }) + if (!validation.success) { + return NextResponse.json( + { error: getValidationErrorMessage(validation.error) }, + { status: 400 } + ) } + const { memberId } = validation.data + try { const result = await getCredentialSetWithAccess(id, session.user.id) diff --git a/apps/sim/app/api/credential-sets/[id]/route.ts b/apps/sim/app/api/credential-sets/[id]/route.ts index 8a7fcb51464..78b0aad2645 100644 --- a/apps/sim/app/api/credential-sets/[id]/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/route.ts @@ -1,21 +1,17 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { credentialSet, credentialSetMember, member } from '@sim/db/schema' +import { credentialSet, credentialSetMember, member, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, count, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateCredentialSetBodySchema } from '@/lib/api/contracts/credential-sets' +import { validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CredentialSet') -const updateCredentialSetSchema = z.object({ - name: z.string().trim().min(1).max(100).optional(), - description: z.string().max(500).nullable().optional(), -}) - async function getCredentialSetWithAccess(credentialSetId: string, userId: string) { const [set] = await db .select({ @@ -27,8 +23,11 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin createdBy: credentialSet.createdBy, createdAt: credentialSet.createdAt, updatedAt: credentialSet.updatedAt, + creatorName: user.name, + creatorEmail: user.email, }) .from(credentialSet) + .leftJoin(user, eq(credentialSet.createdBy, user.id)) .where(eq(credentialSet.id, credentialSetId)) .limit(1) @@ -42,7 +41,23 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin if (!membership) return null - return { set, role: membership.role } + const [memberCount] = await db + .select({ count: count() }) + .from(credentialSetMember) + .where( + and( + eq(credentialSetMember.credentialSetId, credentialSetId), + eq(credentialSetMember.status, 'active') + ) + ) + + return { + set: { + ...set, + memberCount: memberCount?.count ?? 0, + }, + role: membership.role, + } } export const GET = withRouteHandler( @@ -104,7 +119,9 @@ export const PUT = withRouteHandler( } const body = await req.json() - const updates = updateCredentialSetSchema.parse(body) + const validation = validateSchema(updateCredentialSetBodySchema, body) + if (!validation.success) return validation.response + const updates = validation.data if (updates.name) { const existingSet = await db @@ -162,9 +179,6 @@ export const PUT = withRouteHandler( return NextResponse.json({ credentialSet: updated }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) - } logger.error('Error updating credential set', error) return NextResponse.json({ error: 'Failed to update credential set' }, { status: 500 }) } diff --git a/apps/sim/app/api/credential-sets/invite/[token]/route.ts b/apps/sim/app/api/credential-sets/invite/[token]/route.ts index bf27de9db58..155d6e73f31 100644 --- a/apps/sim/app/api/credential-sets/invite/[token]/route.ts +++ b/apps/sim/app/api/credential-sets/invite/[token]/route.ts @@ -10,6 +10,7 @@ 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 { credentialSetInviteTokenParamsSchema } from '@/lib/api/contracts/credential-sets' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { normalizeEmail } from '@/lib/invitations/core' @@ -19,7 +20,7 @@ const logger = createLogger('CredentialSetInviteToken') export const GET = withRouteHandler( async (req: NextRequest, { params }: { params: Promise<{ token: string }> }) => { - const { token } = await params + const { token } = credentialSetInviteTokenParamsSchema.parse(await params) const [invitation] = await db .select({ @@ -69,7 +70,7 @@ export const GET = withRouteHandler( export const POST = withRouteHandler( async (req: NextRequest, { params }: { params: Promise<{ token: string }> }) => { - const { token } = await params + const { token } = credentialSetInviteTokenParamsSchema.parse(await params) const session = await getSession() if (!session?.user?.id) { diff --git a/apps/sim/app/api/credential-sets/memberships/route.ts b/apps/sim/app/api/credential-sets/memberships/route.ts index 1e3846bd0d7..c268e297f49 100644 --- a/apps/sim/app/api/credential-sets/memberships/route.ts +++ b/apps/sim/app/api/credential-sets/memberships/route.ts @@ -5,6 +5,8 @@ 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 { leaveCredentialSetQuerySchema } from '@/lib/api/contracts/credential-sets' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' @@ -55,12 +57,19 @@ export const DELETE = withRouteHandler(async (req: NextRequest) => { } const { searchParams } = new URL(req.url) - const credentialSetId = searchParams.get('credentialSetId') - - if (!credentialSetId) { - return NextResponse.json({ error: 'credentialSetId is required' }, { status: 400 }) + const validation = leaveCredentialSetQuerySchema.safeParse({ + credentialSetId: searchParams.get('credentialSetId') ?? '', + }) + + if (!validation.success) { + return NextResponse.json( + { error: getValidationErrorMessage(validation.error) }, + { status: 400 } + ) } + const { credentialSetId } = validation.data + try { const requestId = generateId().slice(0, 8) diff --git a/apps/sim/app/api/credential-sets/route.ts b/apps/sim/app/api/credential-sets/route.ts index cc5ba887999..6126930ed47 100644 --- a/apps/sim/app/api/credential-sets/route.ts +++ b/apps/sim/app/api/credential-sets/route.ts @@ -5,20 +5,17 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, count, desc, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' -import { z } from 'zod' +import { + createCredentialSetBodySchema, + listCredentialSetsQuerySchema, +} from '@/lib/api/contracts/credential-sets' +import { getValidationErrorMessage, isZodError } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CredentialSets') -const createCredentialSetSchema = z.object({ - organizationId: z.string().min(1), - name: z.string().trim().min(1).max(100), - description: z.string().max(500).optional(), - providerId: z.enum(['google-email', 'outlook']), -}) - export const GET = withRouteHandler(async (req: Request) => { const session = await getSession() @@ -36,12 +33,19 @@ export const GET = withRouteHandler(async (req: Request) => { } const { searchParams } = new URL(req.url) - const organizationId = searchParams.get('organizationId') + const validation = listCredentialSetsQuerySchema.safeParse({ + organizationId: searchParams.get('organizationId') ?? '', + }) - if (!organizationId) { - return NextResponse.json({ error: 'organizationId is required' }, { status: 400 }) + if (!validation.success) { + return NextResponse.json( + { error: getValidationErrorMessage(validation.error) }, + { status: 400 } + ) } + const { organizationId } = validation.data + const membership = await db .select({ id: member.id, role: member.role }) .from(member) @@ -109,7 +113,8 @@ export const POST = withRouteHandler(async (req: Request) => { try { const body = await req.json() - const { organizationId, name, description, providerId } = createCredentialSetSchema.parse(body) + const { organizationId, name, description, providerId } = + createCredentialSetBodySchema.parse(body) const membership = await db .select({ id: member.id, role: member.role }) @@ -184,10 +189,20 @@ export const POST = withRouteHandler(async (req: Request) => { request: req, }) - return NextResponse.json({ credentialSet: newCredentialSet }, { status: 201 }) + return NextResponse.json( + { + credentialSet: { + ...newCredentialSet, + creatorName: session.user.name ?? null, + creatorEmail: session.user.email ?? null, + memberCount: 0, + }, + }, + { status: 201 } + ) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + if (isZodError(error)) { + return NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }) } logger.error('Error creating credential set', error) return NextResponse.json({ error: 'Failed to create credential set' }, { status: 500 }) diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts index 2a9970e1bfa..a2c96ef0a7c 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -4,7 +4,8 @@ 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 { z } from 'zod' +import { addCredentialMemberBodySchema } from '@/lib/api/contracts/credentials' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -90,11 +91,6 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route } }) -const addMemberSchema = z.object({ - userId: z.string().min(1), - role: z.enum(['admin', 'member']).default('member'), -}) - export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { try { const session = await getSession() @@ -109,10 +105,16 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) } - const body = await request.json() - const parsed = addMemberSchema.safeParse(body) + const parsed = await validateJsonBody(request, addCredentialMemberBodySchema) if (!parsed.success) { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + return NextResponse.json( + { + error: parsed.error + ? getValidationErrorMessage(parsed.error, 'Invalid request body') + : 'Invalid request body', + }, + { status: 400 } + ) } const { userId, role } = parsed.data diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index a85a32a72c4..d1a4a63e3e4 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -5,7 +5,8 @@ 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 { z } from 'zod' +import { updateCredentialByIdBodySchema } from '@/lib/api/contracts/credentials' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,24 +19,6 @@ import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialByIdAPI') -const updateCredentialSchema = z - .object({ - displayName: z.string().trim().min(1).max(255).optional(), - description: z.string().trim().max(500).nullish(), - serviceAccountJson: z.string().min(1).optional(), - }) - .strict() - .refine( - (data) => - data.displayName !== undefined || - data.description !== undefined || - data.serviceAccountJson !== undefined, - { - message: 'At least one field must be provided', - path: ['displayName'], - } - ) - async function getCredentialResponse(credentialId: string, userId: string) { const [row] = await db .select({ @@ -102,9 +85,12 @@ export const PUT = withRouteHandler( const { id } = await params try { - const parseResult = updateCredentialSchema.safeParse(await request.json()) + const parseResult = updateCredentialByIdBodySchema.safeParse(await request.json()) if (!parseResult.success) { - return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 }) + return NextResponse.json( + { error: getValidationErrorMessage(parseResult.error) }, + { status: 400 } + ) } const access = await getCredentialActorContext(id, session.user.id) diff --git a/apps/sim/app/api/credentials/draft/route.ts b/apps/sim/app/api/credentials/draft/route.ts index 8fb66fea56f..e3705db4013 100644 --- a/apps/sim/app/api/credentials/draft/route.ts +++ b/apps/sim/app/api/credentials/draft/route.ts @@ -4,7 +4,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, lt } from 'drizzle-orm' import { NextResponse } from 'next/server' -import { z } from 'zod' +import { createCredentialDraftBodySchema } from '@/lib/api/contracts/credentials' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -13,14 +14,6 @@ const logger = createLogger('CredentialDraftAPI') const DRAFT_TTL_MS = 15 * 60 * 1000 -const createDraftSchema = z.object({ - workspaceId: z.string().min(1), - providerId: z.string().min(1), - displayName: z.string().min(1), - description: z.string().trim().max(500).optional(), - credentialId: z.string().min(1).optional(), -}) - export const POST = withRouteHandler(async (request: Request) => { try { const session = await getSession() @@ -28,10 +21,16 @@ export const POST = withRouteHandler(async (request: Request) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const parsed = createDraftSchema.safeParse(body) + const parsed = await validateJsonBody(request, createCredentialDraftBodySchema) if (!parsed.success) { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + return NextResponse.json( + { + error: parsed.error + ? getValidationErrorMessage(parsed.error, 'Invalid request body') + : 'Invalid request body', + }, + { status: 400 } + ) } const { workspaceId, providerId, displayName, description, credentialId } = parsed.data diff --git a/apps/sim/app/api/credentials/memberships/route.ts b/apps/sim/app/api/credentials/memberships/route.ts index 39666550080..7e855d2caca 100644 --- a/apps/sim/app/api/credentials/memberships/route.ts +++ b/apps/sim/app/api/credentials/memberships/route.ts @@ -3,16 +3,13 @@ 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 { z } from 'zod' +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') -const leaveCredentialSchema = z.object({ - credentialId: z.string().min(1), -}) - export const GET = withRouteHandler(async () => { const session = await getSession() if (!session?.user?.id) { @@ -50,11 +47,14 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { } try { - const parseResult = leaveCredentialSchema.safeParse({ + const parseResult = leaveCredentialQuerySchema.safeParse({ credentialId: new URL(request.url).searchParams.get('credentialId'), }) if (!parseResult.success) { - return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 }) + return NextResponse.json( + { error: getValidationErrorMessage(parseResult.error) }, + { status: 400 } + ) } const { credentialId } = parseResult.data diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index ebb429b438d..79fdc460efa 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -5,7 +5,13 @@ 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 { z } from 'zod' +import { + createCredentialBodySchema, + credentialsListGetQuerySchema, + normalizeCredentialEnvKey, + serviceAccountJsonSchema, +} from '@/lib/api/contracts/credentials' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' @@ -15,143 +21,9 @@ import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { getServiceConfigByProviderId } from '@/lib/oauth' import { captureServerEvent } from '@/lib/posthog/server' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { isValidEnvVarName } from '@/executor/constants' const logger = createLogger('CredentialsAPI') -const credentialTypeSchema = z.enum(['oauth', 'env_workspace', 'env_personal', 'service_account']) - -function normalizeEnvKeyInput(raw: string): string { - const trimmed = raw.trim() - const wrappedMatch = /^\{\{\s*([A-Za-z0-9_]+)\s*\}\}$/.exec(trimmed) - return wrappedMatch ? wrappedMatch[1] : trimmed -} - -const listCredentialsSchema = z.object({ - workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), - type: credentialTypeSchema.optional(), - providerId: z.string().optional(), - credentialId: z.string().optional(), -}) - -const serviceAccountJsonSchema = z - .string() - .min(1, 'Service account JSON key is required') - .transform((val, ctx) => { - try { - const parsed = JSON.parse(val) - if (parsed.type !== 'service_account') { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'JSON key must have type "service_account"', - }) - return z.NEVER - } - if (!parsed.client_email || typeof parsed.client_email !== 'string') { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'JSON key must contain a valid client_email', - }) - return z.NEVER - } - if (!parsed.private_key || typeof parsed.private_key !== 'string') { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'JSON key must contain a valid private_key', - }) - return z.NEVER - } - if (!parsed.project_id || typeof parsed.project_id !== 'string') { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'JSON key must contain a valid project_id', - }) - return z.NEVER - } - return parsed as { - type: 'service_account' - client_email: string - private_key: string - project_id: string - [key: string]: unknown - } - } catch { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Invalid JSON format', - }) - return z.NEVER - } - }) - -const createCredentialSchema = z - .object({ - workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), - type: credentialTypeSchema, - displayName: z.string().trim().min(1).max(255).optional(), - description: z.string().trim().max(500).optional(), - providerId: z.string().trim().min(1).optional(), - accountId: z.string().trim().min(1).optional(), - envKey: z.string().trim().min(1).optional(), - envOwnerUserId: z.string().trim().min(1).optional(), - serviceAccountJson: z.string().optional(), - }) - .superRefine((data, ctx) => { - if (data.type === 'oauth') { - if (!data.accountId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'accountId is required for oauth credentials', - path: ['accountId'], - }) - } - if (!data.providerId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'providerId is required for oauth credentials', - path: ['providerId'], - }) - } - if (!data.displayName) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'displayName is required for oauth credentials', - path: ['displayName'], - }) - } - return - } - - if (data.type === 'service_account') { - if (!data.serviceAccountJson) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'serviceAccountJson is required for service account credentials', - path: ['serviceAccountJson'], - }) - } - return - } - - const normalizedEnvKey = data.envKey ? normalizeEnvKeyInput(data.envKey) : '' - if (!normalizedEnvKey) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'envKey is required for env credentials', - path: ['envKey'], - }) - return - } - - if (!isValidEnvVarName(normalizedEnvKey)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'envKey must contain only letters, numbers, and underscores', - path: ['envKey'], - }) - } - }) - interface ExistingCredentialSourceParams { workspaceId: string type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' @@ -244,7 +116,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const rawType = searchParams.get('type') const rawProviderId = searchParams.get('providerId') const rawCredentialId = searchParams.get('credentialId') - const parseResult = listCredentialsSchema.safeParse({ + const parseResult = credentialsListGetQuerySchema.safeParse({ workspaceId: rawWorkspaceId?.trim(), type: rawType?.trim() || undefined, providerId: rawProviderId?.trim() || undefined, @@ -256,9 +128,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { workspaceId: rawWorkspaceId, type: rawType, providerId: rawProviderId, - errors: parseResult.error.errors, + errors: parseResult.error.issues, }) - return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 }) + return NextResponse.json( + { error: getValidationErrorMessage(parseResult.error) }, + { status: 400 } + ) } const { workspaceId, type, providerId, credentialId: lookupCredentialId } = parseResult.data @@ -358,10 +233,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const body = await request.json() - const parseResult = createCredentialSchema.safeParse(body) + const parseResult = createCredentialBodySchema.safeParse(body) if (!parseResult.success) { - return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 }) + return NextResponse.json( + { error: getValidationErrorMessage(parseResult.error) }, + { status: 400 } + ) } const { @@ -385,7 +263,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const resolvedDescription = description?.trim() || null let resolvedProviderId: string | null = providerId ?? null let resolvedAccountId: string | null = accountId ?? null - const resolvedEnvKey: string | null = envKey ? normalizeEnvKeyInput(envKey) : null + const resolvedEnvKey: string | null = envKey ? normalizeCredentialEnvKey(envKey) : null let resolvedEnvOwnerUserId: string | null = null let resolvedEncryptedServiceAccountKey: string | null = null @@ -433,7 +311,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const jsonParseResult = serviceAccountJsonSchema.safeParse(serviceAccountJson) if (!jsonParseResult.success) { return NextResponse.json( - { error: jsonParseResult.error.errors[0]?.message || 'Invalid service account JSON' }, + { + error: getValidationErrorMessage(jsonParseResult.error, 'Invalid service account JSON'), + }, { status: 400 } ) } diff --git a/apps/sim/app/api/cron/cleanup-soft-deletes/route.ts b/apps/sim/app/api/cron/cleanup-soft-deletes/route.ts index 1df6df035e3..f7dd0e400d2 100644 --- a/apps/sim/app/api/cron/cleanup-soft-deletes/route.ts +++ b/apps/sim/app/api/cron/cleanup-soft-deletes/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { verifyCronAuth } from '@/lib/auth/internal' import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,6 +12,9 @@ const logger = createLogger('SoftDeleteCleanupAPI') export const GET = withRouteHandler(async (request: NextRequest) => { try { + const validation = validateSchema(noInputSchema, {}) + if (!validation.success) return validation.response + const authError = verifyCronAuth(request, 'soft-delete cleanup') if (authError) return authError 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 52c9420916c..1e19d43ac05 100644 --- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts +++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts @@ -4,6 +4,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, inArray, lt, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { verifyCronAuth } from '@/lib/auth/internal' import { JOB_RETENTION_HOURS, JOB_STATUS } from '@/lib/core/async-jobs' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' @@ -17,6 +19,9 @@ const MAX_INT32 = 2_147_483_647 export const GET = withRouteHandler(async (request: NextRequest) => { try { + const validation = validateSchema(noInputSchema, {}) + if (!validation.success) return validation.response + const authError = verifyCronAuth(request, 'Stale execution cleanup') if (authError) { return authError diff --git a/apps/sim/app/api/cron/cleanup-tasks/route.ts b/apps/sim/app/api/cron/cleanup-tasks/route.ts index 75b31492a19..928d482b718 100644 --- a/apps/sim/app/api/cron/cleanup-tasks/route.ts +++ b/apps/sim/app/api/cron/cleanup-tasks/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { verifyCronAuth } from '@/lib/auth/internal' import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,6 +12,9 @@ const logger = createLogger('TaskCleanupAPI') export const GET = withRouteHandler(async (request: NextRequest) => { try { + const validation = validateSchema(noInputSchema, {}) + if (!validation.success) return validation.response + const authError = verifyCronAuth(request, 'task cleanup') if (authError) return authError diff --git a/apps/sim/app/api/cron/renew-subscriptions/route.ts b/apps/sim/app/api/cron/renew-subscriptions/route.ts index a22156b3c94..77170b16256 100644 --- a/apps/sim/app/api/cron/renew-subscriptions/route.ts +++ b/apps/sim/app/api/cron/renew-subscriptions/route.ts @@ -3,6 +3,8 @@ import { account, webhook as webhookTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { verifyCronAuth } from '@/lib/auth/internal' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' @@ -36,6 +38,9 @@ async function getCredentialOwner( */ export const GET = withRouteHandler(async (request: NextRequest) => { try { + const validation = validateSchema(noInputSchema, {}) + if (!validation.success) return validation.response + const authError = verifyCronAuth(request, 'Teams subscription renewal') if (authError) { return authError diff --git a/apps/sim/app/api/demo-requests/route.ts b/apps/sim/app/api/demo-requests/route.ts index d2c27dce409..6b76a8fe227 100644 --- a/apps/sim/app/api/demo-requests/route.ts +++ b/apps/sim/app/api/demo-requests/route.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { validationErrorResponse } from '@/lib/api/server' import { env } from '@/lib/core/config/env' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' @@ -50,12 +51,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (!validationResult.success) { logger.warn(`[${requestId}] Invalid demo request data`, { - errors: validationResult.error.format(), + issues: validationResult.error.issues, }) - return NextResponse.json( - { error: 'Invalid request data', details: validationResult.error.format() }, - { status: 400 } - ) + return validationErrorResponse(validationResult.error, 'Invalid request data') } const { firstName, lastName, companyEmail, phoneNumber, companySize, details } = diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts index 5905316cbd5..2837cd6f817 100644 --- a/apps/sim/app/api/emails/preview/route.ts +++ b/apps/sim/app/api/emails/preview/route.ts @@ -16,6 +16,8 @@ import { renderWorkflowNotificationEmail, renderWorkspaceInvitationEmail, } from '@/components/emails' +import { emailPreviewQuerySchema } from '@/lib/api/contracts/common' +import { validateSchema } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const emailTemplates = { @@ -141,9 +143,18 @@ const emailTemplates = { type EmailTemplate = keyof typeof emailTemplates +function isEmailTemplate(template: string): template is EmailTemplate { + return template in emailTemplates +} + export const GET = withRouteHandler(async (request: NextRequest) => { const { searchParams } = new URL(request.url) - const template = searchParams.get('template') as EmailTemplate | null + const queryValidation = validateSchema( + emailPreviewQuerySchema, + Object.fromEntries(searchParams.entries()) + ) + if (!queryValidation.success) return queryValidation.response + const { template } = queryValidation.data if (!template) { const categories = { @@ -198,7 +209,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } - if (!(template in emailTemplates)) { + if (!isEmailTemplate(template)) { return NextResponse.json({ error: `Unknown template: ${template}` }, { status: 400 }) } diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts index 9bd5e3d41c1..3e276afaa92 100644 --- a/apps/sim/app/api/environment/route.ts +++ b/apps/sim/app/api/environment/route.ts @@ -5,7 +5,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { savePersonalEnvironmentBodySchema } from '@/lib/api/contracts/environment' +import { isZodError } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' @@ -15,10 +16,6 @@ import type { EnvironmentVariable } from '@/lib/environment/api' const logger = createLogger('EnvironmentAPI') -const EnvVarSchema = z.object({ - variables: z.record(z.string()), -}) - export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() @@ -32,7 +29,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const body = await req.json() try { - const { variables } = EnvVarSchema.parse(body) + const { variables } = savePersonalEnvironmentBodySchema.parse(body) const encryptedVariables = await Promise.all( Object.entries(variables).map(async ([key, value]) => { @@ -80,12 +77,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ success: true }) } catch (validationError) { - if (validationError instanceof z.ZodError) { + if (isZodError(validationError)) { logger.warn(`[${requestId}] Invalid environment variables data`, { - errors: validationError.errors, + errors: validationError.issues, }) return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, + { error: 'Invalid request data', details: validationError.issues }, { status: 400 } ) } diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index e9938c14940..368f73ba980 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -107,10 +107,14 @@ export async function verifyFileAccess( cloudKey: string, userId: string, customConfig?: StorageConfig, - context?: StorageContext, + context?: StorageContext | 'general', isLocal?: boolean ): Promise { try { + if (context === 'general') { + return await verifyRegularFileAccess(cloudKey, userId, customConfig, isLocal) + } + // Infer context from key if not explicitly provided const inferredContext = context || inferContextFromKey(cloudKey) diff --git a/apps/sim/app/api/files/delete/route.ts b/apps/sim/app/api/files/delete/route.ts index 61628634573..c8cc77671c2 100644 --- a/apps/sim/app/api/files/delete/route.ts +++ b/apps/sim/app/api/files/delete/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { fileDeleteBodySchema } from '@/lib/api/contracts/storage-transfer' +import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { StorageContext } from '@/lib/uploads/config' @@ -36,7 +38,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = authResult.userId - const requestData = await request.json() + const validationResult = fileDeleteBodySchema.safeParse(await request.json()) + if (!validationResult.success) { + throw new InvalidRequestError( + getValidationErrorMessage(validationResult.error, 'Invalid request data') + ) + } + + const requestData = validationResult.data const { filePath, context } = requestData logger.info('File delete request received:', { filePath, context, userId }) diff --git a/apps/sim/app/api/files/download/route.ts b/apps/sim/app/api/files/download/route.ts index 6463260045b..68349c98f86 100644 --- a/apps/sim/app/api/files/download/route.ts +++ b/apps/sim/app/api/files/download/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { fileDownloadBodySchema } from '@/lib/api/contracts/storage-transfer' +import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { StorageContext } from '@/lib/uploads/config' @@ -23,7 +25,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = authResult.userId - const body = await request.json() + const validationResult = fileDownloadBodySchema.safeParse(await request.json()) + if (!validationResult.success) { + return createErrorResponse( + new Error(getValidationErrorMessage(validationResult.error, 'Invalid request data')), + 400 + ) + } + + const body = validationResult.data const { key, name, isExecutionFile, context, url } = body if (!key) { @@ -42,7 +52,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } - let storageContext: StorageContext = context || 'general' + let storageContext: StorageContext | 'general' | undefined = context if (isExecutionFile && !context) { storageContext = 'execution' @@ -63,9 +73,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const { getBaseUrl } = await import('@/lib/core/utils/urls') - const downloadUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(key)}?context=${storageContext}` + const contextQuery = storageContext ? `?context=${storageContext}` : '' + const downloadUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(key)}${contextQuery}` - logger.info(`Generated download URL for ${storageContext} file: ${key}`) + logger.info(`Generated download URL for ${storageContext ?? 'inferred'} file: ${key}`) return NextResponse.json({ downloadUrl, diff --git a/apps/sim/app/api/files/multipart/route.ts b/apps/sim/app/api/files/multipart/route.ts index e9573c01ff6..2eadb3689db 100644 --- a/apps/sim/app/api/files/multipart/route.ts +++ b/apps/sim/app/api/files/multipart/route.ts @@ -1,5 +1,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { + type CompleteMultipartBody, + completeMultipartBodySchema, + getMultipartPartUrlsBodySchema, + initiateMultipartBodySchema, + multipartActionSchema, + tokenBoundMultipartBodySchema, +} from '@/lib/api/contracts/storage-transfer' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -30,30 +39,6 @@ const ALLOWED_UPLOAD_CONTEXTS = new Set([ 'workspace-logos', ]) -interface InitiateMultipartRequest { - fileName: string - contentType: string - fileSize: number - workspaceId: string - context?: StorageContext -} - -interface TokenBoundRequest { - uploadToken: string -} - -interface GetPartUrlsRequest extends TokenBoundRequest { - partNumbers: number[] -} - -interface CompleteSingleRequest extends TokenBoundRequest { - parts: unknown -} - -interface CompleteBatchRequest { - uploads: Array -} - const verifyTokenForUser = (token: string | undefined, userId: string) => { if (!token || typeof token !== 'string') { return null @@ -73,7 +58,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = session.user.id - const action = request.nextUrl.searchParams.get('action') + const actionParam = request.nextUrl.searchParams.get('action') + const actionResult = multipartActionSchema.safeParse(actionParam) + const action = actionResult.success ? actionResult.data : null if (!isUsingCloudStorage()) { return NextResponse.json( @@ -86,23 +73,32 @@ export const POST = withRouteHandler(async (request: NextRequest) => { switch (action) { case 'initiate': { - const data = (await request.json()) as InitiateMultipartRequest + const dataResult = initiateMultipartBodySchema.safeParse(await request.json()) + if (!dataResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(dataResult.error) }, + { status: 400 } + ) + } + + const data = dataResult.data 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)) { + 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(context) + const config = getStorageConfig(storageContext) let uploadId: string let key: string @@ -139,18 +135,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { key, userId, workspaceId, - context, + context: storageContext, }) logger.info( - `Initiated ${storageProvider} multipart upload for ${fileName} (context: ${context}, workspace: ${workspaceId}): ${uploadId}` + `Initiated ${storageProvider} multipart upload for ${fileName} (context: ${storageContext}, workspace: ${workspaceId}): ${uploadId}` ) return NextResponse.json({ uploadId, key, uploadToken }) } case 'get-part-urls': { - const data = (await request.json()) as GetPartUrlsRequest + const dataResult = getMultipartPartUrlsBodySchema.safeParse(await request.json()) + if (!dataResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(dataResult.error) }, + { status: 400 } + ) + } + + const data = dataResult.data const { partNumbers } = data const tokenPayload = verifyTokenForUser(data.uploadToken, userId) @@ -184,7 +188,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } case 'complete': { - const data = (await request.json()) as CompleteSingleRequest | CompleteBatchRequest + const dataResult = completeMultipartBodySchema.safeParse(await request.json()) + if (!dataResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(dataResult.error) }, + { status: 400 } + ) + } + + const data: CompleteMultipartBody = dataResult.data if ('uploads' in data && Array.isArray(data.uploads)) { const verified = data.uploads.map((upload) => { @@ -243,7 +255,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ results }) } - const single = data as CompleteSingleRequest + const single = data const tokenPayload = verifyTokenForUser(single.uploadToken, userId) if (!tokenPayload) { return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) @@ -287,7 +299,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } case 'abort': { - const data = (await request.json()) as TokenBoundRequest + const dataResult = tokenBoundMultipartBodySchema.safeParse(await request.json()) + if (!dataResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(dataResult.error) }, + { status: 400 } + ) + } + + const data = dataResult.data const tokenPayload = verifyTokenForUser(data.uploadToken, userId) if (!tokenPayload) { return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index 74d70ca2e83..279471eceaa 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -5,6 +5,8 @@ import path from 'path' import { createLogger } from '@sim/logger' import binaryExtensionsList from 'binary-extensions' import { type NextRequest, NextResponse } from 'next/server' +import { fileParseBodySchema } from '@/lib/api/contracts/storage-transfer' +import { getValidationErrorMessage } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -84,7 +86,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = authResult.userId - const requestData = await request.json() + const validationResult = fileParseBodySchema.safeParse(await request.json()) + if (!validationResult.success) { + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validationResult.error, 'Invalid request data'), + filePath: '', + }, + { status: 400 } + ) + } + + const requestData = validationResult.data const { filePath, fileType, workspaceId, workflowId, executionId } = requestData if (!filePath || (typeof filePath === 'string' && filePath.trim() === '')) { diff --git a/apps/sim/app/api/files/presigned/batch/route.ts b/apps/sim/app/api/files/presigned/batch/route.ts index ba96146b85c..2e2fefd4129 100644 --- a/apps/sim/app/api/files/presigned/batch/route.ts +++ b/apps/sim/app/api/files/presigned/batch/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { batchPresignedUrlBodySchema, uploadTypeSchema } from '@/lib/api/contracts/storage-transfer' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { StorageContext } from '@/lib/uploads/config' @@ -13,15 +15,8 @@ import { createErrorResponse } from '@/app/api/files/utils' const logger = createLogger('BatchPresignedUploadAPI') -interface BatchFileRequest { - fileName: string - contentType: string - fileSize: number -} - -interface BatchPresignedUrlRequest { - files: BatchFileRequest[] -} +const MAX_FILE_SIZE = 100 * 1024 * 1024 +const VALID_UPLOAD_TYPES = ['knowledge-base', 'chat', 'copilot', 'profile-pictures'] as const export const POST = withRouteHandler(async (request: NextRequest) => { try { @@ -30,13 +25,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - let data: BatchPresignedUrlRequest + let rawData: unknown try { - data = await request.json() + rawData = await request.json() } catch { return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) } + const validationResult = batchPresignedUrlBodySchema.safeParse(rawData) + if (!validationResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(validationResult.error, 'Invalid request data') }, + { status: 400 } + ) + } + + const data = validationResult.data const { files } = data if (!files || !Array.isArray(files) || files.length === 0) { @@ -58,17 +62,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'type query parameter is required' }, { status: 400 }) } - const validTypes: StorageContext[] = ['knowledge-base', 'chat', 'copilot', 'profile-pictures'] - if (!validTypes.includes(uploadTypeParam as StorageContext)) { + const uploadTypeResult = uploadTypeSchema.safeParse(uploadTypeParam) + if (!uploadTypeResult.success) { return NextResponse.json( - { error: `Invalid type parameter. Must be one of: ${validTypes.join(', ')}` }, + { error: `Invalid type parameter. Must be one of: ${VALID_UPLOAD_TYPES.join(', ')}` }, { status: 400 } ) } - const uploadType = uploadTypeParam as StorageContext + const uploadType = uploadTypeResult.data as StorageContext - const MAX_FILE_SIZE = 100 * 1024 * 1024 for (const file of files) { if (!file.fileName?.trim()) { return NextResponse.json({ error: 'fileName is required for all files' }, { status: 400 }) @@ -106,6 +109,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } } + const validatedFiles = files as Array<{ + fileName: string + contentType: string + fileSize: number + }> const sessionUserId = session.user.id @@ -121,7 +129,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { `Local storage detected - batch presigned URLs not available, client will use API fallback` ) return NextResponse.json({ - files: files.map((file) => ({ + files: validatedFiles.map((file) => ({ fileName: file.fileName, presignedUrl: '', // Empty URL signals fallback to API upload fileInfo: { @@ -142,7 +150,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const startTime = Date.now() const presignedUrls = await generateBatchPresignedUploadUrls( - files.map((file) => ({ + validatedFiles.map((file) => ({ fileName: file.fileName, contentType: file.contentType, fileSize: file.fileSize, @@ -162,16 +170,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ files: presignedUrls.map((urlResponse, index) => { const finalPath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(urlResponse.key)}?context=${uploadType}` + const file = validatedFiles[index] return { - fileName: files[index].fileName, + fileName: file.fileName, presignedUrl: urlResponse.url, fileInfo: { path: finalPath, key: urlResponse.key, - name: files[index].fileName, - size: files[index].fileSize, - type: files[index].contentType, + name: file.fileName, + size: file.fileSize, + type: file.contentType, }, uploadHeaders: urlResponse.uploadHeaders, directUploadSupported: true, diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index 7003aa900ae..461525fc9df 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { presignedUrlBodySchema, uploadTypeSchema } from '@/lib/api/contracts/storage-transfer' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CopilotFiles } from '@/lib/uploads' @@ -12,13 +14,8 @@ import { createErrorResponse } from '@/app/api/files/utils' const logger = createLogger('PresignedUploadAPI') -interface PresignedUrlRequest { - fileName: string - contentType: string - fileSize: number - userId?: string - chatId?: string -} +const MAX_FILE_SIZE = 100 * 1024 * 1024 +const VALID_UPLOAD_TYPES = ['knowledge-base', 'chat', 'copilot', 'profile-pictures'] as const class PresignedUrlError extends Error { constructor( @@ -44,13 +41,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - let data: PresignedUrlRequest + let rawData: unknown try { - data = await request.json() + rawData = await request.json() } catch { throw new ValidationError('Invalid JSON in request body') } + const validationResult = presignedUrlBodySchema.safeParse(rawData) + if (!validationResult.success) { + throw new ValidationError( + getValidationErrorMessage(validationResult.error, 'Invalid request data') + ) + } + + const data = validationResult.data const { fileName, contentType, fileSize } = data if (!fileName?.trim()) { @@ -63,7 +68,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { throw new ValidationError('fileSize must be a positive number') } - const MAX_FILE_SIZE = 100 * 1024 * 1024 if (fileSize > MAX_FILE_SIZE) { throw new ValidationError( `File size (${fileSize} bytes) exceeds maximum allowed size (${MAX_FILE_SIZE} bytes)` @@ -75,12 +79,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { throw new ValidationError('type query parameter is required') } - const validTypes: StorageContext[] = ['knowledge-base', 'chat', 'copilot', 'profile-pictures'] - if (!validTypes.includes(uploadTypeParam as StorageContext)) { - throw new ValidationError(`Invalid type parameter. Must be one of: ${validTypes.join(', ')}`) + const uploadTypeResult = uploadTypeSchema.safeParse(uploadTypeParam) + if (!uploadTypeResult.success) { + throw new ValidationError( + `Invalid type parameter. Must be one of: ${VALID_UPLOAD_TYPES.join(', ')}` + ) } - const uploadType = uploadTypeParam as StorageContext + const uploadType = uploadTypeResult.data as StorageContext if (uploadType === 'knowledge-base') { const fileValidationError = validateFileType(fileName, contentType) diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 1125e1d3285..a0fa8457f03 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { fileServeParamsSchema, fileServeQuerySchema } from '@/lib/api/contracts/storage-transfer' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { runSandboxTask } from '@/lib/execution/sandbox/run-task' @@ -108,7 +109,11 @@ function getWorkspaceIdForCompile(key: string): string | undefined { export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) => { try { - const { path } = await params + const paramsResult = fileServeParamsSchema.safeParse(await params) + if (!paramsResult.success) { + throw new FileNotFoundError('No file path provided') + } + const { path } = paramsResult.data if (!path || path.length === 0) { throw new FileNotFoundError('No file path provided') @@ -136,7 +141,10 @@ export const GET = withRouteHandler( return await handleLocalFilePublic(fullPath) } - const raw = request.nextUrl.searchParams.get('raw') === '1' + const query = fileServeQuerySchema.parse({ + raw: request.nextUrl.searchParams.get('raw'), + }) + const raw = query.raw === '1' const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index 705ea2d8b17..f328f5861e5 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -3,6 +3,11 @@ 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 { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' @@ -53,16 +58,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const formData = await request.formData() const rawFiles = formData.getAll('file') - const files = rawFiles.filter((f): f is File => f instanceof File) - - if (files.length === 0) { + const filesResult = uploadFilesFormFilesSchema.safeParse(rawFiles) + if (!filesResult.success) { throw new InvalidRequestError('No files provided') } - - const workflowId = formData.get('workflowId') as string | null - const executionId = formData.get('executionId') as string | null - const workspaceId = formData.get('workspaceId') as string | null - const contextParam = formData.get('context') as string | null + const files = filesResult.data + + 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) { diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 9b7811a822f..db3a3e98617 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -5,7 +5,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull, min } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { duplicateFolderBodySchema } from '@/lib/api/contracts' +import { validateSchema } 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' @@ -14,14 +15,6 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FolderDuplicateAPI') -const DuplicateRequestSchema = z.object({ - name: z.string().min(1, 'Name is required'), - workspaceId: z.string().optional(), - parentId: z.string().nullable().optional(), - color: z.string().optional(), - newId: z.string().uuid().optional(), -}) - // POST /api/folders/[id]/duplicate - Duplicate a folder with all its child folders and workflows export const POST = withRouteHandler( async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { @@ -37,13 +30,14 @@ export const POST = withRouteHandler( try { const body = await req.json() - const { - name, - workspaceId, - parentId, - color, - newId: clientNewId, - } = DuplicateRequestSchema.parse(body) + const validation = validateSchema(duplicateFolderBodySchema, body, 'Invalid request data') + if (!validation.success) { + logger.warn(`[${requestId}] Invalid duplication request data`, { + errors: validation.error.issues, + }) + return validation.response + } + const { name, workspaceId, parentId, color, newId: clientNewId } = validation.data logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`) @@ -165,18 +159,13 @@ export const POST = withRouteHandler( request: req, }) - return NextResponse.json( - { - id: newFolderId, - name, - color: color || sourceFolder.color, - workspaceId: targetWorkspaceId, - parentId: parentId || sourceFolder.parentId, - foldersCount: folderMapping.size, - workflowsCount: workflowStats.succeeded, - }, - { status: 201 } - ) + const duplicatedFolder = await db + .select() + .from(workflowFolder) + .where(eq(workflowFolder.id, newFolderId)) + .then((rows) => rows[0]) + + return NextResponse.json({ folder: duplicatedFolder }, { status: 201 }) } catch (error) { if (error instanceof Error) { if (error.message === 'Source folder not found') { @@ -192,14 +181,6 @@ export const POST = withRouteHandler( } } - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid duplication request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const elapsed = Date.now() - startTime logger.error( `[${requestId}] Error duplicating folder ${sourceFolderId} after ${elapsed}ms:`, diff --git a/apps/sim/app/api/folders/[id]/restore/route.ts b/apps/sim/app/api/folders/[id]/restore/route.ts index 5717c0be22a..c5c08c83535 100644 --- a/apps/sim/app/api/folders/[id]/restore/route.ts +++ b/apps/sim/app/api/folders/[id]/restore/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { restoreFolderBodySchema } from '@/lib/api/contracts' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' @@ -19,12 +21,17 @@ export const POST = withRouteHandler( } const body = await request.json().catch(() => ({})) - const workspaceId = body.workspaceId as string | undefined + const validation = restoreFolderBodySchema.safeParse(body) - if (!workspaceId) { - return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) + if (!validation.success) { + return NextResponse.json( + { error: getValidationErrorMessage(validation.error) }, + { status: 400 } + ) } + const { workspaceId } = validation.data + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) if (permission !== 'admin' && permission !== 'write') { return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index e7966299977..580cdf30deb 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -3,7 +3,7 @@ import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateFolderBodySchema } from '@/lib/api/contracts' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' @@ -13,14 +13,6 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FoldersIDAPI') -const updateFolderSchema = z.object({ - name: z.string().optional(), - color: z.string().optional(), - isExpanded: z.boolean().optional(), - parentId: z.string().nullable().optional(), - sortOrder: z.number().int().min(0).optional(), -}) - // PUT - Update a folder export const PUT = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { @@ -33,12 +25,12 @@ export const PUT = withRouteHandler( const { id } = await params const body = await request.json() - const validationResult = updateFolderSchema.safeParse(body) + const validationResult = updateFolderBodySchema.safeParse(body) if (!validationResult.success) { logger.error('Folder update validation failed:', { - errors: validationResult.error.errors, + errors: validationResult.error.issues, }) - const errorMessages = validationResult.error.errors + const errorMessages = validationResult.error.issues .map((err) => `${err.path.join('.')}: ${err.message}`) .join(', ') return NextResponse.json({ error: `Validation failed: ${errorMessages}` }, { status: 400 }) diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index 1cc59aa77f9..c4e7bdaceed 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -3,7 +3,8 @@ import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { reorderFoldersBodySchema } from '@/lib/api/contracts' +import { validateSchema } 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' @@ -11,17 +12,6 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FolderReorderAPI') -const ReorderSchema = z.object({ - workspaceId: z.string(), - updates: z.array( - z.object({ - id: z.string(), - sortOrder: z.number().int().min(0), - parentId: z.string().nullable().optional(), - }) - ), -}) - export const PUT = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() const session = await getSession() @@ -33,7 +23,14 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { try { const body = await req.json() - const { workspaceId, updates } = ReorderSchema.parse(body) + const validation = validateSchema(reorderFoldersBodySchema, body, 'Invalid request data') + if (!validation.success) { + logger.warn(`[${requestId}] Invalid folder reorder data`, { + errors: validation.error.issues, + }) + return validation.response + } + const { workspaceId, updates } = validation.data const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) if (!permission || permission === 'read') { @@ -78,14 +75,6 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ success: true, updated: validUpdates.length }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid folder reorder data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error reordering folders`, error) return NextResponse.json({ error: 'Failed to reorder folders' }, { status: 500 }) } diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index 14117e2b171..8322a0bac62 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -5,7 +5,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, asc, eq, isNotNull, isNull, min } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { createFolderBodySchema, listFoldersQuerySchema } from '@/lib/api/contracts' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' @@ -13,15 +14,6 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FoldersAPI') -const CreateFolderSchema = z.object({ - id: z.string().uuid().optional(), - name: z.string().min(1, 'Name is required'), - workspaceId: z.string().min(1, 'Workspace ID is required'), - parentId: z.string().optional(), - color: z.string().optional(), - sortOrder: z.number().int().optional(), -}) - // GET - Fetch folders for a workspace export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -31,12 +23,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const { searchParams } = new URL(request.url) - const workspaceId = searchParams.get('workspaceId') + const validation = listFoldersQuerySchema.safeParse(Object.fromEntries(searchParams.entries())) - if (!workspaceId) { - return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) + if (!validation.success) { + return NextResponse.json( + { error: getValidationErrorMessage(validation.error) }, + { status: 400 } + ) } + const { workspaceId, scope } = validation.data + // Check if user has workspace permissions const workspacePermission = await getUserEntityPermissions( session.user.id, @@ -48,7 +45,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 }) } - const scope = searchParams.get('scope') ?? 'active' const archivedFilter = scope === 'archived' ? isNotNull(workflowFolder.archivedAt) @@ -76,6 +72,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const body = await request.json() + const validation = validateSchema(createFolderBodySchema, body, 'Invalid request data') + if (!validation.success) { + logger.warn('Invalid folder creation data', { errors: validation.error.issues }) + return validation.response + } const { id: clientId, name, @@ -83,7 +84,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { parentId, color, sortOrder: providedSortOrder, - } = CreateFolderSchema.parse(body) + } = validation.data const workspacePermission = await getUserEntityPermissions( session.user.id, @@ -181,14 +182,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ folder: newFolder }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid folder creation data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error('Error creating folder:', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/form/[identifier]/route.ts b/apps/sim/app/api/form/[identifier]/route.ts index 62b2faa8bb2..b91c6ef932a 100644 --- a/apps/sim/app/api/form/[identifier]/route.ts +++ b/apps/sim/app/api/form/[identifier]/route.ts @@ -4,7 +4,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { formSubmitBodySchema } from '@/lib/api/contracts/forms' +import { parseJsonBody } from '@/lib/api/server' import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -19,12 +20,6 @@ import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/ const logger = createLogger('FormIdentifierAPI') -const formPostBodySchema = z.object({ - formData: z.record(z.unknown()).optional(), - password: z.string().optional(), - email: z.string().email('Invalid email format').optional().or(z.literal('')), -}) - export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -58,27 +53,25 @@ export const POST = withRouteHandler( const requestId = generateRequestId() try { - let parsedBody - try { - const rawBody = await request.json() - const validation = formPostBodySchema.safeParse(rawBody) - - if (!validation.success) { - const errorMessage = validation.error.errors - .map((err) => `${err.path.join('.')}: ${err.message}`) - .join(', ') - logger.warn(`[${requestId}] Validation error: ${errorMessage}`) - return addCorsHeaders( - createErrorResponse(`Invalid request body: ${errorMessage}`, 400), - request - ) - } - - parsedBody = validation.data - } catch (_error) { + const parsedJson = await parseJsonBody(request) + if (!parsedJson.success) { return addCorsHeaders(createErrorResponse('Invalid request body', 400), request) } + const bodyValidation = formSubmitBodySchema.safeParse(parsedJson.data) + if (!bodyValidation.success) { + const errorMessage = bodyValidation.error.issues + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join(', ') + logger.warn(`[${requestId}] Validation error: ${errorMessage}`) + return addCorsHeaders( + createErrorResponse(`Invalid request body: ${errorMessage}`, 400), + request + ) + } + + const parsedBody = bodyValidation.data + const deploymentResult = await db .select({ id: form.id, diff --git a/apps/sim/app/api/form/manage/[id]/route.ts b/apps/sim/app/api/form/manage/[id]/route.ts index 501f3edbf1a..bc5eeb1a6f9 100644 --- a/apps/sim/app/api/form/manage/[id]/route.ts +++ b/apps/sim/app/api/form/manage/[id]/route.ts @@ -4,7 +4,8 @@ import { form } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { z } from 'zod' +import { formIdParamsSchema, updateFormBodySchema } from '@/lib/api/contracts/forms' +import { getValidationErrorMessage, isZodError } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -13,56 +14,9 @@ import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/ const logger = createLogger('FormManageAPI') -const fieldConfigSchema = z.object({ - name: z.string(), - type: z.string(), - label: z.string(), - description: z.string().optional(), - required: z.boolean().optional(), -}) - -const updateFormSchema = z.object({ - identifier: z - .string() - .min(1, 'Identifier is required') - .max(100, 'Identifier must be 100 characters or less') - .regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens') - .optional(), - title: z - .string() - .min(1, 'Title is required') - .max(200, 'Title must be 200 characters or less') - .optional(), - description: z.string().max(1000, 'Description must be 1000 characters or less').optional(), - customizations: z - .object({ - primaryColor: z.string().optional(), - welcomeMessage: z - .string() - .max(500, 'Welcome message must be 500 characters or less') - .optional(), - thankYouTitle: z - .string() - .max(100, 'Thank you title must be 100 characters or less') - .optional(), - thankYouMessage: z - .string() - .max(500, 'Thank you message must be 500 characters or less') - .optional(), - logoUrl: z.string().url('Logo URL must be a valid URL').optional().or(z.literal('')), - fieldConfigs: z.array(fieldConfigSchema).optional(), - }) - .optional(), - authType: z.enum(['public', 'password', 'email']).optional(), - password: z - .string() - .min(6, 'Password must be at least 6 characters') - .optional() - .or(z.literal('')), - allowedEmails: z.array(z.string()).optional(), - showBranding: z.boolean().optional(), - isActive: z.boolean().optional(), -}) +function getErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback +} export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { @@ -73,7 +27,7 @@ export const GET = withRouteHandler( return createErrorResponse('Unauthorized', 401) } - const { id } = await params + const { id } = formIdParamsSchema.parse(await params) const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id) @@ -89,9 +43,9 @@ export const GET = withRouteHandler( hasPassword: !!formRecord.password, }, }) - } catch (error: any) { + } catch (error) { logger.error('Error fetching form:', error) - return createErrorResponse(error.message || 'Failed to fetch form', 500) + return createErrorResponse(getErrorMessage(error, 'Failed to fetch form'), 500) } } ) @@ -105,7 +59,7 @@ export const PATCH = withRouteHandler( return createErrorResponse('Unauthorized', 401) } - const { id } = await params + const { id } = formIdParamsSchema.parse(await params) const { hasAccess, @@ -120,7 +74,7 @@ export const PATCH = withRouteHandler( const body = await request.json() try { - const validatedData = updateFormSchema.parse(body) + const validatedData = updateFormBodySchema.parse(body) const { identifier, @@ -161,7 +115,7 @@ export const PATCH = withRouteHandler( ) } - const updateData: Record = { + const updateData: Record = { updatedAt: new Date(), } @@ -174,7 +128,8 @@ export const PATCH = withRouteHandler( if (allowedEmails !== undefined) updateData.allowedEmails = allowedEmails if (customizations !== undefined) { - const existingCustomizations = (formRecord.customizations as Record) || {} + const existingCustomizations = + (formRecord.customizations as Record) || {} updateData.customizations = { ...DEFAULT_FORM_CUSTOMIZATIONS, ...existingCustomizations, @@ -216,15 +171,18 @@ export const PATCH = withRouteHandler( message: 'Form updated successfully', }) } catch (validationError) { - if (validationError instanceof z.ZodError) { - const errorMessage = validationError.errors[0]?.message || 'Invalid request data' - return createErrorResponse(errorMessage, 400, 'VALIDATION_ERROR') + if (isZodError(validationError)) { + return createErrorResponse( + getValidationErrorMessage(validationError), + 400, + 'VALIDATION_ERROR' + ) } throw validationError } - } catch (error: any) { + } catch (error) { logger.error('Error updating form:', error) - return createErrorResponse(error.message || 'Failed to update form', 500) + return createErrorResponse(getErrorMessage(error, 'Failed to update form'), 500) } } ) @@ -238,7 +196,7 @@ export const DELETE = withRouteHandler( return createErrorResponse('Unauthorized', 401) } - const { id } = await params + const { id } = formIdParamsSchema.parse(await params) const { hasAccess, @@ -271,9 +229,9 @@ export const DELETE = withRouteHandler( return createSuccessResponse({ message: 'Form deleted successfully', }) - } catch (error: any) { + } catch (error) { logger.error('Error deleting form:', error) - return createErrorResponse(error.message || 'Failed to delete form', 500) + return createErrorResponse(getErrorMessage(error, 'Failed to delete form'), 500) } } ) diff --git a/apps/sim/app/api/form/route.ts b/apps/sim/app/api/form/route.ts index 5336d324502..adda8e80f05 100644 --- a/apps/sim/app/api/form/route.ts +++ b/apps/sim/app/api/form/route.ts @@ -5,7 +5,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { z } from 'zod' +import { createFormBodySchema } from '@/lib/api/contracts/forms' +import { getValidationErrorMessage, isZodError } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { isDev } from '@/lib/core/config/feature-flags' import { encryptSecret } from '@/lib/core/security/encryption' @@ -21,51 +22,9 @@ import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/ const logger = createLogger('FormAPI') -const fieldConfigSchema = z.object({ - name: z.string(), - type: z.string(), - label: z.string(), - description: z.string().optional(), - required: z.boolean().optional(), -}) - -const formSchema = z.object({ - workflowId: z.string().min(1, 'Workflow ID is required'), - identifier: z - .string() - .min(1, 'Identifier is required') - .max(100, 'Identifier must be 100 characters or less') - .regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'), - title: z.string().min(1, 'Title is required').max(200, 'Title must be 200 characters or less'), - description: z.string().max(1000, 'Description must be 1000 characters or less').optional(), - customizations: z - .object({ - primaryColor: z.string().optional(), - welcomeMessage: z - .string() - .max(500, 'Welcome message must be 500 characters or less') - .optional(), - thankYouTitle: z - .string() - .max(100, 'Thank you title must be 100 characters or less') - .optional(), - thankYouMessage: z - .string() - .max(500, 'Thank you message must be 500 characters or less') - .optional(), - logoUrl: z.string().url('Logo URL must be a valid URL').optional().or(z.literal('')), - fieldConfigs: z.array(fieldConfigSchema).optional(), - }) - .optional(), - authType: z.enum(['public', 'password', 'email']).default('public'), - password: z - .string() - .min(6, 'Password must be at least 6 characters') - .optional() - .or(z.literal('')), - allowedEmails: z.array(z.string()).optional().default([]), - showBranding: z.boolean().optional().default(true), -}) +function getErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback +} export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -81,9 +40,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .where(and(eq(form.userId, session.user.id), isNull(form.archivedAt))) return createSuccessResponse({ deployments }) - } catch (error: any) { + } catch (error) { logger.error('Error fetching form deployments:', error) - return createErrorResponse(error.message || 'Failed to fetch form deployments', 500) + return createErrorResponse(getErrorMessage(error, 'Failed to fetch form deployments'), 500) } }) @@ -98,7 +57,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const body = await request.json() try { - const validatedData = formSchema.parse(body) + const validatedData = createFormBodySchema.parse(body) const { workflowId, @@ -222,14 +181,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { message: 'Form deployment created successfully', }) } catch (validationError) { - if (validationError instanceof z.ZodError) { - const errorMessage = validationError.errors[0]?.message || 'Invalid request data' - return createErrorResponse(errorMessage, 400, 'VALIDATION_ERROR') + if (isZodError(validationError)) { + return createErrorResponse( + getValidationErrorMessage(validationError), + 400, + 'VALIDATION_ERROR' + ) } throw validationError } - } catch (error: any) { + } catch (error) { logger.error('Error creating form deployment:', error) - return createErrorResponse(error.message || 'Failed to create form deployment', 500) + return createErrorResponse(getErrorMessage(error, 'Failed to create form deployment'), 500) } }) diff --git a/apps/sim/app/api/form/validate/route.ts b/apps/sim/app/api/form/validate/route.ts index 9af0542314f..42baba4c85d 100644 --- a/apps/sim/app/api/form/validate/route.ts +++ b/apps/sim/app/api/form/validate/route.ts @@ -3,21 +3,14 @@ import { form } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { z } from 'zod' +import { formIdentifierValidationQuerySchema } from '@/lib/api/contracts/forms' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('FormValidateAPI') -const validateQuerySchema = z.object({ - identifier: z - .string() - .min(1, 'Identifier is required') - .regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens') - .max(100, 'Identifier must be 100 characters or less'), -}) - /** * GET endpoint to validate form identifier availability */ @@ -30,10 +23,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { searchParams } = new URL(request.url) const identifier = searchParams.get('identifier') - const validation = validateQuerySchema.safeParse({ identifier }) + const validation = formIdentifierValidationQuerySchema.safeParse({ identifier }) if (!validation.success) { - const errorMessage = validation.error.errors[0]?.message || 'Invalid identifier' + const errorMessage = getValidationErrorMessage(validation.error, 'Invalid identifier') logger.warn(`Validation error: ${errorMessage}`) if (identifier && !/^[a-z0-9-]+$/.test(identifier)) { diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 8b53c5eb057..8cdf1ca8d98 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -301,8 +301,7 @@ describe('Function Execute API Route', () => { const response = await POST(req) const data = await response.json() - expect(response.status).toBe(500) - expect(data.success).toBe(false) + expect(response.status).toBe(400) expect(data).toHaveProperty('error') }) @@ -466,7 +465,7 @@ describe('Function Execute API Route', () => { const response = await POST(req) - expect(response.status).toBe(500) + expect(response.status).toBe(400) }) it.concurrent('should handle timeout parameter', async () => { diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 680a1d158c0..89109b482ef 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { functionExecuteContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { FORMAT_TO_CONTENT_TYPE, @@ -25,8 +27,6 @@ import { export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -export const MAX_DURATION = 210 - const logger = createLogger('FunctionExecuteAPI') const TAG_PATTERN = createReferencePattern() @@ -702,7 +702,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await req.json() + const parsed = await parseRequest(functionExecuteContract, req, {}) + if (!parsed.success) return parsed.response + const { body } = parsed.data const { DEFAULT_EXECUTION_TIMEOUT_MS } = await import('@/lib/execution/constants') diff --git a/apps/sim/app/api/guardrails/validate/route.ts b/apps/sim/app/api/guardrails/validate/route.ts index efd47375f00..e9d19853c04 100644 --- a/apps/sim/app/api/guardrails/validate/route.ts +++ b/apps/sim/app/api/guardrails/validate/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' +import { guardrailsValidateContract } from '@/lib/api/contracts' +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' @@ -25,7 +27,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() + const parsed = await parseRequest(guardrailsValidateContract, request, {}) + if (!parsed.success) return parsed.response + const { body } = parsed.data const { validationType, input, @@ -270,7 +274,7 @@ async function executeValidation( knowledgeBaseId: string | undefined, threshold: string | undefined, topK: string | undefined, - model: string, + model: string | undefined, apiKey: string | undefined, providerCredentials: { azureEndpoint?: string @@ -317,6 +321,12 @@ async function executeValidation( error: 'Knowledge base ID is required for hallucination check', } } + if (!model) { + return { + passed: false, + error: 'Model is required for hallucination validation', + } + } return await validateHallucination({ userInput: inputStr, diff --git a/apps/sim/app/api/health/route.ts b/apps/sim/app/api/health/route.ts index 5486272998c..3d2f392f622 100644 --- a/apps/sim/app/api/health/route.ts +++ b/apps/sim/app/api/health/route.ts @@ -1,7 +1,13 @@ /** * Health check endpoint for deployment platforms and container probes. */ +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' + export async function GET(): Promise { + const validation = validateSchema(noInputSchema, {}) + if (!validation.success) return validation.response + return Response.json( { status: 'ok', diff --git a/apps/sim/app/api/help/integration-request/route.ts b/apps/sim/app/api/help/integration-request/route.ts index cf3c33e0499..bf317f48bb3 100644 --- a/apps/sim/app/api/help/integration-request/route.ts +++ b/apps/sim/app/api/help/integration-request/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { integrationRequestBodySchema } from '@/lib/api/contracts/common' +import { validateSchema } from '@/lib/api/server' import { env } from '@/lib/core/config/env' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' @@ -8,10 +9,7 @@ import { generateRequestId, getClientIp } from '@/lib/core/utils/request' import { getEmailDomain } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' -import { - getFromEmailAddress, - NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, -} from '@/lib/messaging/email/utils' +import { getFromEmailAddress } from '@/lib/messaging/email/utils' const logger = createLogger('IntegrationRequestAPI') @@ -23,17 +21,6 @@ const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = { refillIntervalMs: 60_000, } -const integrationRequestSchema = z.object({ - integrationName: z - .string() - .trim() - .min(1, 'Integration name is required') - .max(200) - .regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'), - email: z.string().email('A valid email is required'), - useCase: z.string().max(2000).optional(), -}) - export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() @@ -59,15 +46,16 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const body = await req.json() - const validationResult = integrationRequestSchema.safeParse(body) + const validationResult = validateSchema( + integrationRequestBodySchema, + body, + 'Invalid request data' + ) if (!validationResult.success) { logger.warn(`[${requestId}] Invalid integration request data`, { - errors: validationResult.error.format(), + issues: validationResult.error.issues, }) - return NextResponse.json( - { error: 'Invalid request data', details: validationResult.error.format() }, - { status: 400 } - ) + return validationResult.response } const { integrationName, email, useCase } = validationResult.data diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index e396d30aadc..abbfee2364b 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -1,30 +1,18 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' import { renderHelpConfirmationEmail } from '@/components/emails' +import { helpFormBodySchema } from '@/lib/api/contracts/common' +import { validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' import { getEmailDomain } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' -import { - getFromEmailAddress, - NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, -} from '@/lib/messaging/email/utils' +import { getFromEmailAddress } from '@/lib/messaging/email/utils' const logger = createLogger('HelpAPI') -const helpFormSchema = z.object({ - subject: z - .string() - .trim() - .min(1, 'Subject is required') - .regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'), - message: z.string().min(1, 'Message is required'), - type: z.enum(['bug', 'feedback', 'feature_request', 'other']), -}) - export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() @@ -51,7 +39,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { email: `${email.substring(0, 3)}***`, // Log partial email for privacy }) - const validationResult = helpFormSchema.safeParse({ + const validationResult = validateSchema(helpFormBodySchema, { subject, message, type, @@ -59,12 +47,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (!validationResult.success) { logger.warn(`[${requestId}] Invalid help request data`, { - errors: validationResult.error.format(), + issues: validationResult.error.issues, }) - return NextResponse.json( - { error: 'Invalid request data', details: validationResult.error.format() }, - { status: 400 } - ) + return validationResult.response } const images: { filename: string; content: Buffer; contentType: string }[] = [] @@ -72,14 +57,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { for (const [key, value] of formData.entries()) { if (key.startsWith('image_') && typeof value !== 'string') { if (value && 'arrayBuffer' in value) { - const blob = value as unknown as Blob - const buffer = Buffer.from(await blob.arrayBuffer()) - const filename = 'name' in value ? (value as any).name : `image_${key.split('_')[1]}` + const buffer = Buffer.from(await value.arrayBuffer()) + const filename = value.name || `image_${key.split('_')[1]}` images.push({ filename, content: buffer, - contentType: 'type' in value ? (value as any).type : 'application/octet-stream', + contentType: value.type || 'application/octet-stream', }) } } diff --git a/apps/sim/app/api/invitations/[id]/accept/route.ts b/apps/sim/app/api/invitations/[id]/accept/route.ts index f3be2b8e1b7..1d6932f8300 100644 --- a/apps/sim/app/api/invitations/[id]/accept/route.ts +++ b/apps/sim/app/api/invitations/[id]/accept/route.ts @@ -1,18 +1,27 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + invitationActionBodySchema, + invitationActionParamsSchema, +} from '@/lib/api/contracts/invitations' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { acceptInvitation } from '@/lib/invitations/core' const logger = createLogger('InvitationAcceptAPI') -const bodySchema = z.object({ token: z.string().min(1).optional() }) - export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id } = await params + const parsedParams = invitationActionParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json( + { error: getValidationErrorMessage(parsedParams.error) }, + { status: 400 } + ) + } + const { id } = parsedParams.data const session = await getSession() if (!session?.user?.id || !session.user.email) { @@ -20,9 +29,12 @@ export const POST = withRouteHandler( } const body = await request.json().catch(() => ({})) - const parsed = bodySchema.safeParse(body) + const parsed = invitationActionBodySchema.safeParse(body) if (!parsed.success) { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + return NextResponse.json( + { error: getValidationErrorMessage(parsed.error, 'Invalid request body') }, + { status: 400 } + ) } const result = await acceptInvitation({ diff --git a/apps/sim/app/api/invitations/[id]/reject/route.ts b/apps/sim/app/api/invitations/[id]/reject/route.ts index 7f9c311b4ca..820f042bdec 100644 --- a/apps/sim/app/api/invitations/[id]/reject/route.ts +++ b/apps/sim/app/api/invitations/[id]/reject/route.ts @@ -1,18 +1,27 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + invitationActionBodySchema, + invitationActionParamsSchema, +} from '@/lib/api/contracts/invitations' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { rejectInvitation } from '@/lib/invitations/core' const logger = createLogger('InvitationRejectAPI') -const bodySchema = z.object({ token: z.string().min(1).optional() }) - export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id } = await params + const parsedParams = invitationActionParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json( + { error: getValidationErrorMessage(parsedParams.error) }, + { status: 400 } + ) + } + const { id } = parsedParams.data const session = await getSession() if (!session?.user?.id || !session.user.email) { @@ -20,9 +29,12 @@ export const POST = withRouteHandler( } const body = await request.json().catch(() => ({})) - const parsed = bodySchema.safeParse(body) + const parsed = invitationActionBodySchema.safeParse(body) if (!parsed.success) { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + return NextResponse.json( + { error: getValidationErrorMessage(parsed.error, 'Invalid request body') }, + { status: 400 } + ) } const result = await rejectInvitation({ diff --git a/apps/sim/app/api/invitations/[id]/resend/route.ts b/apps/sim/app/api/invitations/[id]/resend/route.ts index 99c0721844d..dbd34b75014 100644 --- a/apps/sim/app/api/invitations/[id]/resend/route.ts +++ b/apps/sim/app/api/invitations/[id]/resend/route.ts @@ -4,6 +4,8 @@ import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { invitationParamsSchema } from '@/lib/api/contracts/invitations' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getOrganizationSubscription } from '@/lib/billing/core/billing' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' @@ -23,7 +25,14 @@ const logger = createLogger('InvitationResendAPI') export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id } = await params + const parsedParams = invitationParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json( + { error: getValidationErrorMessage(parsedParams.error) }, + { status: 400 } + ) + } + const { id } = parsedParams.data const session = await getSession() if (!session?.user?.id) { diff --git a/apps/sim/app/api/invitations/[id]/route.ts b/apps/sim/app/api/invitations/[id]/route.ts index 532a3c2cbb6..0a890a21619 100644 --- a/apps/sim/app/api/invitations/[id]/route.ts +++ b/apps/sim/app/api/invitations/[id]/route.ts @@ -4,7 +4,12 @@ import { invitation, invitationWorkspaceGrant } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + invitationParamsSchema, + invitationQuerySchema, + updateInvitationBodySchema, +} from '@/lib/api/contracts/invitations' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -15,7 +20,14 @@ const logger = createLogger('InvitationsAPI') export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id } = await params + const parsedParams = invitationParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json( + { error: getValidationErrorMessage(parsedParams.error) }, + { status: 400 } + ) + } + const { id } = parsedParams.data const session = await getSession() if (!session?.user?.id) { @@ -28,7 +40,9 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) } - const token = request.nextUrl.searchParams.get('token') + const { token } = invitationQuerySchema.parse({ + token: request.nextUrl.searchParams.get('token') || undefined, + }) const isInvitee = normalizeEmail(session.user.email || '') === normalizeEmail(inv.email) const tokenMatches = !!token && token === inv.token @@ -75,25 +89,16 @@ export const GET = withRouteHandler( } ) -const patchSchema = z - .object({ - role: z.enum(['admin', 'member']).optional(), - grants: z - .array( - z.object({ - workspaceId: z.string().min(1), - permission: z.enum(['read', 'write', 'admin']), - }) - ) - .optional(), - }) - .refine((data) => data.role !== undefined || (data.grants && data.grants.length > 0), { - message: 'Provide a role or at least one grant update', - }) - export const PATCH = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id } = await params + const parsedParams = invitationParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json( + { error: getValidationErrorMessage(parsedParams.error) }, + { status: 400 } + ) + } + const { id } = parsedParams.data const session = await getSession() if (!session?.user?.id) { @@ -111,10 +116,10 @@ export const PATCH = withRouteHandler( } const body = await request.json().catch(() => ({})) - const parsed = patchSchema.safeParse(body) + const parsed = updateInvitationBodySchema.safeParse(body) if (!parsed.success) { return NextResponse.json( - { error: parsed.error.errors[0]?.message || 'Invalid request body' }, + { error: getValidationErrorMessage(parsed.error, 'Invalid request body') }, { status: 400 } ) } @@ -211,7 +216,14 @@ export const PATCH = withRouteHandler( export const DELETE = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id } = await params + const parsedParams = invitationParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json( + { error: getValidationErrorMessage(parsedParams.error) }, + { status: 400 } + ) + } + const { id } = parsedParams.data const session = await getSession() if (!session?.user?.id) { diff --git a/apps/sim/app/api/jobs/[jobId]/route.ts b/apps/sim/app/api/jobs/[jobId]/route.ts index 927c33e24dc..35145c89100 100644 --- a/apps/sim/app/api/jobs/[jobId]/route.ts +++ b/apps/sim/app/api/jobs/[jobId]/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jobIdParamsSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { getJobQueue } from '@/lib/core/async-jobs' import { generateRequestId } from '@/lib/core/utils/request' @@ -11,7 +13,8 @@ const logger = createLogger('TaskStatusAPI') export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ jobId: string }> }) => { - const { jobId: taskId } = await params + const paramsResult = validateSchema(jobIdParamsSchema, await params) + const taskId = paramsResult.success ? paramsResult.data.jobId : '' const requestId = generateRequestId() try { 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 index 57593fc9739..4f39294d055 100644 --- 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 @@ -90,7 +90,6 @@ describe('Connector Documents API Route', () => { const url = 'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents' const req = createMockRequest('GET', undefined, undefined, url) - Object.assign(req, { nextUrl: new URL(url) }) const response = await GET(req as never, { params: mockParams }) const data = await response.json() @@ -115,7 +114,6 @@ describe('Connector Documents API Route', () => { const url = 'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents?includeExcluded=true' const req = createMockRequest('GET', undefined, undefined, url) - Object.assign(req, { nextUrl: new URL(url) }) const response = await GET(req as never, { params: mockParams }) const data = await response.json() 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 395da6b2812..5f129f458da 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 @@ -4,7 +4,8 @@ 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 { z } from 'zod' +import { connectorDocumentsPatchBodySchema } from '@/lib/api/contracts/knowledge' +import { parseJsonBody, validateSchema } 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' @@ -116,11 +117,6 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou } }) -const PatchSchema = z.object({ - operation: z.enum(['restore', 'exclude']), - documentIds: z.array(z.string()).min(1), -}) - /** * PATCH /api/knowledge/[id]/connectors/[connectorId]/documents * Restore or exclude connector documents. @@ -158,16 +154,17 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R return NextResponse.json({ error: 'Connector not found' }, { status: 404 }) } - const body = await request.json() - const parsed = PatchSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: 'Invalid request', details: parsed.error.flatten() }, - { status: 400 } - ) - } + const parsedBody = await parseJsonBody(request) + if (!parsedBody.success) return parsedBody.response + + const validation = validateSchema( + connectorDocumentsPatchBodySchema, + parsedBody.data, + 'Invalid request' + ) + if (!validation.success) return validation.response - const { operation, documentIds } = parsed.data + const { operation, documentIds } = validation.data if (operation === 'restore') { const updated = await db 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 77ca9942fbd..d75c69bcbcf 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts @@ -10,7 +10,8 @@ import { import { createLogger } from '@sim/logger' import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateConnectorBodySchema } from '@/lib/api/contracts/knowledge' +import { parseJsonBody, validateSchema } from '@/lib/api/server' import { decryptApiKey } from '@/lib/api-key/crypto' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { hasLiveSyncAccess } from '@/lib/billing/core/subscription' @@ -27,12 +28,6 @@ const logger = createLogger('KnowledgeConnectorByIdAPI') type RouteParams = { params: Promise<{ id: string; connectorId: string }> } -const UpdateConnectorSchema = z.object({ - sourceConfig: z.record(z.unknown()).optional(), - syncIntervalMinutes: z.number().int().min(0).optional(), - status: z.enum(['active', 'paused']).optional(), -}) - /** * GET /api/knowledge/[id]/connectors/[connectorId] - Get connector details with recent sync logs */ @@ -109,14 +104,11 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) } - const body = await request.json() - const parsed = UpdateConnectorSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: 'Invalid request', details: parsed.error.flatten() }, - { status: 400 } - ) - } + const parsedBody = await parseJsonBody(request) + if (!parsedBody.success) return parsedBody.response + + const parsed = validateSchema(updateConnectorBodySchema, parsedBody.data, 'Invalid request') + if (!parsed.success) return parsed.response if ( parsed.data.syncIntervalMinutes !== undefined && 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 63244e17516..d772fa4ff32 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 @@ -4,6 +4,8 @@ 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 { knowledgeConnectorParamsSchema } from '@/lib/api/contracts/knowledge' +import { validateSchema } 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' @@ -20,7 +22,13 @@ type RouteParams = { params: Promise<{ id: string; connectorId: string }> } */ export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { const requestId = generateRequestId() - const { id: knowledgeBaseId, connectorId } = await params + const validation = validateSchema( + knowledgeConnectorParamsSchema, + await params, + 'Invalid request parameters' + ) + if (!validation.success) return validation.response + const { id: knowledgeBaseId, connectorId } = validation.data try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts index 6a6cb4c93b9..ff4b7d01316 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts @@ -5,7 +5,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, desc, eq, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { createConnectorBodySchema } from '@/lib/api/contracts/knowledge' +import { parseJsonBody, validateSchema } from '@/lib/api/server' import { encryptApiKey } from '@/lib/api-key/crypto' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { hasLiveSyncAccess } from '@/lib/billing/core/subscription' @@ -21,14 +22,6 @@ import { CONNECTOR_REGISTRY } from '@/connectors/registry' const logger = createLogger('KnowledgeConnectorsAPI') -const CreateConnectorSchema = z.object({ - connectorType: z.string().min(1), - credentialId: z.string().min(1).optional(), - apiKey: z.string().min(1).optional(), - sourceConfig: z.record(z.unknown()), - syncIntervalMinutes: z.number().int().min(0).default(1440), -}) - /** * GET /api/knowledge/[id]/connectors - List connectors for a knowledge base */ @@ -98,16 +91,18 @@ export const POST = withRouteHandler( ) } - const body = await request.json() - const parsed = CreateConnectorSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: 'Invalid request', details: parsed.error.flatten() }, - { status: 400 } - ) - } + const parsedBody = await parseJsonBody(request) + if (!parsedBody.success) return parsedBody.response + + const validation = validateSchema( + createConnectorBodySchema, + parsedBody.data, + 'Invalid request' + ) + if (!validation.success) return validation.response - const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = parsed.data + const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = + validation.data if (syncIntervalMinutes > 0 && syncIntervalMinutes < 60) { const canUseLiveSync = await hasLiveSyncAccess(auth.userId) @@ -157,10 +152,10 @@ export const POST = withRouteHandler( resolvedCredentialId = credentialId } - const validation = await connectorConfig.validateConfig(accessToken, sourceConfig) - if (!validation.valid) { + const configValidation = await connectorConfig.validateConfig(accessToken, sourceConfig) + if (!configValidation.valid) { return NextResponse.json( - { error: validation.error || 'Invalid source configuration' }, + { error: configValidation.error || 'Invalid source configuration' }, { status: 400 } ) } 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 82c51874de9..b26672dcd96 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,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateChunkBodySchema } from '@/lib/api/contracts/knowledge' +import { parseJsonBody, validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteChunk, updateChunk } from '@/lib/knowledge/chunks/service' @@ -9,11 +10,6 @@ import { checkChunkAccess } from '@/app/api/knowledge/utils' const logger = createLogger('ChunkByIdAPI') -const UpdateChunkSchema = z.object({ - content: z.string().min(1, 'Content is required').optional(), - enabled: z.boolean().optional(), -}) - export const GET = withRouteHandler( async ( req: NextRequest, @@ -109,38 +105,38 @@ export const PUT = withRouteHandler( ) } - const body = await req.json() + const parsedBody = await parseJsonBody(req) + if (!parsedBody.success) return parsedBody.response - try { - const validatedData = UpdateChunkSchema.parse(body) + const validation = validateSchema( + updateChunkBodySchema, + parsedBody.data, + 'Invalid request data' + ) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid chunk update data`, { + errors: validation.error.issues, + }) + return validation.response + } - const updatedChunk = await updateChunk( - chunkId, - validatedData, - requestId, - accessCheck.knowledgeBase?.workspaceId - ) + const validatedData = validation.data - logger.info( - `[${requestId}] Chunk updated: ${chunkId} in document ${documentId} in knowledge base ${knowledgeBaseId}` - ) + const updatedChunk = await updateChunk( + chunkId, + validatedData, + requestId, + accessCheck.knowledgeBase?.workspaceId + ) - return NextResponse.json({ - success: true, - data: updatedChunk, - }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid chunk update data`, { - errors: validationError.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) - } - throw validationError - } + logger.info( + `[${requestId}] Chunk updated: ${chunkId} in document ${documentId} in knowledge base ${knowledgeBaseId}` + ) + + return NextResponse.json({ + success: true, + data: updatedChunk, + }) } catch (error) { logger.error(`[${requestId}] Error updating chunk`, error) return NextResponse.json({ error: 'Failed to update chunk' }, { status: 500 }) 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 6935272ad6b..45aeace5f02 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,7 +1,12 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + bulkChunkOperationBodySchema, + createChunkBodySchema, + listKnowledgeChunksQuerySchema, +} from '@/lib/api/contracts/knowledge' +import { isZodError } 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' @@ -11,28 +16,6 @@ import { calculateCost } from '@/providers/utils' const logger = createLogger('DocumentChunksAPI') -const GetChunksQuerySchema = z.object({ - search: z.string().optional(), - enabled: z.enum(['true', 'false', 'all']).optional().default('all'), - limit: z.coerce.number().min(1).max(100).optional().default(50), - offset: z.coerce.number().min(0).optional().default(0), - sortBy: z.enum(['chunkIndex', 'tokenCount', 'enabled']).optional().default('chunkIndex'), - sortOrder: z.enum(['asc', 'desc']).optional().default('asc'), -}) - -const CreateChunkSchema = z.object({ - content: z.string().min(1, 'Content is required').max(10000, 'Content too long'), - enabled: z.boolean().optional().default(true), -}) - -const BatchOperationSchema = z.object({ - operation: z.enum(['enable', 'disable', 'delete']), - chunkIds: z - .array(z.string()) - .min(1, 'At least one chunk ID is required') - .max(100, 'Cannot operate on more than 100 chunks at once'), -}) - export const GET = withRouteHandler( async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { const requestId = generateRequestId() @@ -84,7 +67,7 @@ export const GET = withRouteHandler( } const { searchParams } = new URL(req.url) - const queryParams = GetChunksQuerySchema.parse({ + const queryResult = listKnowledgeChunksQuerySchema.safeParse({ search: searchParams.get('search') || undefined, enabled: searchParams.get('enabled') || undefined, limit: searchParams.get('limit') || undefined, @@ -92,8 +75,14 @@ export const GET = withRouteHandler( 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, queryParams, requestId) + const result = await queryChunks(documentId, queryResult.data, requestId) return NextResponse.json({ success: true, @@ -178,7 +167,7 @@ export const POST = withRouteHandler( } try { - const validatedData = CreateChunkSchema.parse(searchParams) + const validatedData = createChunkBodySchema.parse(searchParams) const docTags = { // Text tags (7 slots) @@ -248,12 +237,12 @@ export const POST = withRouteHandler( }, }) } catch (validationError) { - if (validationError instanceof z.ZodError) { + if (isZodError(validationError)) { logger.warn(`[${requestId}] Invalid chunk creation data`, { - errors: validationError.errors, + errors: validationError.issues, }) return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, + { error: 'Invalid request data', details: validationError.issues }, { status: 400 } ) } @@ -307,7 +296,7 @@ export const PATCH = withRouteHandler( const body = await req.json() try { - const validatedData = BatchOperationSchema.parse(body) + const validatedData = bulkChunkOperationBodySchema.parse(body) const { operation, chunkIds } = validatedData const result = await batchChunkOperation(documentId, operation, chunkIds, requestId) @@ -323,12 +312,12 @@ export const PATCH = withRouteHandler( }, }) } catch (validationError) { - if (validationError instanceof z.ZodError) { + if (isZodError(validationError)) { logger.warn(`[${requestId}] Invalid batch operation data`, { - errors: validationError.errors, + errors: validationError.issues, }) return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, + { error: 'Invalid request data', details: validationError.issues }, { status: 400 } ) } 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 f49a23a83a9..4bea1850aa7 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts @@ -1,7 +1,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateDocumentBodySchema } from '@/lib/api/contracts/knowledge' +import { isZodError } 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' @@ -16,39 +17,6 @@ import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowled const logger = createLogger('DocumentByIdAPI') -const UpdateDocumentSchema = z.object({ - filename: z.string().min(1, 'Filename is required').optional(), - enabled: z.boolean().optional(), - chunkCount: z.number().min(0).optional(), - tokenCount: z.number().min(0).optional(), - characterCount: z.number().min(0).optional(), - processingStatus: z.enum(['pending', 'processing', 'completed', 'failed']).optional(), - processingError: z.string().optional(), - markFailedDueToTimeout: z.boolean().optional(), - retryProcessing: z.boolean().optional(), - // Text tag fields - tag1: z.string().optional(), - tag2: z.string().optional(), - tag3: z.string().optional(), - tag4: z.string().optional(), - tag5: z.string().optional(), - tag6: z.string().optional(), - tag7: z.string().optional(), - // Number tag fields - number1: z.string().optional(), - number2: z.string().optional(), - number3: z.string().optional(), - number4: z.string().optional(), - number5: z.string().optional(), - // Date tag fields - date1: z.string().optional(), - date2: z.string().optional(), - // Boolean tag fields - boolean1: z.string().optional(), - boolean2: z.string().optional(), - boolean3: z.string().optional(), -}) - export const GET = withRouteHandler( async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { const requestId = generateRequestId() @@ -123,7 +91,7 @@ export const PUT = withRouteHandler( const body = await req.json() try { - const validatedData = UpdateDocumentSchema.parse(body) + const validatedData = updateDocumentBodySchema.parse(body) const updateData: any = {} @@ -225,13 +193,13 @@ export const PUT = withRouteHandler( }) } } catch (validationError) { - if (validationError instanceof z.ZodError) { + if (isZodError(validationError)) { logger.warn(`[${requestId}] Invalid document update data`, { - errors: validationError.errors, + errors: validationError.issues, documentId, }) return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, + { error: 'Invalid request data', details: validationError.issues }, { status: 400 } ) } 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 34a707021a3..4db581f54b2 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,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { saveDocumentTagDefinitionsBodySchema } from '@/lib/api/contracts/knowledge' +import { parseJsonBody, validateSchema } 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' @@ -18,18 +19,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('DocumentTagDefinitionsAPI') -const TagDefinitionSchema = z.object({ - tagSlot: z.string(), // Will be validated against field type slots - displayName: z.string().min(1, 'Display name is required').max(100, 'Display name too long'), - fieldType: z.enum(SUPPORTED_FIELD_TYPES as [string, ...string[]]).default('text'), - // Optional: for editing existing definitions - _originalDisplayName: z.string().optional(), -}) - -const BulkTagDefinitionsSchema = z.object({ - definitions: z.array(TagDefinitionSchema), -}) - // 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 }> }) => { @@ -107,23 +96,39 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - let body - try { - body = await req.json() - } catch (error) { - logger.error(`[${requestId}] Failed to parse JSON body:`, error) - return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) - } + const parsedBody = await parseJsonBody(req) + if (!parsedBody.success) return parsedBody.response - if (!body || typeof body !== 'object') { - logger.error(`[${requestId}] Invalid request body:`, body) + if (!parsedBody.data || typeof parsedBody.data !== 'object') { + logger.error(`[${requestId}] Invalid request body:`, parsedBody.data) return NextResponse.json( { error: 'Request body must be a valid JSON object' }, { status: 400 } ) } - const validatedData = BulkTagDefinitionsSchema.parse(body) + const validation = validateSchema( + saveDocumentTagDefinitionsBodySchema, + parsedBody.data, + 'Invalid request data' + ) + if (!validation.success) return validation.response + + const validatedData = validation.data + + 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) => ({ @@ -145,13 +150,6 @@ export const POST = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error creating/updating tag definitions`, error) return NextResponse.json( { error: 'Failed to create/update tag definitions' }, diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index 40ed102fba7..84b19741d68 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -3,7 +3,13 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + bulkCreateDocumentsBodySchema, + bulkDocumentOperationBodySchema, + createDocumentBodySchema, + listKnowledgeDocumentsQuerySchema, +} from '@/lib/api/contracts/knowledge' +import { isZodError } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -17,55 +23,11 @@ import { processDocumentsWithQueue, type TagFilterCondition, } from '@/lib/knowledge/documents/service' -import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import { captureServerEvent } from '@/lib/posthog/server' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('DocumentsAPI') -const CreateDocumentSchema = z.object({ - filename: z.string().min(1, 'Filename is required'), - fileUrl: z.string().url('File URL must be valid'), - fileSize: z.number().min(1, 'File size must be greater than 0'), - mimeType: z.string().min(1, 'MIME type is required'), - // Document tags for filtering (legacy format) - tag1: z.string().optional(), - tag2: z.string().optional(), - tag3: z.string().optional(), - tag4: z.string().optional(), - tag5: z.string().optional(), - tag6: z.string().optional(), - tag7: z.string().optional(), - // Structured tag data (new format) - documentTagsData: z.string().optional(), -}) - -const BulkCreateDocumentsSchema = z.object({ - documents: z.array(CreateDocumentSchema), - processingOptions: z - .object({ - recipe: z.string().optional(), - lang: z.string().optional(), - }) - .optional(), - bulk: z.literal(true), -}) - -const BulkUpdateDocumentsSchema = z - .object({ - operation: z.enum(['enable', 'disable', 'delete']), - documentIds: z - .array(z.string()) - .min(1, 'At least one document ID is required') - .max(100, 'Cannot operate on more than 100 documents at once') - .optional(), - selectAll: z.boolean().optional(), - enabledFilter: z.enum(['all', 'enabled', 'disabled']).optional(), - }) - .refine((data) => data.selectAll || (data.documentIds && data.documentIds.length > 0), { - message: 'Either selectAll must be true or documentIds must be provided', - }) - export const GET = withRouteHandler( async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateId().slice(0, 8) @@ -91,52 +53,17 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const url = new URL(req.url) - const enabledFilter = url.searchParams.get('enabledFilter') as - | 'all' - | 'enabled' - | 'disabled' - | null - const search = url.searchParams.get('search') || undefined - const limit = Number.parseInt(url.searchParams.get('limit') || '50') - const offset = Number.parseInt(url.searchParams.get('offset') || '0') - const sortByParam = url.searchParams.get('sortBy') - const sortOrderParam = url.searchParams.get('sortOrder') - - const validSortFields: DocumentSortField[] = [ - 'filename', - 'fileSize', - 'tokenCount', - 'chunkCount', - 'uploadedAt', - 'processingStatus', - 'enabled', - ] - const validSortOrders: SortOrder[] = ['asc', 'desc'] - - const sortBy = - sortByParam && validSortFields.includes(sortByParam as DocumentSortField) - ? (sortByParam as DocumentSortField) - : undefined - const sortOrder = - sortOrderParam && validSortOrders.includes(sortOrderParam as SortOrder) - ? (sortOrderParam as SortOrder) - : undefined - - let tagFilters: TagFilterCondition[] | undefined - const tagFiltersParam = url.searchParams.get('tagFilters') - if (tagFiltersParam) { - try { - const parsed = JSON.parse(tagFiltersParam) - if (Array.isArray(parsed)) { - tagFilters = parsed.filter( - (f: TagFilterCondition) => f.tagSlot && f.operator && f.value !== undefined - ) - } - } catch { - logger.warn(`[${requestId}] Invalid tagFilters param`) - } + 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 const result = await getDocuments( knowledgeBaseId, @@ -147,7 +74,7 @@ export const GET = withRouteHandler( offset, ...(sortBy && { sortBy }), ...(sortOrder && { sortOrder }), - tagFilters, + tagFilters: tagFilters as TagFilterCondition[] | undefined, }, requestId ) @@ -223,7 +150,7 @@ export const POST = withRouteHandler( if (body.bulk === true) { try { - const validatedData = BulkCreateDocumentsSchema.parse(body) + const validatedData = bulkCreateDocumentsBodySchema.parse(body) const createdDocuments = await createDocumentRecords( validatedData.documents, @@ -306,12 +233,12 @@ export const POST = withRouteHandler( }, }) } catch (validationError) { - if (validationError instanceof z.ZodError) { + if (isZodError(validationError)) { logger.warn(`[${requestId}] Invalid bulk processing request data`, { - errors: validationError.errors, + errors: validationError.issues, }) return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, + { error: 'Invalid request data', details: validationError.issues }, { status: 400 } ) } @@ -319,7 +246,7 @@ export const POST = withRouteHandler( } } else { try { - const validatedData = CreateDocumentSchema.parse(body) + const validatedData = createDocumentBodySchema.parse(body) const newDocument = await createSingleDocument(validatedData, knowledgeBaseId, requestId) @@ -375,12 +302,12 @@ export const POST = withRouteHandler( data: newDocument, }) } catch (validationError) { - if (validationError instanceof z.ZodError) { + if (isZodError(validationError)) { logger.warn(`[${requestId}] Invalid document data`, { - errors: validationError.errors, + errors: validationError.issues, }) return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, + { error: 'Invalid request data', details: validationError.issues }, { status: 400 } ) } @@ -431,7 +358,7 @@ export const PATCH = withRouteHandler( const body = await req.json() try { - const validatedData = BulkUpdateDocumentsSchema.parse(body) + const validatedData = bulkDocumentOperationBodySchema.parse(body) const { operation, documentIds, selectAll, enabledFilter } = validatedData try { @@ -467,12 +394,12 @@ export const PATCH = withRouteHandler( throw error } } catch (validationError) { - if (validationError instanceof z.ZodError) { + if (isZodError(validationError)) { logger.warn(`[${requestId}] Invalid bulk operation data`, { - errors: validationError.errors, + errors: validationError.issues, }) return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, + { error: 'Invalid request data', details: validationError.issues }, { status: 400 } ) } 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 8dcc1385b61..5662b9512dc 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts @@ -6,7 +6,8 @@ import { generateId } from '@sim/utils/id' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { upsertDocumentBodySchema } from '@/lib/api/contracts/knowledge' +import { parseJsonBody, validateSchema } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -19,29 +20,15 @@ import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('DocumentUpsertAPI') -const UpsertDocumentSchema = z.object({ - documentId: z.string().optional(), - filename: z.string().min(1, 'Filename is required'), - fileUrl: z.string().min(1, 'File URL is required'), - fileSize: z.number().min(1, 'File size must be greater than 0'), - mimeType: z.string().min(1, 'MIME type is required'), - documentTagsData: z.string().optional(), - processingOptions: z - .object({ - recipe: z.string().optional(), - lang: z.string().optional(), - }) - .optional(), - workflowId: z.string().optional(), -}) - 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 body = await req.json() + const parsedBody = await parseJsonBody(req) + if (!parsedBody.success) return parsedBody.response + const body = parsedBody.data as Record logger.info(`[${requestId}] Knowledge base document upsert request`, { knowledgeBaseId, @@ -56,7 +43,14 @@ export const POST = withRouteHandler( } const userId = auth.userId - const validatedData = UpsertDocumentSchema.parse(body) + const validation = validateSchema(upsertDocumentBodySchema, body, 'Invalid request data') + if (!validation.success) { + logger.warn(`[${requestId}] Invalid upsert request data`, { + errors: validation.error.issues, + }) + return validation.response + } + const validatedData = validation.data if (validatedData.workflowId) { const authorization = await authorizeWorkflowByWorkspacePermission({ @@ -231,14 +225,6 @@ export const POST = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid upsert request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error upserting document`, error) const errorMessage = error instanceof Error ? error.message : 'Failed to upsert document' 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 1c845e0eeaa..c58876af4da 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,6 +1,11 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' +import { + knowledgeBaseParamsSchema, + nextAvailableSlotQuerySchema, +} from '@/lib/api/contracts/knowledge' +import { validateSchema } 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' @@ -12,13 +17,24 @@ const logger = createLogger('NextAvailableSlotAPI') export const GET = withRouteHandler( async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await params - const { searchParams } = new URL(req.url) - const fieldType = searchParams.get('fieldType') + const paramsValidation = validateSchema( + knowledgeBaseParamsSchema, + await params, + 'Invalid request parameters' + ) + if (!paramsValidation.success) return paramsValidation.response + const { id: knowledgeBaseId } = paramsValidation.data - if (!fieldType) { + const { searchParams } = new URL(req.url) + const queryValidation = validateSchema( + nextAvailableSlotQuerySchema, + { fieldType: searchParams.get('fieldType') ?? undefined }, + 'fieldType parameter is required' + ) + if (!queryValidation.success) { return NextResponse.json({ error: 'fieldType parameter is required' }, { status: 400 }) } + const { fieldType } = queryValidation.data try { logger.info( diff --git a/apps/sim/app/api/knowledge/[id]/restore/route.ts b/apps/sim/app/api/knowledge/[id]/restore/route.ts index ece42f9f5dc..788e19a019c 100644 --- a/apps/sim/app/api/knowledge/[id]/restore/route.ts +++ b/apps/sim/app/api/knowledge/[id]/restore/route.ts @@ -4,6 +4,8 @@ import { knowledgeBase } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { knowledgeBaseParamsSchema } from '@/lib/api/contracts/knowledge' +import { validateSchema } 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' @@ -15,7 +17,13 @@ const logger = createLogger('RestoreKnowledgeBaseAPI') export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const validation = validateSchema( + knowledgeBaseParamsSchema, + await params, + 'Invalid request parameters' + ) + if (!validation.success) return validation.response + const { id } = validation.data try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) diff --git a/apps/sim/app/api/knowledge/[id]/route.ts b/apps/sim/app/api/knowledge/[id]/route.ts index 6f97a2515c4..e68283d4cbb 100644 --- a/apps/sim/app/api/knowledge/[id]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/route.ts @@ -1,7 +1,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateKnowledgeBaseBodySchema } from '@/lib/api/contracts/knowledge' +import { parseJsonBody, validateSchema } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' @@ -16,42 +17,6 @@ import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/a const logger = createLogger('KnowledgeBaseByIdAPI') -/** - * Schema for updating a knowledge base - * - * Chunking config units: - * - maxSize: tokens (1 token ≈ 4 characters) - * - minSize: characters - * - overlap: tokens (1 token ≈ 4 characters) - */ -const UpdateKnowledgeBaseSchema = z.object({ - name: z.string().min(1, 'Name is required').optional(), - description: z.string().optional(), - embeddingModel: z.literal('text-embedding-3-small').optional(), - embeddingDimension: z.literal(1536).optional(), - workspaceId: z.string().nullable().optional(), - chunkingConfig: z - .object({ - /** Maximum chunk size in tokens (1 token ≈ 4 characters) */ - maxSize: z.number().min(100).max(4000), - /** Minimum chunk size in characters */ - minSize: z.number().min(1).max(2000), - /** Overlap between chunks in characters */ - overlap: z.number().min(0).max(500), - }) - .refine( - (data) => { - // Convert maxSize from tokens to characters for comparison (1 token ≈ 4 chars) - const maxSizeInChars = data.maxSize * 4 - return data.minSize < maxSizeInChars - }, - { - message: 'Min chunk size (characters) must be less than max chunk size (tokens × 4)', - } - ) - .optional(), -}) - export const GET = withRouteHandler( async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() @@ -123,67 +88,67 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await req.json() + const parsedBody = await parseJsonBody(req) + if (!parsedBody.success) return parsedBody.response + + const validation = validateSchema( + updateKnowledgeBaseBodySchema, + parsedBody.data, + 'Invalid request data' + ) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid knowledge base update data`, { + errors: validation.error.issues, + }) + return validation.response + } - try { - const validatedData = UpdateKnowledgeBaseSchema.parse(body) + const validatedData = validation.data - const updatedKnowledgeBase = await updateKnowledgeBase( - id, - { - name: validatedData.name, - description: validatedData.description, - workspaceId: validatedData.workspaceId, - chunkingConfig: validatedData.chunkingConfig, - }, - requestId - ) + const updatedKnowledgeBase = await updateKnowledgeBase( + id, + { + name: validatedData.name, + description: validatedData.description, + workspaceId: validatedData.workspaceId, + chunkingConfig: validatedData.chunkingConfig, + }, + requestId + ) - logger.info(`[${requestId}] Knowledge base updated: ${id} for user ${userId}`) + 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, - }) + 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 (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid knowledge base update data`, { - errors: validationError.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) - } - throw validationError - } + return NextResponse.json({ + success: true, + data: updatedKnowledgeBase, + }) } catch (error) { if (error instanceof KnowledgeBaseConflictError) { return NextResponse.json({ error: error.message }, { status: 409 }) 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 a33bdce2fe5..bc422a193e5 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,6 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' +import { knowledgeTagParamsSchema } from '@/lib/api/contracts/knowledge' +import { validateSchema } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteTagDefinition } from '@/lib/knowledge/tags/service' @@ -14,7 +16,13 @@ const logger = createLogger('TagDefinitionAPI') export const DELETE = withRouteHandler( async (req: NextRequest, { params }: { params: Promise<{ id: string; tagId: string }> }) => { const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId, tagId } = await params + const validation = validateSchema( + knowledgeTagParamsSchema, + await params, + 'Invalid request parameters' + ) + if (!validation.success) return validation.response + const { id: knowledgeBaseId, tagId } = validation.data try { logger.info( 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 395cf3901d4..9fa9dc28e9a 100644 --- a/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts +++ b/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { createTagDefinitionBodySchema } from '@/lib/api/contracts/knowledge' +import { parseJsonBody, validateSchema } 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' @@ -79,27 +80,22 @@ export const POST = withRouteHandler( } } - const body = await req.json() + const parsedBody = await parseJsonBody(req) + if (!parsedBody.success) return parsedBody.response - const CreateTagDefinitionSchema = z.object({ - tagSlot: z.string().min(1, 'Tag slot is required'), - displayName: z.string().min(1, 'Display name is required'), - fieldType: z.enum(SUPPORTED_FIELD_TYPES as [string, ...string[]], { - errorMap: () => ({ message: 'Invalid field type' }), - }), - }) - - let validatedData - try { - validatedData = CreateTagDefinitionSchema.parse(body) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - throw error + const validation = validateSchema( + createTagDefinitionBodySchema, + parsedBody.data, + 'Invalid request data' + ) + if (!validation.success) return validation.response + + const validatedData = validation.data + 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( 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 08d820ddbdb..5a9e416d67c 100644 --- a/apps/sim/app/api/knowledge/[id]/tag-usage/route.ts +++ b/apps/sim/app/api/knowledge/[id]/tag-usage/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' +import { knowledgeBaseParamsSchema } from '@/lib/api/contracts/knowledge' +import { validateSchema } 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' @@ -14,7 +16,13 @@ const logger = createLogger('TagUsageAPI') export const GET = withRouteHandler( async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await params + const validation = validateSchema( + knowledgeBaseParamsSchema, + await params, + 'Invalid request parameters' + ) + if (!validation.success) return validation.response + const { id: knowledgeBaseId } = validation.data try { logger.info( diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts index 133d553388f..c6c17cdbd90 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts @@ -3,6 +3,8 @@ import { knowledgeBase, knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray, isNull, lte } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { verifyCronAuth } from '@/lib/auth/internal' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -20,6 +22,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() logger.info(`[${requestId}] Connector sync scheduler triggered`) + const validation = validateSchema(noInputSchema, {}) + if (!validation.success) return validation.response + const authError = verifyCronAuth(request, 'Connector sync scheduler') if (authError) { return authError diff --git a/apps/sim/app/api/knowledge/route.ts b/apps/sim/app/api/knowledge/route.ts index 7f8b0c1309b..2854cec51c9 100644 --- a/apps/sim/app/api/knowledge/route.ts +++ b/apps/sim/app/api/knowledge/route.ts @@ -1,7 +1,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + createKnowledgeBaseBodySchema, + listKnowledgeBasesQuerySchema, +} from '@/lib/api/contracts/knowledge' +import { isZodError } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' @@ -16,64 +20,6 @@ import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('KnowledgeBaseAPI') -const CreateKnowledgeBaseSchema = z.object({ - name: z.string().min(1, 'Name is required'), - description: z.string().optional(), - workspaceId: z.string().min(1, 'Workspace ID is required'), - embeddingModel: z.literal('text-embedding-3-small').default('text-embedding-3-small'), - embeddingDimension: z.literal(1536).default(1536), - chunkingConfig: 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), - strategy: z - .enum(['auto', 'text', 'regex', 'recursive', 'sentence', 'token']) - .default('auto') - .optional(), - strategyOptions: z - .object({ - pattern: z.string().max(500).optional(), - separators: z.array(z.string()).optional(), - recipe: z.enum(['plain', 'markdown', 'code']).optional(), - }) - .optional(), - }) - .default({ - maxSize: 1024, - minSize: 100, - overlap: 200, - }) - .refine( - (data) => { - const maxSizeInChars = data.maxSize * 4 - return data.minSize < maxSizeInChars - }, - { - message: 'Min chunk size (characters) must be less than max chunk size (tokens × 4)', - } - ) - .refine( - (data) => { - return data.overlap < data.maxSize - }, - { - message: 'Overlap must be less than max chunk size', - } - ) - .refine( - (data) => { - if (data.strategy === 'regex' && !data.strategyOptions?.pattern) { - return false - } - return true - }, - { - message: 'Regex pattern is required when using the regex chunking strategy', - } - ), -}) - export const GET = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() @@ -85,13 +31,23 @@ export const GET = withRouteHandler(async (req: NextRequest) => { } const { searchParams } = new URL(req.url) - const workspaceId = searchParams.get('workspaceId') - const scope = (searchParams.get('scope') ?? 'active') as KnowledgeBaseScope - if (!['active', 'archived', 'all'].includes(scope)) { - return NextResponse.json({ error: 'Invalid scope' }, { status: 400 }) + 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) + const knowledgeBasesWithCounts = await getKnowledgeBases( + session.user.id, + workspaceId, + scope as KnowledgeBaseScope + ) return NextResponse.json({ success: true, @@ -116,7 +72,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const body = await req.json() try { - const validatedData = CreateKnowledgeBaseSchema.parse(body) + const validatedData = createKnowledgeBaseBodySchema.parse(body) const createData = { ...validatedData, @@ -181,12 +137,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { data: newKnowledgeBase, }) } catch (validationError) { - if (validationError instanceof z.ZodError) { + if (isZodError(validationError)) { logger.warn(`[${requestId}] Invalid knowledge base data`, { - errors: validationError.errors, + errors: validationError.issues, }) return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, + { error: 'Invalid request data', details: validationError.issues }, { status: 400 } ) } diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index 6c9db51ccc2..153b559d2fa 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { knowledgeSearchBodySchema } from '@/lib/api/contracts/knowledge' +import { parseJsonBody, validateSchema } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' @@ -25,57 +26,13 @@ import { calculateCost } from '@/providers/utils' const logger = createLogger('VectorSearchAPI') -/** Structured tag filter with operator support */ -const StructuredTagFilterSchema = z.object({ - tagName: z.string(), - tagSlot: z.string().optional(), - fieldType: z.enum(['text', 'number', 'date', 'boolean']).optional(), - operator: z.string().default('eq'), - value: z.union([z.string(), z.number(), z.boolean()]), - valueTo: z.union([z.string(), z.number()]).optional(), -}) - -const VectorSearchSchema = z - .object({ - knowledgeBaseIds: z.union([ - z.string().min(1, 'Knowledge base ID is required'), - z.array(z.string().min(1)).min(1, 'At least one knowledge base ID is required'), - ]), - query: z - .string() - .optional() - .nullable() - .transform((val) => val || undefined), - topK: z - .number() - .min(1) - .max(100) - .optional() - .nullable() - .default(10) - .transform((val) => val ?? 10), - tagFilters: z - .array(StructuredTagFilterSchema) - .optional() - .nullable() - .transform((val) => val || undefined), - }) - .refine( - (data) => { - const hasQuery = data.query && data.query.trim().length > 0 - const hasTagFilters = data.tagFilters && data.tagFilters.length > 0 - return hasQuery || hasTagFilters - }, - { - message: 'Please provide either a search query or tag filters to search your knowledge base', - } - ) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body = await request.json() + const parsedBody = await parseJsonBody(request) + if (!parsedBody.success) return parsedBody.response + const body = parsedBody.data as Record const { workflowId, ...searchParams } = body const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -86,7 +43,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (workflowId) { const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, + workflowId: workflowId as string, userId, action: 'read', }) @@ -98,349 +55,320 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } - try { - const validatedData = VectorSearchSchema.parse(searchParams) + const validation = validateSchema( + knowledgeSearchBodySchema, + searchParams, + 'Invalid request data' + ) + if (!validation.success) return validation.response + const validatedData = validation.data - const knowledgeBaseIds = Array.isArray(validatedData.knowledgeBaseIds) - ? validatedData.knowledgeBaseIds - : [validatedData.knowledgeBaseIds] + const knowledgeBaseIds = Array.isArray(validatedData.knowledgeBaseIds) + ? validatedData.knowledgeBaseIds + : [validatedData.knowledgeBaseIds] - // Check access permissions in parallel for performance - const accessChecks = await Promise.all( - knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId)) - ) - const accessibleKbIds: string[] = knowledgeBaseIds.filter( - (_, idx) => accessChecks[idx]?.hasAccess - ) - - // Map display names to tag slots for filtering - let structuredFilters: StructuredFilter[] = [] + const accessChecks = await Promise.all( + knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId)) + ) + const accessibleKbIds: string[] = knowledgeBaseIds.filter( + (_, idx) => accessChecks[idx]?.hasAccess + ) - // Handle tag filters - if (validatedData.tagFilters && accessibleKbIds.length > 0) { - const kbTagDefs = await Promise.all( - accessibleKbIds.map(async (kbId) => ({ - kbId, - tagDefs: await getDocumentTagDefinitions(kbId), - })) - ) + let structuredFilters: StructuredFilter[] = [] - const displayNameToTagDef: Record = {} - for (const { kbId, tagDefs } of kbTagDefs) { - const perKbMap = new Map( - tagDefs.map((def) => [ - def.displayName, - { tagSlot: def.tagSlot, fieldType: def.fieldType }, - ]) - ) + if (validatedData.tagFilters && accessibleKbIds.length > 0) { + const kbTagDefs = await Promise.all( + accessibleKbIds.map(async (kbId) => ({ + kbId, + tagDefs: await getDocumentTagDefinitions(kbId), + })) + ) - 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 displayNameToTagDef: Record = {} + for (const { kbId, tagDefs } of kbTagDefs) { + const perKbMap = new Map( + tagDefs.map((def) => [ + def.displayName, + { tagSlot: def.tagSlot, fieldType: def.fieldType }, + ]) + ) - const existing = displayNameToTagDef[filter.tagName] - if ( - existing && - (existing.tagSlot !== current.tagSlot || existing.fieldType !== current.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}" is not mapped consistently across the selected knowledge bases. Search those knowledge bases separately.`, + error: `Tag "${filter.tagName}" does not exist in all selected knowledge bases. Search those knowledge bases separately.`, }, { status: 400 } ) } + continue + } - displayNameToTagDef[filter.tagName] = current + 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 } + ) } - logger.debug(`[${requestId}] Loaded tag definitions for KB ${kbId}`, { - tagCount: tagDefs.length, - }) + displayNameToTagDef[filter.tagName] = current } - // Validate all tag filters first - const undefinedTags: string[] = [] - const typeErrors: string[] = [] + logger.debug(`[${requestId}] Loaded tag definitions for KB ${kbId}`, { + tagCount: tagDefs.length, + }) + } - for (const filter of validatedData.tagFilters) { - const tagDef = displayNameToTagDef[filter.tagName] + const undefinedTags: string[] = [] + const typeErrors: string[] = [] - // Check if tag exists - if (!tagDef) { - undefinedTags.push(filter.tagName) - continue - } + for (const filter of validatedData.tagFilters) { + const tagDef = displayNameToTagDef[filter.tagName] - // Validate value type using shared validation - const validationError = validateTagValue( - filter.tagName, - String(filter.value), - tagDef.fieldType - ) - if (validationError) { - typeErrors.push(validationError) - } + if (!tagDef) { + undefinedTags.push(filter.tagName) + continue } - // Throw combined error if there are any validation issues - if (undefinedTags.length > 0 || typeErrors.length > 0) { - const errorParts: string[] = [] - - if (undefinedTags.length > 0) { - errorParts.push(buildUndefinedTagsError(undefinedTags)) - } + const validationError = validateTagValue( + filter.tagName, + String(filter.value), + tagDef.fieldType + ) + if (validationError) { + typeErrors.push(validationError) + } + } - if (typeErrors.length > 0) { - errorParts.push(...typeErrors) - } + if (undefinedTags.length > 0 || typeErrors.length > 0) { + const errorParts: string[] = [] - return NextResponse.json({ error: errorParts.join('\n') }, { status: 400 }) + if (undefinedTags.length > 0) { + errorParts.push(buildUndefinedTagsError(undefinedTags)) } - // Build structured filters with validated data - 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} ${filter.value}` - ) + if (typeErrors.length > 0) { + errorParts.push(...typeErrors) + } - return { - tagSlot, - fieldType, - operator: filter.operator, - value: filter.value, - valueTo: filter.valueTo, - } - }) + return NextResponse.json({ error: errorParts.join('\n') }, { status: 400 }) } - if (accessibleKbIds.length === 0) { - return NextResponse.json( - { error: 'Knowledge base not found or access denied' }, - { status: 404 } + 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} ${filter.value}` ) - } - const workspaceId = accessChecks.find((ac) => ac?.hasAccess)?.knowledgeBase?.workspaceId + 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 workspaceId = accessChecks.find((ac) => ac?.hasAccess)?.knowledgeBase?.workspaceId + + const hasQuery = validatedData.query && validatedData.query.trim().length > 0 + const queryEmbeddingPromise = hasQuery + ? generateSearchEmbedding(validatedData.query!, undefined, workspaceId) + : Promise.resolve(null) - const hasQuery = validatedData.query && validatedData.query.trim().length > 0 - const queryEmbeddingPromise = hasQuery - ? generateSearchEmbedding(validatedData.query!, undefined, workspaceId) - : Promise.resolve(null) + const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id)) - // Check if any requested knowledge bases were not accessible - 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 } + ) + } - if (inaccessibleKbIds.length > 0) { + 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 bases not found or access denied: ${inaccessibleKbIds.join(', ')}` }, - { status: 404 } + { error: 'Knowledge base does not belong to the workflow workspace' }, + { status: 400 } ) } + } - if (workflowId) { - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - 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 } - ) - } - } + let results: SearchResult[] - let results: SearchResult[] + const hasFilters = structuredFilters && structuredFilters.length > 0 - const hasFilters = structuredFilters && structuredFilters.length > 0 + if (!hasQuery && hasFilters) { + results = await handleTagOnlySearch({ + knowledgeBaseIds: accessibleKbIds, + topK: validatedData.topK, + structuredFilters, + }) + } else if (hasQuery && hasFilters) { + logger.debug(`[${requestId}] Executing tag + vector search with filters:`, structuredFilters) + const strategy = getQueryStrategy(accessibleKbIds.length, validatedData.topK) + const queryVector = JSON.stringify(await queryEmbeddingPromise) + + results = await handleTagAndVectorSearch({ + knowledgeBaseIds: accessibleKbIds, + topK: validatedData.topK, + structuredFilters, + queryVector, + distanceThreshold: strategy.distanceThreshold, + }) + } else if (hasQuery && !hasFilters) { + const strategy = getQueryStrategy(accessibleKbIds.length, validatedData.topK) + const queryVector = JSON.stringify(await queryEmbeddingPromise) + + results = await handleVectorOnlySearch({ + knowledgeBaseIds: accessibleKbIds, + topK: validatedData.topK, + queryVector, + distanceThreshold: strategy.distanceThreshold, + }) + } else { + return NextResponse.json( + { + error: + 'Please provide either a search query or tag filters to search your knowledge base', + }, + { status: 400 } + ) + } - if (!hasQuery && hasFilters) { - // Tag-only search without vector similarity - results = await handleTagOnlySearch({ - knowledgeBaseIds: accessibleKbIds, - topK: validatedData.topK, - structuredFilters, - }) - } else if (hasQuery && hasFilters) { - // Tag + Vector search - logger.debug( - `[${requestId}] Executing tag + vector search with filters:`, - structuredFilters - ) - const strategy = getQueryStrategy(accessibleKbIds.length, validatedData.topK) - const queryVector = JSON.stringify(await queryEmbeddingPromise) - - results = await handleTagAndVectorSearch({ - knowledgeBaseIds: accessibleKbIds, - topK: validatedData.topK, - structuredFilters, - queryVector, - distanceThreshold: strategy.distanceThreshold, - }) - } else if (hasQuery && !hasFilters) { - // Vector-only search - const strategy = getQueryStrategy(accessibleKbIds.length, validatedData.topK) - const queryVector = JSON.stringify(await queryEmbeddingPromise) - - results = await handleVectorOnlySearch({ - knowledgeBaseIds: accessibleKbIds, - topK: validatedData.topK, - queryVector, - distanceThreshold: strategy.distanceThreshold, + let cost = null + let tokenCount = null + if (hasQuery) { + try { + tokenCount = estimateTokenCount(validatedData.query!, 'openai') + cost = calculateCost('text-embedding-3-small', tokenCount.count, 0, false) + } catch (error) { + logger.warn(`[${requestId}] Failed to calculate cost for search query`, { + error: error instanceof Error ? error.message : 'Unknown error', }) - } else { - // This should never happen due to schema validation, but just in case - return NextResponse.json( - { - error: - 'Please provide either a search query or tag filters to search your knowledge base', - }, - { status: 400 } - ) } + } - // Calculate cost for the embedding (with fallback if calculation fails) - let cost = null - let tokenCount = null - if (hasQuery) { + const tagDefsResults = await Promise.all( + accessibleKbIds.map(async (kbId) => { try { - tokenCount = estimateTokenCount(validatedData.query!, 'openai') - cost = calculateCost('text-embedding-3-small', tokenCount.count, 0, false) - } catch (error) { - logger.warn(`[${requestId}] Failed to calculate cost for search query`, { - error: error instanceof Error ? error.message : 'Unknown error', + const tagDefs = await getDocumentTagDefinitions(kbId) + const map: Record = {} + tagDefs.forEach((def) => { + map[def.tagSlot] = def.displayName }) - // Continue without cost information rather than failing the search + return { kbId, map } + } catch (error) { + logger.warn(`[${requestId}] Failed to fetch tag definitions for display mapping:`, error) + return { kbId, map: {} as Record } } - } - - // Fetch tag definitions for display name mapping (reuse the same fetch from filtering) - const tagDefsResults = await Promise.all( - accessibleKbIds.map(async (kbId) => { - try { - const tagDefs = await getDocumentTagDefinitions(kbId) - const map: Record = {} - 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 } - } - }) - ) - const tagDefinitionsMap: Record> = {} - tagDefsResults.forEach(({ kbId, map }) => { - tagDefinitionsMap[kbId] = map }) + ) + const tagDefinitionsMap: Record> = {} + tagDefsResults.forEach(({ kbId, map }) => { + tagDefinitionsMap[kbId] = map + }) - // Fetch document names for the results - const documentIds = results.map((result) => result.documentId) - const documentNameMap = await getDocumentNamesByIds(documentIds) + const documentIds = results.map((result) => result.documentId) + const documentNameMap = await getDocumentNamesByIds(documentIds) - try { - PlatformEvents.knowledgeBaseSearched({ - knowledgeBaseId: accessibleKbIds[0], - resultsCount: results.length, - workspaceId: workspaceId || undefined, - }) - } catch { - // Telemetry should not fail the operation - } + try { + PlatformEvents.knowledgeBaseSearched({ + knowledgeBaseId: accessibleKbIds[0], + resultsCount: results.length, + workspaceId: workspaceId || undefined, + }) + } catch { + // Telemetry should not fail the operation + } - return NextResponse.json({ - success: true, - data: { - results: results.map((result) => { - const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} - logger.debug( - `[${requestId}] Result KB: ${result.knowledgeBaseId}, available mappings:`, - kbTagMap - ) + return NextResponse.json({ + success: true, + data: { + results: results.map((result) => { + const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} + logger.debug( + `[${requestId}] Result KB: ${result.knowledgeBaseId}, available mappings:`, + kbTagMap + ) + + const tags: Record = {} - // Create tags object with display names - const tags: Record = {} - - ALL_TAG_SLOTS.forEach((slot) => { - const tagValue = (result as any)[slot] - if (tagValue !== null && tagValue !== undefined) { - const displayName = kbTagMap[slot] || slot - logger.debug( - `[${requestId}] Mapping ${slot}="${tagValue}" -> "${displayName}"="${tagValue}"` - ) - tags[displayName] = tagValue - } - }) - - return { - documentId: result.documentId, - documentName: documentNameMap[result.documentId] || undefined, - content: result.content, - chunkIndex: result.chunkIndex, - metadata: tags, // Clean display name mapped tags - similarity: hasQuery ? 1 - result.distance : 1, // Perfect similarity for tag-only searches + ALL_TAG_SLOTS.forEach((slot) => { + const tagValue = (result as any)[slot] + if (tagValue !== null && tagValue !== undefined) { + const displayName = kbTagMap[slot] || slot + logger.debug( + `[${requestId}] Mapping ${slot}="${tagValue}" -> "${displayName}"="${tagValue}"` + ) + tags[displayName] = tagValue } - }), - query: validatedData.query || '', - knowledgeBaseIds: accessibleKbIds, - knowledgeBaseId: accessibleKbIds[0], - topK: validatedData.topK, - totalResults: results.length, - ...(cost && tokenCount - ? { - cost: { - input: cost.input, - output: cost.output, - total: cost.total, - tokens: { - prompt: tokenCount.count, - completion: 0, - total: tokenCount.count, - }, - model: 'text-embedding-3-small', - pricing: cost.pricing, + }) + + return { + documentId: result.documentId, + documentName: documentNameMap[result.documentId] || undefined, + content: result.content, + chunkIndex: result.chunkIndex, + metadata: tags, + similarity: hasQuery ? 1 - result.distance : 1, + } + }), + query: validatedData.query || '', + knowledgeBaseIds: accessibleKbIds, + knowledgeBaseId: accessibleKbIds[0], + topK: validatedData.topK, + totalResults: results.length, + ...(cost && tokenCount + ? { + cost: { + input: cost.input, + output: cost.output, + total: cost.total, + tokens: { + prompt: tokenCount.count, + completion: 0, + total: tokenCount.count, }, - } - : {}), - }, - }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) - } - throw validationError - } + model: 'text-embedding-3-small', + pricing: cost.pricing, + }, + } + : {}), + }, + }) } catch (error) { return NextResponse.json( { diff --git a/apps/sim/app/api/logs/[id]/route.ts b/apps/sim/app/api/logs/[id]/route.ts index 639132abdd9..575b0867b1a 100644 --- a/apps/sim/app/api/logs/[id]/route.ts +++ b/apps/sim/app/api/logs/[id]/route.ts @@ -9,6 +9,7 @@ import { import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { logIdParamsSchema } from '@/lib/api/contracts/logs' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -29,7 +30,7 @@ export const GET = withRouteHandler( } const userId = session.user.id - const { id } = await params + const { id } = logIdParamsSchema.parse(await params) const rows = await db .select({ diff --git a/apps/sim/app/api/logs/cleanup/route.ts b/apps/sim/app/api/logs/cleanup/route.ts index 7891a763bc6..c2f4f16e9ca 100644 --- a/apps/sim/app/api/logs/cleanup/route.ts +++ b/apps/sim/app/api/logs/cleanup/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { verifyCronAuth } from '@/lib/auth/internal' import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,6 +12,9 @@ const logger = createLogger('LogsCleanupAPI') export const GET = withRouteHandler(async (request: NextRequest) => { try { + const validation = validateSchema(noInputSchema, {}) + if (!validation.success) return validation.response + const authError = verifyCronAuth(request, 'logs cleanup') if (authError) return authError diff --git a/apps/sim/app/api/logs/execution/[executionId]/route.ts b/apps/sim/app/api/logs/execution/[executionId]/route.ts index 41ba9c7776d..71deb54267d 100644 --- a/apps/sim/app/api/logs/execution/[executionId]/route.ts +++ b/apps/sim/app/api/logs/execution/[executionId]/route.ts @@ -9,6 +9,7 @@ import { import { createLogger } from '@sim/logger' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { executionIdParamsSchema } from '@/lib/api/contracts/logs' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -21,7 +22,7 @@ export const GET = withRouteHandler( const requestId = generateRequestId() try { - const { executionId } = await params + const { executionId } = executionIdParamsSchema.parse(await params) const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index 6c34126a9ec..27b071be0f3 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -25,22 +25,17 @@ import { sql, } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { listLogsQuerySchema } from '@/lib/api/contracts/logs' +import { isZodError } 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 { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' +import { buildFilterConditions } from '@/lib/logs/filters' const logger = createLogger('LogsAPI') export const revalidate = 0 -const QueryParamsSchema = LogFilterParamsSchema.extend({ - details: z.enum(['basic', 'full']).optional().default('basic'), - limit: z.coerce.number().optional().default(100), - offset: z.coerce.number().optional().default(0), -}) - export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -55,7 +50,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { const { searchParams } = new URL(request.url) - const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) + const params = listLogsQuerySchema.parse(Object.fromEntries(searchParams.entries())) const selectColumns = params.details === 'full' @@ -589,14 +584,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { { status: 200 } ) } catch (validationError) { - if (validationError instanceof z.ZodError) { + if (isZodError(validationError)) { logger.warn(`[${requestId}] Invalid logs request parameters`, { - errors: validationError.errors, + errors: validationError.issues, }) return NextResponse.json( { error: 'Invalid request parameters', - details: validationError.errors, + details: validationError.issues, }, { status: 400 } ) diff --git a/apps/sim/app/api/logs/stats/route.ts b/apps/sim/app/api/logs/stats/route.ts index 776982855e6..17e6a592328 100644 --- a/apps/sim/app/api/logs/stats/route.ts +++ b/apps/sim/app/api/logs/stats/route.ts @@ -3,49 +3,22 @@ import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + type DashboardStatsResponse, + type SegmentStats, + statsQueryParamsSchema, + type WorkflowStats, +} from '@/lib/api/contracts/logs' +import { isZodError } 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 { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' +import { buildFilterConditions } from '@/lib/logs/filters' const logger = createLogger('LogsStatsAPI') export const revalidate = 0 -const StatsQueryParamsSchema = LogFilterParamsSchema.extend({ - segmentCount: z.coerce.number().optional().default(72), -}) - -export interface SegmentStats { - timestamp: string - totalExecutions: number - successfulExecutions: number - avgDurationMs: number -} - -export interface WorkflowStats { - workflowId: string - workflowName: string - segments: SegmentStats[] - overallSuccessRate: number - totalExecutions: number - totalSuccessful: number -} - -export interface DashboardStatsResponse { - workflows: WorkflowStats[] - aggregateSegments: SegmentStats[] - totalRuns: number - totalErrors: number - avgLatency: number - timeBounds: { - start: string - end: string - } - segmentMs: number -} - export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -60,7 +33,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { const { searchParams } = new URL(request.url) - const params = StatsQueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) + const params = statsQueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) const workspaceFilter = eq(workflowExecutionLogs.workspaceId, params.workspaceId) @@ -277,14 +250,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(response, { status: 200 }) } catch (validationError) { - if (validationError instanceof z.ZodError) { + if (isZodError(validationError)) { logger.warn(`[${requestId}] Invalid logs stats request parameters`, { - errors: validationError.errors, + errors: validationError.issues, }) return NextResponse.json( { error: 'Invalid request parameters', - details: validationError.errors, + details: validationError.issues, }, { status: 400 } ) diff --git a/apps/sim/app/api/logs/triggers/route.ts b/apps/sim/app/api/logs/triggers/route.ts index b1f42fb507f..4a3e4b02f18 100644 --- a/apps/sim/app/api/logs/triggers/route.ts +++ b/apps/sim/app/api/logs/triggers/route.ts @@ -3,7 +3,8 @@ import { permissions, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNotNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { triggersQuerySchema } from '@/lib/api/contracts/logs' +import { searchParamsToObject, validateSchema } 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' @@ -12,10 +13,6 @@ const logger = createLogger('TriggersAPI') export const revalidate = 0 -const QueryParamsSchema = z.object({ - workspaceId: z.string(), -}) - /** * GET /api/logs/triggers * @@ -34,51 +31,49 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const userId = session.user.id - try { - const { searchParams } = new URL(request.url) - const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) - - const triggers = await db - .selectDistinct({ - trigger: workflowExecutionLogs.trigger, - }) - .from(workflowExecutionLogs) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where( - and( - eq(workflowExecutionLogs.workspaceId, params.workspaceId), - isNotNull(workflowExecutionLogs.trigger), - sql`${workflowExecutionLogs.trigger} NOT IN ('api', 'manual', 'webhook', 'chat', 'schedule')` - ) - ) + const { searchParams } = new URL(request.url) + const validation = validateSchema( + triggersQuerySchema, + searchParamsToObject(searchParams), + 'Invalid query parameters' + ) + if (!validation.success) { + logger.error(`[${requestId}] Invalid query parameters`, { error: validation.error }) + return validation.response + } - const triggerValues = triggers - .map((row) => row.trigger) - .filter((t): t is string => Boolean(t)) - .sort() + const params = validation.data - return NextResponse.json({ - triggers: triggerValues, - count: triggerValues.length, + const triggers = await db + .selectDistinct({ + trigger: workflowExecutionLogs.trigger, }) - } catch (err) { - if (err instanceof z.ZodError) { - logger.error(`[${requestId}] Invalid query parameters`, { error: err }) - return NextResponse.json( - { error: 'Invalid query parameters', details: err.errors }, - { status: 400 } + .from(workflowExecutionLogs) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflowExecutionLogs.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where( + and( + eq(workflowExecutionLogs.workspaceId, params.workspaceId), + isNotNull(workflowExecutionLogs.trigger), + sql`${workflowExecutionLogs.trigger} NOT IN ('api', 'manual', 'webhook', 'chat', 'schedule')` ) - } + ) - throw err - } + const triggerValues = triggers + .map((row) => row.trigger) + .filter((t): t is string => Boolean(t)) + .sort() + + return NextResponse.json({ + triggers: triggerValues, + count: triggerValues.length, + }) } catch (err) { logger.error(`[${requestId}] Failed to fetch triggers`, { error: err }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/app/api/mcp/copilot/.well-known/oauth-authorization-server/route.ts b/apps/sim/app/api/mcp/copilot/.well-known/oauth-authorization-server/route.ts index d862fe277f1..3f2976ff027 100644 --- a/apps/sim/app/api/mcp/copilot/.well-known/oauth-authorization-server/route.ts +++ b/apps/sim/app/api/mcp/copilot/.well-known/oauth-authorization-server/route.ts @@ -1,6 +1,12 @@ -import type { NextResponse } from 'next/server' +import type { NextRequest, NextResponse } from 'next/server' +import { mcpOauthAuthorizationServerMetadataContract } from '@/lib/api/contracts/mcp-oauth' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery' -export async function GET(): Promise { +export const GET = withRouteHandler(async (request: NextRequest): Promise => { + const parsed = await parseRequest(mcpOauthAuthorizationServerMetadataContract, request, {}) + if (!parsed.success) return parsed.response as NextResponse + return createMcpAuthorizationServerMetadataResponse() -} +}) diff --git a/apps/sim/app/api/mcp/copilot/.well-known/oauth-protected-resource/route.ts b/apps/sim/app/api/mcp/copilot/.well-known/oauth-protected-resource/route.ts index a419ebda324..1e17b126b31 100644 --- a/apps/sim/app/api/mcp/copilot/.well-known/oauth-protected-resource/route.ts +++ b/apps/sim/app/api/mcp/copilot/.well-known/oauth-protected-resource/route.ts @@ -1,6 +1,12 @@ -import type { NextResponse } from 'next/server' +import type { NextRequest, NextResponse } from 'next/server' +import { mcpOauthProtectedResourceMetadataContract } from '@/lib/api/contracts/mcp-oauth' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createMcpProtectedResourceMetadataResponse } from '@/lib/mcp/oauth-discovery' -export async function GET(): Promise { +export const GET = withRouteHandler(async (request: NextRequest): Promise => { + const parsed = await parseRequest(mcpOauthProtectedResourceMetadataContract, request, {}) + if (!parsed.success) return parsed.response as NextResponse + return createMcpProtectedResourceMetadataResponse() -} +}) diff --git a/apps/sim/app/api/mcp/copilot/route.ts b/apps/sim/app/api/mcp/copilot/route.ts index 93e24c23086..9bc364fb7e2 100644 --- a/apps/sim/app/api/mcp/copilot/route.ts +++ b/apps/sim/app/api/mcp/copilot/route.ts @@ -18,6 +18,8 @@ import { generateId } from '@sim/utils/id' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { mcpRequestBodySchema, mcpToolCallParamsSchema } from '@/lib/api/contracts/mcp' +import { validateSchema } from '@/lib/api/server' import { validateOAuthAccessToken } from '@/lib/auth/oauth-token' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' @@ -282,12 +284,11 @@ function buildMcpServer(abortSignal?: AbortSignal): Server { } } - const params = request.params as - | { name?: string; arguments?: Record } - | undefined - if (!params?.name) { + const paramsValidation = validateSchema(mcpToolCallParamsSchema, request.params) + if (!paramsValidation.success) { throw new McpError(ErrorCode.InvalidParams, 'Tool name required') } + const params = paramsValidation.data const result = await handleToolsCall( { @@ -359,7 +360,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } - return await handleMcpRequestWithSdk(request, parsedBody) + const bodyValidation = validateSchema(mcpRequestBodySchema, parsedBody) + if (!bodyValidation.success) { + return NextResponse.json( + createError(0, ErrorCode.InvalidRequest, 'Invalid JSON-RPC message'), + { + status: 400, + } + ) + } + + return await handleMcpRequestWithSdk(request, bodyValidation.data) } catch (error) { if (request.signal.aborted || (error as Error)?.name === 'AbortError') { return NextResponse.json( diff --git a/apps/sim/app/api/mcp/discover/route.ts b/apps/sim/app/api/mcp/discover/route.ts index 5c63714b0a0..0f095c7c532 100644 --- a/apps/sim/app/api/mcp/discover/route.ts +++ b/apps/sim/app/api/mcp/discover/route.ts @@ -3,6 +3,8 @@ import { permissions, workflowMcpServer, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { unknownRecordSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -16,6 +18,12 @@ export const dynamic = 'force-dynamic' */ export const GET = withRouteHandler(async (request: NextRequest) => { try { + const queryValidation = validateSchema( + unknownRecordSchema, + Object.fromEntries(request.nextUrl.searchParams) + ) + if (!queryValidation.success) return queryValidation.response + const auth = await checkHybridAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { diff --git a/apps/sim/app/api/mcp/events/route.test.ts b/apps/sim/app/api/mcp/events/route.test.ts index 586d87d701b..15d12bd9eba 100644 --- a/apps/sim/app/api/mcp/events/route.test.ts +++ b/apps/sim/app/api/mcp/events/route.test.ts @@ -3,9 +3,14 @@ * * @vitest-environment node */ -import { authMockFns, createMockRequest, permissionsMock, permissionsMockFns } from '@sim/testing' +import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +function createNextRequest(url: string): NextRequest { + return new NextRequest(new URL(url)) +} + const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) @@ -65,14 +70,9 @@ describe('MCP Events SSE Endpoint', () => { it('returns 401 when session is missing', async () => { authMockFns.mockGetSession.mockResolvedValue(null) - const request = createMockRequest( - 'GET', - undefined, - {}, - 'http://localhost:3000/api/mcp/events?workspaceId=ws-123' - ) + const request = createNextRequest('http://localhost:3000/api/mcp/events?workspaceId=ws-123') - const response = await GET(request as any) + const response = await GET(request) expect(response.status).toBe(401) const text = await response.text() @@ -82,9 +82,9 @@ describe('MCP Events SSE Endpoint', () => { it('returns 400 when workspaceId is missing', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: defaultMockUser }) - const request = createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/mcp/events') + const request = createNextRequest('http://localhost:3000/api/mcp/events') - const response = await GET(request as any) + const response = await GET(request) expect(response.status).toBe(400) const text = await response.text() @@ -95,14 +95,9 @@ describe('MCP Events SSE Endpoint', () => { authMockFns.mockGetSession.mockResolvedValue({ user: defaultMockUser }) mockGetUserEntityPermissions.mockResolvedValue(null) - const request = createMockRequest( - 'GET', - undefined, - {}, - 'http://localhost:3000/api/mcp/events?workspaceId=ws-123' - ) + const request = createNextRequest('http://localhost:3000/api/mcp/events?workspaceId=ws-123') - const response = await GET(request as any) + const response = await GET(request) expect(response.status).toBe(403) const text = await response.text() @@ -114,14 +109,9 @@ describe('MCP Events SSE Endpoint', () => { authMockFns.mockGetSession.mockResolvedValue({ user: defaultMockUser }) mockGetUserEntityPermissions.mockResolvedValue({ read: true }) - const request = createMockRequest( - 'GET', - undefined, - {}, - 'http://localhost:3000/api/mcp/events?workspaceId=ws-123' - ) + const request = createNextRequest('http://localhost:3000/api/mcp/events?workspaceId=ws-123') - const response = await GET(request as any) + const response = await GET(request) expect(response.status).toBe(200) expect(response.headers.get('Content-Type')).toBe('text/event-stream') diff --git a/apps/sim/app/api/mcp/events/route.ts b/apps/sim/app/api/mcp/events/route.ts index 8f4ed93d0c9..19a6675ffe9 100644 --- a/apps/sim/app/api/mcp/events/route.ts +++ b/apps/sim/app/api/mcp/events/route.ts @@ -8,6 +8,9 @@ * Auth is handled via session cookies (EventSource sends cookies automatically). */ +import type { NextRequest } from 'next/server' +import { mcpEventsQuerySchema } from '@/lib/api/contracts/mcp' +import { validateSchema } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkspaceSSE } from '@/lib/events/sse-endpoint' import { mcpConnectionManager } from '@/lib/mcp/connection-manager' @@ -15,36 +18,46 @@ import { mcpPubSub } from '@/lib/mcp/pubsub' export const dynamic = 'force-dynamic' -export const GET = withRouteHandler( - createWorkspaceSSE({ - label: 'mcp-events', - subscriptions: [ - { - subscribe: (workspaceId, send) => { - if (!mcpConnectionManager) return () => {} - return mcpConnectionManager.subscribe((event) => { - if (event.workspaceId !== workspaceId) return - send('tools_changed', { - source: 'external', - serverId: event.serverId, - timestamp: event.timestamp, - }) +const mcpEventsHandler = createWorkspaceSSE({ + label: 'mcp-events', + subscriptions: [ + { + subscribe: (workspaceId, send) => { + if (!mcpConnectionManager) return () => {} + return mcpConnectionManager.subscribe((event) => { + if (event.workspaceId !== workspaceId) return + send('tools_changed', { + source: 'external', + serverId: event.serverId, + timestamp: event.timestamp, }) - }, + }) }, - { - subscribe: (workspaceId, send) => { - if (!mcpPubSub) return () => {} - return mcpPubSub.onWorkflowToolsChanged((event) => { - if (event.workspaceId !== workspaceId) return - send('tools_changed', { - source: 'workflow', - serverId: event.serverId, - timestamp: Date.now(), - }) + }, + { + subscribe: (workspaceId, send) => { + if (!mcpPubSub) return () => {} + return mcpPubSub.onWorkflowToolsChanged((event) => { + if (event.workspaceId !== workspaceId) return + send('tools_changed', { + source: 'workflow', + serverId: event.serverId, + timestamp: Date.now(), }) - }, + }) }, - ], + }, + ], +}) + +export const GET = withRouteHandler(async (request: NextRequest) => { + const queryValidation = validateSchema(mcpEventsQuerySchema, { + workspaceId: request.nextUrl.searchParams.get('workspaceId'), }) -) + + if (!queryValidation.success || !queryValidation.data.workspaceId) { + return new Response('Missing workspaceId query parameter', { status: 400 }) + } + + return mcpEventsHandler(request) +}) diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 85c302de282..702c9a57cf4 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -20,6 +20,12 @@ import { workflow, workflowMcpServer, workflowMcpTool, workspace } from '@sim/db import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { + mcpJsonRpcNotificationSchema, + mcpJsonRpcRequestSchema, + mcpServeRouteParamsSchema, + mcpToolCallParamsSchema, +} from '@/lib/api/contracts/mcp' import { type AuthResult, AuthType, checkHybridAuth } from '@/lib/auth/hybrid' import { generateInternalToken } from '@/lib/auth/internal' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' @@ -83,9 +89,8 @@ async function getServer(serverId: string) { export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise }) => { - const { serverId } = await params - try { + const { serverId } = mcpServeRouteParamsSchema.parse(await params) const server = await getServer(serverId) if (!server) { return NextResponse.json({ error: 'Server not found' }, { status: 404 }) @@ -126,9 +131,8 @@ export const GET = withRouteHandler( export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise }) => { - const { serverId } = await params - try { + const { serverId } = mcpServeRouteParamsSchema.parse(await params) const server = await getServer(serverId) if (!server) { return NextResponse.json({ error: 'Server not found' }, { status: 404 }) @@ -161,10 +165,27 @@ export const POST = withRouteHandler( } } - const body = await request.json() + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json(createError(0, ErrorCode.ParseError, 'Invalid JSON body'), { + status: 400, + }) + } const message = body as JSONRPCMessage if (isJSONRPCNotification(message)) { + const notificationValidation = mcpJsonRpcNotificationSchema.safeParse(message) + if (!notificationValidation.success) { + return NextResponse.json( + createError(0, ErrorCode.InvalidRequest, 'Invalid JSON-RPC message'), + { + status: 400, + } + ) + } + logger.info(`Received notification: ${message.method}`) return new NextResponse(null, { status: 202 }) } @@ -178,7 +199,17 @@ export const POST = withRouteHandler( ) } - const { id, method, params: rpcParams } = message + const requestValidation = mcpJsonRpcRequestSchema.safeParse(message) + if (!requestValidation.success) { + return NextResponse.json( + createError(0, ErrorCode.InvalidRequest, 'Invalid JSON-RPC message'), + { + status: 400, + } + ) + } + + const { id, method, params: rpcParams } = requestValidation.data switch (method) { case 'initialize': { @@ -196,15 +227,26 @@ export const POST = withRouteHandler( case 'tools/list': return handleToolsList(id, serverId) - case 'tools/call': + case 'tools/call': { + const paramsValidation = mcpToolCallParamsSchema.safeParse(rpcParams) + if (!paramsValidation.success) { + return NextResponse.json( + createError(id, ErrorCode.InvalidParams, 'Invalid tool call parameters'), + { + status: 400, + } + ) + } + return handleToolsCall( id, serverId, - rpcParams as { name: string; arguments?: Record }, + paramsValidation.data, executeAuthContext, server.isPublic ? server.createdBy : undefined, request.headers.get(SIM_VIA_HEADER) ) + } default: return NextResponse.json( @@ -370,9 +412,8 @@ async function handleToolsCall( export const DELETE = withRouteHandler( async (request: NextRequest, { params }: { params: Promise }) => { - const { serverId } = await params - try { + const { serverId } = mcpServeRouteParamsSchema.parse(await params) const server = await getServer(serverId) if (!server) { return NextResponse.json({ error: 'Server not found' }, { status: 404 }) diff --git a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts index 60a93d98132..937ba85ed2b 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts @@ -4,6 +4,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { mcpServerIdParamsSchema } from '@/lib/api/contracts/mcp' +import { validateSchema } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' @@ -158,9 +160,10 @@ async function syncToolSchemasToWorkflows( export const POST = withRouteHandler( withMcpAuth<{ id: string }>('read')( async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { - const { id: serverId } = await params - try { + const paramsValidation = validateSchema(mcpServerIdParamsSchema, await params) + if (!paramsValidation.success) return paramsValidation.response + const { id: serverId } = paramsValidation.data logger.info(`[${requestId}] Refreshing MCP server: ${serverId}`) const [server] = await db diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index 13005bb6433..6e971636e30 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -5,6 +5,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { mcpServerIdParamsSchema, updateMcpServerBodySchema } from '@/lib/api/contracts/mcp' +import { validateSchema } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { McpDnsResolutionError, @@ -31,10 +33,23 @@ export const PATCH = withRouteHandler( { userId, userName, userEmail, workspaceId, requestId }, { params } ) => { - const { id: serverId } = await params - try { - const body = getParsedBody(request) || (await request.json()) + const paramsValidation = validateSchema(mcpServerIdParamsSchema, await params) + if (!paramsValidation.success) return paramsValidation.response + const { id: serverId } = paramsValidation.data + + const rawBody = getParsedBody(request) ?? (await request.json()) + const parsedBody = validateSchema( + updateMcpServerBodySchema, + rawBody, + 'Invalid request format' + ) + + if (!parsedBody.success) { + return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) + } + + const body = parsedBody.data logger.info( `[${requestId}] Updating MCP server: ${serverId} in workspace: ${workspaceId}`, diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index bab33a9b9cb..1ecb6a0413f 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -6,6 +6,8 @@ import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { createMcpServerBodySchema, deleteMcpServerByQuerySchema } from '@/lib/api/contracts/mcp' +import { validateSchema } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { McpDnsResolutionError, @@ -65,7 +67,18 @@ export const POST = withRouteHandler( withMcpAuth('write')( async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { try { - const body = getParsedBody(request) || (await request.json()) + const rawBody = getParsedBody(request) ?? (await request.json()) + const parsedBody = validateSchema( + createMcpServerBodySchema, + rawBody, + 'Invalid request format' + ) + + if (!parsedBody.success) { + return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) + } + + const body = parsedBody.data logger.info(`[${requestId}] Registering MCP server:`, { name: body.name, @@ -73,14 +86,6 @@ export const POST = withRouteHandler( workspaceId, }) - if (!body.name || !body.transport) { - return createMcpErrorResponse( - new Error('Missing required fields: name or transport'), - 'Missing required fields', - 400 - ) - } - try { validateMcpDomain(body.url) } catch (e) { @@ -233,8 +238,14 @@ export const DELETE = withRouteHandler( async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { try { const { searchParams } = new URL(request.url) - const serverId = searchParams.get('serverId') - const sourceParam = searchParams.get('source') + const queryValidation = validateSchema( + deleteMcpServerByQuerySchema, + Object.fromEntries(searchParams) + ) + if (!queryValidation.success) return queryValidation.response + const query = queryValidation.data + const serverId = query.serverId + const sourceParam = query.source const source = sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined diff --git a/apps/sim/app/api/mcp/servers/test-connection/route.ts b/apps/sim/app/api/mcp/servers/test-connection/route.ts index f565a184f12..46ef05fc2bd 100644 --- a/apps/sim/app/api/mcp/servers/test-connection/route.ts +++ b/apps/sim/app/api/mcp/servers/test-connection/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import type { NextRequest } from 'next/server' +import { mcpServerTestBodySchema } from '@/lib/api/contracts/mcp' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { McpClient } from '@/lib/mcp/client' import { @@ -27,15 +28,6 @@ function isUrlBasedTransport(transport: McpTransport): boolean { return transport === 'streamable-http' } -interface TestConnectionRequest { - name: string - transport: McpTransport - url?: string - headers?: Record - timeout?: number - workspaceId: string -} - interface TestConnectionResult { success: boolean error?: string @@ -69,7 +61,14 @@ function sanitizeConnectionError(error: unknown): string { export const POST = withRouteHandler( withMcpAuth('write')(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { - const body: TestConnectionRequest = getParsedBody(request) || (await request.json()) + const rawBody = getParsedBody(request) ?? (await request.json()) + const parsedBody = mcpServerTestBodySchema.safeParse(rawBody) + + if (!parsedBody.success) { + return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) + } + + const body = parsedBody.data logger.info(`[${requestId}] Testing MCP server connection:`, { name: body.name, @@ -78,14 +77,6 @@ export const POST = withRouteHandler( workspaceId, }) - if (!body.name || !body.transport) { - return createMcpErrorResponse( - new Error('Missing required fields: name and transport are required'), - 'Missing required fields', - 400 - ) - } - if (isUrlBasedTransport(body.transport) && !body.url) { return createMcpErrorResponse( new Error('URL is required for HTTP-based transports'), diff --git a/apps/sim/app/api/mcp/tools/discover/route.ts b/apps/sim/app/api/mcp/tools/discover/route.ts index b6a0f7c09a3..bb8c127cd3a 100644 --- a/apps/sim/app/api/mcp/tools/discover/route.ts +++ b/apps/sim/app/api/mcp/tools/discover/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' +import { mcpToolDiscoveryQuerySchema, refreshMcpToolsBodySchema } from '@/lib/api/contracts/mcp' +import { validateSchema } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' @@ -14,8 +16,14 @@ export const GET = withRouteHandler( withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { const { searchParams } = new URL(request.url) - const serverId = searchParams.get('serverId') - const forceRefresh = searchParams.get('refresh') === 'true' + const queryValidation = validateSchema( + mcpToolDiscoveryQuerySchema, + Object.fromEntries(searchParams) + ) + if (!queryValidation.success) return queryValidation.response + const query = queryValidation.data + const serverId = query.serverId + const forceRefresh = query.refresh === 'true' logger.info(`[${requestId}] Discovering MCP tools`, { serverId, workspaceId, forceRefresh }) @@ -49,17 +57,19 @@ export const GET = withRouteHandler( export const POST = withRouteHandler( withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { - const body = getParsedBody(request) || (await request.json()) - const { serverIds } = body - - if (!Array.isArray(serverIds)) { - return createMcpErrorResponse( - new Error('serverIds must be an array'), - 'Invalid request format', - 400 - ) + const rawBody = getParsedBody(request) ?? (await request.json()) + const parsedBody = validateSchema( + refreshMcpToolsBodySchema, + rawBody, + 'Invalid request format' + ) + + if (!parsedBody.success) { + return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) } + const { serverIds } = parsedBody.data + logger.info(`[${requestId}] Refreshing tools for ${serverIds.length} servers`) const results = await Promise.allSettled( diff --git a/apps/sim/app/api/mcp/tools/execute/route.ts b/apps/sim/app/api/mcp/tools/execute/route.ts index c94dc7d0aba..8e948236b4b 100644 --- a/apps/sim/app/api/mcp/tools/execute/route.ts +++ b/apps/sim/app/api/mcp/tools/execute/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' +import { mcpToolExecutionBodySchema } from '@/lib/api/contracts/mcp' +import { validateSchema } from '@/lib/api/server' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { getExecutionTimeout } from '@/lib/core/execution-limits' import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types' @@ -8,12 +10,7 @@ import { SIM_VIA_HEADER } from '@/lib/execution/call-chain' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' import type { McpTool, McpToolCall, McpToolResult } from '@/lib/mcp/types' -import { - categorizeError, - createMcpErrorResponse, - createMcpSuccessResponse, - validateStringParam, -} from '@/lib/mcp/utils' +import { categorizeError, createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' import { assertPermissionsAllowed, McpToolsNotAllowedError, @@ -24,7 +21,7 @@ const logger = createLogger('McpToolExecutionAPI') export const dynamic = 'force-dynamic' interface SchemaProperty { - type: 'string' | 'number' | 'boolean' | 'object' | 'array' + type: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' description?: string enum?: unknown[] format?: string @@ -48,11 +45,21 @@ function hasType(prop: unknown): prop is SchemaProperty { export const POST = withRouteHandler( withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { - const body = getParsedBody(request) || (await request.json()) + const rawBody = getParsedBody(request) ?? (await request.json()) + const parsedBody = validateSchema( + mcpToolExecutionBodySchema, + rawBody, + 'Invalid request format' + ) + + if (!parsedBody.success) { + return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) + } + + const body = parsedBody.data logger.info(`[${requestId}] MCP tool execution request received`, { hasAuthHeader: !!request.headers.get('authorization'), - authHeaderType: request.headers.get('authorization')?.substring(0, 10), bodyKeys: Object.keys(body), serverId: body.serverId, toolName: body.toolName, @@ -64,18 +71,6 @@ export const POST = withRouteHandler( const { serverId, toolName, arguments: rawArgs } = body const args = rawArgs || {} - const serverIdValidation = validateStringParam(serverId, 'serverId') - if (!serverIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid serverId: ${serverId}`) - return createMcpErrorResponse(new Error(serverIdValidation.error), 'Invalid serverId', 400) - } - - const toolNameValidation = validateStringParam(toolName, 'toolName') - if (!toolNameValidation.isValid) { - logger.warn(`[${requestId}] Invalid toolName: ${toolName}`) - return createMcpErrorResponse(new Error(toolNameValidation.error), 'Invalid toolName', 400) - } - try { await assertPermissionsAllowed({ userId, @@ -95,27 +90,18 @@ export const POST = withRouteHandler( let tool: McpTool | null = null try { - if (body.toolSchema) { - tool = { - name: toolName, - inputSchema: body.toolSchema, - serverId: serverId, - serverName: 'provided-schema', - } as McpTool - } else { - const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) - tool = tools.find((t) => t.name === toolName) ?? null - - if (!tool) { - logger.warn(`[${requestId}] Tool ${toolName} not found on server ${serverId}`, { - availableTools: tools.map((t) => t.name), - }) - return createMcpErrorResponse( - new Error('Tool not found'), - 'Tool not found on the specified server', - 404 - ) - } + const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) + tool = tools.find((t) => t.name === toolName) ?? null + + if (!tool) { + logger.warn(`[${requestId}] Tool ${toolName} not found on server ${serverId}`, { + availableTools: tools.map((t) => t.name), + }) + return createMcpErrorResponse( + new Error('Tool not found'), + 'Tool not found on the specified server', + 404 + ) } if (tool.inputSchema?.properties) { @@ -170,7 +156,7 @@ export const POST = withRouteHandler( } } catch (error) { logger.warn( - `[${requestId}] Failed to discover tools for validation, proceeding anyway:`, + `[${requestId}] Failed to discover tools for validation, proceeding without schema`, error ) } @@ -273,6 +259,12 @@ function validateToolArguments(tool: McpTool, args: Record): st if (expectedType === 'number' && actualType !== 'number') { return `Property ${propName} must be a number` } + if ( + expectedType === 'integer' && + (actualType !== 'number' || !Number.isInteger(propValue)) + ) { + return `Property ${propName} must be an integer` + } if (expectedType === 'boolean' && actualType !== 'boolean') { return `Property ${propName} must be a boolean` } @@ -294,9 +286,15 @@ function validateToolArguments(tool: McpTool, args: Record): st function transformToolResult(result: McpToolResult): ToolExecutionResult { if (result.isError) { + const firstContent = Array.isArray(result.content) ? result.content[0] : undefined + const errorText = + firstContent && typeof firstContent === 'object' && typeof firstContent.text === 'string' + ? firstContent.text + : undefined + return { success: false, - error: result.content?.[0]?.text || 'Tool execution failed', + error: errorText && errorText.trim().length > 0 ? errorText : 'Tool execution failed', } } diff --git a/apps/sim/app/api/mcp/tools/stored/route.ts b/apps/sim/app/api/mcp/tools/stored/route.ts index 59fa5f5102f..861a5aeb04a 100644 --- a/apps/sim/app/api/mcp/tools/stored/route.ts +++ b/apps/sim/app/api/mcp/tools/stored/route.ts @@ -4,6 +4,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { unknownRecordSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withMcpAuth } from '@/lib/mcp/middleware' import type { McpToolSchema, StoredMcpTool } from '@/lib/mcp/types' @@ -16,6 +18,12 @@ export const dynamic = 'force-dynamic' export const GET = withRouteHandler( withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { + const queryValidation = validateSchema( + unknownRecordSchema, + Object.fromEntries(request.nextUrl.searchParams) + ) + if (!queryValidation.success) return queryValidation.response + logger.info(`[${requestId}] Fetching stored MCP tools for workspace ${workspaceId}`) const workflows = await db diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts index f90a962cc22..8836d712a74 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -5,6 +5,10 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { + updateWorkflowMcpServerBodySchema, + workflowMcpServerParamsSchema, +} from '@/lib/api/contracts/workflow-mcp-servers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpPubSub } from '@/lib/mcp/pubsub' @@ -25,7 +29,7 @@ export const GET = withRouteHandler( withMcpAuth('read')( async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { try { - const { id: serverId } = await params + const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) logger.info(`[${requestId}] Getting workflow MCP server: ${serverId}`) @@ -83,8 +87,15 @@ export const PATCH = withRouteHandler( { params } ) => { try { - const { id: serverId } = await params - const body = getParsedBody(request) || (await request.json()) + const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) + const rawBody = getParsedBody(request) ?? (await request.json()) + const parsedBody = updateWorkflowMcpServerBodySchema.safeParse(rawBody) + + if (!parsedBody.success) { + return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) + } + + const body = parsedBody.data logger.info(`[${requestId}] Updating workflow MCP server: ${serverId}`) @@ -164,7 +175,7 @@ export const DELETE = withRouteHandler( { params } ) => { try { - const { id: serverId } = await params + const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) logger.info(`[${requestId}] Deleting workflow MCP server: ${serverId}`) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts index be511aeb868..14eda122b3e 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts @@ -5,6 +5,10 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { + updateWorkflowMcpToolBodySchema, + workflowMcpToolParamsSchema, +} from '@/lib/api/contracts/workflow-mcp-servers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpPubSub } from '@/lib/mcp/pubsub' @@ -27,7 +31,7 @@ export const GET = withRouteHandler( withMcpAuth('read')( async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { try { - const { id: serverId, toolId } = await params + const { id: serverId, toolId } = workflowMcpToolParamsSchema.parse(await params) logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`) @@ -83,8 +87,15 @@ export const PATCH = withRouteHandler( { params } ) => { try { - const { id: serverId, toolId } = await params - const body = getParsedBody(request) || (await request.json()) + const { id: serverId, toolId } = workflowMcpToolParamsSchema.parse(await params) + const rawBody = getParsedBody(request) ?? (await request.json()) + const parsedBody = updateWorkflowMcpToolBodySchema.safeParse(rawBody) + + if (!parsedBody.success) { + return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) + } + + const body = parsedBody.data logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`) @@ -182,7 +193,7 @@ export const DELETE = withRouteHandler( { params } ) => { try { - const { id: serverId, toolId } = await params + const { id: serverId, toolId } = workflowMcpToolParamsSchema.parse(await params) logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts index 611a1b80c5c..cc000883893 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts @@ -6,6 +6,10 @@ import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { + createWorkflowMcpToolBodySchema, + workflowMcpServerParamsSchema, +} from '@/lib/api/contracts/workflow-mcp-servers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpPubSub } from '@/lib/mcp/pubsub' @@ -29,7 +33,7 @@ export const GET = withRouteHandler( withMcpAuth('read')( async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { try { - const { id: serverId } = await params + const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`) @@ -92,21 +96,20 @@ export const POST = withRouteHandler( { params } ) => { try { - const { id: serverId } = await params - const body = getParsedBody(request) || (await request.json()) + const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) + const rawBody = getParsedBody(request) ?? (await request.json()) + const parsedBody = createWorkflowMcpToolBodySchema.safeParse(rawBody) + + if (!parsedBody.success) { + return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) + } + + const body = parsedBody.data logger.info(`[${requestId}] Adding tool to workflow MCP server: ${serverId}`, { workflowId: body.workflowId, }) - if (!body.workflowId) { - return createMcpErrorResponse( - new Error('Missing required field: workflowId'), - 'Missing required field', - 400 - ) - } - const [server] = await db .select({ id: workflowMcpServer.id }) .from(workflowMcpServer) diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index f5c9c838557..49efe49f2a3 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -6,6 +6,7 @@ import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { createWorkflowMcpServerBodySchema } from '@/lib/api/contracts/workflow-mcp-servers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpPubSub } from '@/lib/mcp/pubsub' @@ -96,7 +97,14 @@ export const POST = withRouteHandler( withMcpAuth('write')( async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { try { - const body = getParsedBody(request) || (await request.json()) + const rawBody = getParsedBody(request) ?? (await request.json()) + const parsedBody = createWorkflowMcpServerBodySchema.safeParse(rawBody) + + if (!parsedBody.success) { + return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) + } + + const body = parsedBody.data logger.info(`[${requestId}] Creating workflow MCP server:`, { name: body.name, @@ -104,14 +112,6 @@ export const POST = withRouteHandler( workflowIds: body.workflowIds, }) - if (!body.name) { - return createMcpErrorResponse( - new Error('Missing required field: name'), - 'Missing required field', - 400 - ) - } - const serverId = generateId() const [server] = await db diff --git a/apps/sim/app/api/memory/[id]/route.ts b/apps/sim/app/api/memory/[id]/route.ts index d5a6216d1e0..869d1a8bb20 100644 --- a/apps/sim/app/api/memory/[id]/route.ts +++ b/apps/sim/app/api/memory/[id]/route.ts @@ -3,7 +3,12 @@ import { memory } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + agentMemoryDataSchemaContract, + memoryPutBodySchema, + memoryWorkspaceQuerySchema, +} from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,26 +16,6 @@ import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('MemoryByIdAPI') -const memoryQuerySchema = z.object({ - workspaceId: z.string().uuid('Invalid workspace ID format'), -}) - -const agentMemoryDataSchema = z.object({ - role: z.enum(['user', 'assistant', 'system'], { - errorMap: () => ({ message: 'Role must be user, assistant, or system' }), - }), - content: z.string().min(1, 'Content is required'), -}) - -const genericMemoryDataSchema = z.record(z.unknown()) - -const memoryPutBodySchema = z.object({ - data: z.union([agentMemoryDataSchema, genericMemoryDataSchema], { - errorMap: () => ({ message: 'Invalid memory data structure' }), - }), - workspaceId: z.string().uuid('Invalid workspace ID format'), -}) - async function validateMemoryAccess( request: NextRequest, workspaceId: string, @@ -82,9 +67,9 @@ export const GET = withRouteHandler( const url = new URL(request.url) const workspaceId = url.searchParams.get('workspaceId') - const validation = memoryQuerySchema.safeParse({ workspaceId }) + const validation = validateSchema(memoryWorkspaceQuerySchema, { workspaceId }) if (!validation.success) { - const errorMessage = validation.error.errors + const errorMessage = validation.error.issues .map((err) => `${err.path.join('.')}: ${err.message}`) .join(', ') return NextResponse.json( @@ -145,9 +130,9 @@ export const DELETE = withRouteHandler( const url = new URL(request.url) const workspaceId = url.searchParams.get('workspaceId') - const validation = memoryQuerySchema.safeParse({ workspaceId }) + const validation = validateSchema(memoryWorkspaceQuerySchema, { workspaceId }) if (!validation.success) { - const errorMessage = validation.error.errors + const errorMessage = validation.error.issues .map((err) => `${err.path.join('.')}: ${err.message}`) .join(', ') return NextResponse.json( @@ -210,10 +195,10 @@ export const PUT = withRouteHandler( let validatedWorkspaceId try { const body = await request.json() - const validation = memoryPutBodySchema.safeParse(body) + const validation = validateSchema(memoryPutBodySchema, body) if (!validation.success) { - const errorMessage = validation.error.errors + const errorMessage = validation.error.issues .map((err) => `${err.path.join('.')}: ${err.message}`) .join(', ') return NextResponse.json( @@ -254,9 +239,9 @@ export const PUT = withRouteHandler( ) } - const agentValidation = agentMemoryDataSchema.safeParse(validatedData) + const agentValidation = validateSchema(agentMemoryDataSchemaContract, validatedData) if (!agentValidation.success) { - const errorMessage = agentValidation.error.errors + const errorMessage = agentValidation.error.issues .map((err) => `${err.path.join('.')}: ${err.message}`) .join(', ') return NextResponse.json( diff --git a/apps/sim/app/api/memory/route.ts b/apps/sim/app/api/memory/route.ts index 8e05d527e8c..ba93ef69531 100644 --- a/apps/sim/app/api/memory/route.ts +++ b/apps/sim/app/api/memory/route.ts @@ -4,6 +4,13 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull, like } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { + memoryDeleteQuerySchema, + memoryListQuerySchema, + memoryMessageSchema, + memoryPostBodySchema, +} from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -28,9 +35,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const url = new URL(request.url) - const workspaceId = url.searchParams.get('workspaceId') - const searchQuery = url.searchParams.get('query') - const limit = Number.parseInt(url.searchParams.get('limit') || '50') + const queryValidation = validateSchema( + memoryListQuerySchema, + Object.fromEntries(url.searchParams.entries()) + ) + if (!queryValidation.success) return queryValidation.response + const { workspaceId, query: searchQuery, limit } = queryValidation.data if (!workspaceId) { return NextResponse.json( @@ -100,7 +110,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() + const bodyResult = validateSchema(memoryPostBodySchema, await request.json()) + const body = bodyResult.success + ? bodyResult.data + : { key: undefined, data: undefined, workspaceId: undefined } const { key, data, workspaceId } = body if (!key) { @@ -148,16 +161,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const dataToValidate = Array.isArray(data) ? data : [data] for (const msg of dataToValidate) { - if (!msg || typeof msg !== 'object' || !msg.role || !msg.content) { + const parsedMessage = validateSchema(memoryMessageSchema, msg) + if (!parsedMessage.success) { + const role = + msg && typeof msg === 'object' && 'role' in msg + ? (msg as { role?: unknown }).role + : undefined + const invalidRole = Boolean(role) && !['user', 'assistant', 'system'].includes(String(role)) return NextResponse.json( - { success: false, error: { message: 'Memory requires messages with role and content' } }, - { status: 400 } - ) - } - - if (!['user', 'assistant', 'system'].includes(msg.role)) { - return NextResponse.json( - { success: false, error: { message: 'Message role must be user, assistant, or system' } }, + { + success: false, + error: { + message: invalidRole + ? 'Message role must be user, assistant, or system' + : 'Memory requires messages with role and content', + }, + }, { status: 400 } ) } @@ -240,8 +259,12 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { } const url = new URL(request.url) - const workspaceId = url.searchParams.get('workspaceId') - const conversationId = url.searchParams.get('conversationId') + const queryValidation = validateSchema( + memoryDeleteQuerySchema, + Object.fromEntries(url.searchParams.entries()) + ) + if (!queryValidation.success) return queryValidation.response + const { workspaceId, conversationId } = queryValidation.data if (!workspaceId) { return NextResponse.json( diff --git a/apps/sim/app/api/mothership/chat/abort/route.ts b/apps/sim/app/api/mothership/chat/abort/route.ts index 344d89bfb34..0c8894c394c 100644 --- a/apps/sim/app/api/mothership/chat/abort/route.ts +++ b/apps/sim/app/api/mothership/chat/abort/route.ts @@ -1 +1,23 @@ -export { POST } from '@/app/api/copilot/chat/abort/route' +import type { NextRequest } from 'next/server' +import { mothershipChatAbortEnvelopeSchema } from '@/lib/api/contracts/mothership-tasks' +import { validateSchema } from '@/lib/api/server' +import { POST as copilotAbortPost } from '@/app/api/copilot/chat/abort/route' + +export async function POST(request: NextRequest) { + const body = await request + .clone() + .json() + .catch(() => undefined) + if (body !== undefined) { + const validation = validateSchema( + mothershipChatAbortEnvelopeSchema, + body, + 'Invalid request body' + ) + if (!validation.success) { + return validation.response + } + } + + return copilotAbortPost(request, undefined) +} diff --git a/apps/sim/app/api/mothership/chat/resources/route.ts b/apps/sim/app/api/mothership/chat/resources/route.ts index da747172441..92c619bb045 100644 --- a/apps/sim/app/api/mothership/chat/resources/route.ts +++ b/apps/sim/app/api/mothership/chat/resources/route.ts @@ -1 +1,47 @@ -export { DELETE, PATCH, POST } from '@/app/api/copilot/chat/resources/route' +import type { NextRequest, NextResponse } from 'next/server' +import { mothershipChatResourceEnvelopeSchema } from '@/lib/api/contracts/mothership-tasks' +import { validateSchema } from '@/lib/api/server' +import { + DELETE as copilotResourcesDelete, + PATCH as copilotResourcesPatch, + POST as copilotResourcesPost, +} from '@/app/api/copilot/chat/resources/route' + +async function validateResourceRequestEnvelope(request: NextRequest): Promise { + const body = await request + .clone() + .json() + .catch(() => undefined) + if (body !== undefined) { + const validation = validateSchema( + mothershipChatResourceEnvelopeSchema, + body, + 'Invalid request body' + ) + if (!validation.success) { + return validation.response + } + } + return null +} + +export async function POST(request: NextRequest) { + const validationResponse = await validateResourceRequestEnvelope(request) + if (validationResponse) return validationResponse + + return copilotResourcesPost(request, undefined) +} + +export async function PATCH(request: NextRequest) { + const validationResponse = await validateResourceRequestEnvelope(request) + if (validationResponse) return validationResponse + + return copilotResourcesPatch(request, undefined) +} + +export async function DELETE(request: NextRequest) { + const validationResponse = await validateResourceRequestEnvelope(request) + if (validationResponse) return validationResponse + + return copilotResourcesDelete(request, undefined) +} diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index 596654b186d..de336ead5ac 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -1,3 +1,41 @@ +import type { NextRequest, NextResponse } from 'next/server' +import { + mothershipChatGetQuerySchema, + mothershipChatPostEnvelopeSchema, +} from '@/lib/api/contracts/mothership-tasks' +import { validateSchema } from '@/lib/api/server' +import { handleUnifiedChatPost, maxDuration } from '@/lib/copilot/chat/post' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { GET as copilotChatGet } from '@/app/api/copilot/chat/queries' + +export { maxDuration } + // Unified chat route surface. -export { handleUnifiedChatPost as POST, maxDuration } from '@/lib/copilot/chat/post' -export { GET } from '@/app/api/copilot/chat/queries' +export const GET = withRouteHandler((request: NextRequest) => { + const validation = validateSchema( + mothershipChatGetQuerySchema, + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!validation.success) return validation.response as NextResponse + + return copilotChatGet(request) +}) + +export const POST = withRouteHandler(async (request: NextRequest) => { + const body = await request + .clone() + .json() + .catch(() => undefined) + if (body !== undefined) { + const validation = validateSchema( + mothershipChatPostEnvelopeSchema, + body, + 'Invalid request body' + ) + if (!validation.success) { + return validation.response + } + } + + return handleUnifiedChatPost(request) +}) diff --git a/apps/sim/app/api/mothership/chat/stop/route.ts b/apps/sim/app/api/mothership/chat/stop/route.ts index dc1c7533ccd..80e69908f44 100644 --- a/apps/sim/app/api/mothership/chat/stop/route.ts +++ b/apps/sim/app/api/mothership/chat/stop/route.ts @@ -1,2 +1,24 @@ +import type { NextRequest } from 'next/server' +import { mothershipChatStopEnvelopeSchema } from '@/lib/api/contracts/mothership-tasks' +import { validateSchema } from '@/lib/api/server' +import { POST as copilotStopPost } from '@/app/api/copilot/chat/stop/route' + // Unified stop route surface. -export { POST } from '@/app/api/copilot/chat/stop/route' +export async function POST(request: NextRequest) { + const body = await request + .clone() + .json() + .catch(() => undefined) + if (body !== undefined) { + const validation = validateSchema( + mothershipChatStopEnvelopeSchema, + body, + 'Invalid request body' + ) + if (!validation.success) { + return validation.response + } + } + + return copilotStopPost(request, undefined) +} diff --git a/apps/sim/app/api/mothership/chat/stream/route.ts b/apps/sim/app/api/mothership/chat/stream/route.ts index 1d3bac30889..50a4d52136e 100644 --- a/apps/sim/app/api/mothership/chat/stream/route.ts +++ b/apps/sim/app/api/mothership/chat/stream/route.ts @@ -1 +1,16 @@ -export { GET, maxDuration } from '@/app/api/copilot/chat/stream/route' +import type { NextRequest, NextResponse } from 'next/server' +import { mothershipChatStreamQuerySchema } from '@/lib/api/contracts/mothership-tasks' +import { validateSchema } from '@/lib/api/server' +import { GET as copilotStreamGet, maxDuration } from '@/app/api/copilot/chat/stream/route' + +export { maxDuration } + +export function GET(request: NextRequest) { + const validation = validateSchema( + mothershipChatStreamQuerySchema, + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!validation.success) return validation.response as NextResponse + + return copilotStreamGet(request, undefined) +} diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index 3b3324f733c..ffd5d227a08 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -4,7 +4,11 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + mothershipChatParamsSchema, + updateMothershipChatBodySchema, +} from '@/lib/api/contracts/mothership-tasks' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' import { buildEffectiveChatTranscript } from '@/lib/copilot/chat/effective-transcript' import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' @@ -25,15 +29,6 @@ import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('MothershipChatAPI') -const UpdateChatSchema = z - .object({ - title: z.string().trim().min(1).max(200).optional(), - isUnread: z.boolean().optional(), - }) - .refine((data) => data.title !== undefined || data.isUnread !== undefined, { - message: 'At least one field must be provided', - }) - export const GET = withRouteHandler( async (_request: NextRequest, { params }: { params: Promise<{ chatId: string }> }) => { try { @@ -42,10 +37,11 @@ export const GET = withRouteHandler( return createUnauthorizedResponse() } - const { chatId } = await params - if (!chatId) { - return createBadRequestResponse('chatId is required') + const paramsResult = validateSchema(mothershipChatParamsSchema, await params) + if (!paramsResult.success) { + return createBadRequestResponse(getValidationErrorMessage(paramsResult.error)) } + const { chatId } = paramsResult.data const chat = await getAccessibleCopilotChat(chatId, userId) if (!chat || chat.type !== 'mothership') { @@ -138,13 +134,18 @@ export const PATCH = withRouteHandler( return createUnauthorizedResponse() } - const { chatId } = await params - if (!chatId) { - return createBadRequestResponse('chatId is required') + const paramsResult = validateSchema(mothershipChatParamsSchema, await params) + if (!paramsResult.success) { + return createBadRequestResponse(getValidationErrorMessage(paramsResult.error)) } + const { chatId } = paramsResult.data const body = await request.json() - const { title, isUnread } = UpdateChatSchema.parse(body) + const bodyResult = validateSchema(updateMothershipChatBodySchema, body) + if (!bodyResult.success) { + return createBadRequestResponse(getValidationErrorMessage(bodyResult.error)) + } + const { title, isUnread } = bodyResult.data const updates: Record = {} @@ -209,9 +210,6 @@ export const PATCH = withRouteHandler( return NextResponse.json({ success: true }) } catch (error) { - if (error instanceof z.ZodError) { - return createBadRequestResponse('Invalid request data') - } logger.error('Error updating mothership chat:', error) return createInternalServerErrorResponse('Failed to update chat') } @@ -226,10 +224,11 @@ export const DELETE = withRouteHandler( return createUnauthorizedResponse() } - const { chatId } = await params - if (!chatId) { - return createBadRequestResponse('chatId is required') + const paramsResult = validateSchema(mothershipChatParamsSchema, await params) + if (!paramsResult.success) { + return createBadRequestResponse(getValidationErrorMessage(paramsResult.error)) } + const { chatId } = paramsResult.data const chat = await getAccessibleCopilotChat(chatId, userId) if (!chat || chat.type !== 'mothership') { diff --git a/apps/sim/app/api/mothership/chats/read/route.ts b/apps/sim/app/api/mothership/chats/read/route.ts index af06ceaff8c..b1f7d5be0d1 100644 --- a/apps/sim/app/api/mothership/chats/read/route.ts +++ b/apps/sim/app/api/mothership/chats/read/route.ts @@ -3,10 +3,10 @@ import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { markMothershipChatReadBodySchema } from '@/lib/api/contracts/mothership-tasks' +import { validateJsonBody } from '@/lib/api/server' import { authenticateCopilotRequestSessionOnly, - createBadRequestResponse, createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' @@ -14,10 +14,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('MarkTaskReadAPI') -const MarkReadSchema = z.object({ - chatId: z.string().min(1), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() @@ -25,8 +21,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return createUnauthorizedResponse() } - const body = await request.json() - const { chatId } = MarkReadSchema.parse(body) + const validation = await validateJsonBody(request, markMothershipChatReadBodySchema) + if (!validation.success) return validation.response + const { chatId } = validation.data await db .update(copilotChats) @@ -35,9 +32,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true }) } catch (error) { - if (error instanceof z.ZodError) { - return createBadRequestResponse('chatId is required') - } logger.error('Error marking task as read:', error) return createInternalServerErrorResponse('Failed to mark task as read') } diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index d704451643d..2e79efff9a8 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -3,7 +3,11 @@ import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + createMothershipChatBodySchema, + listMothershipChatsQuerySchema, +} from '@/lib/api/contracts/mothership-tasks' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, @@ -28,10 +32,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return createUnauthorizedResponse() } - const workspaceId = request.nextUrl.searchParams.get('workspaceId') - if (!workspaceId) { - return createBadRequestResponse('workspaceId is required') + const queryResult = validateSchema(listMothershipChatsQuerySchema, { + workspaceId: request.nextUrl.searchParams.get('workspaceId'), + }) + if (!queryResult.success) { + return createBadRequestResponse(getValidationErrorMessage(queryResult.error)) } + const { workspaceId } = queryResult.data await assertActiveWorkspaceAccess(workspaceId, userId) @@ -60,10 +67,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } }) -const CreateChatSchema = z.object({ - workspaceId: z.string().min(1), -}) - /** * POST /api/mothership/chats * Creates an empty mothership chat and returns its ID. @@ -76,7 +79,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const body = await request.json() - const { workspaceId } = CreateChatSchema.parse(body) + const validation = validateSchema(createMothershipChatBodySchema, body) + if (!validation.success) { + return createBadRequestResponse(getValidationErrorMessage(validation.error)) + } + const { workspaceId } = validation.data await assertActiveWorkspaceAccess(workspaceId, userId) @@ -108,9 +115,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, id: chat.id }) } catch (error) { - if (error instanceof z.ZodError) { - return createBadRequestResponse('workspaceId is required') - } logger.error('Error creating mothership chat:', error) return createInternalServerErrorResponse('Failed to create chat') } diff --git a/apps/sim/app/api/mothership/events/route.ts b/apps/sim/app/api/mothership/events/route.ts index 20aee9ebd5c..b0e57d29d76 100644 --- a/apps/sim/app/api/mothership/events/route.ts +++ b/apps/sim/app/api/mothership/events/route.ts @@ -7,29 +7,39 @@ * Auth is handled via session cookies (EventSource sends cookies automatically). */ +import type { NextRequest } from 'next/server' +import { mothershipEventsQuerySchema } from '@/lib/api/contracts/mothership-tasks' +import { validateSchema } from '@/lib/api/server' import { taskPubSub } from '@/lib/copilot/tasks' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkspaceSSE } from '@/lib/events/sse-endpoint' export const dynamic = 'force-dynamic' -export const GET = withRouteHandler( - createWorkspaceSSE({ - label: 'mothership-events', - subscriptions: [ - { - subscribe: (workspaceId, send) => { - if (!taskPubSub) return () => {} - return taskPubSub.onStatusChanged((event) => { - if (event.workspaceId !== workspaceId) return - send('task_status', { - chatId: event.chatId, - type: event.type, - timestamp: Date.now(), - }) +const mothershipEventsHandler = createWorkspaceSSE({ + label: 'mothership-events', + subscriptions: [ + { + subscribe: (workspaceId, send) => { + if (!taskPubSub) return () => {} + return taskPubSub.onStatusChanged((event) => { + if (event.workspaceId !== workspaceId) return + send('task_status', { + chatId: event.chatId, + type: event.type, + timestamp: Date.now(), }) - }, + }) }, - ], - }) -) + }, + ], +}) + +export const GET = withRouteHandler((request: NextRequest) => { + const validation = validateSchema( + mothershipEventsQuerySchema, + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!validation.success) return validation.response + return mothershipEventsHandler(request) +}) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 31fd259fbf2..cf05b59dfe8 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -2,7 +2,8 @@ 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 { z } from 'zod' +import { mothershipExecuteBodySchema } from '@/lib/api/contracts/mothership-tasks' +import { validateSchema } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' @@ -18,23 +19,6 @@ export const maxDuration = 3600 const logger = createLogger('MothershipExecuteAPI') -const MessageSchema = z.object({ - role: z.enum(['system', 'user', 'assistant']), - content: z.string(), -}) - -const ExecuteRequestSchema = z.object({ - messages: z.array(MessageSchema).min(1, 'At least one message is required'), - responseFormat: z.any().optional(), - workspaceId: z.string().min(1, 'workspaceId is required'), - userId: z.string().min(1, 'userId is required'), - chatId: z.string().optional(), - messageId: z.string().optional(), - requestId: z.string().optional(), - workflowId: z.string().optional(), - executionId: z.string().optional(), -}) - function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === 'AbortError' } @@ -57,6 +41,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } const body = await req.json() + const validation = validateSchema(mothershipExecuteBodySchema, body, 'Invalid request data') + if (!validation.success) return validation.response const { messages, responseFormat, @@ -67,7 +53,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { requestId: providedRequestId, workflowId, executionId, - } = ExecuteRequestSchema.parse(body) + } = validation.data await assertActiveWorkspaceAccess(workspaceId, userId) @@ -190,13 +176,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { await explicitAbortRequest } } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - if (req.signal.aborted || isAbortError(error)) { logger.info( messageId diff --git a/apps/sim/app/api/notifications/poll/route.ts b/apps/sim/app/api/notifications/poll/route.ts index ecfd4b419b4..689417dade7 100644 --- a/apps/sim/app/api/notifications/poll/route.ts +++ b/apps/sim/app/api/notifications/poll/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { verifyCronAuth } from '@/lib/auth/internal' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -16,6 +18,11 @@ const LOCK_TTL_SECONDS = 120 export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateShortId() logger.info(`Inactivity alert polling triggered (${requestId})`) + const queryValidation = validateSchema( + noInputSchema, + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!queryValidation.success) return queryValidation.response let lockAcquired = false diff --git a/apps/sim/app/api/organizations/[id]/data-retention/route.ts b/apps/sim/app/api/organizations/[id]/data-retention/route.ts index 8847ce40ad8..77129eefc55 100644 --- a/apps/sim/app/api/organizations/[id]/data-retention/route.ts +++ b/apps/sim/app/api/organizations/[id]/data-retention/route.ts @@ -4,7 +4,8 @@ import { member, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateOrganizationDataRetentionBodySchema } from '@/lib/api/contracts/organization' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { CLEANUP_CONFIG, @@ -16,15 +17,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('DataRetentionAPI') -const MIN_HOURS = 24 -const MAX_HOURS = 43800 - -const updateRetentionSchema = z.object({ - logRetentionHours: z.number().int().min(MIN_HOURS).max(MAX_HOURS).nullable().optional(), - softDeleteRetentionHours: z.number().int().min(MIN_HOURS).max(MAX_HOURS).nullable().optional(), - taskCleanupHours: z.number().int().min(MIN_HOURS).max(MAX_HOURS).nullable().optional(), -}) - function enterpriseDefaults(): OrganizationRetentionSettings { return { logRetentionHours: CLEANUP_CONFIG['cleanup-logs'].defaults.enterprise, @@ -111,10 +103,10 @@ export const PUT = withRouteHandler( const { id: organizationId } = await params const body = await request.json() - const parsed = updateRetentionSchema.safeParse(body) + const parsed = updateOrganizationDataRetentionBodySchema.safeParse(body) if (!parsed.success) { return NextResponse.json( - { error: parsed.error.errors[0]?.message ?? 'Invalid request body' }, + { error: getValidationErrorMessage(parsed.error, 'Invalid request body') }, { status: 400 } ) } diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index dec09e2e1f8..cd3877fc720 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -4,6 +4,12 @@ import { invitation, member, organization, user, workspace } from '@sim/db/schem import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { + createOrganizationInvitationBodySchema, + organizationInvitationsQuerySchema, + organizationParamsSchema, +} from '@/lib/api/contracts/organization' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { validateBulkInvitations, @@ -38,7 +44,15 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: organizationId } = await params + const paramsResult = organizationParamsSchema.safeParse(await params) + if (!paramsResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, + { status: 400 } + ) + } + + const { id: organizationId } = paramsResult.data const [memberEntry] = await db .select() @@ -94,26 +108,47 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: organizationId } = await params + const paramsResult = organizationParamsSchema.safeParse(await params) + if (!paramsResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, + { status: 400 } + ) + } + + const { id: organizationId } = paramsResult.data await validateInvitationsAllowed(session.user.id, { organizationId }) - const url = new URL(request.url) - const validateOnly = url.searchParams.get('validate') === 'true' - const isBatch = url.searchParams.get('batch') === 'true' + const queryResult = organizationInvitationsQuerySchema.safeParse( + Object.fromEntries(new URL(request.url).searchParams.entries()) + ) + if (!queryResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(queryResult.error, 'Invalid query parameters') }, + { status: 400 } + ) + } + const validateOnly = queryResult.data.validate === true + const isBatch = queryResult.data.batch === true + + const bodyResult = createOrganizationInvitationBodySchema.safeParse( + await request.json().catch(() => ({})) + ) + if (!bodyResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(bodyResult.error, 'Invalid request body') }, + { status: 400 } + ) + } - const body = await request.json() - const { email, emails, role = 'member', workspaceInvitations } = body + const { email, emails, role = 'member', workspaceInvitations } = bodyResult.data const invitationEmails = email ? [email] : emails if (!invitationEmails || !Array.isArray(invitationEmails) || invitationEmails.length === 0) { return NextResponse.json({ error: 'Email or emails array is required' }, { status: 400 }) } - if (!['member', 'admin'].includes(role)) { - return NextResponse.json({ error: 'Invalid role' }, { status: 400 }) - } - const [memberEntry] = await db .select() .from(member) @@ -154,7 +189,7 @@ export const POST = withRouteHandler( const processedEmails = Array.from( new Set( invitationEmails - .map((raw: string) => { + .map((raw) => { const normalized = raw.trim().toLowerCase() return quickValidateEmail(normalized).isValid ? normalized : null }) @@ -310,7 +345,7 @@ export const POST = withRouteHandler( email, inviterId: session.user.id, organizationId, - role: role as 'admin' | 'member', + role, grants: validGrants, }) @@ -321,7 +356,7 @@ export const POST = withRouteHandler( email, inviterName, organizationId, - organizationRole: role as 'admin' | 'member', + organizationRole: role, grants: validGrants, }) @@ -381,7 +416,7 @@ export const POST = withRouteHandler( existingMembers: processedEmails.filter((email) => existingEmails.includes(email)), pendingInvitations: processedEmails.filter((email) => pendingEmails.includes(email)), invalidEmails: invitationEmails.filter( - (email: string) => !quickValidateEmail(email.trim().toLowerCase()).isValid + (email) => !quickValidateEmail(email.trim().toLowerCase()).isValid ), workspaceGrantsPerInvite: validGrants.length, seatInfo: { diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts index f672686ac7d..1339d71781a 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -4,7 +4,11 @@ import { member, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + removeOrganizationMemberQuerySchema, + updateOrganizationMemberRoleBodySchema, +} from '@/lib/api/contracts/organization' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' import { getUserUsageData } from '@/lib/billing/core/usage' @@ -17,12 +21,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('OrganizationMemberAPI') -const updateMemberSchema = z.object({ - role: z.enum(['owner', 'admin', 'member'], { - errorMap: () => ({ message: 'Invalid role' }), - }), -}) - /** * GET /api/organizations/[id]/members/[memberId] * Get individual organization member details @@ -156,10 +154,12 @@ export const PUT = withRouteHandler( const { id: organizationId, memberId } = await params const body = await request.json() - const validation = updateMemberSchema.safeParse(body) + const validation = updateOrganizationMemberRoleBodySchema.safeParse(body) if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) + return NextResponse.json( + { error: getValidationErrorMessage(validation.error) }, + { status: 400 } + ) } const { role } = validation.data @@ -286,7 +286,12 @@ export const DELETE = withRouteHandler( } const { id: organizationId, memberId: targetUserId } = await params - const shouldReduceSeats = request.nextUrl.searchParams.get('shouldReduceSeats') === 'true' + const queryResult = removeOrganizationMemberQuerySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + const shouldReduceSeats = queryResult.success + ? queryResult.data.shouldReduceSeats === true + : false const userMember = await db .select() diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 36a4d1d4b65..01fcf86e571 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -10,6 +10,12 @@ import { import { createLogger } from '@sim/logger' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { + inviteOrganizationMemberBodySchema, + organizationMemberQuerySchema, + organizationParamsSchema, +} from '@/lib/api/contracts/organization' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' @@ -40,9 +46,25 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: organizationId } = await params - const url = new URL(request.url) - const includeUsage = url.searchParams.get('include') === 'usage' + const paramsResult = organizationParamsSchema.safeParse(await params) + if (!paramsResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, + { status: 400 } + ) + } + + const { id: organizationId } = paramsResult.data + const queryResult = organizationMemberQuerySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!queryResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(queryResult.error, 'Invalid query parameters') }, + { status: 400 } + ) + } + const includeUsage = queryResult.data.include === 'usage' // Verify user has access to this organization const memberEntry = await db @@ -165,19 +187,29 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: organizationId } = await params + const paramsResult = organizationParamsSchema.safeParse(await params) + if (!paramsResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, + { status: 400 } + ) + } - await validateInvitationsAllowed(session.user.id, { organizationId }) + const { id: organizationId } = paramsResult.data - const { email, role = 'member' } = await request.json() + await validateInvitationsAllowed(session.user.id, { organizationId }) - if (!email) { - return NextResponse.json({ error: 'Email is required' }, { status: 400 }) + const bodyResult = inviteOrganizationMemberBodySchema.safeParse( + await request.json().catch(() => ({})) + ) + if (!bodyResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(bodyResult.error) }, + { status: 400 } + ) } - if (!['admin', 'member'].includes(role)) { - return NextResponse.json({ error: 'Invalid role' }, { status: 400 }) - } + const { email, role = 'member' } = bodyResult.data // Validate and normalize email const normalizedEmail = email.trim().toLowerCase() @@ -268,7 +300,7 @@ export const POST = withRouteHandler( email: normalizedEmail, inviterId: session.user.id, organizationId, - role: role as 'admin' | 'member', + role, grants: [], }) @@ -286,7 +318,7 @@ export const POST = withRouteHandler( email: normalizedEmail, inviterName, organizationId, - organizationRole: role as 'admin' | 'member', + organizationRole: role, grants: [], }) diff --git a/apps/sim/app/api/organizations/[id]/roster/route.ts b/apps/sim/app/api/organizations/[id]/roster/route.ts index 86229747db7..8ccdcfe6227 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.ts @@ -10,6 +10,8 @@ import { import { createLogger } from '@sim/logger' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { organizationParamsSchema } from '@/lib/api/contracts/organization' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { expireStalePendingInvitationsForOrganization } from '@/lib/invitations/core' @@ -30,7 +32,15 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: organizationId } = await params + const paramsResult = organizationParamsSchema.safeParse(await params) + if (!paramsResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, + { status: 400 } + ) + } + + const { id: organizationId } = paramsResult.data const [callerMembership] = await db .select({ role: member.role }) diff --git a/apps/sim/app/api/organizations/[id]/route.ts b/apps/sim/app/api/organizations/[id]/route.ts index 70326038cca..e0974efac7e 100644 --- a/apps/sim/app/api/organizations/[id]/route.ts +++ b/apps/sim/app/api/organizations/[id]/route.ts @@ -4,7 +4,8 @@ import { member, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateOrganizationBodySchema } from '@/lib/api/contracts/organization' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getOrganizationSeatAnalytics, @@ -14,19 +15,22 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('OrganizationAPI') -const updateOrganizationSchema = z.object({ - name: z.string().trim().min(1, 'Organization name is required').optional(), - slug: z - .string() - .trim() - .min(1, 'Organization slug is required') - .regex( - /^[a-z0-9-_]+$/, - 'Slug can only contain lowercase letters, numbers, hyphens, and underscores' - ) - .optional(), - logo: z.string().nullable().optional(), -}) +type OrganizationDetailsResponse = { + success: true + data: { + id: string + name: string + slug: string | null + logo: string | null + metadata: unknown + createdAt: Date + updatedAt: Date + seats?: NonNullable>> + seatAnalytics?: NonNullable>> + } + userRole: string + hasAdminAccess: boolean +} /** * GET /api/organizations/[id] @@ -73,7 +77,7 @@ export const GET = withRouteHandler( const userRole = memberEntry[0].role const hasAdminAccess = ['owner', 'admin'].includes(userRole) - const response: any = { + const response: OrganizationDetailsResponse = { success: true, data: { id: organizationEntry[0].id, @@ -133,10 +137,12 @@ export const PUT = withRouteHandler( const { id: organizationId } = await params const body = await request.json() - const validation = updateOrganizationSchema.safeParse(body) + const validation = updateOrganizationBodySchema.safeParse(body) if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) + return NextResponse.json( + { error: getValidationErrorMessage(validation.error) }, + { status: 400 } + ) } const { name, slug, logo } = validation.data @@ -175,7 +181,12 @@ export const PUT = withRouteHandler( } // Build update object with only provided fields - const updateData: any = { updatedAt: new Date() } + const updateData: { + updatedAt: Date + name?: string + slug?: string + logo?: string | null + } = { updatedAt: new Date() } if (name !== undefined) updateData.name = name if (slug !== undefined) updateData.slug = slug if (logo !== undefined) updateData.logo = logo diff --git a/apps/sim/app/api/organizations/[id]/seats/route.ts b/apps/sim/app/api/organizations/[id]/seats/route.ts index cce91dfc8a8..b3b661968e5 100644 --- a/apps/sim/app/api/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/organizations/[id]/seats/route.ts @@ -3,7 +3,8 @@ import { invitation, member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq, gt, inArray, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateSeatsBodySchema } from '@/lib/api/contracts/organization' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' import { getPlanPricing } from '@/lib/billing/core/billing' @@ -20,10 +21,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('OrganizationSeatsAPI') -const updateSeatsSchema = z.object({ - seats: z.number().int().min(1, 'Minimum 1 seat required').max(50, 'Maximum 50 seats allowed'), -}) - /** * PUT /api/organizations/[id]/seats * Update organization seat count using Stripe's subscription.update API. @@ -45,10 +42,12 @@ export const PUT = withRouteHandler( const { id: organizationId } = await params const body = await request.json() - const validation = updateSeatsSchema.safeParse(body) + const validation = updateSeatsBodySchema.safeParse(body) if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) + return NextResponse.json( + { error: getValidationErrorMessage(validation.error) }, + { status: 400 } + ) } const { seats: newSeatCount } = validation.data @@ -264,7 +263,7 @@ export const PUT = withRouteHandler( // Handle Stripe-specific errors if (error instanceof Error && 'type' in error) { - const stripeError = error as any + const stripeError = error as Error & { type?: unknown; code?: unknown } logger.error('Stripe error updating seats', { organizationId, type: stripeError.type, @@ -275,7 +274,7 @@ export const PUT = withRouteHandler( return NextResponse.json( { error: stripeError.message || 'Failed to update seats in Stripe', - code: stripeError.code, + code: typeof stripeError.code === 'string' ? stripeError.code : undefined, }, { status: 400 } ) diff --git a/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts b/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts index 6eac1e6d644..e7c2a89ae57 100644 --- a/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts +++ b/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts @@ -4,7 +4,8 @@ import { member, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { transferOwnershipBodySchema } from '@/lib/api/contracts/organization' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' import { @@ -15,11 +16,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('TransferOwnershipAPI') -const transferOwnershipSchema = z.object({ - newOwnerUserId: z.string().min(1), - alsoLeave: z.boolean().optional().default(false), -}) - export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { try { @@ -30,10 +26,10 @@ export const POST = withRouteHandler( const { id: organizationId } = await params const body = await request.json().catch(() => ({})) - const validation = transferOwnershipSchema.safeParse(body) + const validation = transferOwnershipBodySchema.safeParse(body) if (!validation.success) { return NextResponse.json( - { error: validation.error.errors[0]?.message ?? 'Invalid request' }, + { error: getValidationErrorMessage(validation.error, 'Invalid request') }, { status: 400 } ) } diff --git a/apps/sim/app/api/organizations/[id]/whitelabel/route.ts b/apps/sim/app/api/organizations/[id]/whitelabel/route.ts index ef1dab40e7c..9635e4a506a 100644 --- a/apps/sim/app/api/organizations/[id]/whitelabel/route.ts +++ b/apps/sim/app/api/organizations/[id]/whitelabel/route.ts @@ -4,55 +4,15 @@ import { member, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateOrganizationWhitelabelBodySchema } from '@/lib/api/contracts/organization' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' -import { HEX_COLOR_REGEX } from '@/lib/branding' import type { OrganizationWhitelabelSettings } from '@/lib/branding/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('WhitelabelAPI') -const updateWhitelabelSchema = z.object({ - brandName: z - .string() - .trim() - .max(64, 'Brand name must be 64 characters or fewer') - .nullable() - .optional(), - logoUrl: z.string().min(1).nullable().optional(), - wordmarkUrl: z.string().min(1).nullable().optional(), - primaryColor: z - .string() - .regex(HEX_COLOR_REGEX, 'Primary color must be a valid hex color (e.g. #701ffc)') - .nullable() - .optional(), - primaryHoverColor: z - .string() - .regex(HEX_COLOR_REGEX, 'Primary hover color must be a valid hex color') - .nullable() - .optional(), - accentColor: z - .string() - .regex(HEX_COLOR_REGEX, 'Accent color must be a valid hex color') - .nullable() - .optional(), - accentHoverColor: z - .string() - .regex(HEX_COLOR_REGEX, 'Accent hover color must be a valid hex color') - .nullable() - .optional(), - supportEmail: z - .string() - .email('Support email must be a valid email address') - .nullable() - .optional(), - documentationUrl: z.string().url('Documentation URL must be a valid URL').nullable().optional(), - termsUrl: z.string().url('Terms URL must be a valid URL').nullable().optional(), - privacyUrl: z.string().url('Privacy URL must be a valid URL').nullable().optional(), - hidePoweredBySim: z.boolean().optional(), -}) - /** * GET /api/organizations/[id]/whitelabel * Returns the organization's whitelabel settings. @@ -120,11 +80,11 @@ export const PUT = withRouteHandler( const { id: organizationId } = await params const body = await request.json() - const parsed = updateWhitelabelSchema.safeParse(body) + const parsed = updateOrganizationWhitelabelBodySchema.safeParse(body) if (!parsed.success) { return NextResponse.json( - { error: parsed.error.errors[0]?.message ?? 'Invalid request body' }, + { error: getValidationErrorMessage(parsed.error, 'Invalid request body') }, { status: 400 } ) } diff --git a/apps/sim/app/api/organizations/route.ts b/apps/sim/app/api/organizations/route.ts index c79e30c017c..f75a1d56d84 100644 --- a/apps/sim/app/api/organizations/route.ts +++ b/apps/sim/app/api/organizations/route.ts @@ -4,6 +4,8 @@ import { member, organization, subscription as subscriptionTable } from '@sim/db import { createLogger } from '@sim/logger' import { and, eq, inArray, or } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { createOrganizationBodySchema } from '@/lib/api/contracts/organization' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' import { @@ -77,20 +79,22 @@ export const POST = withRouteHandler(async (request: Request) => { const user = session.user - // Parse request body for optional name and slug let organizationName = user.name let organizationSlug: string | undefined - try { - const body = await request.json() - if (body.name && typeof body.name === 'string') { - organizationName = body.name - } - if (body.slug && typeof body.slug === 'string') { - organizationSlug = body.slug - } - } catch { - // If no body or invalid JSON, use defaults + const rawBody = await request.json().catch(() => ({})) + const parsedBody = createOrganizationBodySchema.safeParse(rawBody) + if (!parsedBody.success) { + return NextResponse.json( + { error: getValidationErrorMessage(parsedBody.error, 'Invalid request body') }, + { status: 400 } + ) + } + if (parsedBody.data.name) { + organizationName = parsedBody.data.name + } + if (parsedBody.data.slug) { + organizationSlug = parsedBody.data.slug } const existingOrgMembership = await db diff --git a/apps/sim/app/api/permission-groups/user/route.ts b/apps/sim/app/api/permission-groups/user/route.ts index 4dbddafb599..c70b392826e 100644 --- a/apps/sim/app/api/permission-groups/user/route.ts +++ b/apps/sim/app/api/permission-groups/user/route.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { permissionGroup, permissionGroupMember } from '@sim/db/schema' import { and, asc, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { userPermissionConfigQuerySchema } from '@/lib/api/contracts/permission-groups' import { getSession } from '@/lib/auth' import { isWorkspaceOnEnterprisePlan } from '@/lib/billing' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -14,12 +15,13 @@ export const GET = withRouteHandler(async (req: Request) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { searchParams } = new URL(req.url) - const workspaceId = searchParams.get('workspaceId') - - if (!workspaceId) { + const queryResult = userPermissionConfigQuerySchema.safeParse( + Object.fromEntries(new URL(req.url).searchParams.entries()) + ) + if (!queryResult.success) { return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) } + const { workspaceId } = queryResult.data const access = await checkWorkspaceAccess(workspaceId, session.user.id) if (!access.exists) { diff --git a/apps/sim/app/api/providers/base/models/route.ts b/apps/sim/app/api/providers/base/models/route.ts index 93c6da59762..e0ccec24a27 100644 --- a/apps/sim/app/api/providers/base/models/route.ts +++ b/apps/sim/app/api/providers/base/models/route.ts @@ -1,11 +1,12 @@ import { NextResponse } from 'next/server' +import { providerModelsResponseSchema } from '@/lib/api/contracts/providers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getBaseModelProviders } from '@/providers/utils' export const GET = withRouteHandler(async () => { try { const allModels = Object.keys(getBaseModelProviders()) - return NextResponse.json({ models: allModels }) + return NextResponse.json(providerModelsResponseSchema.parse({ models: allModels })) } catch (error) { return NextResponse.json({ models: [], error: 'Failed to fetch models' }, { status: 500 }) } diff --git a/apps/sim/app/api/providers/fireworks/models/route.ts b/apps/sim/app/api/providers/fireworks/models/route.ts index 8bd47a78862..424ad0d9911 100644 --- a/apps/sim/app/api/providers/fireworks/models/route.ts +++ b/apps/sim/app/api/providers/fireworks/models/route.ts @@ -1,5 +1,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { + fireworksProviderModelsQuerySchema, + fireworksUpstreamResponseSchema, + providerModelsResponseSchema, +} from '@/lib/api/contracts/providers' +import { validateSchema } from '@/lib/api/server' import { getBYOKKey } from '@/lib/api-key/byok' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' @@ -29,7 +35,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { let apiKey: string | undefined - const workspaceId = request.nextUrl.searchParams.get('workspaceId') + const queryValidation = validateSchema(fireworksProviderModelsQuerySchema, { + workspaceId: request.nextUrl.searchParams.get('workspaceId') ?? undefined, + }) + if (!queryValidation.success) return queryValidation.response + const { workspaceId } = queryValidation.data if (workspaceId) { const session = await getSession() if (session?.user?.id) { @@ -69,7 +79,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ models: [] }) } - const data = (await response.json()) as FireworksModelsResponse + const data: FireworksModelsResponse = fireworksUpstreamResponseSchema.parse( + await response.json() + ) const allModels: string[] = [] for (const model of data.data ?? []) { @@ -84,7 +96,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { filtered: uniqueModels.length - models.length, }) - return NextResponse.json({ models }) + return NextResponse.json(providerModelsResponseSchema.parse({ models })) } catch (error) { logger.error('Error fetching Fireworks models', { error: error instanceof Error ? error.message : 'Unknown error', diff --git a/apps/sim/app/api/providers/ollama/models/route.ts b/apps/sim/app/api/providers/ollama/models/route.ts index eccdb717279..1c366322314 100644 --- a/apps/sim/app/api/providers/ollama/models/route.ts +++ b/apps/sim/app/api/providers/ollama/models/route.ts @@ -1,8 +1,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { + ollamaUpstreamResponseSchema, + providerModelsResponseSchema, +} from '@/lib/api/contracts/providers' import { getOllamaUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { ModelsObject } from '@/providers/ollama/types' import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils' const logger = createLogger('OllamaModelsAPI') @@ -37,7 +40,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { return NextResponse.json({ models: [] }) } - const data = (await response.json()) as ModelsObject + const data = ollamaUpstreamResponseSchema.parse(await response.json()) const allModels = data.models.map((model) => model.name) const models = filterBlacklistedModels(allModels) @@ -47,7 +50,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { models, }) - return NextResponse.json({ models }) + return NextResponse.json(providerModelsResponseSchema.parse({ models })) } catch (error) { logger.error('Failed to fetch Ollama models', { error: error instanceof Error ? error.message : 'Unknown error', diff --git a/apps/sim/app/api/providers/openrouter/models/route.ts b/apps/sim/app/api/providers/openrouter/models/route.ts index b0e3346d4a5..c44162b7e2d 100644 --- a/apps/sim/app/api/providers/openrouter/models/route.ts +++ b/apps/sim/app/api/providers/openrouter/models/route.ts @@ -1,5 +1,9 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { + openRouterUpstreamResponseSchema, + providerModelsResponseSchema, +} from '@/lib/api/contracts/providers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils' @@ -50,7 +54,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { return NextResponse.json({ models: [], modelInfo: {} }) } - const data = (await response.json()) as OpenRouterResponse + const data: OpenRouterResponse = openRouterUpstreamResponseSchema.parse(await response.json()) const modelInfo: Record = {} const allModels: string[] = [] @@ -87,7 +91,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { withStructuredOutputs: structuredOutputCount, }) - return NextResponse.json({ models, modelInfo }) + return NextResponse.json(providerModelsResponseSchema.parse({ models, modelInfo })) } catch (error) { logger.error('Error fetching OpenRouter models', { error: error instanceof Error ? error.message : 'Unknown error', diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index ddf2f0d59c0..a68b8c790bd 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -4,6 +4,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { providerApiRequestBodySchema } from '@/lib/api/contracts/providers' +import { validateSchema } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -44,7 +46,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { contentType: request.headers.get('Content-Type'), }) - const body = await request.json() + const bodyResult = validateSchema(providerApiRequestBodySchema, await request.json()) + if (!bodyResult.success) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + + const body = bodyResult.data const { provider, model, diff --git a/apps/sim/app/api/providers/vllm/models/route.ts b/apps/sim/app/api/providers/vllm/models/route.ts index 3f1dcc3a260..b933e1a9815 100644 --- a/apps/sim/app/api/providers/vllm/models/route.ts +++ b/apps/sim/app/api/providers/vllm/models/route.ts @@ -1,5 +1,9 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { + providerModelsResponseSchema, + vllmUpstreamResponseSchema, +} from '@/lib/api/contracts/providers' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils' @@ -48,7 +52,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { return NextResponse.json({ models: [] }) } - const data = (await response.json()) as { data: Array<{ id: string }> } + const data = vllmUpstreamResponseSchema.parse(await response.json()) const allModels = data.data.map((model) => `vllm/${model.id}`) const models = filterBlacklistedModels(allModels) @@ -58,7 +62,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { models, }) - return NextResponse.json({ models }) + return NextResponse.json(providerModelsResponseSchema.parse({ models })) } catch (error) { logger.error('Failed to fetch vLLM models', { error: error instanceof Error ? error.message : 'Unknown error', diff --git a/apps/sim/app/api/proxy/tts/stream/route.ts b/apps/sim/app/api/proxy/tts/stream/route.ts index ad6d51e7bb7..b7859c69990 100644 --- a/apps/sim/app/api/proxy/tts/stream/route.ts +++ b/apps/sim/app/api/proxy/tts/stream/route.ts @@ -3,6 +3,7 @@ import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { ttsStreamBodySchema } from '@/lib/api/contracts/media-tools' import { env } from '@/lib/core/config/env' import { validateAuthToken } from '@/lib/core/security/deployment' import { validateAlphanumericId } from '@/lib/core/security/input-validation' @@ -54,23 +55,28 @@ async function validateChatAuth(request: NextRequest, chatId: string): Promise { try { - let body: any + let rawBody: unknown try { - body = await request.json() + rawBody = await request.json() } catch { return new Response('Invalid request body', { status: 400 }) } - const { text, voiceId, modelId = 'eleven_turbo_v2_5', chatId } = body + const bodyResult = ttsStreamBodySchema.safeParse(rawBody) - if (!chatId) { + if ( + !bodyResult.success && + bodyResult.error.issues.some((issue) => issue.path[0] === 'chatId') + ) { return new Response('chatId is required', { status: 400 }) } - if (!text || !voiceId) { + if (!bodyResult.success) { return new Response('Missing required parameters', { status: 400 }) } + const { text, voiceId, modelId, chatId } = bodyResult.data + const isChatAuthed = await validateChatAuth(request, chatId) if (!isChatAuthed) { logger.warn('Chat authentication failed for TTS, chatId:', chatId) 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 e5a2c7cd251..5e84ca28da6 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -2,6 +2,8 @@ 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 { resumeExecutionContextParamsSchema } from '@/lib/api/contracts/workflows' +import { validateSchema } from '@/lib/api/server' import { AuthType } from '@/lib/auth/hybrid' import { getJobQueue } from '@/lib/core/async-jobs' import { generateRequestId } from '@/lib/core/utils/request' @@ -48,7 +50,9 @@ export const POST = withRouteHandler( params: Promise<{ workflowId: string; executionId: string; contextId: string }> } ) => { - const { workflowId, executionId, contextId } = await params + const paramsValidation = validateSchema(resumeExecutionContextParamsSchema, await params) + if (!paramsValidation.success) return paramsValidation.response + const { workflowId, executionId, contextId } = paramsValidation.data const access = await validateWorkflowAccess(request, workflowId, false) if (access.error) { @@ -57,14 +61,17 @@ export const POST = withRouteHandler( const workflow = access.workflow - let payload: Record = {} + let payload: unknown = {} try { payload = await request.json() } catch { payload = {} } - const resumeInput = payload?.input ?? payload ?? {} + const resumeInput = + typeof payload === 'object' && payload !== null && 'input' in payload + ? payload.input + : (payload ?? {}) const isPersonalApiKeyCaller = access.auth?.authType === AuthType.API_KEY && access.auth?.apiKeyType === 'personal' @@ -300,7 +307,9 @@ export const GET = withRouteHandler( params: Promise<{ workflowId: string; executionId: string; contextId: string }> } ) => { - const { workflowId, executionId, contextId } = await params + const paramsValidation = validateSchema(resumeExecutionContextParamsSchema, await params) + if (!paramsValidation.success) return paramsValidation.response + const { workflowId, executionId, contextId } = paramsValidation.data const access = await validateWorkflowAccess(request, workflowId, false) if (access.error) { diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/route.ts index 264f6d592e7..5ebe1f278ea 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { resumeExecutionParamsSchema } from '@/lib/api/contracts/workflows' +import { validateSchema } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' @@ -18,7 +20,9 @@ export const GET = withRouteHandler( params: Promise<{ workflowId: string; executionId: string }> } ) => { - const { workflowId, executionId } = await params + const paramsValidation = validateSchema(resumeExecutionParamsSchema, await params) + if (!paramsValidation.success) return paramsValidation.response + const { workflowId, executionId } = paramsValidation.data const access = await validateWorkflowAccess(request, workflowId, false) if (access.error) { diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index e8e3a486e60..6142eebdfea 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { scheduleUpdateSchema } from '@/lib/api/contracts/schedules' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -17,20 +17,6 @@ const logger = createLogger('ScheduleAPI') export const dynamic = 'force-dynamic' -const scheduleUpdateSchema = z.discriminatedUnion('action', [ - z.object({ action: z.literal('reactivate') }), - z.object({ action: z.literal('disable') }), - z.object({ - action: z.literal('update'), - title: z.string().min(1).optional(), - prompt: z.string().min(1).optional(), - cronExpression: z.string().optional(), - timezone: z.string().optional(), - lifecycle: z.enum(['persistent', 'until_complete']).optional(), - maxRuns: z.number().nullable().optional(), - }), -]) - type ScheduleRow = { id: string workflowId: string | null diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index 6b0b17a8450..eb3ebe0595e 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -6,6 +6,8 @@ import { generateId } from '@sim/utils/id' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { createScheduleBodySchema, scheduleQuerySchema } from '@/lib/api/contracts/schedules' +import { validateSchema } 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' @@ -25,9 +27,12 @@ const logger = createLogger('ScheduledAPI') export const GET = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() const url = new URL(req.url) - const workflowId = url.searchParams.get('workflowId') - const workspaceId = url.searchParams.get('workspaceId') - const blockId = url.searchParams.get('blockId') + const queryValidation = validateSchema( + scheduleQuerySchema, + Object.fromEntries(url.searchParams.entries()) + ) + if (!queryValidation.success) return queryValidation.response + const { workflowId, workspaceId, blockId } = queryValidation.data try { const session = await getSession() @@ -202,33 +207,18 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await req.json() - const { - workspaceId, - title, - prompt, - cronExpression, - timezone = 'UTC', - lifecycle = 'persistent', - maxRuns, - startDate, - } = body as { - workspaceId: string - title: string - prompt: string - cronExpression: string - timezone?: string - lifecycle?: 'persistent' | 'until_complete' - maxRuns?: number - startDate?: string - } - - if (!workspaceId || !title?.trim() || !prompt?.trim() || !cronExpression?.trim()) { + const bodyResult = createScheduleBodySchema.safeParse(await req.json()) + if (!bodyResult.success) { return NextResponse.json( - { error: 'Missing required fields: workspaceId, title, prompt, cronExpression' }, + { + error: 'Invalid request body', + details: bodyResult.error.issues, + }, { status: 400 } ) } + const { workspaceId, title, prompt, cronExpression, timezone, lifecycle, maxRuns, startDate } = + bodyResult.data const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId) if (!hasPermission) { diff --git a/apps/sim/app/api/settings/allowed-integrations/route.ts b/apps/sim/app/api/settings/allowed-integrations/route.ts index 7f4a45dada4..8060d4bf876 100644 --- a/apps/sim/app/api/settings/allowed-integrations/route.ts +++ b/apps/sim/app/api/settings/allowed-integrations/route.ts @@ -1,9 +1,14 @@ import { NextResponse } from 'next/server' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const GET = withRouteHandler(async () => { + const validation = validateSchema(noInputSchema, {}) + if (!validation.success) return validation.response + const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) diff --git a/apps/sim/app/api/settings/allowed-mcp-domains/route.ts b/apps/sim/app/api/settings/allowed-mcp-domains/route.ts index 207eb706165..8e2868f2c8b 100644 --- a/apps/sim/app/api/settings/allowed-mcp-domains/route.ts +++ b/apps/sim/app/api/settings/allowed-mcp-domains/route.ts @@ -1,10 +1,15 @@ import { NextResponse } from 'next/server' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getAllowedMcpDomainsFromEnv } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const GET = withRouteHandler(async () => { + const validation = validateSchema(noInputSchema, {}) + if (!validation.success) return validation.response + const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) diff --git a/apps/sim/app/api/settings/allowed-providers/route.ts b/apps/sim/app/api/settings/allowed-providers/route.ts index 81b0b66b11c..80d81519b41 100644 --- a/apps/sim/app/api/settings/allowed-providers/route.ts +++ b/apps/sim/app/api/settings/allowed-providers/route.ts @@ -1,9 +1,14 @@ import { NextResponse } from 'next/server' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getBlacklistedProvidersFromEnv } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const GET = withRouteHandler(async () => { + const validation = validateSchema(noInputSchema, {}) + if (!validation.success) return validation.response + const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) diff --git a/apps/sim/app/api/settings/voice/route.ts b/apps/sim/app/api/settings/voice/route.ts index a7c9af35cea..76b1974e0ef 100644 --- a/apps/sim/app/api/settings/voice/route.ts +++ b/apps/sim/app/api/settings/voice/route.ts @@ -1,12 +1,15 @@ import { NextResponse } from 'next/server' +import { getVoiceSettingsContract } from '@/lib/api/contracts' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { hasSTTService } from '@/lib/speech/config' +const voiceSettingsResponseSchema = getVoiceSettingsContract.response.schema + /** * Returns whether server-side STT is configured. * Unauthenticated — the response is a single boolean, * not sensitive data, and deployed chat visitors need it. */ export const GET = withRouteHandler(async () => { - return NextResponse.json({ sttAvailable: hasSTTService() }) + return NextResponse.json(voiceSettingsResponseSchema.parse({ sttAvailable: hasSTTService() })) }) diff --git a/apps/sim/app/api/skills/import/route.ts b/apps/sim/app/api/skills/import/route.ts index 8ce31a22fbf..e84886d674f 100644 --- a/apps/sim/app/api/skills/import/route.ts +++ b/apps/sim/app/api/skills/import/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { importSkillBodySchema } from '@/lib/api/contracts' +import { validateSchema } 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' @@ -9,10 +10,6 @@ const logger = createLogger('SkillsImportAPI') const FETCH_TIMEOUT_MS = 15_000 -const ImportSchema = z.object({ - url: z.string().url('A valid URL is required'), -}) - /** * Converts a standard GitHub file URL to its raw.githubusercontent.com equivalent. * @@ -54,7 +51,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } const body = await req.json() - const { url } = ImportSchema.parse(body) + const validation = validateSchema(importSkillBodySchema, body, 'Invalid request') + if (!validation.success) return validation.response + const { url } = validation.data let rawUrl: string try { @@ -93,10 +92,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ content }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: 'Invalid request', details: error.errors }, { status: 400 }) - } - if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) { logger.warn(`[${requestId}] GitHub fetch timed out`) return NextResponse.json({ error: 'Request timed out' }, { status: 504 }) diff --git a/apps/sim/app/api/skills/route.ts b/apps/sim/app/api/skills/route.ts index 6c91c9d1d7b..29ffc50c8ee 100644 --- a/apps/sim/app/api/skills/route.ts +++ b/apps/sim/app/api/skills/route.ts @@ -1,7 +1,12 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + deleteSkillQuerySchema, + listSkillsQuerySchema, + upsertSkillsBodySchema, +} from '@/lib/api/contracts' +import { isZodError } 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' @@ -11,28 +16,9 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('SkillsAPI') -const SkillSchema = z.object({ - skills: z.array( - z.object({ - id: z.string().optional(), - name: 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)'), - description: z.string().min(1, 'Description is required').max(1024), - content: z.string().min(1, 'Content is required').max(50000, 'Content is too large'), - }) - ), - workspaceId: z.string().optional(), - source: z.enum(['settings', 'tool_input']).optional(), -}) - /** GET - Fetch all skills for a workspace */ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() - const searchParams = request.nextUrl.searchParams - const workspaceId = searchParams.get('workspaceId') try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -42,11 +28,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const userId = authResult.userId - - if (!workspaceId) { - logger.warn(`[${requestId}] Missing workspaceId`) - return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) + const query = listSkillsQuerySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!query.success) { + logger.warn(`[${requestId}] Invalid skills query`, { errors: query.error.issues }) + return NextResponse.json( + { error: 'Invalid request data', details: query.error.issues }, + { status: 400 } + ) } + const { workspaceId } = query.data const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!userPermission) { @@ -78,12 +70,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const body = await req.json() try { - const { skills, workspaceId, source } = SkillSchema.parse(body) - - if (!workspaceId) { - logger.warn(`[${requestId}] Missing workspaceId in request body`) - return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) - } + const { skills, workspaceId, source } = upsertSkillsBodySchema.parse(body) const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!userPermission || (userPermission !== 'admin' && userPermission !== 'write')) { @@ -123,12 +110,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ success: true, data: resultSkills }) } catch (validationError) { - if (validationError instanceof z.ZodError) { + if (isZodError(validationError)) { logger.warn(`[${requestId}] Invalid skills data`, { - errors: validationError.errors, + errors: validationError.issues, }) return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, + { error: 'Invalid request data', details: validationError.issues }, { status: 400 } ) } @@ -146,12 +133,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { /** DELETE - Delete a skill by ID */ export const DELETE = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() - const searchParams = request.nextUrl.searchParams - const skillId = searchParams.get('id') - const workspaceId = searchParams.get('workspaceId') - const sourceParam = searchParams.get('source') - const source = - sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -161,16 +142,17 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { } const userId = authResult.userId - - if (!skillId) { - logger.warn(`[${requestId}] Missing skill ID for deletion`) - return NextResponse.json({ error: 'Skill ID is required' }, { status: 400 }) - } - - if (!workspaceId) { - logger.warn(`[${requestId}] Missing workspaceId for deletion`) - return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) + const query = deleteSkillQuerySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!query.success) { + logger.warn(`[${requestId}] Invalid skill deletion query`, { errors: query.error.issues }) + return NextResponse.json( + { error: 'Invalid request data', details: query.error.issues }, + { status: 400 } + ) } + const { id: skillId, workspaceId, source } = query.data const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!userPermission || (userPermission !== 'admin' && userPermission !== 'write')) { diff --git a/apps/sim/app/api/speech/token/route.ts b/apps/sim/app/api/speech/token/route.ts index c662cbdc4c2..c1b3e050b08 100644 --- a/apps/sim/app/api/speech/token/route.ts +++ b/apps/sim/app/api/speech/token/route.ts @@ -3,6 +3,7 @@ import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { speechTokenBodySchema } from '@/lib/api/contracts/media-tools' import { getSession } from '@/lib/auth' import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { recordUsage } from '@/lib/billing/core/usage-log' @@ -73,8 +74,10 @@ async function validateChatAuth( export const POST = withRouteHandler(async (request: NextRequest) => { try { - const body = await request.json().catch(() => ({})) - const chatId = body?.chatId as string | undefined + const rawBody = await request.json().catch(() => ({})) + const body = speechTokenBodySchema.safeParse(rawBody) + const chatId = + body.success && typeof body.data.chatId === 'string' ? body.data.chatId : undefined let billingUserId: string | undefined diff --git a/apps/sim/app/api/stars/route.ts b/apps/sim/app/api/stars/route.ts index 9d2c9bb15de..0f178dc5eeb 100644 --- a/apps/sim/app/api/stars/route.ts +++ b/apps/sim/app/api/stars/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,8 +13,14 @@ function formatStarCount(num: number): string { return formatted.endsWith('.0') ? `${formatted.slice(0, -2)}k` : `${formatted}k` } -export const GET = withRouteHandler(async () => { +export const GET = withRouteHandler(async (request: NextRequest) => { try { + const queryValidation = validateSchema( + noInputSchema, + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!queryValidation.success) return queryValidation.response + const token = env.GITHUB_TOKEN const response = await fetch('https://api.github.com/repos/simstudioai/sim', { headers: { diff --git a/apps/sim/app/api/status/route.ts b/apps/sim/app/api/status/route.ts index b4e37e13071..841f3de0bcb 100644 --- a/apps/sim/app/api/status/route.ts +++ b/apps/sim/app/api/status/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { IncidentIOWidgetResponse, StatusResponse, StatusType } from '@/app/api/status/types' @@ -31,8 +33,14 @@ function determineStatus(data: IncidentIOWidgetResponse): { return { status: 'operational', message: 'All Systems Operational' } } -export const GET = withRouteHandler(async () => { +export const GET = withRouteHandler(async (request: NextRequest) => { try { + const queryValidation = validateSchema( + noInputSchema, + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!queryValidation.success) return queryValidation.response + const now = Date.now() if (cachedResponse && now - cachedResponse.timestamp < CACHE_TTL) { diff --git a/apps/sim/app/api/superuser/import-workflow/route.ts b/apps/sim/app/api/superuser/import-workflow/route.ts index 72a9cf0af80..d8137d5c45b 100644 --- a/apps/sim/app/api/superuser/import-workflow/route.ts +++ b/apps/sim/app/api/superuser/import-workflow/route.ts @@ -4,6 +4,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { importWorkflowAsSuperuserPermissiveBodySchema } from '@/lib/api/contracts/workflows' +import { validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { verifyEffectiveSuperUser } from '@/lib/templates/permissions' @@ -51,7 +53,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Forbidden: Superuser access required' }, { status: 403 }) } - const body: ImportWorkflowRequest = await request.json() + const bodyResult = validateSchema( + importWorkflowAsSuperuserPermissiveBodySchema, + await request.json() + ) + const body = ( + bodyResult.success ? bodyResult.data : { workflowId: undefined, targetWorkspaceId: undefined } + ) as ImportWorkflowRequest const { workflowId, targetWorkspaceId } = body if (!workflowId) { diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index 9b6864c33d4..6b7d2760892 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { isZodError, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -60,11 +60,8 @@ export const POST = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error, 'Invalid request data') } if (error instanceof Error) { @@ -147,11 +144,8 @@ export const PATCH = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error, 'Invalid request data') } if (error instanceof Error) { @@ -215,11 +209,8 @@ export const DELETE = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error, 'Invalid request data') } if (error instanceof Error) { diff --git a/apps/sim/app/api/table/[tableId]/import-csv/route.ts b/apps/sim/app/api/table/[tableId]/import-csv/route.ts index 771f145e8b4..6d808afd03d 100644 --- a/apps/sim/app/api/table/[tableId]/import-csv/route.ts +++ b/apps/sim/app/api/table/[tableId]/import-csv/route.ts @@ -2,6 +2,13 @@ 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, + csvImportMappingSchema, + csvImportModeSchema, +} from '@/lib/api/contracts/tables' +import { getValidationErrorMessage } 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' @@ -9,7 +16,6 @@ import { batchInsertRows, buildAutoMapping, CSV_MAX_BATCH_SIZE, - CSV_MAX_FILE_SIZE_BYTES, type CsvHeaderMapping, CsvImportValidationError, coerceRowsForTable, @@ -21,8 +27,6 @@ import { accessError, checkAccess } from '@/app/api/table/utils' const logger = createLogger('TableImportCSVExisting') -const IMPORT_MODES = new Set(['append', 'replace']) - interface RouteParams { params: Promise<{ tableId: string }> } @@ -38,39 +42,38 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro } const formData = await request.formData() - const file = formData.get('file') - const workspaceId = formData.get('workspaceId') as string | null - const rawMode = (formData.get('mode') as string | null) ?? 'append' - const rawMapping = formData.get('mapping') as string | null - - if (!file || !(file instanceof File)) { - return NextResponse.json({ error: 'CSV file is required' }, { status: 400 }) - } + const formValidation = csvImportFormSchema.safeParse({ + file: formData.get('file'), + workspaceId: formData.get('workspaceId'), + }) + const rawMode = formData.get('mode') ?? 'append' + const rawMapping = formData.get('mapping') - if (file.size > CSV_MAX_FILE_SIZE_BYTES) { + if (!formValidation.success) { return NextResponse.json( - { - error: `File exceeds maximum allowed size of ${CSV_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB`, - }, + { error: getValidationErrorMessage(formValidation.error) }, { status: 400 } ) } - if (!workspaceId) { - return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) - } + const { file, workspaceId } = formValidation.data - if (!IMPORT_MODES.has(rawMode)) { + const modeValidation = csvImportModeSchema.safeParse(rawMode) + if (!modeValidation.success) { return NextResponse.json( { error: `Invalid mode "${rawMode}". Must be "append" or "replace".` }, { status: 400 } ) } - const mode = rawMode as 'append' | 'replace' + const mode = modeValidation.data const ext = file.name.split('.').pop()?.toLowerCase() - if (ext !== 'csv' && ext !== 'tsv') { - return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 }) + const extensionValidation = csvExtensionSchema.safeParse(ext) + if (!extensionValidation.success) { + return NextResponse.json( + { error: getValidationErrorMessage(extensionValidation.error) }, + { status: 400 } + ) } const accessResult = await checkAccess(tableId, authResult.userId, 'write') @@ -91,22 +94,18 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro let mapping: CsvHeaderMapping | undefined if (rawMapping) { - try { - const parsed = JSON.parse(rawMapping) - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - return NextResponse.json( - { error: 'mapping must be a JSON object mapping CSV headers to column names' }, - { status: 400 } - ) - } - mapping = parsed as CsvHeaderMapping - } catch { - return NextResponse.json({ error: 'mapping must be valid JSON' }, { status: 400 }) + const mappingValidation = csvImportMappingSchema.safeParse(rawMapping) + if (!mappingValidation.success) { + return NextResponse.json( + { error: getValidationErrorMessage(mappingValidation.error) }, + { status: 400 } + ) } + mapping = mappingValidation.data } const buffer = Buffer.from(await file.arrayBuffer()) - const delimiter = ext === 'tsv' ? '\t' : ',' + const delimiter = extensionValidation.data === 'tsv' ? '\t' : ',' const { headers, rows } = await parseCsvBuffer(buffer, delimiter) const effectiveMapping = mapping ?? buildAutoMapping(headers, table.schema) diff --git a/apps/sim/app/api/table/[tableId]/metadata/route.ts b/apps/sim/app/api/table/[tableId]/metadata/route.ts index 4634bf428ed..a02f3ec881f 100644 --- a/apps/sim/app/api/table/[tableId]/metadata/route.ts +++ b/apps/sim/app/api/table/[tableId]/metadata/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateTableMetadataBodySchema } from '@/lib/api/contracts/tables' +import { isZodError, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,14 +11,6 @@ import { accessError, checkAccess } from '@/app/api/table/utils' const logger = createLogger('TableMetadataAPI') -const MetadataSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - metadata: z.object({ - columnWidths: z.record(z.number().positive()).optional(), - columnOrder: z.array(z.string()).optional(), - }), -}) - interface TableRouteParams { params: Promise<{ tableId: string }> } @@ -35,7 +28,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, { params }: Tab } const body = await request.json() - const validated = MetadataSchema.parse(body) + const validated = updateTableMetadataBodySchema.parse(body) const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) @@ -54,11 +47,8 @@ export const PUT = withRouteHandler(async (request: NextRequest, { params }: Tab return NextResponse.json({ success: true, data: { metadata: updated } }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error) } logger.error(`[${requestId}] Error updating table metadata:`, error) diff --git a/apps/sim/app/api/table/[tableId]/restore/route.ts b/apps/sim/app/api/table/[tableId]/restore/route.ts index 6e5ee48c0a1..c6955984fed 100644 --- a/apps/sim/app/api/table/[tableId]/restore/route.ts +++ b/apps/sim/app/api/table/[tableId]/restore/route.ts @@ -1,6 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { tableIdParamsSchema } from '@/lib/api/contracts/tables' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -12,7 +13,7 @@ const logger = createLogger('RestoreTableAPI') export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ tableId: string }> }) => { const requestId = generateRequestId() - const { tableId } = await params + const { tableId } = tableIdParamsSchema.parse(await params) try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index bdcb42a8a92..b0c68f56dd9 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -1,26 +1,16 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { getTableQuerySchema, renameTableBodySchema } from '@/lib/api/contracts/tables' +import { isZodError, validationErrorResponse } from '@/lib/api/server/validation' 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 { - deleteTable, - NAME_PATTERN, - renameTable, - TABLE_LIMITS, - TableConflictError, - type TableSchema, -} from '@/lib/table' +import { deleteTable, renameTable, TableConflictError, type TableSchema } from '@/lib/table' import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' const logger = createLogger('TableDetailAPI') -const GetTableSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), -}) - interface TableRouteParams { params: Promise<{ tableId: string }> } @@ -38,7 +28,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab } const { searchParams } = new URL(request.url) - const validated = GetTableSchema.parse({ + const validated = getTableQuerySchema.parse({ workspaceId: searchParams.get('workspaceId'), }) @@ -80,11 +70,8 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error) } logger.error(`[${requestId}] Error getting table:`, error) @@ -92,21 +79,6 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab } }) -const PatchTableSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - name: z - .string() - .min(1, 'Name is required') - .max( - TABLE_LIMITS.MAX_TABLE_NAME_LENGTH, - `Name must be at most ${TABLE_LIMITS.MAX_TABLE_NAME_LENGTH} characters` - ) - .regex( - NAME_PATTERN, - 'Name must start with letter or underscore, followed by alphanumeric or underscore' - ), -}) - /** PATCH /api/table/[tableId] - Renames a table. */ export const PATCH = withRouteHandler( async (request: NextRequest, { params }: TableRouteParams) => { @@ -121,7 +93,7 @@ export const PATCH = withRouteHandler( } const body = await request.json() - const validated = PatchTableSchema.parse(body) + const validated = renameTableBodySchema.parse(body) const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) @@ -139,11 +111,8 @@ export const PATCH = withRouteHandler( data: { table: updated }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error) } if (error instanceof TableConflictError) { @@ -173,7 +142,7 @@ export const DELETE = withRouteHandler( } const { searchParams } = new URL(request.url) - const validated = GetTableSchema.parse({ + const validated = getTableQuerySchema.parse({ workspaceId: searchParams.get('workspaceId'), }) @@ -202,11 +171,8 @@ export const DELETE = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error) } logger.error(`[${requestId}] Error deleting table:`, error) 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 f5a9df02593..2b61f15eeb6 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -4,7 +4,12 @@ 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 { z } from 'zod' +import { + deleteTableRowBodySchema, + getTableQuerySchema, + updateTableRowBodySchema, +} from '@/lib/api/contracts/tables' +import { isZodError, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -14,19 +19,6 @@ import { accessError, checkAccess } from '@/app/api/table/utils' const logger = createLogger('TableRowAPI') -const GetRowSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), -}) - -const UpdateRowSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - data: z.record(z.unknown(), { required_error: 'Row data is required' }), -}) - -const DeleteRowSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), -}) - interface RowRouteParams { params: Promise<{ tableId: string; rowId: string }> } @@ -43,7 +35,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Row } const { searchParams } = new URL(request.url) - const validated = GetRowSchema.parse({ + const validated = getTableQuerySchema.parse({ workspaceId: searchParams.get('workspaceId'), }) @@ -95,11 +87,8 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Row }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error) } logger.error(`[${requestId}] Error getting row:`, error) @@ -125,7 +114,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) } - const validated = UpdateRowSchema.parse(body) + const validated = updateTableRowBodySchema.parse(body) const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) @@ -167,11 +156,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error) } const errorMessage = toError(error).message @@ -213,7 +199,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, { params }: return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) } - const validated = DeleteRowSchema.parse(body) + const validated = deleteTableRowBodySchema.parse(body) const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) @@ -234,11 +220,8 @@ export const DELETE = withRouteHandler(async (request: NextRequest, { params }: }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error) } const errorMessage = toError(error).message diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index a3db48c875e..0c8bcbf9169 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -4,7 +4,15 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + batchInsertTableRowsBodySchema, + batchUpdateTableRowsBodySchema, + deleteTableRowsBodySchema, + insertTableRowBodySchema, + tableRowsQuerySchema, + updateRowsByFilterBodySchema, +} from '@/lib/api/contracts/tables' +import { isZodError, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -15,7 +23,6 @@ import { deleteRowsByFilter, deleteRowsByIds, insertRow, - TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME, updateRowsByFilter, validateBatchRows, @@ -27,107 +34,6 @@ import { accessError, checkAccess } from '@/app/api/table/utils' const logger = createLogger('TableRowsAPI') -const InsertRowSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - data: z.record(z.unknown(), { required_error: 'Row data is required' }), - position: z.number().int().min(0).optional(), -}) - -const BatchInsertRowsSchema = z - .object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - rows: z - .array(z.record(z.unknown()), { required_error: 'Rows array is required' }) - .min(1, 'At least one row is required') - .max(1000, 'Cannot insert more than 1000 rows per batch'), - positions: z.array(z.number().int().min(0)).max(1000).optional(), - }) - .refine((d) => !d.positions || d.positions.length === d.rows.length, { - message: 'positions array length must match rows array length', - }) - .refine((d) => !d.positions || new Set(d.positions).size === d.positions.length, { - message: 'positions must not contain duplicates', - }) - -const QueryRowsSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - filter: z.record(z.unknown()).optional(), - sort: z.record(z.enum(['asc', 'desc'])).optional(), - limit: z.coerce - .number({ required_error: 'Limit must be a number' }) - .int('Limit must be an integer') - .min(1, 'Limit must be at least 1') - .max(TABLE_LIMITS.MAX_QUERY_LIMIT, `Limit cannot exceed ${TABLE_LIMITS.MAX_QUERY_LIMIT}`) - .optional() - .default(100), - offset: z.coerce - .number({ required_error: 'Offset must be a number' }) - .int('Offset must be an integer') - .min(0, 'Offset must be 0 or greater') - .optional() - .default(0), - includeTotal: z - .preprocess( - (val) => (val === null || val === undefined || val === '' ? undefined : val === 'true'), - z.boolean().optional() - ) - .default(true), -}) - -const nonEmptyFilter = z - .record(z.unknown(), { required_error: 'Filter criteria is required' }) - .refine((f) => Object.keys(f).length > 0, { message: 'Filter must not be empty' }) - -const optionalPositiveLimit = (max: number, label: string) => - z.preprocess( - (val) => (val === null || val === undefined || val === '' ? undefined : Number(val)), - z - .number() - .int(`${label} must be an integer`) - .min(1, `${label} must be at least 1`) - .max(max, `Cannot ${label.toLowerCase()} more than ${max} rows per operation`) - .optional() - ) - -const UpdateRowsByFilterSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - filter: nonEmptyFilter, - data: z.record(z.unknown(), { required_error: 'Update data is required' }), - limit: optionalPositiveLimit(1000, 'Limit'), -}) - -const DeleteRowsByFilterSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - filter: nonEmptyFilter, - limit: optionalPositiveLimit(1000, 'Limit'), -}) - -const DeleteRowsByIdsSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - rowIds: z - .array(z.string().min(1), { required_error: 'Row IDs are required' }) - .min(1, 'At least one row ID is required') - .max(1000, 'Cannot delete more than 1000 rows per operation'), -}) - -const DeleteRowsRequestSchema = z.union([DeleteRowsByFilterSchema, DeleteRowsByIdsSchema]) - -const BatchUpdateByIdsSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - updates: z - .array( - z.object({ - rowId: z.string().min(1), - data: z.record(z.unknown()), - }) - ) - .min(1, 'At least one update is required') - .max(1000, 'Cannot update more than 1000 rows per batch') - .refine((d) => new Set(d.map((u) => u.rowId)).size === d.length, { - message: 'updates must not contain duplicate rowId values', - }), -}) - interface TableRowsRouteParams { params: Promise<{ tableId: string }> } @@ -135,10 +41,10 @@ interface TableRowsRouteParams { async function handleBatchInsert( requestId: string, tableId: string, - body: z.infer, + body: unknown, userId: string ): Promise { - const validated = BatchInsertRowsSchema.parse(body) + const validated = batchInsertTableRowsBodySchema.parse(body) const accessResult = await checkAccess(tableId, userId, 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) @@ -232,15 +138,10 @@ export const POST = withRouteHandler( 'rows' in body && Array.isArray((body as Record).rows) ) { - return await handleBatchInsert( - requestId, - tableId, - body as z.infer, - authResult.userId - ) + return handleBatchInsert(requestId, tableId, body, authResult.userId) } - const validated = InsertRowSchema.parse(body) + const validated = insertTableRowBodySchema.parse(body) const accessResult = await checkAccess(tableId, authResult.userId, 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) @@ -291,11 +192,8 @@ export const POST = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error) } const errorMessage = toError(error).message @@ -350,7 +248,7 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 }) } - const validated = QueryRowsSchema.parse({ + const validated = tableRowsQuerySchema.parse({ workspaceId, filter, sort, @@ -440,11 +338,8 @@ export const GET = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error) } logger.error(`[${requestId}] Error querying rows:`, error) @@ -472,7 +367,7 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) } - const validated = UpdateRowsByFilterSchema.parse(body) + const validated = updateRowsByFilterBodySchema.parse(body) const accessResult = await checkAccess(tableId, authResult.userId, 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) @@ -528,11 +423,8 @@ export const PUT = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error) } const errorMessage = toError(error).message @@ -573,7 +465,7 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) } - const validated = DeleteRowsRequestSchema.parse(body) + const validated = deleteTableRowsBodySchema.parse(body) const accessResult = await checkAccess(tableId, authResult.userId, 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) @@ -587,7 +479,7 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - if ('rowIds' in validated) { + if (validated.rowIds) { const result = await deleteRowsByIds( { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, requestId @@ -630,11 +522,8 @@ export const DELETE = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error) } const errorMessage = toError(error).message @@ -668,7 +557,7 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) } - const validated = BatchUpdateByIdsSchema.parse(body) + const validated = batchUpdateTableRowsBodySchema.parse(body) const accessResult = await checkAccess(tableId, authResult.userId, 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) @@ -701,11 +590,8 @@ export const PATCH = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error) } const errorMessage = toError(error).message diff --git a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts index 7acf22085ae..f8840fd157f 100644 --- a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { upsertTableRowBodySchema } from '@/lib/api/contracts/tables' +import { isZodError, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,12 +12,6 @@ import { accessError, checkAccess } from '@/app/api/table/utils' const logger = createLogger('TableUpsertAPI') -const UpsertRowSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - data: z.record(z.unknown(), { required_error: 'Row data is required' }), - conflictTarget: z.string().optional(), -}) - interface UpsertRouteParams { params: Promise<{ tableId: string }> } @@ -40,7 +35,7 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) } - const validated = UpsertRowSchema.parse(body) + const validated = upsertTableRowBodySchema.parse(body) const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) @@ -83,11 +78,8 @@ export const POST = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error) } const errorMessage = toError(error).message diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index 42127780360..66b0e1f3c0d 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -2,13 +2,14 @@ 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' +import { getValidationErrorMessage } 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 { batchInsertRows, CSV_MAX_BATCH_SIZE, - CSV_MAX_FILE_SIZE_BYTES, coerceRowsForTable, createTable, deleteTable, @@ -33,25 +34,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const formData = await request.formData() - const file = formData.get('file') - const workspaceId = formData.get('workspaceId') as string | null + const validation = csvImportFormSchema.safeParse({ + file: formData.get('file'), + workspaceId: formData.get('workspaceId'), + }) - if (!file || !(file instanceof File)) { - return NextResponse.json({ error: 'CSV file is required' }, { status: 400 }) - } - - if (file.size > CSV_MAX_FILE_SIZE_BYTES) { + if (!validation.success) { return NextResponse.json( - { - error: `File exceeds maximum allowed size of ${CSV_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB`, - }, + { error: getValidationErrorMessage(validation.error) }, { status: 400 } ) } - if (!workspaceId) { - return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) - } + const { file, workspaceId } = validation.data const permission = await getUserEntityPermissions(authResult.userId, 'workspace', workspaceId) if (permission !== 'write' && permission !== 'admin') { @@ -59,12 +54,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const ext = file.name.split('.').pop()?.toLowerCase() - if (ext !== 'csv' && ext !== 'tsv') { - return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 }) + const extensionValidation = csvExtensionSchema.safeParse(ext) + if (!extensionValidation.success) { + return NextResponse.json( + { error: getValidationErrorMessage(extensionValidation.error) }, + { status: 400 } + ) } const buffer = Buffer.from(await file.arrayBuffer()) - const delimiter = ext === 'tsv' ? '\t' : ',' + const delimiter = extensionValidation.data === 'tsv' ? '\t' : ',' const { headers, rows } = await parseCsvBuffer(buffer, delimiter) const { columns, headerToColumn } = inferSchemaFromCsv(headers, rows) diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index 2925cfe5dc3..0f3b42e9523 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { createTableBodySchema, listTablesQuerySchema } from '@/lib/api/contracts/tables' +import { isZodError, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -9,7 +10,6 @@ import { createTable, getWorkspaceTableLimits, listTables, - TABLE_LIMITS, type TableSchema, type TableScope, } from '@/lib/table' @@ -18,64 +18,6 @@ import { normalizeColumn } from '@/app/api/table/utils' const logger = createLogger('TableAPI') -const ColumnSchema = z.object({ - name: z - .string() - .min(1, 'Column name is required') - .max( - TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH, - `Column name must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less` - ) - .regex( - /^[a-z_][a-z0-9_]*$/i, - 'Column name must start with a letter or underscore and contain only alphanumeric characters and underscores' - ), - type: z.enum(['string', 'number', 'boolean', 'date', 'json'], { - errorMap: () => ({ - message: 'Column type must be one of: string, number, boolean, date, json', - }), - }), - required: z.boolean().optional().default(false), - unique: z.boolean().optional().default(false), -}) - -const CreateTableSchema = z.object({ - name: z - .string() - .min(1, 'Table name is required') - .max( - TABLE_LIMITS.MAX_TABLE_NAME_LENGTH, - `Table name must be ${TABLE_LIMITS.MAX_TABLE_NAME_LENGTH} characters or less` - ) - .regex( - /^[a-z_][a-z0-9_]*$/i, - 'Table name must start with a letter or underscore and contain only alphanumeric characters and underscores' - ), - description: z - .string() - .max( - TABLE_LIMITS.MAX_DESCRIPTION_LENGTH, - `Description must be ${TABLE_LIMITS.MAX_DESCRIPTION_LENGTH} characters or less` - ) - .optional(), - schema: z.object({ - columns: z - .array(ColumnSchema) - .min(1, 'Table must have at least one column') - .max( - TABLE_LIMITS.MAX_COLUMNS_PER_TABLE, - `Table cannot have more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} columns` - ), - }), - workspaceId: z.string().min(1, 'Workspace ID is required'), - initialRowCount: z.number().int().min(0).max(100).optional(), -}) - -const ListTablesSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - scope: z.enum(['active', 'archived', 'all']).optional().default('active'), -}) - interface WorkspaceAccessResult { hasAccess: boolean canWrite: boolean @@ -112,7 +54,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) } - const params = CreateTableSchema.parse(body) + const params = createTableBodySchema.parse(body) const { hasAccess, canWrite } = await checkWorkspaceAccess( params.workspaceId, @@ -182,11 +124,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error) } if (error instanceof Error) { @@ -221,10 +160,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const workspaceId = searchParams.get('workspaceId') const scope = searchParams.get('scope') - const validation = ListTablesSchema.safeParse({ workspaceId, scope }) + const validation = listTablesQuerySchema.safeParse({ + workspaceId, + scope: scope ?? undefined, + }) if (!validation.success) { return NextResponse.json( - { error: 'Validation error', details: validation.error.errors }, + { error: 'Validation error', details: validation.error.issues }, { status: 400 } ) } @@ -272,11 +214,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) + if (isZodError(error)) { + return validationErrorResponse(error) } logger.error(`[${requestId}] Error listing tables:`, error) diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 091fc9f8985..e80c9dbf0be 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -1,8 +1,12 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' -import { z } from 'zod' +import { + createTableColumnBodySchema, + deleteTableColumnBodySchema, + updateTableColumnBodySchema, +} from '@/lib/api/contracts/tables' import type { ColumnDefinition, TableDefinition } from '@/lib/table' -import { COLUMN_TYPES, getTableById } from '@/lib/table' +import { getTableById } from '@/lib/table' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('TableUtils') @@ -155,36 +159,13 @@ export function serverErrorResponse(message = 'Internal server error') { return errorResponse(message, 500) } -const columnTypeEnum = z.enum( - COLUMN_TYPES as unknown as [(typeof COLUMN_TYPES)[number], ...(typeof COLUMN_TYPES)[number][]] -) - -export const CreateColumnSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - column: z.object({ - name: z.string().min(1, 'Column name is required'), - type: columnTypeEnum, - required: z.boolean().optional(), - unique: z.boolean().optional(), - position: z.number().int().min(0).optional(), - }), -}) - -export const UpdateColumnSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - columnName: z.string().min(1, 'Column name is required'), - updates: z.object({ - name: z.string().min(1).optional(), - type: columnTypeEnum.optional(), - required: z.boolean().optional(), - unique: z.boolean().optional(), - }), -}) - -export const DeleteColumnSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - columnName: z.string().min(1, 'Column name is required'), -}) +/** + * Re-exports from `lib/api/contracts/tables` so existing routes that import + * these names keep working while sharing a single source of truth. + */ +export const CreateColumnSchema = createTableColumnBodySchema +export const UpdateColumnSchema = updateTableColumnBodySchema +export const DeleteColumnSchema = deleteTableColumnBodySchema export function normalizeColumn(col: ColumnDefinition): ColumnDefinition { return { diff --git a/apps/sim/app/api/telemetry/route.ts b/apps/sim/app/api/telemetry/route.ts index 5bac3854d9c..a1567a20d5e 100644 --- a/apps/sim/app/api/telemetry/route.ts +++ b/apps/sim/app/api/telemetry/route.ts @@ -1,49 +1,19 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { telemetryEventSchema } from '@/lib/api/contracts/telemetry' +import { validateJsonBody } from '@/lib/api/server' import { env } from '@/lib/core/config/env' import { isProd } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('TelemetryAPI') -const ALLOWED_CATEGORIES = [ - 'page_view', - 'feature_usage', - 'performance', - 'error', - 'workflow', - 'consent', - 'batch', -] - -const DEFAULT_TIMEOUT = 5000 // 5 seconds timeout - -/** - * Validates telemetry data to ensure it doesn't contain sensitive information - */ -function validateTelemetryData(data: any): boolean { - if (!data || typeof data !== 'object') { - return false - } - - if (!data.category || !data.action) { - return false - } - - if (!ALLOWED_CATEGORIES.includes(data.category)) { - return false - } - - const jsonStr = JSON.stringify(data).toLowerCase() - const sensitivePatterns = [/password/, /token/, /secret/, /key/, /auth/, /credential/, /private/] - - return !sensitivePatterns.some((pattern) => pattern.test(jsonStr)) -} +const DEFAULT_TIMEOUT = 5000 /** * Safely converts a value to string, handling undefined and null values */ -function safeStringValue(value: any): string { +function safeStringValue(value: unknown): string { if (value === undefined || value === null) { return '' } @@ -59,7 +29,7 @@ function safeStringValue(value: any): string { * Creates a safe attribute object for OpenTelemetry */ function createSafeAttributes( - data: Record + data: Record ): Array<{ key: string; value: { stringValue: string } }> { if (!data || typeof data !== 'object') { return [] @@ -82,7 +52,7 @@ function createSafeAttributes( /** * Forwards telemetry data to OpenTelemetry collector */ -async function forwardToCollector(data: any): Promise { +async function forwardToCollector(data: Record): Promise { if (!data || typeof data !== 'object') { logger.error('Invalid telemetry data format') return false @@ -179,21 +149,18 @@ async function forwardToCollector(data: any): Promise { */ export const POST = withRouteHandler(async (req: NextRequest) => { try { - let eventData - try { - eventData = await req.json() - } catch (_parseError) { - return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) - } - - if (!validateTelemetryData(eventData)) { + const validation = await validateJsonBody(req, telemetryEventSchema) + if (!validation.success) { + if (!validation.error) { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) + } return NextResponse.json( { error: 'Invalid telemetry data format or contains sensitive information' }, { status: 400 } ) } - const forwarded = await forwardToCollector(eventData) + const forwarded = await forwardToCollector(validation.data as Record) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/templates/[id]/og-image/route.ts b/apps/sim/app/api/templates/[id]/og-image/route.ts index 17cddb2f000..028a9c9f8a5 100644 --- a/apps/sim/app/api/templates/[id]/og-image/route.ts +++ b/apps/sim/app/api/templates/[id]/og-image/route.ts @@ -3,6 +3,11 @@ import { templates } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { + templateIdParamsSchema, + updateTemplateOgImageBodySchema, +} from '@/lib/api/contracts/templates' +import { parseJsonBody, validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -21,7 +26,9 @@ const logger = createLogger('TemplateOGImageAPI') export const PUT = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const paramsValidation = validateSchema(templateIdParamsSchema, await params) + if (!paramsValidation.success) return paramsValidation.response + const { id } = paramsValidation.data try { const session = await getSession() @@ -40,7 +47,13 @@ export const PUT = withRouteHandler( return NextResponse.json({ error }, { status: status || 403 }) } - const body = await request.json() + const parsedBody = await parseJsonBody(request) + const bodyResult = parsedBody.success + ? validateSchema(updateTemplateOgImageBodySchema, parsedBody.data) + : null + const body = bodyResult?.success + ? bodyResult.data + : ({ imageData: undefined } as { imageData?: string }) const { imageData } = body if (!imageData || typeof imageData !== 'string') { @@ -110,7 +123,9 @@ export const PUT = withRouteHandler( export const DELETE = withRouteHandler( async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const paramsValidation = validateSchema(templateIdParamsSchema, await params) + if (!paramsValidation.success) return paramsValidation.response + const { id } = paramsValidation.data try { const session = await getSession() diff --git a/apps/sim/app/api/templates/[id]/route.ts b/apps/sim/app/api/templates/[id]/route.ts index 55b1a25445f..92424935e17 100644 --- a/apps/sim/app/api/templates/[id]/route.ts +++ b/apps/sim/app/api/templates/[id]/route.ts @@ -4,7 +4,7 @@ import { templateCreators, templates, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { templateIdParamsSchema, updateTemplateBodySchema } from '@/lib/api/contracts/templates' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -22,7 +22,7 @@ export const revalidate = 0 export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const { id } = templateIdParamsSchema.parse(await params) try { const session = await getSession() @@ -89,32 +89,18 @@ export const GET = withRouteHandler( isStarred, }, }) - } catch (error: any) { + } catch (error) { logger.error(`[${requestId}] Error fetching template: ${id}`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } ) -const updateTemplateSchema = z.object({ - name: z.string().min(1).max(100).optional(), - details: z - .object({ - tagline: z.string().max(500, 'Tagline must be less than 500 characters').optional(), - about: z.string().optional(), // Markdown long description - }) - .optional(), - creatorId: z.string().optional(), // Creator profile ID - tags: z.array(z.string()).max(10, 'Maximum 10 tags allowed').optional(), - updateState: z.boolean().optional(), // Explicitly request state update from current workflow - status: z.enum(['approved', 'rejected', 'pending']).optional(), // Status change (super users only) -}) - // PUT /api/templates/[id] - Update a template export const PUT = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const { id } = templateIdParamsSchema.parse(await params) try { const session = await getSession() @@ -124,7 +110,7 @@ export const PUT = withRouteHandler( } const body = await request.json() - const validationResult = updateTemplateSchema.safeParse(body) + const validationResult = updateTemplateBodySchema.safeParse(body) if (!validationResult.success) { logger.warn( @@ -132,7 +118,7 @@ export const PUT = withRouteHandler( validationResult.error ) return NextResponse.json( - { error: 'Invalid template data', details: validationResult.error.errors }, + { error: 'Invalid template data', details: validationResult.error.issues }, { status: 400 } ) } @@ -192,7 +178,7 @@ export const PUT = withRouteHandler( } } - const updateData: any = { + const updateData: Record = { updatedAt: new Date(), } @@ -284,7 +270,7 @@ export const PUT = withRouteHandler( data: updatedTemplate[0], message: 'Template updated successfully', }) - } catch (error: any) { + } catch (error) { logger.error(`[${requestId}] Error updating template: ${id}`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } @@ -295,7 +281,7 @@ export const PUT = withRouteHandler( export const DELETE = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const { id } = templateIdParamsSchema.parse(await params) try { const session = await getSession() @@ -353,7 +339,7 @@ export const DELETE = withRouteHandler( }) return NextResponse.json({ success: true }) - } catch (error: any) { + } catch (error) { logger.error(`[${requestId}] Error deleting template: ${id}`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/templates/[id]/star/route.ts b/apps/sim/app/api/templates/[id]/star/route.ts index c31b598619c..0d45a7ed48d 100644 --- a/apps/sim/app/api/templates/[id]/star/route.ts +++ b/apps/sim/app/api/templates/[id]/star/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { templateIdParamsSchema } from '@/lib/api/contracts/templates' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -13,11 +14,18 @@ const logger = createLogger('TemplateStarAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 +function getErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== 'object' || !('code' in error)) return undefined + + const { code } = error as { code?: unknown } + return typeof code === 'string' ? code : undefined +} + // GET /api/templates/[id]/star - Check if user has starred this template export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const { id } = templateIdParamsSchema.parse(await params) try { const session = await getSession() @@ -42,7 +50,7 @@ export const GET = withRouteHandler( logger.info(`[${requestId}] Star status checked: ${isStarred} for template: ${id}`) return NextResponse.json({ data: { isStarred } }) - } catch (error: any) { + } catch (error) { logger.error(`[${requestId}] Error checking star status for template: ${id}`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } @@ -53,7 +61,7 @@ export const GET = withRouteHandler( export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const { id } = templateIdParamsSchema.parse(await params) try { const session = await getSession() @@ -108,9 +116,9 @@ export const POST = withRouteHandler( logger.info(`[${requestId}] Successfully starred template: ${id}`) return NextResponse.json({ message: 'Template starred successfully' }, { status: 201 }) - } catch (error: any) { + } catch (error) { // Handle unique constraint violations gracefully - if (error.code === '23505') { + if (getErrorCode(error) === '23505') { logger.info(`[${requestId}] Duplicate star attempt for template: ${id}`) return NextResponse.json({ message: 'Template already starred' }, { status: 200 }) } @@ -125,7 +133,7 @@ export const POST = withRouteHandler( export const DELETE = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const { id } = templateIdParamsSchema.parse(await params) try { const session = await getSession() @@ -164,7 +172,7 @@ export const DELETE = withRouteHandler( logger.info(`[${requestId}] Successfully unstarred template: ${id}`) return NextResponse.json({ message: 'Template unstarred successfully' }, { status: 200 }) - } catch (error: any) { + } catch (error) { logger.error(`[${requestId}] Error unstarring template: ${id}`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/templates/[id]/use/route.ts b/apps/sim/app/api/templates/[id]/use/route.ts index 35b1d84507f..e741933f178 100644 --- a/apps/sim/app/api/templates/[id]/use/route.ts +++ b/apps/sim/app/api/templates/[id]/use/route.ts @@ -4,6 +4,8 @@ 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 { templateIdParamsSchema, useTemplateBodySchema } from '@/lib/api/contracts/templates' +import { parseJsonBody, validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' @@ -31,7 +33,9 @@ interface TemplateDetails { export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const paramsValidation = validateSchema(templateIdParamsSchema, await params) + if (!paramsValidation.success) return paramsValidation.response + const { id } = paramsValidation.data try { const session = await getSession() @@ -40,8 +44,16 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // Get workspace ID and connectToTemplate flag from request body - const body = await request.json() + const parsedBody = await parseJsonBody(request) + const bodyResult = parsedBody.success + ? validateSchema(useTemplateBodySchema, parsedBody.data) + : null + const body = bodyResult?.success + ? bodyResult.data + : ({ workspaceId: undefined, connectToTemplate: false } as { + workspaceId?: string + connectToTemplate?: boolean + }) const { workspaceId, connectToTemplate = false } = body if (!workspaceId) { diff --git a/apps/sim/app/api/templates/approved/sanitized/route.ts b/apps/sim/app/api/templates/approved/sanitized/route.ts index 3e4db735db0..28299cd915a 100644 --- a/apps/sim/app/api/templates/approved/sanitized/route.ts +++ b/apps/sim/app/api/templates/approved/sanitized/route.ts @@ -4,6 +4,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { checkInternalApiKey } from '@/lib/copilot/request/http' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -22,7 +24,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const url = new URL(request.url) + const queryValidation = validateSchema( + noInputSchema, + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!queryValidation.success) return queryValidation.response const hasApiKey = !!request.headers.get('x-api-key') // Check internal API key authentication @@ -130,6 +136,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { // Add a helpful OPTIONS handler for CORS preflight export const OPTIONS = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() + const queryValidation = validateSchema( + noInputSchema, + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!queryValidation.success) return queryValidation.response logger.info(`[${requestId}] OPTIONS request received for /api/templates/approved/sanitized`) return new NextResponse(null, { diff --git a/apps/sim/app/api/templates/route.ts b/apps/sim/app/api/templates/route.ts index 96bb31b15ad..a16bbc84163 100644 --- a/apps/sim/app/api/templates/route.ts +++ b/apps/sim/app/api/templates/route.ts @@ -12,7 +12,8 @@ import { generateId } from '@sim/utils/id' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq, ilike, or, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { createTemplateBodySchema, templateListQuerySchema } from '@/lib/api/contracts/templates' +import { validateSchema } 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' @@ -21,41 +22,16 @@ import { extractRequiredCredentials, sanitizeCredentials, } from '@/lib/workflows/credentials/credential-extractor' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('TemplatesAPI') export const revalidate = 0 -// Function to sanitize sensitive data from workflow state -// Now uses the more comprehensive sanitizeCredentials from credential-extractor -function sanitizeWorkflowState(state: any): any { +function sanitizeWorkflowState(state: Partial | null | undefined): unknown { return sanitizeCredentials(state) } -// Schema for creating a template -const CreateTemplateSchema = z.object({ - workflowId: z.string().min(1, 'Workflow ID is required'), - name: z.string().min(1, 'Name is required').max(100, 'Name must be less than 100 characters'), - details: z - .object({ - tagline: z.string().max(500, 'Tagline must be less than 500 characters').optional(), - about: z.string().optional(), // Markdown long description - }) - .optional(), - creatorId: z.string().min(1, 'Creator profile is required'), - tags: z.array(z.string()).max(10, 'Maximum 10 tags allowed').optional().default([]), -}) - -// Schema for query parameters -const QueryParamsSchema = z.object({ - limit: z.coerce.number().optional().default(50), - offset: z.coerce.number().optional().default(0), - search: z.string().optional(), - workflowId: z.string().optional(), - status: z.enum(['pending', 'approved', 'rejected']).optional(), - includeAllStatuses: z.coerce.boolean().optional().default(false), // For super users -}) - // GET /api/templates - Retrieve templates export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -68,7 +44,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const { searchParams } = new URL(request.url) - const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) + const validation = validateSchema( + templateListQuerySchema, + Object.fromEntries(searchParams.entries()), + 'Invalid query parameters' + ) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid query parameters`, { + errors: validation.error.issues, + }) + return validation.response + } + const params = validation.data // Check if user is a super user const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id) @@ -209,15 +196,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ), }, }) - } catch (error: any) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid query parameters`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid query parameters', details: error.errors }, - { status: 400 } - ) - } - + } catch (error) { logger.error(`[${requestId}] Error fetching templates`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } @@ -235,7 +214,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const body = await request.json() - const data = CreateTemplateSchema.parse(body) + const validation = validateSchema(createTemplateBodySchema, body, 'Invalid template data') + if (!validation.success) { + logger.warn(`[${requestId}] Invalid template data`, { errors: validation.error.issues }) + return validation.response + } + const data = validation.data const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ workflowId: data.workflowId, @@ -297,7 +281,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } // Ensure the state includes workflow variables (if not already included) - let stateWithVariables = activeVersion[0].state as any + let stateWithVariables = activeVersion[0].state as Partial | null | undefined if (stateWithVariables && !stateWithVariables.variables) { // Fetch workflow variables if not in deployment version const [workflowRecord] = await db @@ -308,7 +292,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { stateWithVariables = { ...stateWithVariables, - variables: workflowRecord?.variables || undefined, + variables: (workflowRecord?.variables as WorkflowState['variables']) || undefined, } } @@ -365,15 +349,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, { status: 201 } ) - } catch (error: any) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid template data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid template data', details: error.errors }, - { status: 400 } - ) - } - + } catch (error) { logger.error(`[${requestId}] Error creating template`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/tools/a2a/cancel-task/route.ts b/apps/sim/app/api/tools/a2a/cancel-task/route.ts index 4c349282310..259716ffa5f 100644 --- a/apps/sim/app/api/tools/a2a/cancel-task/route.ts +++ b/apps/sim/app/api/tools/a2a/cancel-task/route.ts @@ -1,8 +1,9 @@ import type { Task } from '@a2a-js/sdk' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' import { createA2AClient } from '@/lib/a2a/utils' +import { a2aCancelTaskContract } from '@/lib/api/contracts/internal-tools' +import { getValidationErrorMessage, 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' @@ -11,12 +12,6 @@ const logger = createLogger('A2ACancelTaskAPI') export const dynamic = 'force-dynamic' -const A2ACancelTaskSchema = z.object({ - agentUrl: z.string().min(1, 'Agent URL is required'), - taskId: z.string().min(1, 'Task ID is required'), - apiKey: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -34,8 +29,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = A2ACancelTaskSchema.parse(body) + const parsed = await parseRequest( + a2aCancelTaskContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Canceling A2A task`, { agentUrl: validatedData.agentUrl, @@ -59,20 +70,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid A2A cancel task request`, { - errors: error.errors, - }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error canceling A2A task:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/a2a/delete-push-notification/route.ts b/apps/sim/app/api/tools/a2a/delete-push-notification/route.ts index a2224719608..15214e6f80f 100644 --- a/apps/sim/app/api/tools/a2a/delete-push-notification/route.ts +++ b/apps/sim/app/api/tools/a2a/delete-push-notification/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' import { createA2AClient } from '@/lib/a2a/utils' +import { a2aDeletePushNotificationContract } from '@/lib/api/contracts/internal-tools' +import { getValidationErrorMessage, 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' @@ -10,13 +11,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('A2ADeletePushNotificationAPI') -const A2ADeletePushNotificationSchema = z.object({ - agentUrl: z.string().min(1, 'Agent URL is required'), - taskId: z.string().min(1, 'Task ID is required'), - pushNotificationConfigId: z.string().optional(), - apiKey: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -43,8 +37,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = A2ADeletePushNotificationSchema.parse(body) + const parsed = await parseRequest( + a2aDeletePushNotificationContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Deleting A2A push notification config`, { agentUrl: validatedData.agentUrl, @@ -70,18 +80,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error deleting A2A push notification:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/a2a/get-agent-card/route.ts b/apps/sim/app/api/tools/a2a/get-agent-card/route.ts index fd988043318..d9a30704190 100644 --- a/apps/sim/app/api/tools/a2a/get-agent-card/route.ts +++ b/apps/sim/app/api/tools/a2a/get-agent-card/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' import { createA2AClient } from '@/lib/a2a/utils' +import { a2aGetAgentCardContract } from '@/lib/api/contracts/internal-tools' +import { getValidationErrorMessage, 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' @@ -10,11 +11,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('A2AGetAgentCardAPI') -const A2AGetAgentCardSchema = z.object({ - agentUrl: z.string().min(1, 'Agent URL is required'), - apiKey: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -39,8 +35,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = A2AGetAgentCardSchema.parse(body) + const parsed = await parseRequest( + a2aGetAgentCardContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Fetching Agent Card`, { agentUrl: validatedData.agentUrl, @@ -68,18 +80,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error fetching Agent Card:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/a2a/get-push-notification/route.ts b/apps/sim/app/api/tools/a2a/get-push-notification/route.ts index 9b6bacd415a..bd847425260 100644 --- a/apps/sim/app/api/tools/a2a/get-push-notification/route.ts +++ b/apps/sim/app/api/tools/a2a/get-push-notification/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' import { createA2AClient } from '@/lib/a2a/utils' +import { a2aGetPushNotificationContract } from '@/lib/api/contracts/internal-tools' +import { getValidationErrorMessage, 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' @@ -10,12 +11,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('A2AGetPushNotificationAPI') -const A2AGetPushNotificationSchema = z.object({ - agentUrl: z.string().min(1, 'Agent URL is required'), - taskId: z.string().min(1, 'Task ID is required'), - apiKey: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -42,8 +37,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = A2AGetPushNotificationSchema.parse(body) + const parsed = await parseRequest( + a2aGetPushNotificationContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Getting push notification config`, { agentUrl: validatedData.agentUrl, @@ -81,18 +92,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - if (error instanceof Error && error.message.includes('not found')) { logger.info(`[${requestId}] Task not found, returning exists: false`) return NextResponse.json({ diff --git a/apps/sim/app/api/tools/a2a/get-task/route.ts b/apps/sim/app/api/tools/a2a/get-task/route.ts index 635aa39bee4..abedc302cdf 100644 --- a/apps/sim/app/api/tools/a2a/get-task/route.ts +++ b/apps/sim/app/api/tools/a2a/get-task/route.ts @@ -1,8 +1,9 @@ import type { Task } from '@a2a-js/sdk' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' import { createA2AClient } from '@/lib/a2a/utils' +import { a2aGetTaskContract } from '@/lib/api/contracts/internal-tools' +import { getValidationErrorMessage, 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' @@ -11,13 +12,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('A2AGetTaskAPI') -const A2AGetTaskSchema = z.object({ - agentUrl: z.string().min(1, 'Agent URL is required'), - taskId: z.string().min(1, 'Task ID is required'), - apiKey: z.string().optional(), - historyLength: z.number().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -39,8 +33,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = A2AGetTaskSchema.parse(body) + const parsed = await parseRequest( + a2aGetTaskContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Getting A2A task`, { agentUrl: validatedData.agentUrl, @@ -71,18 +81,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error getting A2A task:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/a2a/resubscribe/route.ts b/apps/sim/app/api/tools/a2a/resubscribe/route.ts index 8b7fa0198d8..b179b95922b 100644 --- a/apps/sim/app/api/tools/a2a/resubscribe/route.ts +++ b/apps/sim/app/api/tools/a2a/resubscribe/route.ts @@ -8,8 +8,9 @@ import type { } from '@a2a-js/sdk' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' import { createA2AClient, extractTextContent, isTerminalState } from '@/lib/a2a/utils' +import { a2aResubscribeContract } from '@/lib/api/contracts/internal-tools' +import { getValidationErrorMessage, 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' @@ -18,12 +19,6 @@ const logger = createLogger('A2AResubscribeAPI') export const dynamic = 'force-dynamic' -const A2AResubscribeSchema = z.object({ - agentUrl: z.string().min(1, 'Agent URL is required'), - taskId: z.string().min(1, 'Task ID is required'), - apiKey: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -41,8 +36,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = A2AResubscribeSchema.parse(body) + const parsed = await parseRequest( + a2aResubscribeContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey) @@ -96,18 +107,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid A2A resubscribe data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error resubscribing to A2A task:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/a2a/send-message/route.ts b/apps/sim/app/api/tools/a2a/send-message/route.ts index 0cc2553e898..c6c420ad3d4 100644 --- a/apps/sim/app/api/tools/a2a/send-message/route.ts +++ b/apps/sim/app/api/tools/a2a/send-message/route.ts @@ -3,8 +3,9 @@ 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 { z } from 'zod' import { createA2AClient, extractTextContent, isTerminalState } from '@/lib/a2a/utils' +import { a2aSendMessageContract } from '@/lib/api/contracts/internal-tools' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' @@ -14,23 +15,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('A2ASendMessageAPI') -const FileInputSchema = z.object({ - type: z.enum(['file', 'url']), - data: z.string(), - name: z.string(), - mime: z.string().optional(), -}) - -const A2ASendMessageSchema = z.object({ - agentUrl: z.string().min(1, 'Agent URL is required'), - message: z.string().min(1, 'Message is required'), - taskId: z.string().optional(), - contextId: z.string().optional(), - data: z.string().optional(), - files: z.array(FileInputSchema).optional(), - apiKey: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -55,8 +39,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = A2ASendMessageSchema.parse(body) + const parsed = await parseRequest( + a2aSendMessageContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Sending A2A message`, { agentUrl: validatedData.agentUrl, @@ -204,18 +204,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error sending A2A message:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/a2a/set-push-notification/route.ts b/apps/sim/app/api/tools/a2a/set-push-notification/route.ts index 27890837897..a8e92d614a8 100644 --- a/apps/sim/app/api/tools/a2a/set-push-notification/route.ts +++ b/apps/sim/app/api/tools/a2a/set-push-notification/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' import { createA2AClient } from '@/lib/a2a/utils' +import { a2aSetPushNotificationContract } from '@/lib/api/contracts/internal-tools' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' @@ -11,14 +12,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('A2ASetPushNotificationAPI') -const A2ASetPushNotificationSchema = z.object({ - agentUrl: z.string().min(1, 'Agent URL is required'), - taskId: z.string().min(1, 'Task ID is required'), - webhookUrl: z.string().min(1, 'Webhook URL is required'), - token: z.string().optional(), - apiKey: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -38,8 +31,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = A2ASetPushNotificationSchema.parse(body) + const parsed = await parseRequest( + a2aSetPushNotificationContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const urlValidation = await validateUrlWithDNS(validatedData.webhookUrl, 'Webhook URL') if (!urlValidation.isValid) { @@ -82,18 +91,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error setting A2A push notification:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/agiloft/attach/route.ts b/apps/sim/app/api/tools/agiloft/attach/route.ts index 792d3df1235..d0ec62e0fd8 100644 --- a/apps/sim/app/api/tools/agiloft/attach/route.ts +++ b/apps/sim/app/api/tools/agiloft/attach/route.ts @@ -1,11 +1,12 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { agiloftAttachContract } from '@/lib/api/contracts/tools/agiloft' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { FileInputSchema, type RawFileInput } from '@/lib/uploads/utils/file-schemas' +import type { RawFileInput } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { agiloftLogin, agiloftLogout, buildAttachFileUrl } from '@/tools/agiloft/utils' @@ -14,18 +15,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('AgiloftAttachAPI') -const AgiloftAttachSchema = z.object({ - instanceUrl: z.string().min(1, 'Instance URL is required'), - knowledgeBase: z.string().min(1, 'Knowledge base is required'), - login: z.string().min(1, 'Login is required'), - password: z.string().min(1, 'Password is required'), - table: z.string().min(1, 'Table is required'), - recordId: z.string().min(1, 'Record ID is required'), - fieldName: z.string().min(1, 'Field name is required'), - file: FileInputSchema.optional().nullable(), - fileName: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -40,8 +29,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const data = AgiloftAttachSchema.parse(body) + const parsed = await parseRequest( + agiloftAttachContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const data = parsed.data.body if (!data.file) { return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) @@ -127,14 +134,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await agiloftLogout(data.instanceUrl, data.knowledgeBase, token) } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { success: false, error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error attaching file to Agiloft:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/agiloft/retrieve/route.ts b/apps/sim/app/api/tools/agiloft/retrieve/route.ts index f7154d8d7f8..3f94c8bc739 100644 --- a/apps/sim/app/api/tools/agiloft/retrieve/route.ts +++ b/apps/sim/app/api/tools/agiloft/retrieve/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { agiloftRetrieveContract } from '@/lib/api/contracts/tools/agiloft' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' @@ -11,17 +12,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('AgiloftRetrieveAPI') -const AgiloftRetrieveSchema = z.object({ - instanceUrl: z.string().min(1, 'Instance URL is required'), - knowledgeBase: z.string().min(1, 'Knowledge base is required'), - login: z.string().min(1, 'Login is required'), - password: z.string().min(1, 'Password is required'), - table: z.string().min(1, 'Table is required'), - recordId: z.string().min(1, 'Record ID is required'), - fieldName: z.string().min(1, 'Field name is required'), - position: z.string().min(1, 'Position is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -36,8 +26,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const data = AgiloftRetrieveSchema.parse(body) + const parsed = await parseRequest( + agiloftRetrieveContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const data = parsed.data.body const urlValidation = await validateUrlWithDNS(data.instanceUrl, 'instanceUrl') if (!urlValidation.isValid) { @@ -117,14 +125,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await agiloftLogout(data.instanceUrl, data.knowledgeBase, token) } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { success: false, error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error retrieving Agiloft attachment:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/airtable/bases/route.ts b/apps/sim/app/api/tools/airtable/bases/route.ts index 12e5486e2d3..04c47308df3 100644 --- a/apps/sim/app/api/tools/airtable/bases/route.ts +++ b/apps/sim/app/api/tools/airtable/bases/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' +import { selectorContractsByPath } from '@/lib/api/contracts/selectors' +import { getValidationErrorMessage, validateJsonBody } 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' @@ -12,13 +14,21 @@ export const dynamic = 'force-dynamic' export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId } = body - - if (!credential) { + const validation = await validateJsonBody( + request, + selectorContractsByPath['/api/tools/airtable/bases'].body! + ) + if (!validation.success) { logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + if (validation.error) { + return NextResponse.json( + { error: getValidationErrorMessage(validation.error, 'Invalid request') }, + { status: 400 } + ) + } + return validation.response } + const { credential, workflowId } = validation.data const authz = await authorizeCredentialUse(request as any, { credentialId: credential, diff --git a/apps/sim/app/api/tools/airtable/tables/route.ts b/apps/sim/app/api/tools/airtable/tables/route.ts index be7ba8e38eb..507f2b5f836 100644 --- a/apps/sim/app/api/tools/airtable/tables/route.ts +++ b/apps/sim/app/api/tools/airtable/tables/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' +import { selectorContractsByPath } from '@/lib/api/contracts/selectors' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAirtableId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -13,18 +15,24 @@ export const dynamic = 'force-dynamic' export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId, baseId } = body - - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } - - if (!baseId) { - logger.error('Missing baseId in request') - return NextResponse.json({ error: 'Base ID is required' }, { status: 400 }) + const validation = await validateJsonBody( + request, + selectorContractsByPath['/api/tools/airtable/tables'].body! + ) + if (!validation.success) { + if (validation.error) { + const firstPath = validation.error.issues.at(0)?.path[0] + logger.error( + firstPath === 'baseId' ? 'Missing baseId in request' : 'Missing credential in request' + ) + return NextResponse.json( + { error: getValidationErrorMessage(validation.error, 'Invalid request') }, + { status: 400 } + ) + } + return validation.response } + const { credential, workflowId, baseId } = validation.data const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') if (!baseIdValidation.isValid) { diff --git a/apps/sim/app/api/tools/asana/add-comment/route.ts b/apps/sim/app/api/tools/asana/add-comment/route.ts index 188475dddeb..bbe40e66584 100644 --- a/apps/sim/app/api/tools/asana/add-comment/route.ts +++ b/apps/sim/app/api/tools/asana/add-comment/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { asanaAddCommentContract } from '@/lib/api/contracts/tools/asana' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -15,22 +17,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { accessToken, taskGid, text } = await request.json() - - if (!accessToken) { - logger.error('Missing access token in request') - return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) - } - - if (!taskGid) { - logger.error('Missing task GID in request') - return NextResponse.json({ error: 'Task GID is required' }, { status: 400 }) - } - - if (!text) { - logger.error('Missing comment text in request') - return NextResponse.json({ error: 'Comment text is required' }, { status: 400 }) - } + const parsed = await parseRequest(asanaAddCommentContract, request, {}) + if (!parsed.success) return parsed.response + const { accessToken, taskGid, text } = parsed.data.body const taskGidValidation = validateAlphanumericId(taskGid, 'taskGid', 100) if (!taskGidValidation.isValid) { diff --git a/apps/sim/app/api/tools/asana/create-task/route.ts b/apps/sim/app/api/tools/asana/create-task/route.ts index fe88dfe8786..0a14cf6fc91 100644 --- a/apps/sim/app/api/tools/asana/create-task/route.ts +++ b/apps/sim/app/api/tools/asana/create-task/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { asanaCreateTaskContract } from '@/lib/api/contracts/tools/asana' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -16,22 +18,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { accessToken, workspace, name, notes, assignee, due_on } = await request.json() - - if (!accessToken) { - logger.error('Missing access token in request') - return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) - } - - if (!name) { - logger.error('Missing task name in request') - return NextResponse.json({ error: 'Task name is required' }, { status: 400 }) - } - - if (!workspace) { - logger.error('Missing workspace in request') - return NextResponse.json({ error: 'Workspace GID is required' }, { status: 400 }) - } + const parsed = await parseRequest(asanaCreateTaskContract, request, {}) + if (!parsed.success) return parsed.response + const { accessToken, workspace, name, notes, assignee, due_on } = parsed.data.body const workspaceValidation = validateAlphanumericId(workspace, 'workspace', 100) if (!workspaceValidation.isValid) { @@ -40,7 +29,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const url = 'https://app.asana.com/api/1.0/tasks' - const taskData: Record = { + const taskData: Record = { name, workspace, } @@ -115,7 +104,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { created_at: task.created_at, permalink_url: task.permalink_url, }) - } catch (error: any) { + } catch (error) { logger.error('Error creating Asana task:', { error: toError(error).message, stack: error instanceof Error ? error.stack : undefined, diff --git a/apps/sim/app/api/tools/asana/get-projects/route.ts b/apps/sim/app/api/tools/asana/get-projects/route.ts index d3b175c2a01..b05e9d2d4b8 100644 --- a/apps/sim/app/api/tools/asana/get-projects/route.ts +++ b/apps/sim/app/api/tools/asana/get-projects/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { asanaGetProjectsContract } from '@/lib/api/contracts/tools/asana' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -15,17 +17,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { accessToken, workspace } = await request.json() - - if (!accessToken) { - logger.error('Missing access token in request') - return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) - } - - if (!workspace) { - logger.error('Missing workspace in request') - return NextResponse.json({ error: 'Workspace is required' }, { status: 400 }) - } + const parsed = await parseRequest(asanaGetProjectsContract, request, {}) + if (!parsed.success) return parsed.response + const { accessToken, workspace } = parsed.data.body const workspaceValidation = validateAlphanumericId(workspace, 'workspace', 100) if (!workspaceValidation.isValid) { @@ -81,7 +75,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, ts: new Date().toISOString(), - projects: projects.map((project: any) => ({ + projects: projects.map((project: { gid: string; name: string; resource_type: string }) => ({ gid: project.gid, name: project.name, resource_type: project.resource_type, diff --git a/apps/sim/app/api/tools/asana/get-task/route.ts b/apps/sim/app/api/tools/asana/get-task/route.ts index 045d41f4090..304308b52c4 100644 --- a/apps/sim/app/api/tools/asana/get-task/route.ts +++ b/apps/sim/app/api/tools/asana/get-task/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { asanaGetTaskContract } from '@/lib/api/contracts/tools/asana' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -15,12 +17,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { accessToken, taskGid, workspace, project, limit } = await request.json() - - if (!accessToken) { - logger.error('Missing access token in request') - return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) - } + const parsed = await parseRequest(asanaGetTaskContract, request, {}) + if (!parsed.success) return parsed.response + const { accessToken, taskGid, workspace, project, limit } = parsed.data.body if (taskGid) { const taskGidValidation = validateAlphanumericId(taskGid, 'taskGid', 100) diff --git a/apps/sim/app/api/tools/asana/search-tasks/route.ts b/apps/sim/app/api/tools/asana/search-tasks/route.ts index 5a7d6141be9..d3d0488f997 100644 --- a/apps/sim/app/api/tools/asana/search-tasks/route.ts +++ b/apps/sim/app/api/tools/asana/search-tasks/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { asanaSearchTasksContract } from '@/lib/api/contracts/tools/asana' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -15,17 +17,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { accessToken, workspace, text, assignee, projects, completed } = await request.json() - - if (!accessToken) { - logger.error('Missing access token in request') - return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) - } - - if (!workspace) { - logger.error('Missing workspace in request') - return NextResponse.json({ error: 'Workspace is required' }, { status: 400 }) - } + const parsed = await parseRequest(asanaSearchTasksContract, request, {}) + if (!parsed.success) return parsed.response + const { accessToken, workspace, text, assignee, projects, completed } = parsed.data.body const workspaceValidation = validateAlphanumericId(workspace, 'workspace', 100) if (!workspaceValidation.isValid) { diff --git a/apps/sim/app/api/tools/asana/update-task/route.ts b/apps/sim/app/api/tools/asana/update-task/route.ts index 2649b77ba96..d03b0dee3bb 100644 --- a/apps/sim/app/api/tools/asana/update-task/route.ts +++ b/apps/sim/app/api/tools/asana/update-task/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { asanaUpdateTaskContract } from '@/lib/api/contracts/tools/asana' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -16,17 +18,9 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { accessToken, taskGid, name, notes, assignee, completed, due_on } = await request.json() - - if (!accessToken) { - logger.error('Missing access token in request') - return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) - } - - if (!taskGid) { - logger.error('Missing task GID in request') - return NextResponse.json({ error: 'Task GID is required' }, { status: 400 }) - } + const parsed = await parseRequest(asanaUpdateTaskContract, request, {}) + if (!parsed.success) return parsed.response + const { accessToken, taskGid, name, notes, assignee, completed, due_on } = parsed.data.body const taskGidValidation = validateAlphanumericId(taskGid, 'taskGid', 100) if (!taskGidValidation.isValid) { @@ -35,7 +29,7 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { const url = `https://app.asana.com/api/1.0/tasks/${taskGid}` - const taskData: Record = {} + const taskData: Record = {} if (name !== undefined) { taskData.name = name @@ -114,7 +108,7 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { completed: task.completed || false, modified_at: task.modified_at, }) - } catch (error: any) { + } catch (error) { logger.error('Error updating Asana task:', { error: toError(error).message, stack: error instanceof Error ? error.stack : undefined, diff --git a/apps/sim/app/api/tools/asana/workspaces/route.ts b/apps/sim/app/api/tools/asana/workspaces/route.ts index 1ecbe151c08..ee66783722c 100644 --- a/apps/sim/app/api/tools/asana/workspaces/route.ts +++ b/apps/sim/app/api/tools/asana/workspaces/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { asanaWorkspacesSelectorContract } from '@/lib/api/contracts/selectors' +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' @@ -9,18 +11,14 @@ const logger = createLogger('AsanaWorkspacesAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId } = body + const parsed = await parseRequest(asanaWorkspacesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId } = parsed.data.body - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) diff --git a/apps/sim/app/api/tools/athena/create-named-query/route.ts b/apps/sim/app/api/tools/athena/create-named-query/route.ts index 3d7c090a86d..4f93d86a553 100644 --- a/apps/sim/app/api/tools/athena/create-named-query/route.ts +++ b/apps/sim/app/api/tools/athena/create-named-query/route.ts @@ -1,24 +1,14 @@ import { CreateNamedQueryCommand } from '@aws-sdk/client-athena' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsAthenaCreateNamedQueryContract } from '@/lib/api/contracts/tools/aws/athena-create-named-query' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createAthenaClient } from '@/app/api/tools/athena/utils' const logger = createLogger('AthenaCreateNamedQuery') -const CreateNamedQuerySchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - name: z.string().min(1, 'Query name is required'), - database: z.string().min(1, 'Database is required'), - queryString: z.string().min(1, 'Query string is required'), - description: z.string().optional(), - workGroup: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -26,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = CreateNamedQuerySchema.parse(body) + const parsed = await parseAwsToolRequest(awsAthenaCreateNamedQueryContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = createAthenaClient({ region: data.region, @@ -56,12 +50,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = error instanceof Error ? error.message : 'Failed to create Athena named query' logger.error('CreateNamedQuery failed', { error: errorMessage }) diff --git a/apps/sim/app/api/tools/athena/get-named-query/route.ts b/apps/sim/app/api/tools/athena/get-named-query/route.ts index 1cd6d99aa89..2a6ace15c3f 100644 --- a/apps/sim/app/api/tools/athena/get-named-query/route.ts +++ b/apps/sim/app/api/tools/athena/get-named-query/route.ts @@ -1,20 +1,14 @@ import { GetNamedQueryCommand } from '@aws-sdk/client-athena' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsAthenaGetNamedQueryContract } from '@/lib/api/contracts/tools/aws/athena-get-named-query' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createAthenaClient } from '@/app/api/tools/athena/utils' const logger = createLogger('AthenaGetNamedQuery') -const GetNamedQuerySchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - namedQueryId: z.string().min(1, 'Named query ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -22,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = GetNamedQuerySchema.parse(body) + const parsed = await parseAwsToolRequest(awsAthenaGetNamedQueryContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = createAthenaClient({ region: data.region, @@ -54,12 +52,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = error instanceof Error ? error.message : 'Failed to get Athena named query' logger.error('GetNamedQuery failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) diff --git a/apps/sim/app/api/tools/athena/get-query-execution/route.ts b/apps/sim/app/api/tools/athena/get-query-execution/route.ts index 362e20e86e4..b8b55151441 100644 --- a/apps/sim/app/api/tools/athena/get-query-execution/route.ts +++ b/apps/sim/app/api/tools/athena/get-query-execution/route.ts @@ -1,20 +1,14 @@ import { GetQueryExecutionCommand } from '@aws-sdk/client-athena' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsAthenaGetQueryExecutionContract } from '@/lib/api/contracts/tools/aws/athena-get-query-execution' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createAthenaClient } from '@/app/api/tools/athena/utils' const logger = createLogger('AthenaGetQueryExecution') -const GetQueryExecutionSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - queryExecutionId: z.string().min(1, 'Query execution ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -22,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = GetQueryExecutionSchema.parse(body) + const parsed = await parseAwsToolRequest(awsAthenaGetQueryExecutionContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = createAthenaClient({ region: data.region, @@ -64,12 +62,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = error instanceof Error ? error.message : 'Failed to get Athena query execution' logger.error('GetQueryExecution failed', { error: errorMessage }) diff --git a/apps/sim/app/api/tools/athena/get-query-results/route.ts b/apps/sim/app/api/tools/athena/get-query-results/route.ts index 3a35b52071d..ff2d661ee79 100644 --- a/apps/sim/app/api/tools/athena/get-query-results/route.ts +++ b/apps/sim/app/api/tools/athena/get-query-results/route.ts @@ -1,25 +1,14 @@ import { GetQueryResultsCommand } from '@aws-sdk/client-athena' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsAthenaGetQueryResultsContract } from '@/lib/api/contracts/tools/aws/athena-get-query-results' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createAthenaClient } from '@/app/api/tools/athena/utils' const logger = createLogger('AthenaGetQueryResults') -const GetQueryResultsSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - queryExecutionId: z.string().min(1, 'Query execution ID is required'), - maxResults: z.preprocess( - (v) => (v === '' || v === undefined || v === null ? undefined : v), - z.number({ coerce: true }).int().positive().max(999).optional() - ), - nextToken: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -27,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = GetQueryResultsSchema.parse(body) + const parsed = await parseAwsToolRequest(awsAthenaGetQueryResultsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = createAthenaClient({ region: data.region, @@ -75,12 +68,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = error instanceof Error ? error.message : 'Failed to get Athena query results' logger.error('GetQueryResults failed', { error: errorMessage }) diff --git a/apps/sim/app/api/tools/athena/list-named-queries/route.ts b/apps/sim/app/api/tools/athena/list-named-queries/route.ts index 6209890c23f..661b40d2865 100644 --- a/apps/sim/app/api/tools/athena/list-named-queries/route.ts +++ b/apps/sim/app/api/tools/athena/list-named-queries/route.ts @@ -1,25 +1,14 @@ import { ListNamedQueriesCommand } from '@aws-sdk/client-athena' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsAthenaListNamedQueriesContract } from '@/lib/api/contracts/tools/aws/athena-list-named-queries' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createAthenaClient } from '@/app/api/tools/athena/utils' const logger = createLogger('AthenaListNamedQueries') -const ListNamedQueriesSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - workGroup: z.string().optional(), - maxResults: z.preprocess( - (v) => (v === '' || v === undefined || v === null ? undefined : v), - z.number({ coerce: true }).int().min(0).max(50).optional() - ), - nextToken: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -27,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = ListNamedQueriesSchema.parse(body) + const parsed = await parseAwsToolRequest(awsAthenaListNamedQueriesContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = createAthenaClient({ region: data.region, @@ -52,12 +45,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = error instanceof Error ? error.message : 'Failed to list Athena named queries' logger.error('ListNamedQueries failed', { error: errorMessage }) diff --git a/apps/sim/app/api/tools/athena/list-query-executions/route.ts b/apps/sim/app/api/tools/athena/list-query-executions/route.ts index f8fa197e8af..949bbf32165 100644 --- a/apps/sim/app/api/tools/athena/list-query-executions/route.ts +++ b/apps/sim/app/api/tools/athena/list-query-executions/route.ts @@ -1,25 +1,14 @@ import { ListQueryExecutionsCommand } from '@aws-sdk/client-athena' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsAthenaListQueryExecutionsContract } from '@/lib/api/contracts/tools/aws/athena-list-query-executions' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createAthenaClient } from '@/app/api/tools/athena/utils' const logger = createLogger('AthenaListQueryExecutions') -const ListQueryExecutionsSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - workGroup: z.string().optional(), - maxResults: z.preprocess( - (v) => (v === '' || v === undefined || v === null ? undefined : v), - z.number({ coerce: true }).int().min(0).max(50).optional() - ), - nextToken: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -27,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = ListQueryExecutionsSchema.parse(body) + const parsed = await parseAwsToolRequest(awsAthenaListQueryExecutionsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = createAthenaClient({ region: data.region, @@ -52,12 +45,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = error instanceof Error ? error.message : 'Failed to list Athena query executions' logger.error('ListQueryExecutions failed', { error: errorMessage }) diff --git a/apps/sim/app/api/tools/athena/start-query/route.ts b/apps/sim/app/api/tools/athena/start-query/route.ts index bdbaa318a1e..d6df32c5a4a 100644 --- a/apps/sim/app/api/tools/athena/start-query/route.ts +++ b/apps/sim/app/api/tools/athena/start-query/route.ts @@ -1,24 +1,14 @@ import { StartQueryExecutionCommand } from '@aws-sdk/client-athena' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsAthenaStartQueryContract } from '@/lib/api/contracts/tools/aws/athena-start-query' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createAthenaClient } from '@/app/api/tools/athena/utils' const logger = createLogger('AthenaStartQuery') -const StartQuerySchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - queryString: z.string().min(1, 'Query string is required'), - database: z.string().optional(), - catalog: z.string().optional(), - outputLocation: z.string().optional(), - workGroup: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -26,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = StartQuerySchema.parse(body) + const parsed = await parseAwsToolRequest(awsAthenaStartQueryContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = createAthenaClient({ region: data.region, @@ -68,12 +62,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = error instanceof Error ? error.message : 'Failed to start Athena query' logger.error('StartQuery failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) diff --git a/apps/sim/app/api/tools/athena/stop-query/route.ts b/apps/sim/app/api/tools/athena/stop-query/route.ts index a9f0b7d1ec2..9b5317c4723 100644 --- a/apps/sim/app/api/tools/athena/stop-query/route.ts +++ b/apps/sim/app/api/tools/athena/stop-query/route.ts @@ -1,20 +1,14 @@ import { StopQueryExecutionCommand } from '@aws-sdk/client-athena' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsAthenaStopQueryContract } from '@/lib/api/contracts/tools/aws/athena-stop-query' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createAthenaClient } from '@/app/api/tools/athena/utils' const logger = createLogger('AthenaStopQuery') -const StopQuerySchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - queryExecutionId: z.string().min(1, 'Query execution ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -22,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = StopQuerySchema.parse(body) + const parsed = await parseAwsToolRequest(awsAthenaStopQueryContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = createAthenaClient({ region: data.region, @@ -44,12 +42,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = error instanceof Error ? error.message : 'Failed to stop Athena query' logger.error('StopQuery failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) diff --git a/apps/sim/app/api/tools/attio/lists/route.ts b/apps/sim/app/api/tools/attio/lists/route.ts index ba872dc143c..96ce7208119 100644 --- a/apps/sim/app/api/tools/attio/lists/route.ts +++ b/apps/sim/app/api/tools/attio/lists/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' +import { credentialWorkflowBodySchema } from '@/lib/api/contracts/selectors/shared' +import { getValidationErrorMessage, validateSchema } 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' @@ -13,12 +15,15 @@ export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() - const { credential, workflowId } = body - - if (!credential) { + const validation = validateSchema(credentialWorkflowBodySchema, body) + if (!validation.success) { logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + return NextResponse.json( + { error: getValidationErrorMessage(validation.error, 'Invalid request') }, + { status: 400 } + ) } + const { credential, workflowId } = validation.data const authz = await authorizeCredentialUse(request as any, { credentialId: credential, diff --git a/apps/sim/app/api/tools/attio/objects/route.ts b/apps/sim/app/api/tools/attio/objects/route.ts index 78bd1b1ffde..609693c5c60 100644 --- a/apps/sim/app/api/tools/attio/objects/route.ts +++ b/apps/sim/app/api/tools/attio/objects/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' +import { credentialWorkflowBodySchema } from '@/lib/api/contracts/selectors/shared' +import { getValidationErrorMessage, validateSchema } 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' @@ -13,12 +15,15 @@ export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() - const { credential, workflowId } = body - - if (!credential) { + const validation = validateSchema(credentialWorkflowBodySchema, body) + if (!validation.success) { logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + return NextResponse.json( + { error: getValidationErrorMessage(validation.error, 'Invalid request') }, + { status: 400 } + ) } + const { credential, workflowId } = validation.data const authz = await authorizeCredentialUse(request as any, { credentialId: credential, diff --git a/apps/sim/app/api/tools/box/upload/route.ts b/apps/sim/app/api/tools/box/upload/route.ts index 3d1bd9b613a..9bd50e77634 100644 --- a/apps/sim/app/api/tools/box/upload/route.ts +++ b/apps/sim/app/api/tools/box/upload/route.ts @@ -1,10 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { boxUploadContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -12,14 +12,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('BoxUploadAPI') -const BoxUploadSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - parentFolderId: z.string().min(1, 'Parent folder ID is required'), - file: FileInputSchema.optional().nullable(), - fileContent: z.string().optional().nullable(), - fileName: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -36,8 +28,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Authenticated Box upload request via ${authResult.authType}`) - const body = await request.json() - const validatedData = BoxUploadSchema.parse(body) + const parsed = await parseRequest(boxUploadContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body let fileBuffer: Buffer let fileName: string @@ -124,14 +117,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Validation error:`, error.errors) - return NextResponse.json( - { success: false, error: error.errors[0]?.message || 'Validation failed' }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Unexpected error:`, error) return NextResponse.json( { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, 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 74c3b1b1481..a9ab63da8e4 100644 --- a/apps/sim/app/api/tools/calcom/event-types/route.ts +++ b/apps/sim/app/api/tools/calcom/event-types/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { calcomEventTypesSelectorContract } from '@/lib/api/contracts/selectors' +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' @@ -9,18 +11,20 @@ const logger = createLogger('CalcomEventTypesAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +interface CalcomEventType { + id: number + title: string + slug: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId } = body - - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } + const parsed = await parseRequest(calcomEventTypesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId } = parsed.data.body - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) @@ -64,14 +68,12 @@ export const POST = withRouteHandler(async (request: Request) => { ) } - const data = await response.json() - const eventTypes = (data.data || []).map( - (eventType: { id: number; title: string; slug: string }) => ({ - id: String(eventType.id), - title: eventType.title, - slug: eventType.slug, - }) - ) + const data = (await response.json()) as { data?: CalcomEventType[] } + const eventTypes = (data.data || []).map((eventType) => ({ + id: String(eventType.id), + title: eventType.title, + slug: eventType.slug, + })) return NextResponse.json({ eventTypes }) } catch (error) { diff --git a/apps/sim/app/api/tools/calcom/schedules/route.ts b/apps/sim/app/api/tools/calcom/schedules/route.ts index 108c1540b25..15b6e1dfc6e 100644 --- a/apps/sim/app/api/tools/calcom/schedules/route.ts +++ b/apps/sim/app/api/tools/calcom/schedules/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { calcomSchedulesSelectorContract } from '@/lib/api/contracts/selectors' +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' @@ -9,18 +11,19 @@ const logger = createLogger('CalcomSchedulesAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +interface CalcomSchedule { + id: number + name: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId } = body - - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } + const parsed = await parseRequest(calcomSchedulesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId } = parsed.data.body - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) @@ -64,8 +67,8 @@ export const POST = withRouteHandler(async (request: Request) => { ) } - const data = await response.json() - const schedules = (data.data || []).map((schedule: { id: number; name: string }) => ({ + const data = (await response.json()) as { data?: CalcomSchedule[] } + const schedules = (data.data || []).map((schedule) => ({ id: String(schedule.id), name: schedule.name, })) diff --git a/apps/sim/app/api/tools/cloudformation/describe-stack-drift-detection-status/route.ts b/apps/sim/app/api/tools/cloudformation/describe-stack-drift-detection-status/route.ts index 86ebfe76cbc..6d58d68ae5a 100644 --- a/apps/sim/app/api/tools/cloudformation/describe-stack-drift-detection-status/route.ts +++ b/apps/sim/app/api/tools/cloudformation/describe-stack-drift-detection-status/route.ts @@ -4,19 +4,13 @@ import { } from '@aws-sdk/client-cloudformation' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudformationDescribeStackDriftDetectionStatusContract } from '@/lib/api/contracts/tools/aws/cloudformation-describe-stack-drift-detection-status' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudFormationDescribeStackDriftDetectionStatus') -const DescribeStackDriftDetectionStatusSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - stackDriftDetectionId: z.string().min(1, 'Stack drift detection ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -24,8 +18,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = DescribeStackDriftDetectionStatusSchema.parse(body) + const parsed = await parseAwsToolRequest( + awsCloudformationDescribeStackDriftDetectionStatusContract, + request, + { + errorFormat: 'details', + logger, + } + ) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const client = new CloudFormationClient({ region: validatedData.region, @@ -54,12 +56,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = error instanceof Error ? error.message : 'Failed to describe stack drift detection status' logger.error('DescribeStackDriftDetectionStatus failed', { error: errorMessage }) diff --git a/apps/sim/app/api/tools/cloudformation/describe-stack-events/route.ts b/apps/sim/app/api/tools/cloudformation/describe-stack-events/route.ts index a7173ed7282..256ab4e6fcb 100644 --- a/apps/sim/app/api/tools/cloudformation/describe-stack-events/route.ts +++ b/apps/sim/app/api/tools/cloudformation/describe-stack-events/route.ts @@ -5,23 +5,13 @@ import { } from '@aws-sdk/client-cloudformation' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudformationDescribeStackEventsContract } from '@/lib/api/contracts/tools/aws/cloudformation-describe-stack-events' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudFormationDescribeStackEvents') -const DescribeStackEventsSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - stackName: z.string().min(1, 'Stack name is required'), - limit: z.preprocess( - (v) => (v === '' || v === undefined || v === null ? undefined : v), - z.number({ coerce: true }).int().positive().optional() - ), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -29,8 +19,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = DescribeStackEventsSchema.parse(body) + const parsed = await parseAwsToolRequest( + awsCloudformationDescribeStackEventsContract, + request, + { + errorFormat: 'details', + logger, + } + ) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const client = new CloudFormationClient({ region: validatedData.region, @@ -71,12 +69,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: { events }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = error instanceof Error ? error.message : 'Failed to describe CloudFormation stack events' logger.error('DescribeStackEvents failed', { error: errorMessage }) diff --git a/apps/sim/app/api/tools/cloudformation/describe-stacks/route.ts b/apps/sim/app/api/tools/cloudformation/describe-stacks/route.ts index 50e515abcc7..9c3efe6a25d 100644 --- a/apps/sim/app/api/tools/cloudformation/describe-stacks/route.ts +++ b/apps/sim/app/api/tools/cloudformation/describe-stacks/route.ts @@ -5,19 +5,13 @@ import { } from '@aws-sdk/client-cloudformation' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudformationDescribeStacksContract } from '@/lib/api/contracts/tools/aws/cloudformation-describe-stacks' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudFormationDescribeStacks') -const DescribeStacksSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - stackName: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -25,8 +19,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = DescribeStacksSchema.parse(body) + const parsed = await parseAwsToolRequest(awsCloudformationDescribeStacksContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const client = new CloudFormationClient({ region: validatedData.region, @@ -79,12 +77,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: { stacks }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = error instanceof Error ? error.message : 'Failed to describe CloudFormation stacks' logger.error('DescribeStacks failed', { error: errorMessage }) diff --git a/apps/sim/app/api/tools/cloudformation/detect-stack-drift/route.ts b/apps/sim/app/api/tools/cloudformation/detect-stack-drift/route.ts index 0f23c1aced6..19a4798752f 100644 --- a/apps/sim/app/api/tools/cloudformation/detect-stack-drift/route.ts +++ b/apps/sim/app/api/tools/cloudformation/detect-stack-drift/route.ts @@ -1,19 +1,13 @@ import { CloudFormationClient, DetectStackDriftCommand } from '@aws-sdk/client-cloudformation' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudformationDetectStackDriftContract } from '@/lib/api/contracts/tools/aws/cloudformation-detect-stack-drift' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudFormationDetectStackDrift') -const DetectStackDriftSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - stackName: z.string().min(1, 'Stack name is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -21,8 +15,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = DetectStackDriftSchema.parse(body) + const parsed = await parseAwsToolRequest(awsCloudformationDetectStackDriftContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const client = new CloudFormationClient({ region: validatedData.region, @@ -49,12 +47,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = error instanceof Error ? error.message : 'Failed to detect CloudFormation stack drift' logger.error('DetectStackDrift failed', { error: errorMessage }) diff --git a/apps/sim/app/api/tools/cloudformation/get-template/route.ts b/apps/sim/app/api/tools/cloudformation/get-template/route.ts index 46d72b28ae1..76f1f1e748b 100644 --- a/apps/sim/app/api/tools/cloudformation/get-template/route.ts +++ b/apps/sim/app/api/tools/cloudformation/get-template/route.ts @@ -1,19 +1,13 @@ import { CloudFormationClient, GetTemplateCommand } from '@aws-sdk/client-cloudformation' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudformationGetTemplateContract } from '@/lib/api/contracts/tools/aws/cloudformation-get-template' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudFormationGetTemplate') -const GetTemplateSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - stackName: z.string().min(1, 'Stack name is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -21,8 +15,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = GetTemplateSchema.parse(body) + const parsed = await parseAwsToolRequest(awsCloudformationGetTemplateContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const client = new CloudFormationClient({ region: validatedData.region, @@ -46,12 +44,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = error instanceof Error ? error.message : 'Failed to get CloudFormation template' logger.error('GetTemplate failed', { error: errorMessage }) diff --git a/apps/sim/app/api/tools/cloudformation/list-stack-resources/route.ts b/apps/sim/app/api/tools/cloudformation/list-stack-resources/route.ts index 1f6ad8fa24e..d7350855a1c 100644 --- a/apps/sim/app/api/tools/cloudformation/list-stack-resources/route.ts +++ b/apps/sim/app/api/tools/cloudformation/list-stack-resources/route.ts @@ -5,19 +5,13 @@ import { } from '@aws-sdk/client-cloudformation' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudformationListStackResourcesContract } from '@/lib/api/contracts/tools/aws/cloudformation-list-stack-resources' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudFormationListStackResources') -const ListStackResourcesSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - stackName: z.string().min(1, 'Stack name is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -25,8 +19,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = ListStackResourcesSchema.parse(body) + const parsed = await parseAwsToolRequest(awsCloudformationListStackResourcesContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const client = new CloudFormationClient({ region: validatedData.region, @@ -68,12 +66,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: { resources }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = error instanceof Error ? error.message : 'Failed to list CloudFormation stack resources' logger.error('ListStackResources failed', { error: errorMessage }) diff --git a/apps/sim/app/api/tools/cloudformation/validate-template/route.ts b/apps/sim/app/api/tools/cloudformation/validate-template/route.ts index c526ce267e7..e9bf22fe8be 100644 --- a/apps/sim/app/api/tools/cloudformation/validate-template/route.ts +++ b/apps/sim/app/api/tools/cloudformation/validate-template/route.ts @@ -1,19 +1,13 @@ import { CloudFormationClient, ValidateTemplateCommand } from '@aws-sdk/client-cloudformation' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudformationValidateTemplateContract } from '@/lib/api/contracts/tools/aws/cloudformation-validate-template' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudFormationValidateTemplate') -const ValidateTemplateSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - templateBody: z.string().min(1, 'Template body is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -21,8 +15,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = ValidateTemplateSchema.parse(body) + const parsed = await parseAwsToolRequest(awsCloudformationValidateTemplateContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const client = new CloudFormationClient({ region: validatedData.region, @@ -54,12 +52,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = error instanceof Error ? error.message : 'Failed to validate CloudFormation template' logger.error('ValidateTemplate failed', { error: errorMessage }) diff --git a/apps/sim/app/api/tools/cloudwatch/describe-alarms/route.ts b/apps/sim/app/api/tools/cloudwatch/describe-alarms/route.ts index 38c689cf68c..f905a2b76c7 100644 --- a/apps/sim/app/api/tools/cloudwatch/describe-alarms/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/describe-alarms/route.ts @@ -7,37 +7,13 @@ import { import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudwatchDescribeAlarmsContract } from '@/lib/api/contracts/tools/aws/cloudwatch-describe-alarms' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudWatchDescribeAlarms') -const DescribeAlarmsSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - alarmNamePrefix: z.string().optional(), - stateValue: z.preprocess( - (v) => (v === '' ? undefined : v), - z.enum(['OK', 'ALARM', 'INSUFFICIENT_DATA']).optional() - ), - alarmType: z.preprocess( - (v) => (v === '' ? undefined : v), - z.enum(['MetricAlarm', 'CompositeAlarm']).optional() - ), - limit: z.preprocess( - (v) => (v === '' || v === undefined || v === null ? undefined : v), - z.number({ coerce: true }).int().positive().optional() - ), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -45,8 +21,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = DescribeAlarmsSchema.parse(body) + const parsed = await parseAwsToolRequest(awsCloudwatchDescribeAlarmsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info('Describing CloudWatch alarms') @@ -108,13 +88,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } logger.error('DescribeAlarms failed', { error: toError(error).message }) return NextResponse.json( { error: `Failed to describe CloudWatch alarms: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts b/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts index b58c9cfe8a0..eee09e265d8 100644 --- a/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts @@ -2,30 +2,14 @@ import { DescribeLogGroupsCommand } from '@aws-sdk/client-cloudwatch-logs' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { cloudwatchLogGroupsSelectorContract } from '@/lib/api/contracts/selectors/cloudwatch' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createCloudWatchLogsClient } from '@/app/api/tools/cloudwatch/utils' const logger = createLogger('CloudWatchDescribeLogGroups') -const DescribeLogGroupsSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - prefix: z.string().optional(), - limit: z.preprocess( - (v) => (v === '' || v === undefined || v === null ? undefined : v), - z.number({ coerce: true }).int().positive().optional() - ), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) @@ -33,8 +17,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = DescribeLogGroupsSchema.parse(body) + const parsed = await parseAwsToolRequest(cloudwatchLogGroupsSelectorContract, request, { + errorFormat: 'firstError', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info('Describing CloudWatch log groups') @@ -70,13 +58,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } logger.error('DescribeLogGroups failed', { error: toError(error).message }) return NextResponse.json( { error: `Failed to describe CloudWatch log groups: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts b/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts index 5a79264236f..bfc9af8d524 100644 --- a/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts @@ -1,31 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { cloudwatchLogStreamsSelectorContract } from '@/lib/api/contracts/selectors/cloudwatch' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createCloudWatchLogsClient, describeLogStreams } from '@/app/api/tools/cloudwatch/utils' const logger = createLogger('CloudWatchDescribeLogStreams') -const DescribeLogStreamsSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - logGroupName: z.string().min(1, 'Log group name is required'), - prefix: z.string().optional(), - limit: z.preprocess( - (v) => (v === '' || v === undefined || v === null ? undefined : v), - z.number({ coerce: true }).int().positive().optional() - ), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) @@ -33,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = DescribeLogStreamsSchema.parse(body) + const parsed = await parseAwsToolRequest(cloudwatchLogStreamsSelectorContract, request, { + errorFormat: 'firstError', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`Describing log streams for group: ${validatedData.logGroupName}`) @@ -60,13 +47,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } logger.error('DescribeLogStreams failed', { error: toError(error).message }) return NextResponse.json( { error: `Failed to describe CloudWatch log streams: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/cloudwatch/get-log-events/route.ts b/apps/sim/app/api/tools/cloudwatch/get-log-events/route.ts index 60c1324649c..cdc6a44d099 100644 --- a/apps/sim/app/api/tools/cloudwatch/get-log-events/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/get-log-events/route.ts @@ -1,33 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudwatchGetLogEventsContract } from '@/lib/api/contracts/tools/aws/cloudwatch-get-log-events' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createCloudWatchLogsClient, getLogEvents } from '@/app/api/tools/cloudwatch/utils' const logger = createLogger('CloudWatchGetLogEvents') -const GetLogEventsSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - logGroupName: z.string().min(1, 'Log group name is required'), - logStreamName: z.string().min(1, 'Log stream name is required'), - startTime: z.number({ coerce: true }).int().optional(), - endTime: z.number({ coerce: true }).int().optional(), - limit: z.preprocess( - (v) => (v === '' || v === undefined || v === null ? undefined : v), - z.number({ coerce: true }).int().positive().optional() - ), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -35,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = GetLogEventsSchema.parse(body) + const parsed = await parseAwsToolRequest(awsCloudwatchGetLogEventsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info( `Getting log events from ${validatedData.logGroupName}/${validatedData.logStreamName}` @@ -70,13 +55,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } logger.error('GetLogEvents failed', { error: toError(error).message }) return NextResponse.json( { error: `Failed to get CloudWatch log events: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/cloudwatch/get-metric-statistics/route.ts b/apps/sim/app/api/tools/cloudwatch/get-metric-statistics/route.ts index f19fe2aeba0..b754c52fd34 100644 --- a/apps/sim/app/api/tools/cloudwatch/get-metric-statistics/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/get-metric-statistics/route.ts @@ -2,31 +2,13 @@ import { CloudWatchClient, GetMetricStatisticsCommand } from '@aws-sdk/client-cl import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudwatchGetMetricStatisticsContract } from '@/lib/api/contracts/tools/aws/cloudwatch-get-metric-statistics' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudWatchGetMetricStatistics') -const GetMetricStatisticsSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - namespace: z.string().min(1, 'Namespace is required'), - metricName: z.string().min(1, 'Metric name is required'), - startTime: z.number({ coerce: true }).int(), - endTime: z.number({ coerce: true }).int(), - period: z.number({ coerce: true }).int().min(1), - statistics: z.array(z.enum(['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'])).min(1), - dimensions: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -34,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = GetMetricStatisticsSchema.parse(body) + const parsed = await parseAwsToolRequest(awsCloudwatchGetMetricStatisticsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info( `Getting metric statistics for ${validatedData.namespace}/${validatedData.metricName}` @@ -107,13 +93,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } logger.error('GetMetricStatistics failed', { error: toError(error).message }) return NextResponse.json( { error: `Failed to get CloudWatch metric statistics: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/cloudwatch/list-metrics/route.ts b/apps/sim/app/api/tools/cloudwatch/list-metrics/route.ts index 9d9443cb3b9..5a9818f7f26 100644 --- a/apps/sim/app/api/tools/cloudwatch/list-metrics/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/list-metrics/route.ts @@ -2,31 +2,13 @@ import { CloudWatchClient, ListMetricsCommand } from '@aws-sdk/client-cloudwatch import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudwatchListMetricsContract } from '@/lib/api/contracts/tools/aws/cloudwatch-list-metrics' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudWatchListMetrics') -const ListMetricsSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - namespace: z.string().optional(), - metricName: z.string().optional(), - recentlyActive: z.boolean().optional(), - limit: z.preprocess( - (v) => (v === '' || v === undefined || v === null ? undefined : v), - z.number({ coerce: true }).int().positive().optional() - ), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -34,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = ListMetricsSchema.parse(body) + const parsed = await parseAwsToolRequest(awsCloudwatchListMetricsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info('Listing CloudWatch metrics') @@ -77,13 +63,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } logger.error('ListMetrics failed', { error: toError(error).message }) return NextResponse.json( { error: `Failed to list CloudWatch metrics: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/cloudwatch/put-metric-data/route.ts b/apps/sim/app/api/tools/cloudwatch/put-metric-data/route.ts index dc69f04d499..cc87b2568c9 100644 --- a/apps/sim/app/api/tools/cloudwatch/put-metric-data/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/put-metric-data/route.ts @@ -6,75 +6,13 @@ import { import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudwatchPutMetricDataContract } from '@/lib/api/contracts/tools/aws/cloudwatch-put-metric-data' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudWatchPutMetricData') -const VALID_UNITS = [ - 'Seconds', - 'Microseconds', - 'Milliseconds', - 'Bytes', - 'Kilobytes', - 'Megabytes', - 'Gigabytes', - 'Terabytes', - 'Bits', - 'Kilobits', - 'Megabits', - 'Gigabits', - 'Terabits', - 'Percent', - 'Count', - 'Bytes/Second', - 'Kilobytes/Second', - 'Megabytes/Second', - 'Gigabytes/Second', - 'Terabytes/Second', - 'Bits/Second', - 'Kilobits/Second', - 'Megabits/Second', - 'Gigabits/Second', - 'Terabits/Second', - 'Count/Second', - 'None', -] as const - -const PutMetricDataSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - namespace: z.string().min(1, 'Namespace is required'), - metricName: z.string().min(1, 'Metric name is required'), - value: z.number({ coerce: true }).refine((v) => Number.isFinite(v), { - message: 'Metric value must be a finite number', - }), - unit: z.enum(VALID_UNITS).optional(), - dimensions: z - .string() - .optional() - .refine( - (val) => { - if (!val) return true - try { - const parsed = JSON.parse(val) - return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) - } catch { - return false - } - }, - { message: 'dimensions must be a valid JSON object string' } - ), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -82,8 +20,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = PutMetricDataSchema.parse(body) + const parsed = await parseAwsToolRequest(awsCloudwatchPutMetricDataContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`Publishing metric ${validatedData.namespace}/${validatedData.metricName}`) @@ -138,13 +80,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } logger.error('PutMetricData failed', { error: toError(error).message }) return NextResponse.json( { error: `Failed to publish CloudWatch metric: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/cloudwatch/query-logs/route.ts b/apps/sim/app/api/tools/cloudwatch/query-logs/route.ts index 473f89c6553..abcb3643008 100644 --- a/apps/sim/app/api/tools/cloudwatch/query-logs/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/query-logs/route.ts @@ -2,33 +2,14 @@ import { StartQueryCommand } from '@aws-sdk/client-cloudwatch-logs' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudwatchQueryLogsContract } from '@/lib/api/contracts/tools/aws/cloudwatch-query-logs' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createCloudWatchLogsClient, pollQueryResults } from '@/app/api/tools/cloudwatch/utils' const logger = createLogger('CloudWatchQueryLogs') -const QueryLogsSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - logGroupNames: z.array(z.string().min(1)).min(1, 'At least one log group name is required'), - queryString: z.string().min(1, 'Query string is required'), - startTime: z.number({ coerce: true }).int(), - endTime: z.number({ coerce: true }).int(), - limit: z.preprocess( - (v) => (v === '' || v === undefined || v === null ? undefined : v), - z.number({ coerce: true }).int().positive().optional() - ), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -36,8 +17,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = QueryLogsSchema.parse(body) + const parsed = await parseAwsToolRequest(awsCloudwatchQueryLogsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info('Running CloudWatch Log Insights query') @@ -79,13 +64,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } logger.error('QueryLogs failed', { error: toError(error).message }) return NextResponse.json( { error: `CloudWatch Log Insights query failed: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/confluence/attachment/route.ts b/apps/sim/app/api/tools/confluence/attachment/route.ts index 9aaedef7686..81e2c1465af 100644 --- a/apps/sim/app/api/tools/confluence/attachment/route.ts +++ b/apps/sim/app/api/tools/confluence/attachment/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluenceDeleteAttachmentContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,7 +20,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { domain, accessToken, cloudId: providedCloudId, attachmentId } = await request.json() + const parsed = await parseRequest(confluenceDeleteAttachmentContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, cloudId: providedCloudId, attachmentId } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/attachments/route.ts b/apps/sim/app/api/tools/confluence/attachments/route.ts index 788ea7dd8e8..e9a0afa691b 100644 --- a/apps/sim/app/api/tools/confluence/attachments/route.ts +++ b/apps/sim/app/api/tools/confluence/attachments/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluenceListAttachmentsContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,13 +20,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const domain = searchParams.get('domain') - const accessToken = searchParams.get('accessToken') - const pageId = searchParams.get('pageId') - const providedCloudId = searchParams.get('cloudId') - const limit = searchParams.get('limit') || '50' - const cursor = searchParams.get('cursor') + const parsed = await parseRequest(confluenceListAttachmentsContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + pageId, + cloudId: providedCloudId, + limit, + cursor, + } = parsed.data.query if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/blogposts/route.ts b/apps/sim/app/api/tools/confluence/blogposts/route.ts index 92aa7c8d712..458f2c7cba8 100644 --- a/apps/sim/app/api/tools/confluence/blogposts/route.ts +++ b/apps/sim/app/api/tools/confluence/blogposts/route.ts @@ -1,8 +1,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + confluenceBlogPostOperationContract, + confluenceDeleteBlogPostContract, + confluenceListBlogPostsContract, + confluenceUpdateBlogPostContract, +} from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -11,35 +17,6 @@ const logger = createLogger('ConfluenceBlogPostsAPI') export const dynamic = 'force-dynamic' -const getBlogPostSchema = z - .object({ - domain: z.string().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: z.string().optional(), - blogPostId: z.string().min(1, 'Blog post ID is required'), - bodyFormat: z.string().optional(), - }) - .refine( - (data) => { - const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255) - return validation.isValid - }, - (data) => { - const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255) - return { message: validation.error || 'Invalid blog post ID', path: ['blogPostId'] } - } - ) - -const createBlogPostSchema = z.object({ - domain: z.string().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: z.string().optional(), - spaceId: z.string().min(1, 'Space ID is required'), - title: z.string().min(1, 'Title is required'), - content: z.string().min(1, 'Content is required'), - status: z.enum(['current', 'draft']).optional(), -}) - /** * List all blog posts or get a specific blog post */ @@ -50,14 +27,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const domain = searchParams.get('domain') - const accessToken = searchParams.get('accessToken') - const providedCloudId = searchParams.get('cloudId') - const limit = searchParams.get('limit') || '25' - const status = searchParams.get('status') - const sortOrder = searchParams.get('sort') - const cursor = searchParams.get('cursor') + const parsed = await parseRequest(confluenceListBlogPostsContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: providedCloudId, + limit, + status, + sort: sortOrder, + cursor, + } = parsed.data.query if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) @@ -150,17 +131,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() + const parsed = await parseRequest(confluenceBlogPostOperationContract, request, {}) + if (!parsed.success) return parsed.response + const body = parsed.data.body - // Check if this is a create or get request - if (body.title && body.content && body.spaceId) { + if ('title' in body && 'content' in body && 'spaceId' in body) { // Create blog post - const validation = createBlogPostSchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } - const { domain, accessToken, @@ -169,7 +145,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { title, content, status, - } = validation.data + } = body const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) @@ -222,19 +198,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } // Get blog post by ID - const validation = getBlogPostSchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } - - const { - domain, - accessToken, - cloudId: providedCloudId, - blogPostId, - bodyFormat, - } = validation.data + const { domain, accessToken, cloudId: providedCloudId, blogPostId, bodyFormat } = body const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) @@ -302,20 +266,17 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { domain, accessToken, blogPostId, title, content, cloudId: providedCloudId } = body - - if (!domain || !accessToken || !blogPostId) { - return NextResponse.json( - { error: 'Domain, access token, and blog post ID are required' }, - { status: 400 } - ) - } + const parsed = await parseRequest(confluenceUpdateBlogPostContract, request, {}) + if (!parsed.success) return parsed.response - const blogPostIdValidation = validateAlphanumericId(blogPostId, 'blogPostId', 255) - if (!blogPostIdValidation.isValid) { - return NextResponse.json({ error: blogPostIdValidation.error }, { status: 400 }) - } + const { + domain, + accessToken, + blogPostId, + title, + content, + cloudId: providedCloudId, + } = parsed.data.body const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) @@ -406,20 +367,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { domain, accessToken, blogPostId, cloudId: providedCloudId } = body - - if (!domain || !accessToken || !blogPostId) { - return NextResponse.json( - { error: 'Domain, access token, and blog post ID are required' }, - { status: 400 } - ) - } + const parsed = await parseRequest(confluenceDeleteBlogPostContract, request, {}) + if (!parsed.success) return parsed.response - const blogPostIdValidation = validateAlphanumericId(blogPostId, 'blogPostId', 255) - if (!blogPostIdValidation.isValid) { - return NextResponse.json({ error: blogPostIdValidation.error }, { status: 400 }) - } + const { domain, accessToken, blogPostId, cloudId: providedCloudId } = parsed.data.body const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) diff --git a/apps/sim/app/api/tools/confluence/comment/route.ts b/apps/sim/app/api/tools/confluence/comment/route.ts index bf1adac74d8..d422a8f5fec 100644 --- a/apps/sim/app/api/tools/confluence/comment/route.ts +++ b/apps/sim/app/api/tools/confluence/comment/route.ts @@ -1,8 +1,12 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + confluenceDeleteCommentContract, + confluenceUpdateCommentContract, +} from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -11,43 +15,6 @@ const logger = createLogger('ConfluenceCommentAPI') export const dynamic = 'force-dynamic' -const putCommentSchema = z - .object({ - domain: z.string().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: z.string().optional(), - commentId: z.string().min(1, 'Comment ID is required'), - comment: z.string().min(1, 'Comment is required'), - }) - .refine( - (data) => { - const validation = validateAlphanumericId(data.commentId, 'commentId', 255) - return validation.isValid - }, - (data) => { - const validation = validateAlphanumericId(data.commentId, 'commentId', 255) - return { message: validation.error || 'Invalid comment ID', path: ['commentId'] } - } - ) - -const deleteCommentSchema = z - .object({ - domain: z.string().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: z.string().optional(), - commentId: z.string().min(1, 'Comment ID is required'), - }) - .refine( - (data) => { - const validation = validateAlphanumericId(data.commentId, 'commentId', 255) - return validation.isValid - }, - (data) => { - const validation = validateAlphanumericId(data.commentId, 'commentId', 255) - return { message: validation.error || 'Invalid comment ID', path: ['commentId'] } - } - ) - // Update a comment export const PUT = withRouteHandler(async (request: NextRequest) => { try { @@ -56,15 +23,10 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() + const parsed = await parseRequest(confluenceUpdateCommentContract, request, {}) + if (!parsed.success) return parsed.response - const validation = putCommentSchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } - - const { domain, accessToken, cloudId: providedCloudId, commentId, comment } = validation.data + const { domain, accessToken, cloudId: providedCloudId, commentId, comment } = parsed.data.body const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) @@ -147,15 +109,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - - const validation = deleteCommentSchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } + const parsed = await parseRequest(confluenceDeleteCommentContract, request, {}) + if (!parsed.success) return parsed.response - const { domain, accessToken, cloudId: providedCloudId, commentId } = validation.data + const { domain, accessToken, cloudId: providedCloudId, commentId } = parsed.data.body const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) diff --git a/apps/sim/app/api/tools/confluence/comments/route.ts b/apps/sim/app/api/tools/confluence/comments/route.ts index 7354ca7e6c8..88484f7853f 100644 --- a/apps/sim/app/api/tools/confluence/comments/route.ts +++ b/apps/sim/app/api/tools/confluence/comments/route.ts @@ -1,5 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { + confluenceCreateCommentContract, + confluenceListCommentsContract, +} from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,7 +23,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { domain, accessToken, cloudId: providedCloudId, pageId, comment } = await request.json() + const parsed = await parseRequest(confluenceCreateCommentContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, cloudId: providedCloudId, pageId, comment } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) @@ -102,14 +110,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const domain = searchParams.get('domain') - const accessToken = searchParams.get('accessToken') - const pageId = searchParams.get('pageId') - const providedCloudId = searchParams.get('cloudId') - const limit = searchParams.get('limit') || '25' - const bodyFormat = searchParams.get('bodyFormat') || 'storage' - const cursor = searchParams.get('cursor') + const parsed = await parseRequest(confluenceListCommentsContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + pageId, + cloudId: providedCloudId, + limit, + bodyFormat, + cursor, + } = parsed.data.query if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/create-page/route.ts b/apps/sim/app/api/tools/confluence/create-page/route.ts index 303897a0014..1fa8fd95836 100644 --- a/apps/sim/app/api/tools/confluence/create-page/route.ts +++ b/apps/sim/app/api/tools/confluence/create-page/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluenceCreatePageContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -17,6 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } + const parsed = await parseRequest(confluenceCreatePageContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -25,7 +30,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { title, content, parentId, - } = await request.json() + } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/labels/route.ts b/apps/sim/app/api/tools/confluence/labels/route.ts index b91a169ccf2..4d7316fb424 100644 --- a/apps/sim/app/api/tools/confluence/labels/route.ts +++ b/apps/sim/app/api/tools/confluence/labels/route.ts @@ -1,5 +1,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { + confluenceDeleteLabelContract, + confluenceLabelMutationContract, + confluenceListLabelsContract, +} from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,6 +24,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } + const parsed = await parseRequest(confluenceLabelMutationContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -25,7 +34,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { pageId, labelName, prefix: labelPrefix, - } = await request.json() + } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) @@ -113,13 +122,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const domain = searchParams.get('domain') - const accessToken = searchParams.get('accessToken') - const pageId = searchParams.get('pageId') - const providedCloudId = searchParams.get('cloudId') - const limit = searchParams.get('limit') || '25' - const cursor = searchParams.get('cursor') + const parsed = await parseRequest(confluenceListLabelsContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + pageId, + cloudId: providedCloudId, + limit, + cursor, + } = parsed.data.query if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) @@ -204,13 +217,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { - domain, - accessToken, - cloudId: providedCloudId, - pageId, - labelName, - } = await request.json() + const parsed = await parseRequest(confluenceDeleteLabelContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, cloudId: providedCloudId, pageId, labelName } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/page-ancestors/route.ts b/apps/sim/app/api/tools/confluence/page-ancestors/route.ts index 8373d603809..dd982c12afe 100644 --- a/apps/sim/app/api/tools/confluence/page-ancestors/route.ts +++ b/apps/sim/app/api/tools/confluence/page-ancestors/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluencePageAncestorsContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -21,8 +23,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { domain, accessToken, pageId, cloudId: providedCloudId, limit = 25 } = body + const parsed = await parseRequest(confluencePageAncestorsContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, pageId, cloudId: providedCloudId, limit } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/page-children/route.ts b/apps/sim/app/api/tools/confluence/page-children/route.ts index a4266b00aa1..e3b0f18bef2 100644 --- a/apps/sim/app/api/tools/confluence/page-children/route.ts +++ b/apps/sim/app/api/tools/confluence/page-children/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluencePageChildrenContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -21,8 +23,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { domain, accessToken, pageId, cloudId: providedCloudId, limit = 50, cursor } = body + const parsed = await parseRequest(confluencePageChildrenContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + pageId, + cloudId: providedCloudId, + limit, + cursor, + } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/page-descendants/route.ts b/apps/sim/app/api/tools/confluence/page-descendants/route.ts index 8993dca11b7..3e021760653 100644 --- a/apps/sim/app/api/tools/confluence/page-descendants/route.ts +++ b/apps/sim/app/api/tools/confluence/page-descendants/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluencePageDescendantsContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, @@ -25,8 +27,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { domain, accessToken, pageId, cloudId: providedCloudId, limit = 50, cursor } = body + const parsed = await parseRequest(confluencePageDescendantsContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + pageId, + cloudId: providedCloudId, + limit, + cursor, + } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/page-properties/route.ts b/apps/sim/app/api/tools/confluence/page-properties/route.ts index 0fc5fe25426..e2a19eb40ef 100644 --- a/apps/sim/app/api/tools/confluence/page-properties/route.ts +++ b/apps/sim/app/api/tools/confluence/page-properties/route.ts @@ -1,6 +1,12 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + confluenceCreatePagePropertyContract, + confluenceDeletePagePropertyContract, + confluenceListPagePropertiesContract, + confluenceUpdatePagePropertyContract, +} from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,34 +17,6 @@ const logger = createLogger('ConfluencePagePropertiesAPI') export const dynamic = 'force-dynamic' -const createPropertySchema = z.object({ - domain: z.string().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: z.string().optional(), - pageId: z.string().min(1, 'Page ID is required'), - key: z.string().min(1, 'Property key is required'), - value: z.any(), -}) - -const updatePropertySchema = z.object({ - domain: z.string().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: z.string().optional(), - pageId: z.string().min(1, 'Page ID is required'), - propertyId: z.string().min(1, 'Property ID is required'), - key: z.string().min(1, 'Property key is required'), - value: z.any(), - versionNumber: z.number().min(1, 'Version number is required'), -}) - -const deletePropertySchema = z.object({ - domain: z.string().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: z.string().optional(), - pageId: z.string().min(1, 'Page ID is required'), - propertyId: z.string().min(1, 'Property ID is required'), -}) - /** * List all content properties on a page. */ @@ -49,13 +27,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const domain = searchParams.get('domain') - const accessToken = searchParams.get('accessToken') - const pageId = searchParams.get('pageId') - const providedCloudId = searchParams.get('cloudId') - const limit = searchParams.get('limit') || '50' - const cursor = searchParams.get('cursor') + const parsed = await parseRequest(confluenceListPagePropertiesContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + pageId, + cloudId: providedCloudId, + limit, + cursor, + } = parsed.data.query if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) @@ -144,15 +126,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - - const validation = createPropertySchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } + const parsed = await parseRequest(confluenceCreatePagePropertyContract, request, {}) + if (!parsed.success) return parsed.response - const { domain, accessToken, cloudId: providedCloudId, pageId, key, value } = validation.data + const { domain, accessToken, cloudId: providedCloudId, pageId, key, value } = parsed.data.body const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255) if (!pageIdValidation.isValid) { @@ -218,13 +195,8 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - - const validation = updatePropertySchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } + const parsed = await parseRequest(confluenceUpdatePagePropertyContract, request, {}) + if (!parsed.success) return parsed.response const { domain, @@ -235,7 +207,7 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { key, value, versionNumber, - } = validation.data + } = parsed.data.body const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255) if (!pageIdValidation.isValid) { @@ -310,15 +282,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - - const validation = deletePropertySchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } + const parsed = await parseRequest(confluenceDeletePagePropertyContract, request, {}) + if (!parsed.success) return parsed.response - const { domain, accessToken, cloudId: providedCloudId, pageId, propertyId } = validation.data + const { domain, accessToken, cloudId: providedCloudId, pageId, propertyId } = parsed.data.body const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255) if (!pageIdValidation.isValid) { diff --git a/apps/sim/app/api/tools/confluence/page-versions/route.ts b/apps/sim/app/api/tools/confluence/page-versions/route.ts index 006791ef53e..7c424baaf06 100644 --- a/apps/sim/app/api/tools/confluence/page-versions/route.ts +++ b/apps/sim/app/api/tools/confluence/page-versions/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluencePageVersionsContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, @@ -27,7 +29,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() + const parsed = await parseRequest(confluencePageVersionsContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -36,7 +40,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { cloudId: providedCloudId, limit = 50, cursor, - } = body + } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/page/route.ts b/apps/sim/app/api/tools/confluence/page/route.ts index 8b4cbf35b83..5adfe362bc4 100644 --- a/apps/sim/app/api/tools/confluence/page/route.ts +++ b/apps/sim/app/api/tools/confluence/page/route.ts @@ -1,8 +1,13 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + confluenceDeletePageContract, + confluencePageSelectorContract, + confluenceUpdatePageContract, +} from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -11,72 +16,6 @@ const logger = createLogger('ConfluencePageAPI') export const dynamic = 'force-dynamic' -const postPageSchema = z - .object({ - domain: z.string().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: z.string().optional(), - pageId: z.string().min(1, 'Page ID is required'), - }) - .refine( - (data) => { - const validation = validateAlphanumericId(data.pageId, 'pageId', 255) - return validation.isValid - }, - (data) => { - const validation = validateAlphanumericId(data.pageId, 'pageId', 255) - return { message: validation.error || 'Invalid page ID', path: ['pageId'] } - } - ) - -const putPageSchema = z - .object({ - domain: z.string().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: z.string().optional(), - pageId: z.string().min(1, 'Page ID is required'), - title: z.string().optional(), - body: z - .object({ - value: z.string().optional(), - }) - .optional(), - version: z - .object({ - message: z.string().optional(), - }) - .optional(), - }) - .refine( - (data) => { - const validation = validateAlphanumericId(data.pageId, 'pageId', 255) - return validation.isValid - }, - (data) => { - const validation = validateAlphanumericId(data.pageId, 'pageId', 255) - return { message: validation.error || 'Invalid page ID', path: ['pageId'] } - } - ) - -const deletePageSchema = z - .object({ - domain: z.string().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: z.string().optional(), - pageId: z.string().min(1, 'Page ID is required'), - purge: z.boolean().optional(), - }) - .refine( - (data) => { - const validation = validateAlphanumericId(data.pageId, 'pageId', 255) - return validation.isValid - }, - (data) => { - const validation = validateAlphanumericId(data.pageId, 'pageId', 255) - return { message: validation.error || 'Invalid page ID', path: ['pageId'] } - } - ) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) @@ -84,15 +23,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() + const parsed = await parseRequest(confluencePageSelectorContract, request, {}) + if (!parsed.success) return parsed.response - const validation = postPageSchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } - - const { domain, accessToken, cloudId: providedCloudId, pageId } = validation.data + const { domain, accessToken, cloudId: providedCloudId, pageId } = parsed.data.body const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) @@ -159,13 +93,8 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - - const validation = putPageSchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } + const parsed = await parseRequest(confluenceUpdatePageContract, request, {}) + if (!parsed.success) return parsed.response const { domain, @@ -175,7 +104,7 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { title, body: pageBody, version, - } = validation.data + } = parsed.data.body const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) @@ -274,15 +203,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - - const validation = deletePageSchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } + const parsed = await parseRequest(confluenceDeletePageContract, request, {}) + if (!parsed.success) return parsed.response - const { domain, accessToken, cloudId: providedCloudId, pageId, purge } = validation.data + const { domain, accessToken, cloudId: providedCloudId, pageId, purge } = parsed.data.body const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) diff --git a/apps/sim/app/api/tools/confluence/pages-by-label/route.ts b/apps/sim/app/api/tools/confluence/pages-by-label/route.ts index 26a4a938de0..5d50f49cb3b 100644 --- a/apps/sim/app/api/tools/confluence/pages-by-label/route.ts +++ b/apps/sim/app/api/tools/confluence/pages-by-label/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluencePagesByLabelContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -17,13 +19,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const domain = searchParams.get('domain') - const accessToken = searchParams.get('accessToken') - const labelId = searchParams.get('labelId') - const providedCloudId = searchParams.get('cloudId') - const limit = searchParams.get('limit') || '50' - const cursor = searchParams.get('cursor') + const parsed = await parseRequest(confluencePagesByLabelContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + labelId, + cloudId: providedCloudId, + limit, + cursor, + } = parsed.data.query if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/pages/route.ts b/apps/sim/app/api/tools/confluence/pages/route.ts index 1c970b596ae..7d470190c42 100644 --- a/apps/sim/app/api/tools/confluence/pages/route.ts +++ b/apps/sim/app/api/tools/confluence/pages/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluencePagesSelectorContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,21 +20,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { - domain, - accessToken, - title, - cloudId: providedCloudId, - limit = 50, - } = await request.json() + const parsed = await parseRequest(confluencePagesSelectorContract, request, {}) + if (!parsed.success) return parsed.response - if (!domain) { - return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) - } - - if (!accessToken) { - return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) - } + const { domain, accessToken, title, cloudId: providedCloudId, limit } = parsed.data.body const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) diff --git a/apps/sim/app/api/tools/confluence/search-in-space/route.ts b/apps/sim/app/api/tools/confluence/search-in-space/route.ts index b65607c1236..3be5877f6ed 100644 --- a/apps/sim/app/api/tools/confluence/search-in-space/route.ts +++ b/apps/sim/app/api/tools/confluence/search-in-space/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluenceSearchInSpaceContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -20,7 +22,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() + const parsed = await parseRequest(confluenceSearchInSpaceContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -29,7 +33,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { cloudId: providedCloudId, limit = 25, contentType, - } = body + } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/search/route.ts b/apps/sim/app/api/tools/confluence/search/route.ts index 5b0bb8aed23..a78ecdfc01a 100644 --- a/apps/sim/app/api/tools/confluence/search/route.ts +++ b/apps/sim/app/api/tools/confluence/search/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluenceSearchContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -17,13 +19,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { - domain, - accessToken, - cloudId: providedCloudId, - query, - limit = 25, - } = await request.json() + const parsed = await parseRequest(confluenceSearchContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, cloudId: providedCloudId, query, limit } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) 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 42e539710ce..59a674df87a 100644 --- a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts +++ b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { confluenceSpacesSelectorContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -12,11 +14,13 @@ const logger = createLogger('ConfluenceSelectorSpacesAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId, domain } = body + const parsed = await parseRequest(confluenceSpacesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + + const { credential, workflowId, domain } = parsed.data.body if (!credential) { logger.error('Missing credential in request') @@ -27,7 +31,7 @@ export const POST = withRouteHandler(async (request: Request) => { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) } - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) diff --git a/apps/sim/app/api/tools/confluence/space-blogposts/route.ts b/apps/sim/app/api/tools/confluence/space-blogposts/route.ts index 46907131535..291af150d89 100644 --- a/apps/sim/app/api/tools/confluence/space-blogposts/route.ts +++ b/apps/sim/app/api/tools/confluence/space-blogposts/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluenceSpaceBlogPostsContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -21,7 +23,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() + const parsed = await parseRequest(confluenceSpaceBlogPostsContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -31,7 +35,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { status, bodyFormat, cursor, - } = body + } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/space-labels/route.ts b/apps/sim/app/api/tools/confluence/space-labels/route.ts index 5ba96d11161..3a1cb43b15b 100644 --- a/apps/sim/app/api/tools/confluence/space-labels/route.ts +++ b/apps/sim/app/api/tools/confluence/space-labels/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluenceSpaceLabelsContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -17,13 +19,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const domain = searchParams.get('domain') - const accessToken = searchParams.get('accessToken') - const spaceId = searchParams.get('spaceId') - const providedCloudId = searchParams.get('cloudId') - const limit = searchParams.get('limit') || '25' - const cursor = searchParams.get('cursor') + const parsed = await parseRequest(confluenceSpaceLabelsContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + spaceId, + cloudId: providedCloudId, + limit, + cursor, + } = parsed.data.query if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/space-pages/route.ts b/apps/sim/app/api/tools/confluence/space-pages/route.ts index 6dd488f982c..05b07b2bb68 100644 --- a/apps/sim/app/api/tools/confluence/space-pages/route.ts +++ b/apps/sim/app/api/tools/confluence/space-pages/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluenceSpacePagesContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -21,7 +23,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() + const parsed = await parseRequest(confluenceSpacePagesContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -31,7 +35,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { status, bodyFormat, cursor, - } = body + } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/space-permissions/route.ts b/apps/sim/app/api/tools/confluence/space-permissions/route.ts index a10161d69cd..c18179eca80 100644 --- a/apps/sim/app/api/tools/confluence/space-permissions/route.ts +++ b/apps/sim/app/api/tools/confluence/space-permissions/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluenceSpacePermissionsContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, @@ -25,8 +27,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { domain, accessToken, spaceId, cloudId: providedCloudId, limit = 50, cursor } = body + const parsed = await parseRequest(confluenceSpacePermissionsContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + spaceId, + cloudId: providedCloudId, + limit, + cursor, + } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/space-properties/route.ts b/apps/sim/app/api/tools/confluence/space-properties/route.ts index 1030da77be8..3fb5a64c2cf 100644 --- a/apps/sim/app/api/tools/confluence/space-properties/route.ts +++ b/apps/sim/app/api/tools/confluence/space-properties/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluenceSpacePropertiesContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, @@ -26,7 +28,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() + const parsed = await parseRequest(confluenceSpacePropertiesContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -38,7 +42,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { propertyId, limit = 50, cursor, - } = body + } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/space/route.ts b/apps/sim/app/api/tools/confluence/space/route.ts index bbdd9c597fc..61526cfd138 100644 --- a/apps/sim/app/api/tools/confluence/space/route.ts +++ b/apps/sim/app/api/tools/confluence/space/route.ts @@ -1,5 +1,12 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { + confluenceCreateSpaceContract, + confluenceDeleteSpaceContract, + confluenceGetSpaceContract, + confluenceUpdateSpaceContract, +} from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,11 +25,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const domain = searchParams.get('domain') - const accessToken = searchParams.get('accessToken') - const spaceId = searchParams.get('spaceId') - const providedCloudId = searchParams.get('cloudId') + const parsed = await parseRequest(confluenceGetSpaceContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, spaceId, cloudId: providedCloudId } = parsed.data.query if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) @@ -93,8 +99,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { domain, accessToken, name, key, description, cloudId: providedCloudId } = body + const parsed = await parseRequest(confluenceCreateSpaceContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + name, + key, + description, + cloudId: providedCloudId, + } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) @@ -173,8 +188,17 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { domain, accessToken, spaceId, name, description, cloudId: providedCloudId } = body + const parsed = await parseRequest(confluenceUpdateSpaceContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + spaceId, + name, + description, + cloudId: providedCloudId, + } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) @@ -288,8 +312,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { domain, accessToken, spaceId, cloudId: providedCloudId } = body + const parsed = await parseRequest(confluenceDeleteSpaceContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, spaceId, cloudId: providedCloudId } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/spaces/route.ts b/apps/sim/app/api/tools/confluence/spaces/route.ts index 2f8517ef638..6346c1345b3 100644 --- a/apps/sim/app/api/tools/confluence/spaces/route.ts +++ b/apps/sim/app/api/tools/confluence/spaces/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluenceListSpacesContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,12 +20,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const domain = searchParams.get('domain') - const accessToken = searchParams.get('accessToken') - const providedCloudId = searchParams.get('cloudId') - const limit = searchParams.get('limit') || '25' - const cursor = searchParams.get('cursor') + const parsed = await parseRequest(confluenceListSpacesContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, cloudId: providedCloudId, limit, cursor } = parsed.data.query if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/tasks/route.ts b/apps/sim/app/api/tools/confluence/tasks/route.ts index 4aade319d5d..923209fcbf7 100644 --- a/apps/sim/app/api/tools/confluence/tasks/route.ts +++ b/apps/sim/app/api/tools/confluence/tasks/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluenceTasksContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, @@ -26,7 +28,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() + const parsed = await parseRequest(confluenceTasksContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -39,7 +43,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { assignedTo, limit = 50, cursor, - } = body + } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/confluence/upload-attachment/route.ts b/apps/sim/app/api/tools/confluence/upload-attachment/route.ts index e8cf09c43d1..713ee1a011a 100644 --- a/apps/sim/app/api/tools/confluence/upload-attachment/route.ts +++ b/apps/sim/app/api/tools/confluence/upload-attachment/route.ts @@ -1,9 +1,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluenceUploadAttachmentContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' +import { processSingleFileToUserFile, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { getConfluenceCloudId } from '@/tools/confluence/utils' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -19,8 +21,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { domain, accessToken, cloudId: providedCloudId, pageId, file, fileName, comment } = body + const parsed = await parseRequest(confluenceUploadAttachmentContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: providedCloudId, + pageId, + file, + fileName, + comment, + } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) @@ -50,12 +62,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } - let fileToProcess = file + let fileToProcess = file as RawFileInput if (Array.isArray(file)) { if (file.length === 0) { return NextResponse.json({ error: 'No file provided' }, { status: 400 }) } - fileToProcess = file[0] + fileToProcess = file[0] as RawFileInput } let userFile diff --git a/apps/sim/app/api/tools/confluence/user/route.ts b/apps/sim/app/api/tools/confluence/user/route.ts index ec208d8b7cb..f761d0286e6 100644 --- a/apps/sim/app/api/tools/confluence/user/route.ts +++ b/apps/sim/app/api/tools/confluence/user/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { confluenceUserContract } from '@/lib/api/contracts/selectors/confluence' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validatePathSegment } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -21,8 +23,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { domain, accessToken, accountId, cloudId: providedCloudId } = body + const parsed = await parseRequest(confluenceUserContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, accountId, cloudId: providedCloudId } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/crowdstrike/query/route.ts b/apps/sim/app/api/tools/crowdstrike/query/route.ts index ca4454e0f73..280775b3b41 100644 --- a/apps/sim/app/api/tools/crowdstrike/query/route.ts +++ b/apps/sim/app/api/tools/crowdstrike/query/route.ts @@ -1,102 +1,23 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { crowdstrikeQueryContract } from '@/lib/api/contracts/tools/crowdstrike' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { CrowdStrikeAggregateQuery, + CrowdStrikeBaseParams, CrowdStrikeCloud, + CrowdStrikeQuerySensorsParams, CrowdStrikeSensorAggregateBucket, CrowdStrikeSensorAggregateResult, } from '@/tools/crowdstrike/types' const logger = createLogger('CrowdStrikeIdentityProtectionAPI') -const CROWDSTRIKE_CLOUDS = ['us-1', 'us-2', 'eu-1', 'us-gov-1', 'us-gov-2'] as const - type JsonRecord = Record -const BaseRequestSchema = z.object({ - clientId: z.string().min(1, 'Client ID is required'), - clientSecret: z.string().min(1, 'Client Secret is required'), - cloud: z.enum(CROWDSTRIKE_CLOUDS), -}) - -const DateRangeSchema = z.object({ - from: z.string(), - to: z.string(), -}) - -const ExtendedBoundsSchema = z.object({ - max: z.string(), - min: z.string(), -}) - -const RangeSpecSchema = z.object({ - from: z.number(), - to: z.number(), -}) - -const AggregateQuerySchema: z.ZodType = z.lazy(() => - z.object({ - date_ranges: z.array(DateRangeSchema).optional(), - exclude: z.string().optional(), - extended_bounds: ExtendedBoundsSchema.optional(), - field: z.string().optional(), - filter: z.string().optional(), - from: z.number().int().nonnegative().optional(), - include: z.string().optional(), - interval: z.string().optional(), - max_doc_count: z.number().int().nonnegative().optional(), - min_doc_count: z.number().int().nonnegative().optional(), - missing: z.string().optional(), - name: z.string().optional(), - q: z.string().optional(), - ranges: z.array(RangeSpecSchema).optional(), - size: z.number().int().nonnegative().optional(), - sort: z.string().optional(), - sub_aggregates: z.array(AggregateQuerySchema).optional(), - time_zone: z.string().optional(), - type: z.string().optional(), - }) -) - -const QuerySensorsSchema = BaseRequestSchema.extend({ - operation: z.literal('crowdstrike_query_sensors'), - filter: z.string().optional(), - limit: z - .number() - .int() - .min(1, 'Limit must be at least 1') - .max(200, 'Limit must be at most 200') - .optional(), - offset: z.number().int().nonnegative('Offset must be 0 or greater').optional(), - sort: z.string().optional(), -}) - -const GetSensorDetailsSchema = BaseRequestSchema.extend({ - operation: z.literal('crowdstrike_get_sensor_details'), - ids: z - .array(z.string().trim().min(1, 'Sensor IDs must not be empty')) - .min(1, 'At least one sensor ID is required') - .max(5000, 'CrowdStrike supports up to 5000 sensor IDs per request'), -}) - -const GetSensorAggregatesSchema = BaseRequestSchema.extend({ - operation: z.literal('crowdstrike_get_sensor_aggregates'), - aggregateQuery: AggregateQuerySchema, -}) - -const RequestSchema = z.discriminatedUnion('operation', [ - QuerySensorsSchema, - GetSensorDetailsSchema, - GetSensorAggregatesSchema, -]) - -type CrowdStrikeAuthRequest = z.infer -type CrowdStrikeQuerySensorsRequest = z.infer - function getCloudBaseUrl(cloud: CrowdStrikeCloud): string { const cloudMap: Record = { 'eu-1': 'https://api.eu-1.crowdstrike.com', @@ -201,7 +122,7 @@ function getErrorMessage(data: unknown, fallback: string): string { ) } -function buildQueryUrl(baseUrl: string, params: CrowdStrikeQuerySensorsRequest): string { +function buildQueryUrl(baseUrl: string, params: CrowdStrikeQuerySensorsParams): string { const url = new URL(baseUrl) url.pathname = '/identity-protection/queries/devices/v1' @@ -236,7 +157,7 @@ function buildSensorAggregatesUrl(baseUrl: string): string { return url.toString() } -async function getAccessToken(params: CrowdStrikeAuthRequest): Promise { +async function getAccessToken(params: CrowdStrikeBaseParams): Promise { const baseUrl = getCloudBaseUrl(params.cloud) const response = await fetch(`${baseUrl}/oauth2/token`, { method: 'POST', @@ -360,8 +281,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const rawBody: unknown = await request.json() - const params = RequestSchema.parse(rawBody) + const parsed = await parseRequest( + crowdstrikeQueryContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body const baseUrl = getCloudBaseUrl(params.cloud) const accessToken = await getAccessToken(params) @@ -468,17 +405,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: normalizeAggregatesOutput(aggregateData), }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { - success: false, - error: error.errors[0]?.message ?? 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - const message = error instanceof Error ? error.message : 'Unknown error' logger.error(`[${requestId}] CrowdStrike request failed`, { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) diff --git a/apps/sim/app/api/tools/cursor/download-artifact/route.ts b/apps/sim/app/api/tools/cursor/download-artifact/route.ts index 329e1bcb65e..4f7fffd0309 100644 --- a/apps/sim/app/api/tools/cursor/download-artifact/route.ts +++ b/apps/sim/app/api/tools/cursor/download-artifact/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { cursorDownloadArtifactContract } from '@/lib/api/contracts/internal-tools' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -13,12 +14,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('CursorDownloadArtifactAPI') -const DownloadArtifactSchema = z.object({ - apiKey: z.string().min(1, 'API key is required'), - agentId: z.string().min(1, 'Agent ID is required'), - path: z.string().min(1, 'Artifact path is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -42,8 +37,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const { apiKey, agentId, path } = DownloadArtifactSchema.parse(body) + const parsed = await parseRequest( + cursorDownloadArtifactContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + const { apiKey, agentId, path } = parsed.data.body const authHeader = `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}` diff --git a/apps/sim/app/api/tools/custom/route.ts b/apps/sim/app/api/tools/custom/route.ts index 9145c585524..6ea7c199b63 100644 --- a/apps/sim/app/api/tools/custom/route.ts +++ b/apps/sim/app/api/tools/custom/route.ts @@ -5,7 +5,12 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + deleteCustomToolContract, + listCustomToolsContract, + upsertCustomToolsContract, +} from '@/lib/api/contracts/custom-tools' +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' @@ -15,48 +20,23 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CustomToolsAPI') -const CustomToolSchema = z.object({ - tools: z.array( - z.object({ - id: z.string().optional(), - title: z.string().min(1, 'Tool title is required'), - schema: z.object({ - type: z.literal('function'), - function: z.object({ - name: z.string().min(1, 'Function name is required'), - description: z.string().optional(), - parameters: z.object({ - type: z.string(), - properties: z.record(z.any()), - required: z.array(z.string()).optional(), - }), - }), - }), - code: z.string(), - }) - ), - workspaceId: z.string().optional(), - source: z.enum(['settings', 'tool_input']).optional(), -}) - -// GET - Fetch all custom tools for the workspace export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() - const searchParams = request.nextUrl.searchParams - const workspaceId = searchParams.get('workspaceId') - const workflowId = searchParams.get('workflowId') try { - // Use session/internal auth to support session and internal JWT (no API key access) const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized custom tools access attempt`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const parsed = await parseRequest(listCustomToolsContract, request, {}) + if (!parsed.success) return parsed.response + const userId = authResult.userId + const { workspaceId, workflowId } = parsed.data.query - let resolvedWorkspaceId: string | null = workspaceId + let resolvedWorkspaceId: string | null = workspaceId ?? null let resolvedFromWorkflowAuthorization = false if (!resolvedWorkspaceId && workflowId) { @@ -81,7 +61,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { resolvedFromWorkflowAuthorization = true } - // Check workspace permissions for all auth types if (resolvedWorkspaceId && !resolvedFromWorkflowAuthorization) { const userPermission = await getUserEntityPermissions( userId, @@ -96,16 +75,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } } - // Build query to fetch tools - // 1. Workspace-scoped tools: tools with matching workspaceId - // 2. User-scoped legacy tools: tools with null workspaceId and matching userId const conditions = [] if (resolvedWorkspaceId) { conditions.push(eq(customTools.workspaceId, resolvedWorkspaceId)) } - // Always include legacy user-scoped tools for backward compatibility conditions.push(and(isNull(customTools.workspaceId), eq(customTools.userId, userId))) const result = await db @@ -121,93 +96,90 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } }) -// POST - Create or update custom tools export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() try { - // Use session/internal auth (no API key access) const authResult = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized custom tools update attempt`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const parsed = await parseRequest( + upsertCustomToolsContract, + req, + {}, + { + invalidJson: 'throw', + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid custom tools data`, { errors: error.issues }) + return NextResponse.json( + { + error: 'Invalid request data', + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const userId = authResult.userId - const body = await req.json() + const { tools, workspaceId, source } = parsed.data.body - try { - // Validate the request body - const { tools, workspaceId, source } = CustomToolSchema.parse(body) + if (!workspaceId) { + logger.warn(`[${requestId}] Missing workspaceId in request body`) + return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) + } - if (!workspaceId) { - logger.warn(`[${requestId}] Missing workspaceId in request body`) - return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) - } + const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!userPermission) { + logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } - // Check workspace permissions - const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!userPermission) { - logger.warn( - `[${requestId}] User ${userId} does not have access to workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } + if (userPermission !== 'admin' && userPermission !== 'write') { + logger.warn( + `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) + } - // Check write permission - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) - } + const resultTools = await upsertCustomTools({ + tools, + workspaceId, + userId, + requestId, + }) - // Use the extracted upsert function - const resultTools = await upsertCustomTools({ - tools, - workspaceId, + for (const tool of resultTools) { + captureServerEvent( userId, - requestId, - }) - - for (const tool of resultTools) { - captureServerEvent( - userId, - 'custom_tool_saved', - { tool_id: tool.id, workspace_id: workspaceId, tool_name: tool.title, source }, - { - groups: { workspace: workspaceId }, - setOnce: { first_custom_tool_saved_at: new Date().toISOString() }, - } - ) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: authResult.userName ?? undefined, - actorEmail: authResult.userEmail ?? undefined, - action: AuditAction.CUSTOM_TOOL_CREATED, - resourceType: AuditResourceType.CUSTOM_TOOL, - resourceId: tool.id, - resourceName: tool.title, - description: `Created/updated custom tool "${tool.title}"`, - metadata: { source }, - }) - } + 'custom_tool_saved', + { tool_id: tool.id, workspace_id: workspaceId, tool_name: tool.title, source }, + { + groups: { workspace: workspaceId }, + setOnce: { first_custom_tool_saved_at: new Date().toISOString() }, + } + ) - return NextResponse.json({ success: true, data: resultTools }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid custom tools data`, { - errors: validationError.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) - } - throw validationError + recordAudit({ + workspaceId, + actorId: userId, + actorName: authResult.userName ?? undefined, + actorEmail: authResult.userEmail ?? undefined, + action: AuditAction.CUSTOM_TOOL_CREATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: tool.id, + resourceName: tool.title, + description: `Created/updated custom tool "${tool.title}"`, + metadata: { source }, + }) } + + return NextResponse.json({ success: true, data: resultTools }) } catch (error) { logger.error(`[${requestId}] Error updating custom tools`, error) const errorMessage = error instanceof Error ? error.message : 'Failed to update custom tools' @@ -215,23 +187,30 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } }) -// DELETE - Delete a custom tool by ID export const DELETE = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() - const searchParams = request.nextUrl.searchParams - const toolId = searchParams.get('id') - const workspaceId = searchParams.get('workspaceId') - const sourceParam = searchParams.get('source') - const source = - sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined - - if (!toolId) { - logger.warn(`[${requestId}] Missing tool ID for deletion`) - return NextResponse.json({ error: 'Tool ID is required' }, { status: 400 }) - } + const parsed = await parseRequest( + deleteCustomToolContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Missing tool ID for deletion`) + return NextResponse.json( + { + error: 'Tool ID is required', + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + + const { id: toolId, workspaceId, source } = parsed.data.query try { - // Use session/internal auth (no API key access) const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized custom tool deletion attempt`) @@ -240,7 +219,6 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { const userId = authResult.userId - // Check if the tool exists const existingTool = await db .select() .from(customTools) @@ -254,14 +232,12 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { const tool = existingTool[0] - // Handle workspace-scoped tools if (tool.workspaceId) { if (!workspaceId) { logger.warn(`[${requestId}] Missing workspaceId for workspace-scoped tool`) return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) } - // Check workspace permissions const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!userPermission) { logger.warn( @@ -270,7 +246,6 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - // Check write permission if (userPermission !== 'admin' && userPermission !== 'write') { logger.warn( `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` @@ -278,23 +253,17 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) } - // Verify tool belongs to this workspace if (tool.workspaceId !== workspaceId) { logger.warn(`[${requestId}] Tool ${toolId} does not belong to workspace ${workspaceId}`) return NextResponse.json({ error: 'Tool not found' }, { status: 404 }) } - } else { - // Handle legacy user-scoped tools (no workspaceId) - // Only allow deletion if user owns the tool - if (tool.userId !== userId) { - logger.warn( - `[${requestId}] User ${userId} attempted to delete tool they don't own: ${toolId}` - ) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } + } else if (tool.userId !== userId) { + logger.warn( + `[${requestId}] User ${userId} attempted to delete tool they don't own: ${toolId}` + ) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - // Delete the tool await db.delete(customTools).where(eq(customTools.id, toolId)) const toolWorkspaceId = tool.workspaceId ?? workspaceId ?? '' diff --git a/apps/sim/app/api/tools/discord/channels/route.ts b/apps/sim/app/api/tools/discord/channels/route.ts index e254b7e1a5c..354539ce155 100644 --- a/apps/sim/app/api/tools/discord/channels/route.ts +++ b/apps/sim/app/api/tools/discord/channels/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { discordChannelsBodySchema } from '@/lib/api/contracts' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateNumericId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -22,17 +24,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const { botToken, serverId, channelId } = await request.json() - - if (!botToken) { - logger.error('Missing bot token in request') - return NextResponse.json({ error: 'Bot token is required' }, { status: 400 }) - } - - if (!serverId) { - logger.error('Missing server ID in request') - return NextResponse.json({ error: 'Server ID is required' }, { status: 400 }) + const validation = await validateJsonBody(request, discordChannelsBodySchema) + if (!validation.success) { + if (!validation.error) return validation.response + return NextResponse.json( + { + error: getValidationErrorMessage(validation.error, 'Invalid request data'), + details: validation.error.issues, + }, + { status: 400 } + ) } + const { botToken, serverId, channelId } = validation.data const serverIdValidation = validateNumericId(serverId, 'serverId') if (!serverIdValidation.isValid) { diff --git a/apps/sim/app/api/tools/discord/send-message/route.ts b/apps/sim/app/api/tools/discord/send-message/route.ts index 3af7e2876bc..3f153e0fde4 100644 --- a/apps/sim/app/api/tools/discord/send-message/route.ts +++ b/apps/sim/app/api/tools/discord/send-message/route.ts @@ -1,11 +1,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { discordSendMessageBodySchema } from '@/lib/api/contracts' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateNumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -13,13 +13,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('DiscordSendMessageAPI') -const DiscordSendMessageSchema = z.object({ - botToken: z.string().min(1, 'Bot token is required'), - channelId: z.string().min(1, 'Channel ID is required'), - content: z.string().optional().nullable(), - files: RawFileInputArraySchema.optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -41,8 +34,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = DiscordSendMessageSchema.parse(body) + const validation = await validateJsonBody(request, discordSendMessageBodySchema) + if (!validation.success) { + if (!validation.error) return validation.response + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request data'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data const channelIdValidation = validateNumericId(validatedData.channelId, 'channelId') if (!channelIdValidation.isValid) { diff --git a/apps/sim/app/api/tools/discord/servers/route.ts b/apps/sim/app/api/tools/discord/servers/route.ts index e853e3e7291..8eb09e706b6 100644 --- a/apps/sim/app/api/tools/discord/servers/route.ts +++ b/apps/sim/app/api/tools/discord/servers/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { discordServersBodySchema } from '@/lib/api/contracts' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateNumericId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -21,12 +23,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const { botToken, serverId } = await request.json() - - if (!botToken) { - logger.error('Missing bot token in request') - return NextResponse.json({ error: 'Bot token is required' }, { status: 400 }) + const validation = await validateJsonBody(request, discordServersBodySchema) + if (!validation.success) { + if (!validation.error) return validation.response + return NextResponse.json( + { + error: getValidationErrorMessage(validation.error, 'Invalid request data'), + details: validation.error.issues, + }, + { status: 400 } + ) } + const { botToken, serverId } = validation.data if (serverId) { const serverIdValidation = validateNumericId(serverId, 'serverId') diff --git a/apps/sim/app/api/tools/docusign/route.ts b/apps/sim/app/api/tools/docusign/route.ts index fa7344928a2..b64610fa58c 100644 --- a/apps/sim/app/api/tools/docusign/route.ts +++ b/apps/sim/app/api/tools/docusign/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { docusignToolContract } from '@/lib/api/contracts/internal-tools' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' @@ -56,16 +58,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { accessToken, operation, ...params } = body - - if (!accessToken) { - return NextResponse.json({ success: false, error: 'Access token is required' }, { status: 400 }) - } + const parsed = await parseRequest( + docusignToolContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Invalid request data') }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response - if (!operation) { - return NextResponse.json({ success: false, error: 'Operation is required' }, { status: 400 }) - } + const { accessToken, operation, ...params } = parsed.data.body try { const account = await resolveAccount(accessToken) diff --git a/apps/sim/app/api/tools/drive/file/route.ts b/apps/sim/app/api/tools/drive/file/route.ts index 0b1ad0fc675..85af8e72bc7 100644 --- a/apps/sim/app/api/tools/drive/file/route.ts +++ b/apps/sim/app/api/tools/drive/file/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { googleDriveFileSelectorContract } from '@/lib/api/contracts/selectors/google' +import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' @@ -24,16 +26,25 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } try { - const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const fileId = searchParams.get('fileId') - const workflowId = searchParams.get('workflowId') || undefined - const impersonateEmail = searchParams.get('impersonateEmail') || undefined - - if (!credentialId || !fileId) { - logger.warn(`[${requestId}] Missing required parameters`) - return NextResponse.json({ error: 'Credential ID and File ID are required' }, { status: 400 }) - } + const parsed = await parseRequest( + googleDriveFileSelectorContract, + request, + {}, + { + validationErrorResponse: () => { + logger.warn(`[${requestId}] Missing required parameters`) + return NextResponse.json( + { error: 'Credential ID and File ID are required' }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + + const { credentialId, fileId } = parsed.data.query + const workflowId = parsed.data.query.workflowId || undefined + const impersonateEmail = parsed.data.query.impersonateEmail || undefined const fileIdValidation = validateAlphanumericId(fileId, 'fileId', 255) if (!fileIdValidation.isValid) { @@ -41,7 +52,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: fileIdValidation.error }, { status: 400 }) } - const authz = await authorizeCredentialUse(request, { credentialId: credentialId, workflowId }) + const authz = await authorizeCredentialUse(request, { credentialId, workflowId }) if (!authz.ok || !authz.credentialOwnerUserId) { return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } diff --git a/apps/sim/app/api/tools/drive/files/route.ts b/apps/sim/app/api/tools/drive/files/route.ts index 64721fe7a6c..773fd2ae971 100644 --- a/apps/sim/app/api/tools/drive/files/route.ts +++ b/apps/sim/app/api/tools/drive/files/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { googleDriveFilesSelectorContract } from '@/lib/api/contracts/selectors/google' +import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' @@ -31,7 +33,7 @@ interface DriveFile { createdTime?: string modifiedTime?: string size?: string - owners?: any[] + owners?: unknown[] parents?: string[] } @@ -81,27 +83,33 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } try { - const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const mimeType = searchParams.get('mimeType') - const query = searchParams.get('query') || '' - const folderId = searchParams.get('folderId') || searchParams.get('parentId') || '' - const workflowId = searchParams.get('workflowId') || undefined - const impersonateEmail = searchParams.get('impersonateEmail') || undefined - - if (!credentialId) { - logger.warn(`[${requestId}] Missing credential ID`) - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) - } + const parsed = await parseRequest( + googleDriveFilesSelectorContract, + request, + {}, + { + validationErrorResponse: () => { + logger.warn(`[${requestId}] Missing credential ID`) + return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + }, + } + ) + if (!parsed.success) return parsed.response + + const { credentialId, mimeType } = parsed.data.query + const query = parsed.data.query.query || '' + const folderId = parsed.data.query.folderId || parsed.data.query.parentId || '' + const workflowId = parsed.data.query.workflowId || undefined + const impersonateEmail = parsed.data.query.impersonateEmail || undefined - const authz = await authorizeCredentialUse(request, { credentialId: credentialId!, workflowId }) + const authz = await authorizeCredentialUse(request, { credentialId, workflowId }) if (!authz.ok || !authz.credentialOwnerUserId) { logger.warn(`[${requestId}] Unauthorized credential access attempt`, authz) return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } const accessToken = await refreshAccessTokenIfNeeded( - credentialId!, + credentialId, authz.credentialOwnerUserId, requestId, getScopesForService('google-drive'), diff --git a/apps/sim/app/api/tools/dropbox/upload/route.ts b/apps/sim/app/api/tools/dropbox/upload/route.ts index 7bd4a888c4a..055ccb140ae 100644 --- a/apps/sim/app/api/tools/dropbox/upload/route.ts +++ b/apps/sim/app/api/tools/dropbox/upload/route.ts @@ -1,11 +1,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { dropboxUploadContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { httpHeaderSafeJson } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -13,18 +13,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('DropboxUploadAPI') -const DropboxUploadSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - path: z.string().min(1, 'Destination path is required'), - file: FileInputSchema.optional().nullable(), - // Legacy field for backwards compatibility - fileContent: z.string().optional().nullable(), - fileName: z.string().optional().nullable(), - mode: z.enum(['add', 'overwrite']).optional().nullable(), - autorename: z.boolean().optional().nullable(), - mute: z.boolean().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -41,8 +29,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Authenticated Dropbox upload request via ${authResult.authType}`) - const body = await request.json() - const validatedData = DropboxUploadSchema.parse(body) + const parsed = await parseRequest(dropboxUploadContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body let fileBuffer: Buffer let fileName: string @@ -116,14 +105,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Validation error:`, error.errors) - return NextResponse.json( - { success: false, error: error.errors[0]?.message || 'Validation failed' }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Unexpected error:`, error) return NextResponse.json( { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, diff --git a/apps/sim/app/api/tools/dynamodb/delete/route.ts b/apps/sim/app/api/tools/dynamodb/delete/route.ts index f51c9704c56..593480d5c0b 100644 --- a/apps/sim/app/api/tools/dynamodb/delete/route.ts +++ b/apps/sim/app/api/tools/dynamodb/delete/route.ts @@ -1,32 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsDynamodbDeleteContract } from '@/lib/api/contracts/tools/aws/dynamodb-delete' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDynamoDBClient, deleteItem } from '@/app/api/tools/dynamodb/utils' const logger = createLogger('DynamoDBDeleteAPI') -const DeleteSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - tableName: z.string().min(1, 'Table name is required'), - key: z.record(z.unknown()).refine((val) => Object.keys(val).length > 0, { - message: 'Key is required', - }), - conditionExpression: z.string().optional(), - expressionAttributeNames: z.record(z.string()).optional(), - expressionAttributeValues: z.record(z.unknown()).optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -34,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = DeleteSchema.parse(body) + const parsed = await parseAwsToolRequest(awsDynamodbDeleteContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`Deleting item from table '${validatedData.tableName}'`) @@ -61,13 +47,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = toError(error).message || 'DynamoDB delete failed' logger.error('DynamoDB delete failed:', error) return NextResponse.json({ error: errorMessage }, { status: 500 }) diff --git a/apps/sim/app/api/tools/dynamodb/get/route.ts b/apps/sim/app/api/tools/dynamodb/get/route.ts index 1356105eab8..51dc9b249b4 100644 --- a/apps/sim/app/api/tools/dynamodb/get/route.ts +++ b/apps/sim/app/api/tools/dynamodb/get/route.ts @@ -1,36 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsDynamodbGetContract } from '@/lib/api/contracts/tools/aws/dynamodb-get' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDynamoDBClient, getItem } from '@/app/api/tools/dynamodb/utils' const logger = createLogger('DynamoDBGetAPI') -const GetSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - tableName: z.string().min(1, 'Table name is required'), - key: z.record(z.unknown()).refine((val) => Object.keys(val).length > 0, { - message: 'Key is required', - }), - consistentRead: z - .union([z.boolean(), z.string()]) - .optional() - .transform((val) => { - if (val === true || val === 'true') return true - return undefined - }), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -38,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = GetSchema.parse(body) + const parsed = await parseAwsToolRequest(awsDynamodbGetContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`Getting item from table '${validatedData.tableName}'`) @@ -67,13 +49,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = toError(error).message || 'DynamoDB get failed' logger.error('DynamoDB get failed:', error) return NextResponse.json({ error: errorMessage }, { status: 500 }) diff --git a/apps/sim/app/api/tools/dynamodb/introspect/route.ts b/apps/sim/app/api/tools/dynamodb/introspect/route.ts index ee8ea193603..91d6ccb12c4 100644 --- a/apps/sim/app/api/tools/dynamodb/introspect/route.ts +++ b/apps/sim/app/api/tools/dynamodb/introspect/route.ts @@ -1,26 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsDynamodbIntrospectContract } from '@/lib/api/contracts/tools/aws/dynamodb-introspect' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createRawDynamoDBClient, describeTable, listTables } from '@/app/api/tools/dynamodb/utils' const logger = createLogger('DynamoDBIntrospectAPI') -const IntrospectSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - tableName: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -28,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = IntrospectSchema.parse(body) + const parsed = await parseAwsToolRequest(awsDynamodbIntrospectContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Introspecting DynamoDB in region ${params.region}`) @@ -65,14 +57,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = toError(error).message || 'Unknown error occurred' logger.error('DynamoDB introspection failed:', error) diff --git a/apps/sim/app/api/tools/dynamodb/put/route.ts b/apps/sim/app/api/tools/dynamodb/put/route.ts index f88ab229c8a..0ffdc9df683 100644 --- a/apps/sim/app/api/tools/dynamodb/put/route.ts +++ b/apps/sim/app/api/tools/dynamodb/put/route.ts @@ -1,32 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsDynamodbPutContract } from '@/lib/api/contracts/tools/aws/dynamodb-put' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDynamoDBClient, putItem } from '@/app/api/tools/dynamodb/utils' const logger = createLogger('DynamoDBPutAPI') -const PutSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - tableName: z.string().min(1, 'Table name is required'), - item: z.record(z.unknown()).refine((val) => Object.keys(val).length > 0, { - message: 'Item is required', - }), - conditionExpression: z.string().optional(), - expressionAttributeNames: z.record(z.string()).optional(), - expressionAttributeValues: z.record(z.unknown()).optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -34,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = PutSchema.parse(body) + const parsed = await parseAwsToolRequest(awsDynamodbPutContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`Putting item into table '${validatedData.tableName}'`) @@ -62,13 +48,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = toError(error).message || 'DynamoDB put failed' logger.error('DynamoDB put failed:', error) return NextResponse.json({ error: errorMessage }, { status: 500 }) diff --git a/apps/sim/app/api/tools/dynamodb/query/route.ts b/apps/sim/app/api/tools/dynamodb/query/route.ts index 4f5acd119a7..2930a59a9fc 100644 --- a/apps/sim/app/api/tools/dynamodb/query/route.ts +++ b/apps/sim/app/api/tools/dynamodb/query/route.ts @@ -1,34 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsDynamodbQueryContract } from '@/lib/api/contracts/tools/aws/dynamodb-query' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDynamoDBClient, queryItems } from '@/app/api/tools/dynamodb/utils' const logger = createLogger('DynamoDBQueryAPI') -const QuerySchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - tableName: z.string().min(1, 'Table name is required'), - keyConditionExpression: z.string().min(1, 'Key condition expression is required'), - filterExpression: z.string().optional(), - expressionAttributeNames: z.record(z.string()).optional(), - expressionAttributeValues: z.record(z.unknown()).optional(), - indexName: z.string().optional(), - limit: z.number().positive().optional(), - exclusiveStartKey: z.record(z.unknown()).optional(), - scanIndexForward: z.boolean().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -36,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = QuerySchema.parse(body) + const parsed = await parseAwsToolRequest(awsDynamodbQueryContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`Querying table '${validatedData.tableName}'`) @@ -77,13 +61,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = toError(error).message || 'DynamoDB query failed' logger.error('DynamoDB query failed:', error) return NextResponse.json({ error: errorMessage }, { status: 500 }) diff --git a/apps/sim/app/api/tools/dynamodb/scan/route.ts b/apps/sim/app/api/tools/dynamodb/scan/route.ts index 1e1630e2484..9e9dd91cc3d 100644 --- a/apps/sim/app/api/tools/dynamodb/scan/route.ts +++ b/apps/sim/app/api/tools/dynamodb/scan/route.ts @@ -1,32 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsDynamodbScanContract } from '@/lib/api/contracts/tools/aws/dynamodb-scan' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDynamoDBClient, scanItems } from '@/app/api/tools/dynamodb/utils' const logger = createLogger('DynamoDBScanAPI') -const ScanSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - tableName: z.string().min(1, 'Table name is required'), - filterExpression: z.string().optional(), - projectionExpression: z.string().optional(), - expressionAttributeNames: z.record(z.string()).optional(), - expressionAttributeValues: z.record(z.unknown()).optional(), - limit: z.number().positive().optional(), - exclusiveStartKey: z.record(z.unknown()).optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -34,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = ScanSchema.parse(body) + const parsed = await parseAwsToolRequest(awsDynamodbScanContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`Scanning table '${validatedData.tableName}'`) @@ -69,13 +55,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = toError(error).message || 'DynamoDB scan failed' logger.error('DynamoDB scan failed:', error) return NextResponse.json({ error: errorMessage }, { status: 500 }) diff --git a/apps/sim/app/api/tools/dynamodb/update/route.ts b/apps/sim/app/api/tools/dynamodb/update/route.ts index 0342688bace..19f44aa60aa 100644 --- a/apps/sim/app/api/tools/dynamodb/update/route.ts +++ b/apps/sim/app/api/tools/dynamodb/update/route.ts @@ -1,33 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsDynamodbUpdateContract } from '@/lib/api/contracts/tools/aws/dynamodb-update' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDynamoDBClient, updateItem } from '@/app/api/tools/dynamodb/utils' const logger = createLogger('DynamoDBUpdateAPI') -const UpdateSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - tableName: z.string().min(1, 'Table name is required'), - key: z.record(z.unknown()).refine((val) => Object.keys(val).length > 0, { - message: 'Key is required', - }), - updateExpression: z.string().min(1, 'Update expression is required'), - expressionAttributeNames: z.record(z.string()).optional(), - expressionAttributeValues: z.record(z.unknown()).optional(), - conditionExpression: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -35,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validatedData = UpdateSchema.parse(body) + const parsed = await parseAwsToolRequest(awsDynamodbUpdateContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`Updating item in table '${validatedData.tableName}'`) @@ -69,13 +54,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } const errorMessage = toError(error).message || 'DynamoDB update failed' logger.error('DynamoDB update failed:', error) return NextResponse.json({ error: errorMessage }, { status: 500 }) diff --git a/apps/sim/app/api/tools/evernote/copy-note/route.ts b/apps/sim/app/api/tools/evernote/copy-note/route.ts index c0d588962cf..d3ee0699df4 100644 --- a/apps/sim/app/api/tools/evernote/copy-note/route.ts +++ b/apps/sim/app/api/tools/evernote/copy-note/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { evernoteCopyNoteContract } from '@/lib/api/contracts/tools/evernote' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { copyNote } from '@/app/api/tools/evernote/lib/client' @@ -15,16 +17,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { apiKey, noteGuid, toNotebookGuid } = body - - if (!apiKey || !noteGuid || !toNotebookGuid) { - return NextResponse.json( - { success: false, error: 'apiKey, noteGuid, and toNotebookGuid are required' }, - { status: 400 } - ) - } + const parsed = await parseRequest( + evernoteCopyNoteContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Invalid request') }, + { status: 400 } + ), + invalidJsonResponse: () => + NextResponse.json({ success: false, error: 'Invalid request' }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response + const { apiKey, noteGuid, toNotebookGuid } = parsed.data.body const note = await copyNote(apiKey, noteGuid, toNotebookGuid) return NextResponse.json({ diff --git a/apps/sim/app/api/tools/evernote/create-note/route.ts b/apps/sim/app/api/tools/evernote/create-note/route.ts index 74613be061c..3230109e955 100644 --- a/apps/sim/app/api/tools/evernote/create-note/route.ts +++ b/apps/sim/app/api/tools/evernote/create-note/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { evernoteCreateNoteContract } from '@/lib/api/contracts/tools/evernote' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createNote } from '@/app/api/tools/evernote/lib/client' @@ -15,16 +17,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { apiKey, title, content, notebookGuid, tagNames } = body - - if (!apiKey || !title || !content) { - return NextResponse.json( - { success: false, error: 'apiKey, title, and content are required' }, - { status: 400 } - ) - } - + const parsed = await parseRequest( + evernoteCreateNoteContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Invalid request') }, + { status: 400 } + ), + invalidJsonResponse: () => + NextResponse.json({ success: false, error: 'Invalid request' }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response + + const { apiKey, title, content, notebookGuid, tagNames } = parsed.data.body const parsedTags = tagNames ? (() => { const tags = diff --git a/apps/sim/app/api/tools/evernote/create-notebook/route.ts b/apps/sim/app/api/tools/evernote/create-notebook/route.ts index 988ad39f68d..33fa687ecfe 100644 --- a/apps/sim/app/api/tools/evernote/create-notebook/route.ts +++ b/apps/sim/app/api/tools/evernote/create-notebook/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { evernoteCreateNotebookContract } from '@/lib/api/contracts/tools/evernote' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createNotebook } from '@/app/api/tools/evernote/lib/client' @@ -15,16 +17,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { apiKey, name, stack } = body - - if (!apiKey || !name) { - return NextResponse.json( - { success: false, error: 'apiKey and name are required' }, - { status: 400 } - ) - } + const parsed = await parseRequest( + evernoteCreateNotebookContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Invalid request') }, + { status: 400 } + ), + invalidJsonResponse: () => + NextResponse.json({ success: false, error: 'Invalid request' }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response + const { apiKey, name, stack } = parsed.data.body const notebook = await createNotebook(apiKey, name, stack || undefined) return NextResponse.json({ diff --git a/apps/sim/app/api/tools/evernote/create-tag/route.ts b/apps/sim/app/api/tools/evernote/create-tag/route.ts index c70cd531fae..bdde802b929 100644 --- a/apps/sim/app/api/tools/evernote/create-tag/route.ts +++ b/apps/sim/app/api/tools/evernote/create-tag/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { evernoteCreateTagContract } from '@/lib/api/contracts/tools/evernote' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createTag } from '@/app/api/tools/evernote/lib/client' @@ -15,16 +17,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { apiKey, name, parentGuid } = body - - if (!apiKey || !name) { - return NextResponse.json( - { success: false, error: 'apiKey and name are required' }, - { status: 400 } - ) - } + const parsed = await parseRequest( + evernoteCreateTagContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Invalid request') }, + { status: 400 } + ), + invalidJsonResponse: () => + NextResponse.json({ success: false, error: 'Invalid request' }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response + const { apiKey, name, parentGuid } = parsed.data.body const tag = await createTag(apiKey, name, parentGuid || undefined) return NextResponse.json({ diff --git a/apps/sim/app/api/tools/evernote/delete-note/route.ts b/apps/sim/app/api/tools/evernote/delete-note/route.ts index 36d4e0d981c..48c26786a91 100644 --- a/apps/sim/app/api/tools/evernote/delete-note/route.ts +++ b/apps/sim/app/api/tools/evernote/delete-note/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { evernoteDeleteNoteContract } from '@/lib/api/contracts/tools/evernote' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteNote } from '@/app/api/tools/evernote/lib/client' @@ -15,16 +17,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { apiKey, noteGuid } = body - - if (!apiKey || !noteGuid) { - return NextResponse.json( - { success: false, error: 'apiKey and noteGuid are required' }, - { status: 400 } - ) - } + const parsed = await parseRequest( + evernoteDeleteNoteContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Invalid request') }, + { status: 400 } + ), + invalidJsonResponse: () => + NextResponse.json({ success: false, error: 'Invalid request' }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response + const { apiKey, noteGuid } = parsed.data.body await deleteNote(apiKey, noteGuid) return NextResponse.json({ diff --git a/apps/sim/app/api/tools/evernote/get-note/route.ts b/apps/sim/app/api/tools/evernote/get-note/route.ts index 837152b3c63..55b2c0ace17 100644 --- a/apps/sim/app/api/tools/evernote/get-note/route.ts +++ b/apps/sim/app/api/tools/evernote/get-note/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { evernoteGetNoteContract } from '@/lib/api/contracts/tools/evernote' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getNote } from '@/app/api/tools/evernote/lib/client' @@ -15,17 +17,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { apiKey, noteGuid, withContent = true } = body + const parsed = await parseRequest( + evernoteGetNoteContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Invalid request') }, + { status: 400 } + ), + invalidJsonResponse: () => + NextResponse.json({ success: false, error: 'Invalid request' }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response - if (!apiKey || !noteGuid) { - return NextResponse.json( - { success: false, error: 'apiKey and noteGuid are required' }, - { status: 400 } - ) - } - - const note = await getNote(apiKey, noteGuid, withContent) + const { apiKey, noteGuid, withContent } = parsed.data.body + const note = await getNote(apiKey, noteGuid, withContent ?? true) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/tools/evernote/get-notebook/route.ts b/apps/sim/app/api/tools/evernote/get-notebook/route.ts index 637e88e58ce..464d1e28142 100644 --- a/apps/sim/app/api/tools/evernote/get-notebook/route.ts +++ b/apps/sim/app/api/tools/evernote/get-notebook/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { evernoteGetNotebookContract } from '@/lib/api/contracts/tools/evernote' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getNotebook } from '@/app/api/tools/evernote/lib/client' @@ -15,16 +17,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { apiKey, notebookGuid } = body - - if (!apiKey || !notebookGuid) { - return NextResponse.json( - { success: false, error: 'apiKey and notebookGuid are required' }, - { status: 400 } - ) - } + const parsed = await parseRequest( + evernoteGetNotebookContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Invalid request') }, + { status: 400 } + ), + invalidJsonResponse: () => + NextResponse.json({ success: false, error: 'Invalid request' }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response + const { apiKey, notebookGuid } = parsed.data.body const notebook = await getNotebook(apiKey, notebookGuid) return NextResponse.json({ diff --git a/apps/sim/app/api/tools/evernote/list-notebooks/route.ts b/apps/sim/app/api/tools/evernote/list-notebooks/route.ts index 41b2a5d56f4..3280a833283 100644 --- a/apps/sim/app/api/tools/evernote/list-notebooks/route.ts +++ b/apps/sim/app/api/tools/evernote/list-notebooks/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { evernoteListNotebooksContract } from '@/lib/api/contracts/tools/evernote' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { listNotebooks } from '@/app/api/tools/evernote/lib/client' @@ -15,13 +17,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { apiKey } = body - - if (!apiKey) { - return NextResponse.json({ success: false, error: 'apiKey is required' }, { status: 400 }) - } + const parsed = await parseRequest( + evernoteListNotebooksContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Invalid request') }, + { status: 400 } + ), + invalidJsonResponse: () => + NextResponse.json({ success: false, error: 'Invalid request' }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response + const { apiKey } = parsed.data.body const notebooks = await listNotebooks(apiKey) return NextResponse.json({ diff --git a/apps/sim/app/api/tools/evernote/list-tags/route.ts b/apps/sim/app/api/tools/evernote/list-tags/route.ts index 568b92ca922..5162dcba273 100644 --- a/apps/sim/app/api/tools/evernote/list-tags/route.ts +++ b/apps/sim/app/api/tools/evernote/list-tags/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { evernoteListTagsContract } from '@/lib/api/contracts/tools/evernote' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { listTags } from '@/app/api/tools/evernote/lib/client' @@ -15,13 +17,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { apiKey } = body - - if (!apiKey) { - return NextResponse.json({ success: false, error: 'apiKey is required' }, { status: 400 }) - } + const parsed = await parseRequest( + evernoteListTagsContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Invalid request') }, + { status: 400 } + ), + invalidJsonResponse: () => + NextResponse.json({ success: false, error: 'Invalid request' }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response + const { apiKey } = parsed.data.body const tags = await listTags(apiKey) return NextResponse.json({ diff --git a/apps/sim/app/api/tools/evernote/search-notes/route.ts b/apps/sim/app/api/tools/evernote/search-notes/route.ts index 9e451b800cc..6b14897a31e 100644 --- a/apps/sim/app/api/tools/evernote/search-notes/route.ts +++ b/apps/sim/app/api/tools/evernote/search-notes/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { evernoteSearchNotesContract } from '@/lib/api/contracts/tools/evernote' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { searchNotes } from '@/app/api/tools/evernote/lib/client' @@ -15,16 +17,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { apiKey, query, notebookGuid, offset = 0, maxNotes = 25 } = body - - if (!apiKey || !query) { - return NextResponse.json( - { success: false, error: 'apiKey and query are required' }, - { status: 400 } - ) - } + const parsed = await parseRequest( + evernoteSearchNotesContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Invalid request') }, + { status: 400 } + ), + invalidJsonResponse: () => + NextResponse.json({ success: false, error: 'Invalid request' }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response + const { apiKey, query, notebookGuid, offset, maxNotes } = parsed.data.body const clampedMaxNotes = Math.min(Math.max(Number(maxNotes) || 25, 1), 250) const result = await searchNotes( diff --git a/apps/sim/app/api/tools/evernote/update-note/route.ts b/apps/sim/app/api/tools/evernote/update-note/route.ts index 258917f73bf..896a17a817f 100644 --- a/apps/sim/app/api/tools/evernote/update-note/route.ts +++ b/apps/sim/app/api/tools/evernote/update-note/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { evernoteUpdateNoteContract } from '@/lib/api/contracts/tools/evernote' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { updateNote } from '@/app/api/tools/evernote/lib/client' @@ -15,16 +17,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { apiKey, noteGuid, title, content, notebookGuid, tagNames } = body - - if (!apiKey || !noteGuid) { - return NextResponse.json( - { success: false, error: 'apiKey and noteGuid are required' }, - { status: 400 } - ) - } + const parsed = await parseRequest( + evernoteUpdateNoteContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Invalid request') }, + { status: 400 } + ), + invalidJsonResponse: () => + NextResponse.json({ success: false, error: 'Invalid request' }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response + const { apiKey, noteGuid, title, content, notebookGuid, tagNames } = parsed.data.body const parsedTags = tagNames ? (() => { const tags = diff --git a/apps/sim/app/api/tools/extend/parse/route.ts b/apps/sim/app/api/tools/extend/parse/route.ts index c7f2a4da888..473deee7af2 100644 --- a/apps/sim/app/api/tools/extend/parse/route.ts +++ b/apps/sim/app/api/tools/extend/parse/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { extendParseBodySchema } from '@/lib/api/contracts/media-tools' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -8,7 +9,6 @@ import { } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server' @@ -16,15 +16,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('ExtendParseAPI') -const ExtendParseSchema = z.object({ - apiKey: z.string().min(1, 'API key is required'), - filePath: z.string().optional(), - file: RawFileInputSchema.optional(), - outputFormat: z.enum(['markdown', 'spatial']).optional(), - chunking: z.enum(['page', 'document', 'section']).optional(), - engine: z.enum(['parse_performance', 'parse_light']).optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -46,7 +37,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userId = authResult.userId const body = await request.json() - const validatedData = ExtendParseSchema.parse(body) + const validation = validateSchema(extendParseBodySchema, body) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request data`, { errors: validation.error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request data'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Extend parse request`, { fileName: validatedData.file?.name, @@ -164,18 +167,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error in Extend parse:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 07519a30abd..9b4ee4779cd 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -1,5 +1,12 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { + fileManageAppendBodySchema, + fileManageBaseBodySchema, + fileManageQuerySchema, + fileManageWriteBodySchema, +} from '@/lib/api/contracts/media-tools' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { ensureAbsoluteUrl } from '@/lib/core/utils/urls' @@ -16,6 +23,13 @@ export const dynamic = 'force-dynamic' const logger = createLogger('FileManageAPI') +function validationResponse(errorMessage: string) { + return NextResponse.json( + { success: false, error: errorMessage || 'Invalid request data' }, + { status: 400 } + ) +} + export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request, { requireWorkflowId: false }) if (!auth.success) { @@ -23,46 +37,47 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const { searchParams } = new URL(request.url) - const userId = auth.userId || searchParams.get('userId') + const queryResult = validateSchema(fileManageQuerySchema, { + userId: searchParams.get('userId'), + workspaceId: searchParams.get('workspaceId'), + }) + if (!queryResult.success) { + return validationResponse(getValidationErrorMessage(queryResult.error)) + } + const query = queryResult.data + const userId = auth.userId || query.userId if (!userId) { return NextResponse.json({ success: false, error: 'userId is required' }, { status: 400 }) } - let body: Record + let body: unknown try { body = await request.json() } catch { return NextResponse.json({ success: false, error: 'Invalid JSON body' }, { status: 400 }) } - const workspaceId = (body.workspaceId as string) || searchParams.get('workspaceId') + const baseResult = validateSchema(fileManageBaseBodySchema, body) + if (!baseResult.success) { + return validationResponse(getValidationErrorMessage(baseResult.error)) + } + + const workspaceId = baseResult.data.workspaceId || query.workspaceId if (!workspaceId) { return NextResponse.json({ success: false, error: 'workspaceId is required' }, { status: 400 }) } - const operation = body.operation as string + const operation = baseResult.data.operation try { switch (operation) { case 'write': { - const fileName = body.fileName as string | undefined - const content = body.content as string | undefined - const contentType = body.contentType as string | undefined - - if (!fileName) { - return NextResponse.json( - { success: false, error: 'fileName is required for write operation' }, - { status: 400 } - ) - } - - if (!content && content !== '') { - return NextResponse.json( - { success: false, error: 'content is required for write operation' }, - { status: 400 } - ) + const writeResult = validateSchema(fileManageWriteBodySchema, body) + if (!writeResult.success) { + return validationResponse(getValidationErrorMessage(writeResult.error)) } + const { fileName, content, contentType } = writeResult.data const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(fileName)) const fileBuffer = Buffer.from(content ?? '', 'utf-8') @@ -92,22 +107,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } case 'append': { - const fileName = body.fileName as string | undefined - const content = body.content as string | undefined - - if (!fileName) { - return NextResponse.json( - { success: false, error: 'fileName is required for append operation' }, - { status: 400 } - ) - } - - if (!content && content !== '') { - return NextResponse.json( - { success: false, error: 'content is required for append operation' }, - { status: 400 } - ) + const appendResult = validateSchema(fileManageAppendBodySchema, body) + if (!appendResult.success) { + return validationResponse(getValidationErrorMessage(appendResult.error)) } + const { fileName, content } = appendResult.data const existing = await getWorkspaceFileByName(workspaceId, fileName) if (!existing) { diff --git a/apps/sim/app/api/tools/github/latest-commit/route.ts b/apps/sim/app/api/tools/github/latest-commit/route.ts index c06eb03b712..8a5cdd91152 100644 --- a/apps/sim/app/api/tools/github/latest-commit/route.ts +++ b/apps/sim/app/api/tools/github/latest-commit/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { githubLatestCommitContract } from '@/lib/api/contracts/tools/github' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -40,13 +41,6 @@ interface GitHubCommitResponse { }> } -const GitHubLatestCommitSchema = z.object({ - owner: z.string().min(1, 'Owner is required'), - repo: z.string().min(1, 'Repo is required'), - branch: z.string().optional().nullable(), - apiKey: z.string().min(1, 'API key is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -64,10 +58,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = GitHubLatestCommitSchema.parse(body) + const parsed = await parseRequest(githubLatestCommitContract, request, {}) + if (!parsed.success) return parsed.response - const { owner, repo, branch, apiKey } = validatedData + const { owner, repo, branch, apiKey } = parsed.data.body const baseUrl = `https://api.github.com/repos/${owner}/${repo}` const commitUrl = branch ? `${baseUrl}/commits/${branch}` : `${baseUrl}/commits/HEAD` diff --git a/apps/sim/app/api/tools/gmail/add-label/route.ts b/apps/sim/app/api/tools/gmail/add-label/route.ts index c8eb5e4eaf6..960d51f20d7 100644 --- a/apps/sim/app/api/tools/gmail/add-label/route.ts +++ b/apps/sim/app/api/tools/gmail/add-label/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailAddLabelContract } from '@/lib/api/contracts/google-tools' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -12,12 +13,6 @@ const logger = createLogger('GmailAddLabelAPI') const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me' -const GmailAddLabelSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - messageId: z.string().min(1, 'Message ID is required'), - labelIds: z.string().min(1, 'At least one label ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -39,8 +34,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = GmailAddLabelSchema.parse(body) + const parsed = await parseRequest(gmailAddLabelContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Adding label(s) to Gmail email`, { messageId: validatedData.messageId, @@ -117,18 +113,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error adding label to Gmail email:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/gmail/archive/route.ts b/apps/sim/app/api/tools/gmail/archive/route.ts index 605209ebd44..a064a9323ad 100644 --- a/apps/sim/app/api/tools/gmail/archive/route.ts +++ b/apps/sim/app/api/tools/gmail/archive/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailArchiveContract } from '@/lib/api/contracts/google-tools' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,11 +12,6 @@ const logger = createLogger('GmailArchiveAPI') const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me' -const GmailArchiveSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - messageId: z.string().min(1, 'Message ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -37,8 +33,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = GmailArchiveSchema.parse(body) + const parsed = await parseRequest(gmailArchiveContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Archiving Gmail email`, { messageId: validatedData.messageId, @@ -86,18 +83,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error archiving Gmail email:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/gmail/delete/route.ts b/apps/sim/app/api/tools/gmail/delete/route.ts index 720faab7e80..8ab1d284f5d 100644 --- a/apps/sim/app/api/tools/gmail/delete/route.ts +++ b/apps/sim/app/api/tools/gmail/delete/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailDeleteContract } from '@/lib/api/contracts/google-tools' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,11 +12,6 @@ const logger = createLogger('GmailDeleteAPI') const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me' -const GmailDeleteSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - messageId: z.string().min(1, 'Message ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -37,8 +33,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = GmailDeleteSchema.parse(body) + const parsed = await parseRequest(gmailDeleteContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Deleting Gmail email`, { messageId: validatedData.messageId, @@ -83,18 +80,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error deleting Gmail email:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/gmail/draft/route.ts b/apps/sim/app/api/tools/gmail/draft/route.ts index 3186d7c1ee9..ee8c3a64e5d 100644 --- a/apps/sim/app/api/tools/gmail/draft/route.ts +++ b/apps/sim/app/api/tools/gmail/draft/route.ts @@ -1,10 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailDraftContract } from '@/lib/api/contracts/google-tools' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { @@ -20,19 +20,6 @@ const logger = createLogger('GmailDraftAPI') const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me' -const GmailDraftSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - to: z.string().min(1, 'Recipient email is required'), - subject: z.string().optional().nullable(), - body: z.string().min(1, 'Email body is required'), - contentType: z.enum(['text', 'html']).optional().nullable(), - threadId: z.string().optional().nullable(), - replyToMessageId: z.string().optional().nullable(), - cc: z.string().optional().nullable(), - bcc: z.string().optional().nullable(), - attachments: RawFileInputArraySchema.optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -54,8 +41,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = GmailDraftSchema.parse(body) + const parsed = await parseRequest(gmailDraftContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Creating Gmail draft`, { to: validatedData.to, @@ -198,18 +186,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error creating Gmail draft:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/gmail/label/route.ts b/apps/sim/app/api/tools/gmail/label/route.ts index 3524f76420e..d3a601ab65e 100644 --- a/apps/sim/app/api/tools/gmail/label/route.ts +++ b/apps/sim/app/api/tools/gmail/label/route.ts @@ -3,6 +3,8 @@ import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { gmailLabelSelectorContract } from '@/lib/api/contracts/selectors/google' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -30,18 +32,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const labelId = searchParams.get('labelId') - const impersonateEmail = searchParams.get('impersonateEmail') || undefined - - if (!credentialId || !labelId) { - logger.warn(`[${requestId}] Missing required parameters`) - return NextResponse.json( - { error: 'Credential ID and Label ID are required' }, - { status: 400 } - ) - } + const parsed = await parseRequest(gmailLabelSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credentialId, labelId } = parsed.data.query + const impersonateEmail = parsed.data.query.impersonateEmail || undefined const labelIdValidation = validateAlphanumericId(labelId, 'labelId', 255) if (!labelIdValidation.isValid) { diff --git a/apps/sim/app/api/tools/gmail/labels/route.ts b/apps/sim/app/api/tools/gmail/labels/route.ts index 073c0226f2b..d675ea6e486 100644 --- a/apps/sim/app/api/tools/gmail/labels/route.ts +++ b/apps/sim/app/api/tools/gmail/labels/route.ts @@ -3,6 +3,8 @@ import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { gmailLabelsSelectorContract } from '@/lib/api/contracts/selectors/google' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -37,15 +39,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const query = searchParams.get('query') - const impersonateEmail = searchParams.get('impersonateEmail') || undefined - - if (!credentialId) { - logger.warn(`[${requestId}] Missing credentialId parameter`) - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) - } + const parsed = await parseRequest(gmailLabelsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credentialId, query } = parsed.data.query + const impersonateEmail = parsed.data.query.impersonateEmail || undefined const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) if (!credentialIdValidation.isValid) { diff --git a/apps/sim/app/api/tools/gmail/mark-read/route.ts b/apps/sim/app/api/tools/gmail/mark-read/route.ts index 7fe2b909be5..af06af2b601 100644 --- a/apps/sim/app/api/tools/gmail/mark-read/route.ts +++ b/apps/sim/app/api/tools/gmail/mark-read/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailMarkReadContract } from '@/lib/api/contracts/google-tools' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,11 +12,6 @@ const logger = createLogger('GmailMarkReadAPI') const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me' -const GmailMarkReadSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - messageId: z.string().min(1, 'Message ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -37,8 +33,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = GmailMarkReadSchema.parse(body) + const parsed = await parseRequest(gmailMarkReadContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Marking Gmail email as read`, { messageId: validatedData.messageId, @@ -86,18 +83,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error marking Gmail email as read:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/gmail/mark-unread/route.ts b/apps/sim/app/api/tools/gmail/mark-unread/route.ts index 8f194eb7a7d..b729489b859 100644 --- a/apps/sim/app/api/tools/gmail/mark-unread/route.ts +++ b/apps/sim/app/api/tools/gmail/mark-unread/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailMarkUnreadContract } from '@/lib/api/contracts/google-tools' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,11 +12,6 @@ const logger = createLogger('GmailMarkUnreadAPI') const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me' -const GmailMarkUnreadSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - messageId: z.string().min(1, 'Message ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -40,8 +36,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = GmailMarkUnreadSchema.parse(body) + const parsed = await parseRequest(gmailMarkUnreadContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Marking Gmail email as unread`, { messageId: validatedData.messageId, @@ -89,18 +86,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error marking Gmail email as unread:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/gmail/move/route.ts b/apps/sim/app/api/tools/gmail/move/route.ts index 6b599f8edc0..3185dcd53ce 100644 --- a/apps/sim/app/api/tools/gmail/move/route.ts +++ b/apps/sim/app/api/tools/gmail/move/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailMoveContract } from '@/lib/api/contracts/google-tools' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,13 +12,6 @@ const logger = createLogger('GmailMoveAPI') const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me' -const GmailMoveSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - messageId: z.string().min(1, 'Message ID is required'), - addLabelIds: z.string().min(1, 'At least one label to add is required'), - removeLabelIds: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -39,8 +33,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = GmailMoveSchema.parse(body) + const parsed = await parseRequest(gmailMoveContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Moving Gmail email`, { messageId: validatedData.messageId, @@ -110,18 +105,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error moving Gmail email:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/gmail/remove-label/route.ts b/apps/sim/app/api/tools/gmail/remove-label/route.ts index 2a57fb5f9e8..5d8e3e7a420 100644 --- a/apps/sim/app/api/tools/gmail/remove-label/route.ts +++ b/apps/sim/app/api/tools/gmail/remove-label/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailRemoveLabelContract } from '@/lib/api/contracts/google-tools' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -12,12 +13,6 @@ const logger = createLogger('GmailRemoveLabelAPI') const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me' -const GmailRemoveLabelSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - messageId: z.string().min(1, 'Message ID is required'), - labelIds: z.string().min(1, 'At least one label ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -42,8 +37,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = GmailRemoveLabelSchema.parse(body) + const parsed = await parseRequest(gmailRemoveLabelContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Removing label(s) from Gmail email`, { messageId: validatedData.messageId, @@ -120,18 +116,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error removing label from Gmail email:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/gmail/send/route.ts b/apps/sim/app/api/tools/gmail/send/route.ts index ee1f7767021..d691d51ac85 100644 --- a/apps/sim/app/api/tools/gmail/send/route.ts +++ b/apps/sim/app/api/tools/gmail/send/route.ts @@ -1,10 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailSendContract } from '@/lib/api/contracts/google-tools' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { @@ -20,19 +20,6 @@ const logger = createLogger('GmailSendAPI') const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me' -const GmailSendSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - to: z.string().min(1, 'Recipient email is required'), - subject: z.string().optional().nullable(), - body: z.string().min(1, 'Email body is required'), - contentType: z.enum(['text', 'html']).optional().nullable(), - threadId: z.string().optional().nullable(), - replyToMessageId: z.string().optional().nullable(), - cc: z.string().optional().nullable(), - bcc: z.string().optional().nullable(), - attachments: RawFileInputArraySchema.optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -54,8 +41,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = GmailSendSchema.parse(body) + const parsed = await parseRequest(gmailSendContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Sending Gmail email`, { to: validatedData.to, @@ -193,18 +181,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error sending Gmail email:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/gmail/unarchive/route.ts b/apps/sim/app/api/tools/gmail/unarchive/route.ts index f6f774e1574..6b19b34e175 100644 --- a/apps/sim/app/api/tools/gmail/unarchive/route.ts +++ b/apps/sim/app/api/tools/gmail/unarchive/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailUnarchiveContract } from '@/lib/api/contracts/google-tools' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,11 +12,6 @@ const logger = createLogger('GmailUnarchiveAPI') const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me' -const GmailUnarchiveSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - messageId: z.string().min(1, 'Message ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -37,8 +33,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = GmailUnarchiveSchema.parse(body) + const parsed = await parseRequest(gmailUnarchiveContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Unarchiving Gmail email`, { messageId: validatedData.messageId, @@ -86,18 +83,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error unarchiving Gmail email:`, error) return NextResponse.json( 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 56b69f90874..a4c97d68505 100644 --- a/apps/sim/app/api/tools/google_bigquery/datasets/route.ts +++ b/apps/sim/app/api/tools/google_bigquery/datasets/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { bigQueryDatasetsSelectorContract } from '@/lib/api/contracts/selectors/bigquery' +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' @@ -18,23 +20,32 @@ export const dynamic = 'force-dynamic' * @param request - Incoming request containing `credential`, `workflowId`, and `projectId` in the JSON body * @returns JSON response with a `datasets` array, each entry containing `datasetReference` and optional `friendlyName` */ -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId, projectId, impersonateEmail } = body - - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } + const parsed = await parseRequest( + bigQueryDatasetsSelectorContract, + request, + {}, + { + validationErrorResponse: (error) => { + const path = error.issues.at(0)?.path[0] + const message = + path === 'credential' + ? 'Credential is required' + : path === 'projectId' + ? 'Project ID is required' + : getValidationErrorMessage(error, 'Invalid request') + logger.error(`Validation failed for BigQuery datasets request: ${message}`) + return NextResponse.json({ error: message }, { status: 400 }) + }, + } + ) + if (!parsed.success) return parsed.response - if (!projectId) { - logger.error('Missing project ID in request') - return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }) - } + const { credential, workflowId, projectId, impersonateEmail } = parsed.data.body - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) 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 c4754591ddf..2f6320a0fe5 100644 --- a/apps/sim/app/api/tools/google_bigquery/tables/route.ts +++ b/apps/sim/app/api/tools/google_bigquery/tables/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { bigQueryTablesSelectorContract } from '@/lib/api/contracts/selectors/bigquery' +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' @@ -10,28 +12,37 @@ const logger = createLogger('GoogleBigQueryTablesAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId, projectId, datasetId, impersonateEmail } = body + const parsed = await parseRequest( + bigQueryTablesSelectorContract, + request, + {}, + { + validationErrorResponse: (error) => { + const hasCredentialError = error.issues.some((issue) => issue.path[0] === 'credential') + if (hasCredentialError) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } + const hasProjectIdError = error.issues.some((issue) => issue.path[0] === 'projectId') + if (hasProjectIdError) { + logger.error('Missing project ID in request') + return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }) + } - if (!projectId) { - logger.error('Missing project ID in request') - return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }) - } + logger.error('Missing dataset ID in request') + return NextResponse.json({ error: 'Dataset ID is required' }, { status: 400 }) + }, + } + ) + if (!parsed.success) return parsed.response - if (!datasetId) { - logger.error('Missing dataset ID in request') - return NextResponse.json({ error: 'Dataset ID is required' }, { status: 400 }) - } + const { credential, workflowId, projectId, datasetId, impersonateEmail } = parsed.data.body - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) 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 c551eca55e8..e1ac55b13ef 100644 --- a/apps/sim/app/api/tools/google_calendar/calendars/route.ts +++ b/apps/sim/app/api/tools/google_calendar/calendars/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { googleCalendarSelectorContract } from '@/lib/api/contracts/selectors/google' +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' @@ -27,15 +29,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Google Calendar calendars request received`) try { - const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const workflowId = searchParams.get('workflowId') || undefined - const impersonateEmail = searchParams.get('impersonateEmail') || undefined + const parsed = await parseRequest( + googleCalendarSelectorContract, + request, + {}, + { + validationErrorResponse: () => { + logger.warn(`[${requestId}] Missing credentialId parameter`) + return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + }, + } + ) + if (!parsed.success) return parsed.response + + const { credentialId } = parsed.data.query + const workflowId = parsed.data.query.workflowId || undefined + const impersonateEmail = parsed.data.query.impersonateEmail || undefined - if (!credentialId) { - logger.warn(`[${requestId}] Missing credentialId parameter`) - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) - } const authz = await authorizeCredentialUse(request, { credentialId, workflowId }) if (!authz.ok || !authz.credentialOwnerUserId) { return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) diff --git a/apps/sim/app/api/tools/google_drive/download/route.ts b/apps/sim/app/api/tools/google_drive/download/route.ts index 2d1044fc2e2..d2f50e19863 100644 --- a/apps/sim/app/api/tools/google_drive/download/route.ts +++ b/apps/sim/app/api/tools/google_drive/download/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { googleDriveDownloadContract } from '@/lib/api/contracts/google-tools' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -36,14 +37,6 @@ interface GoogleDriveRevisionsResponse { nextPageToken?: string } -const GoogleDriveDownloadSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - fileId: z.string().min(1, 'File ID is required'), - mimeType: z.string().optional().nullable(), - fileName: z.string().optional().nullable(), - includeRevisions: z.boolean().optional().default(true), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -61,8 +54,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = GoogleDriveDownloadSchema.parse(body) + const parsed = await parseRequest( + googleDriveDownloadContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Invalid request') }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const { accessToken, @@ -262,12 +267,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { success: false, error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } logger.error(`[${requestId}] Error downloading Google Drive file:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/google_drive/upload/route.ts b/apps/sim/app/api/tools/google_drive/upload/route.ts index d5b0321f20f..d8201d1cb56 100644 --- a/apps/sim/app/api/tools/google_drive/upload/route.ts +++ b/apps/sim/app/api/tools/google_drive/upload/route.ts @@ -1,10 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { googleDriveUploadContract } from '@/lib/api/contracts/google-tools' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { @@ -19,14 +19,6 @@ const logger = createLogger('GoogleDriveUploadAPI') const GOOGLE_DRIVE_API_BASE = 'https://www.googleapis.com/upload/drive/v3/files' -const GoogleDriveUploadSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - fileName: z.string().min(1, 'File name is required'), - file: RawFileInputSchema.optional().nullable(), - mimeType: z.string().optional().nullable(), - folderId: z.string().optional().nullable(), -}) - /** * Build multipart upload body for Google Drive API */ @@ -78,8 +70,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = GoogleDriveUploadSchema.parse(body) + const parsed = await parseRequest(googleDriveUploadContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Uploading file to Google Drive`, { fileName: validatedData.fileName, @@ -276,18 +269,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error uploading file to Google Drive:`, error) return NextResponse.json( 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 22bc17cfb61..951c31f67e8 100644 --- a/apps/sim/app/api/tools/google_sheets/sheets/route.ts +++ b/apps/sim/app/api/tools/google_sheets/sheets/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { googleSheetsSelectorContract } from '@/lib/api/contracts/selectors/google' +import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' @@ -38,21 +40,28 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } try { - const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const spreadsheetId = searchParams.get('spreadsheetId') - const workflowId = searchParams.get('workflowId') || undefined - const impersonateEmail = searchParams.get('impersonateEmail') || undefined - - if (!credentialId) { - logger.warn(`[${requestId}] Missing credentialId parameter`) - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) - } + const parsed = await parseRequest( + googleSheetsSelectorContract, + request, + {}, + { + validationErrorResponse: (error) => { + const missingCredential = error.issues.some((issue) => issue.path[0] === 'credentialId') + if (missingCredential) { + logger.warn(`[${requestId}] Missing credentialId parameter`) + return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + } + + logger.warn(`[${requestId}] Missing spreadsheetId parameter`) + return NextResponse.json({ error: 'Spreadsheet ID is required' }, { status: 400 }) + }, + } + ) + if (!parsed.success) return parsed.response - if (!spreadsheetId) { - logger.warn(`[${requestId}] Missing spreadsheetId parameter`) - return NextResponse.json({ error: 'Spreadsheet ID is required' }, { status: 400 }) - } + const { credentialId, spreadsheetId } = parsed.data.query + const workflowId = parsed.data.query.workflowId || undefined + const impersonateEmail = parsed.data.query.impersonateEmail || undefined const authz = await authorizeCredentialUse(request, { credentialId, workflowId }) if (!authz.ok || !authz.credentialOwnerUserId) { 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 7d9f0938872..c5ee97bb234 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 @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { googleTasksTaskListsSelectorContract } from '@/lib/api/contracts/selectors/google' +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' @@ -10,18 +12,25 @@ const logger = createLogger('GoogleTasksTaskListsAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId, impersonateEmail } = body + const parsed = await parseRequest( + googleTasksTaskListsSelectorContract, + request, + {}, + { + validationErrorResponse: () => { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + }, + } + ) + if (!parsed.success) return parsed.response - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } + const { credential, workflowId, impersonateEmail } = parsed.data.body - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) diff --git a/apps/sim/app/api/tools/google_vault/download-export-file/route.ts b/apps/sim/app/api/tools/google_vault/download-export-file/route.ts index 7befe05ee5c..6c155629d89 100644 --- a/apps/sim/app/api/tools/google_vault/download-export-file/route.ts +++ b/apps/sim/app/api/tools/google_vault/download-export-file/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { googleVaultDownloadExportFileContract } from '@/lib/api/contracts/google-tools' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -14,13 +15,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('GoogleVaultDownloadExportFileAPI') -const GoogleVaultDownloadExportFileSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - bucketName: z.string().min(1, 'Bucket name is required'), - objectName: z.string().min(1, 'Object name is required'), - fileName: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -38,8 +32,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = GoogleVaultDownloadExportFileSchema.parse(body) + const parsed = await parseRequest(googleVaultDownloadExportFileContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const { accessToken, bucketName, objectName, fileName } = validatedData diff --git a/apps/sim/app/api/tools/iam/add-user-to-group/route.ts b/apps/sim/app/api/tools/iam/add-user-to-group/route.ts index 34dd4fbe6ae..0a1cf949fa8 100644 --- a/apps/sim/app/api/tools/iam/add-user-to-group/route.ts +++ b/apps/sim/app/api/tools/iam/add-user-to-group/route.ts @@ -1,27 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamAddUserToGroupContract } from '@/lib/api/contracts/tools/aws/iam-add-user-to-group' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { addUserToGroup, createIAMClient } from '../utils' const logger = createLogger('IAMAddUserToGroupAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().min(1, 'User name is required'), - groupName: z.string().min(1, 'Group name is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -29,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamAddUserToGroupContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Adding user "${params.userName}" to group "${params.groupName}"`) @@ -50,13 +41,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to add user to group:`, error) return NextResponse.json( { error: `Failed to add user to group: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/attach-role-policy/route.ts b/apps/sim/app/api/tools/iam/attach-role-policy/route.ts index 570b17ea854..28fd8d46329 100644 --- a/apps/sim/app/api/tools/iam/attach-role-policy/route.ts +++ b/apps/sim/app/api/tools/iam/attach-role-policy/route.ts @@ -1,27 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamAttachRolePolicyContract } from '@/lib/api/contracts/tools/aws/iam-attach-role-policy' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { attachRolePolicy, createIAMClient } from '../utils' const logger = createLogger('IAMAttachRolePolicyAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - roleName: z.string().min(1, 'Role name is required'), - policyArn: z.string().min(1, 'Policy ARN is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -29,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamAttachRolePolicyContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Attaching policy to IAM role "${params.roleName}"`) @@ -50,13 +41,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to attach role policy:`, error) return NextResponse.json( { error: `Failed to attach role policy: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/attach-user-policy/route.ts b/apps/sim/app/api/tools/iam/attach-user-policy/route.ts index de722bb5cb0..3ee98209e44 100644 --- a/apps/sim/app/api/tools/iam/attach-user-policy/route.ts +++ b/apps/sim/app/api/tools/iam/attach-user-policy/route.ts @@ -1,27 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamAttachUserPolicyContract } from '@/lib/api/contracts/tools/aws/iam-attach-user-policy' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { attachUserPolicy, createIAMClient } from '../utils' const logger = createLogger('IAMAttachUserPolicyAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().min(1, 'User name is required'), - policyArn: z.string().min(1, 'Policy ARN is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -29,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamAttachUserPolicyContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Attaching policy to IAM user "${params.userName}"`) @@ -50,13 +41,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to attach user policy:`, error) return NextResponse.json( { error: `Failed to attach user policy: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/create-access-key/route.ts b/apps/sim/app/api/tools/iam/create-access-key/route.ts index 1acd770787f..3d009148a21 100644 --- a/apps/sim/app/api/tools/iam/create-access-key/route.ts +++ b/apps/sim/app/api/tools/iam/create-access-key/route.ts @@ -1,26 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamCreateAccessKeyContract } from '@/lib/api/contracts/tools/aws/iam-create-access-key' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createAccessKey, createIAMClient } from '../utils' const logger = createLogger('IAMCreateAccessKeyAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -28,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamCreateAccessKeyContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Creating IAM access key`) @@ -50,13 +42,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to create access key:`, error) return NextResponse.json( { error: `Failed to create access key: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/create-role/route.ts b/apps/sim/app/api/tools/iam/create-role/route.ts index c065ecbfd18..c19ef7fa660 100644 --- a/apps/sim/app/api/tools/iam/create-role/route.ts +++ b/apps/sim/app/api/tools/iam/create-role/route.ts @@ -1,30 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamCreateRoleContract } from '@/lib/api/contracts/tools/aws/iam-create-role' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIAMClient, createRole } from '../utils' const logger = createLogger('IAMCreateRoleAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - roleName: z.string().min(1, 'Role name is required'), - assumeRolePolicyDocument: z.string().min(1, 'Assume role policy document is required'), - description: z.string().optional().nullable(), - path: z.string().optional().nullable(), - maxSessionDuration: z.number().int().min(3600).max(43200).optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -32,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamCreateRoleContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Creating IAM role "${params.roleName}"`) @@ -61,13 +49,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to create IAM role:`, error) return NextResponse.json( { error: `Failed to create IAM role: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/create-user/route.ts b/apps/sim/app/api/tools/iam/create-user/route.ts index 288f97140ca..f666f29ec70 100644 --- a/apps/sim/app/api/tools/iam/create-user/route.ts +++ b/apps/sim/app/api/tools/iam/create-user/route.ts @@ -1,27 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamCreateUserContract } from '@/lib/api/contracts/tools/aws/iam-create-user' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIAMClient, createUser } from '../utils' const logger = createLogger('IAMCreateUserAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().min(1, 'User name is required'), - path: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -29,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamCreateUserContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Creating IAM user "${params.userName}"`) @@ -51,13 +42,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to create IAM user:`, error) return NextResponse.json( { error: `Failed to create IAM user: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/delete-access-key/route.ts b/apps/sim/app/api/tools/iam/delete-access-key/route.ts index 7023abafb97..e70f5c1fbc9 100644 --- a/apps/sim/app/api/tools/iam/delete-access-key/route.ts +++ b/apps/sim/app/api/tools/iam/delete-access-key/route.ts @@ -1,27 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamDeleteAccessKeyContract } from '@/lib/api/contracts/tools/aws/iam-delete-access-key' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIAMClient, deleteAccessKey } from '../utils' const logger = createLogger('IAMDeleteAccessKeyAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - accessKeyIdToDelete: z.string().min(1, 'Access key ID to delete is required'), - userName: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -29,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamDeleteAccessKeyContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Deleting IAM access key "${params.accessKeyIdToDelete}"`) @@ -48,13 +39,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to delete access key:`, error) return NextResponse.json( { error: `Failed to delete access key: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/delete-role/route.ts b/apps/sim/app/api/tools/iam/delete-role/route.ts index 0e399ac03e9..0f76bc69469 100644 --- a/apps/sim/app/api/tools/iam/delete-role/route.ts +++ b/apps/sim/app/api/tools/iam/delete-role/route.ts @@ -1,26 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamDeleteRoleContract } from '@/lib/api/contracts/tools/aws/iam-delete-role' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIAMClient, deleteRole } from '../utils' const logger = createLogger('IAMDeleteRoleAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - roleName: z.string().min(1, 'Role name is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -28,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamDeleteRoleContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Deleting IAM role "${params.roleName}"`) @@ -47,13 +39,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to delete IAM role:`, error) return NextResponse.json( { error: `Failed to delete IAM role: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/delete-user/route.ts b/apps/sim/app/api/tools/iam/delete-user/route.ts index ec9a30b1d7a..73eda0299b7 100644 --- a/apps/sim/app/api/tools/iam/delete-user/route.ts +++ b/apps/sim/app/api/tools/iam/delete-user/route.ts @@ -1,26 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamDeleteUserContract } from '@/lib/api/contracts/tools/aws/iam-delete-user' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIAMClient, deleteUser } from '../utils' const logger = createLogger('IAMDeleteUserAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().min(1, 'User name is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -28,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamDeleteUserContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Deleting IAM user "${params.userName}"`) @@ -47,13 +39,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to delete IAM user:`, error) return NextResponse.json( { error: `Failed to delete IAM user: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/detach-role-policy/route.ts b/apps/sim/app/api/tools/iam/detach-role-policy/route.ts index 02e48465668..1f4dafd7f8f 100644 --- a/apps/sim/app/api/tools/iam/detach-role-policy/route.ts +++ b/apps/sim/app/api/tools/iam/detach-role-policy/route.ts @@ -1,27 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamDetachRolePolicyContract } from '@/lib/api/contracts/tools/aws/iam-detach-role-policy' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIAMClient, detachRolePolicy } from '../utils' const logger = createLogger('IAMDetachRolePolicyAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - roleName: z.string().min(1, 'Role name is required'), - policyArn: z.string().min(1, 'Policy ARN is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -29,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamDetachRolePolicyContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Detaching policy from IAM role "${params.roleName}"`) @@ -50,13 +41,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to detach role policy:`, error) return NextResponse.json( { error: `Failed to detach role policy: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/detach-user-policy/route.ts b/apps/sim/app/api/tools/iam/detach-user-policy/route.ts index 12fb38f1ba7..5544a3411ca 100644 --- a/apps/sim/app/api/tools/iam/detach-user-policy/route.ts +++ b/apps/sim/app/api/tools/iam/detach-user-policy/route.ts @@ -1,27 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamDetachUserPolicyContract } from '@/lib/api/contracts/tools/aws/iam-detach-user-policy' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIAMClient, detachUserPolicy } from '../utils' const logger = createLogger('IAMDetachUserPolicyAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().min(1, 'User name is required'), - policyArn: z.string().min(1, 'Policy ARN is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -29,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamDetachUserPolicyContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Detaching policy from IAM user "${params.userName}"`) @@ -50,13 +41,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to detach user policy:`, error) return NextResponse.json( { error: `Failed to detach user policy: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/get-role/route.ts b/apps/sim/app/api/tools/iam/get-role/route.ts index 2efdbfa6362..57724f5c342 100644 --- a/apps/sim/app/api/tools/iam/get-role/route.ts +++ b/apps/sim/app/api/tools/iam/get-role/route.ts @@ -1,26 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamGetRoleContract } from '@/lib/api/contracts/tools/aws/iam-get-role' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIAMClient, getRole } from '../utils' const logger = createLogger('IAMGetRoleAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - roleName: z.string().min(1, 'Role name is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -28,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamGetRoleContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Getting IAM role "${params.roleName}"`) @@ -47,13 +39,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to get IAM role:`, error) return NextResponse.json( { error: `Failed to get IAM role: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/get-user/route.ts b/apps/sim/app/api/tools/iam/get-user/route.ts index 83f9a2dd5e1..fe47850da11 100644 --- a/apps/sim/app/api/tools/iam/get-user/route.ts +++ b/apps/sim/app/api/tools/iam/get-user/route.ts @@ -1,26 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamGetUserContract } from '@/lib/api/contracts/tools/aws/iam-get-user' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIAMClient, getUser } from '../utils' const logger = createLogger('IAMGetUserAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().min(1).optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -28,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamGetUserContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Getting IAM user "${params.userName}"`) @@ -47,13 +39,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to get IAM user:`, error) return NextResponse.json( { error: `Failed to get IAM user: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/list-attached-role-policies/route.ts b/apps/sim/app/api/tools/iam/list-attached-role-policies/route.ts index 1ff209e96e0..5de2baaa08b 100644 --- a/apps/sim/app/api/tools/iam/list-attached-role-policies/route.ts +++ b/apps/sim/app/api/tools/iam/list-attached-role-policies/route.ts @@ -1,29 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamListAttachedRolePoliciesContract } from '@/lib/api/contracts/tools/aws/iam-list-attached-role-policies' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIAMClient, listAttachedRolePolicies } from '../utils' const logger = createLogger('IAMListAttachedRolePoliciesAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - roleName: z.string().min(1, 'Role name is required'), - pathPrefix: z.string().optional().nullable(), - maxItems: z.number().int().min(1).max(1000).optional().nullable(), - marker: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -31,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamListAttachedRolePoliciesContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Listing policies attached to IAM role "${params.roleName}"`) @@ -56,13 +45,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to list attached role policies:`, error) return NextResponse.json( { error: `Failed to list attached role policies: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/list-attached-user-policies/route.ts b/apps/sim/app/api/tools/iam/list-attached-user-policies/route.ts index f46ed5cfd00..7b6ebd619e6 100644 --- a/apps/sim/app/api/tools/iam/list-attached-user-policies/route.ts +++ b/apps/sim/app/api/tools/iam/list-attached-user-policies/route.ts @@ -1,29 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamListAttachedUserPoliciesContract } from '@/lib/api/contracts/tools/aws/iam-list-attached-user-policies' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIAMClient, listAttachedUserPolicies } from '../utils' const logger = createLogger('IAMListAttachedUserPoliciesAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().min(1, 'User name is required'), - pathPrefix: z.string().optional().nullable(), - maxItems: z.number().int().min(1).max(1000).optional().nullable(), - marker: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -31,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamListAttachedUserPoliciesContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Listing policies attached to IAM user "${params.userName}"`) @@ -56,13 +45,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to list attached user policies:`, error) return NextResponse.json( { error: `Failed to list attached user policies: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/list-groups/route.ts b/apps/sim/app/api/tools/iam/list-groups/route.ts index 3fe2aba916c..2f7134738e9 100644 --- a/apps/sim/app/api/tools/iam/list-groups/route.ts +++ b/apps/sim/app/api/tools/iam/list-groups/route.ts @@ -1,28 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamListGroupsContract } from '@/lib/api/contracts/tools/aws/iam-list-groups' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIAMClient, listGroups } from '../utils' const logger = createLogger('IAMListGroupsAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - pathPrefix: z.string().optional().nullable(), - maxItems: z.number().int().min(1).max(1000).optional().nullable(), - marker: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -30,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamListGroupsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Listing IAM groups`) @@ -49,13 +39,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to list IAM groups:`, error) return NextResponse.json( { error: `Failed to list IAM groups: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/list-policies/route.ts b/apps/sim/app/api/tools/iam/list-policies/route.ts index ad1d232144c..321fc3f8117 100644 --- a/apps/sim/app/api/tools/iam/list-policies/route.ts +++ b/apps/sim/app/api/tools/iam/list-policies/route.ts @@ -1,30 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamListPoliciesContract } from '@/lib/api/contracts/tools/aws/iam-list-policies' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIAMClient, listPolicies } from '../utils' const logger = createLogger('IAMListPoliciesAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - scope: z.string().optional().nullable(), - onlyAttached: z.boolean().optional().nullable(), - pathPrefix: z.string().optional().nullable(), - maxItems: z.number().int().min(1).max(1000).optional().nullable(), - marker: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -32,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamListPoliciesContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Listing IAM policies`) @@ -58,13 +46,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to list IAM policies:`, error) return NextResponse.json( { error: `Failed to list IAM policies: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/list-roles/route.ts b/apps/sim/app/api/tools/iam/list-roles/route.ts index b6e7eafdc6c..37b7cc3de0e 100644 --- a/apps/sim/app/api/tools/iam/list-roles/route.ts +++ b/apps/sim/app/api/tools/iam/list-roles/route.ts @@ -1,28 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamListRolesContract } from '@/lib/api/contracts/tools/aws/iam-list-roles' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIAMClient, listRoles } from '../utils' const logger = createLogger('IAMListRolesAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - pathPrefix: z.string().optional().nullable(), - maxItems: z.number().int().min(1).max(1000).optional().nullable(), - marker: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -30,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamListRolesContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Listing IAM roles`) @@ -49,13 +39,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to list IAM roles:`, error) return NextResponse.json( { error: `Failed to list IAM roles: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/list-users/route.ts b/apps/sim/app/api/tools/iam/list-users/route.ts index c3ddcf68c70..9da6f467049 100644 --- a/apps/sim/app/api/tools/iam/list-users/route.ts +++ b/apps/sim/app/api/tools/iam/list-users/route.ts @@ -1,28 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamListUsersContract } from '@/lib/api/contracts/tools/aws/iam-list-users' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIAMClient, listUsers } from '../utils' const logger = createLogger('IAMListUsersAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - pathPrefix: z.string().optional().nullable(), - maxItems: z.number().int().min(1).max(1000).optional().nullable(), - marker: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -30,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamListUsersContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Listing IAM users`) @@ -49,13 +39,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to list IAM users:`, error) return NextResponse.json( { error: `Failed to list IAM users: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/remove-user-from-group/route.ts b/apps/sim/app/api/tools/iam/remove-user-from-group/route.ts index cf149ea77cd..d49938b3d8a 100644 --- a/apps/sim/app/api/tools/iam/remove-user-from-group/route.ts +++ b/apps/sim/app/api/tools/iam/remove-user-from-group/route.ts @@ -1,27 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamRemoveUserFromGroupContract } from '@/lib/api/contracts/tools/aws/iam-remove-user-from-group' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIAMClient, removeUserFromGroup } from '../utils' const logger = createLogger('IAMRemoveUserFromGroupAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().min(1, 'User name is required'), - groupName: z.string().min(1, 'Group name is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -29,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamRemoveUserFromGroupContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Removing user "${params.userName}" from group "${params.groupName}"`) @@ -50,13 +41,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to remove user from group:`, error) return NextResponse.json( { error: `Failed to remove user from group: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/iam/simulate-principal-policy/route.ts b/apps/sim/app/api/tools/iam/simulate-principal-policy/route.ts index 8744baa6396..ae98b8f6561 100644 --- a/apps/sim/app/api/tools/iam/simulate-principal-policy/route.ts +++ b/apps/sim/app/api/tools/iam/simulate-principal-policy/route.ts @@ -1,30 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIamSimulatePrincipalPolicyContract } from '@/lib/api/contracts/tools/aws/iam-simulate-principal-policy' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIAMClient, simulatePrincipalPolicy } from '../utils' const logger = createLogger('IAMSimulatePrincipalPolicyAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - policySourceArn: z.string().min(1, 'Policy source ARN is required'), - actionNames: z.string().min(1, 'Action names are required'), - resourceArns: z.string().optional().nullable(), - maxResults: z.number().int().min(1).max(1000).optional().nullable(), - marker: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -32,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIamSimulatePrincipalPolicyContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `Simulating principal policy for "${params.policySourceArn}" on actions: ${params.actionNames}` @@ -60,13 +48,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error(`Failed to simulate principal policy:`, error) return NextResponse.json( { error: `Failed to simulate principal policy: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/identity-center/check-assignment-deletion-status/route.ts b/apps/sim/app/api/tools/identity-center/check-assignment-deletion-status/route.ts index b9493c4ecff..27e010ca3d3 100644 --- a/apps/sim/app/api/tools/identity-center/check-assignment-deletion-status/route.ts +++ b/apps/sim/app/api/tools/identity-center/check-assignment-deletion-status/route.ts @@ -1,27 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIdentityCenterCheckAssignmentDeletionStatusContract } from '@/lib/api/contracts/tools/aws/identity-center-check-assignment-deletion-status' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkAssignmentDeletionStatus, createSSOAdminClient } from '../utils' const logger = createLogger('IdentityCenterCheckAssignmentDeletionStatusAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - instanceArn: z.string().min(1, 'Instance ARN is required'), - requestId: z.string().min(1, 'Request ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -29,8 +16,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest( + awsIdentityCenterCheckAssignmentDeletionStatusContract, + request, + { + errorFormat: 'details', + logger, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Checking assignment deletion status for request ${params.requestId}`) @@ -50,13 +45,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error('Failed to check assignment deletion status:', error) return NextResponse.json( { error: `Failed to check assignment deletion status: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/identity-center/check-assignment-status/route.ts b/apps/sim/app/api/tools/identity-center/check-assignment-status/route.ts index 964fbdccde6..f511e243358 100644 --- a/apps/sim/app/api/tools/identity-center/check-assignment-status/route.ts +++ b/apps/sim/app/api/tools/identity-center/check-assignment-status/route.ts @@ -1,27 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIdentityCenterCheckAssignmentStatusContract } from '@/lib/api/contracts/tools/aws/identity-center-check-assignment-status' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkAssignmentCreationStatus, createSSOAdminClient } from '../utils' const logger = createLogger('IdentityCenterCheckAssignmentStatusAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - instanceArn: z.string().min(1, 'Instance ARN is required'), - requestId: z.string().min(1, 'Request ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -29,8 +16,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest( + awsIdentityCenterCheckAssignmentStatusContract, + request, + { + errorFormat: 'details', + logger, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Checking assignment status for request ${params.requestId}`) @@ -50,13 +45,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error('Failed to check assignment status:', error) return NextResponse.json( { error: `Failed to check assignment status: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/identity-center/create-account-assignment/route.ts b/apps/sim/app/api/tools/identity-center/create-account-assignment/route.ts index cf0205b811d..5e36a408ac0 100644 --- a/apps/sim/app/api/tools/identity-center/create-account-assignment/route.ts +++ b/apps/sim/app/api/tools/identity-center/create-account-assignment/route.ts @@ -6,30 +6,14 @@ import { import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIdentityCenterCreateAccountAssignmentContract } from '@/lib/api/contracts/tools/aws/identity-center-create-account-assignment' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSOAdminClient, mapAssignmentStatus } from '../utils' const logger = createLogger('IdentityCenterCreateAccountAssignmentAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - instanceArn: z.string().min(1, 'Instance ARN is required'), - accountId: z.string().min(1, 'Account ID is required'), - permissionSetArn: z.string().min(1, 'Permission set ARN is required'), - principalType: z.enum(['USER', 'GROUP']), - principalId: z.string().min(1, 'Principal ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -37,8 +21,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest( + awsIdentityCenterCreateAccountAssignmentContract, + request, + { + errorFormat: 'details', + logger, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `Creating account assignment for ${params.principalType} ${params.principalId} on account ${params.accountId}` @@ -70,13 +62,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error('Failed to create account assignment:', error) return NextResponse.json( { error: `Failed to create account assignment: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/identity-center/delete-account-assignment/route.ts b/apps/sim/app/api/tools/identity-center/delete-account-assignment/route.ts index 2ab887e83ba..c06c32069c8 100644 --- a/apps/sim/app/api/tools/identity-center/delete-account-assignment/route.ts +++ b/apps/sim/app/api/tools/identity-center/delete-account-assignment/route.ts @@ -6,30 +6,14 @@ import { import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIdentityCenterDeleteAccountAssignmentContract } from '@/lib/api/contracts/tools/aws/identity-center-delete-account-assignment' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSOAdminClient, mapAssignmentStatus } from '../utils' const logger = createLogger('IdentityCenterDeleteAccountAssignmentAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - instanceArn: z.string().min(1, 'Instance ARN is required'), - accountId: z.string().min(1, 'Account ID is required'), - permissionSetArn: z.string().min(1, 'Permission set ARN is required'), - principalType: z.enum(['USER', 'GROUP']), - principalId: z.string().min(1, 'Principal ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -37,8 +21,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest( + awsIdentityCenterDeleteAccountAssignmentContract, + request, + { + errorFormat: 'details', + logger, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `Deleting account assignment for ${params.principalType} ${params.principalId} on account ${params.accountId}` @@ -70,13 +62,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error('Failed to delete account assignment:', error) return NextResponse.json( { error: `Failed to delete account assignment: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/identity-center/describe-account/route.ts b/apps/sim/app/api/tools/identity-center/describe-account/route.ts index fc67bc47290..1342b9d180b 100644 --- a/apps/sim/app/api/tools/identity-center/describe-account/route.ts +++ b/apps/sim/app/api/tools/identity-center/describe-account/route.ts @@ -1,26 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIdentityCenterDescribeAccountContract } from '@/lib/api/contracts/tools/aws/identity-center-describe-account' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createOrganizationsClient, describeAccount } from '../utils' const logger = createLogger('IdentityCenterDescribeAccountAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - accountId: z.string().min(12, 'Account ID must be 12 digits').max(12), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -28,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIdentityCenterDescribeAccountContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Describing AWS account ${params.accountId}`) @@ -42,13 +34,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error('Failed to describe account:', error) return NextResponse.json( { error: `Failed to describe account: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/identity-center/get-group/route.ts b/apps/sim/app/api/tools/identity-center/get-group/route.ts index f4e7a5d1df9..88e4ad7838e 100644 --- a/apps/sim/app/api/tools/identity-center/get-group/route.ts +++ b/apps/sim/app/api/tools/identity-center/get-group/route.ts @@ -1,27 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIdentityCenterGetGroupContract } from '@/lib/api/contracts/tools/aws/identity-center-get-group' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIdentityStoreClient, getGroupByDisplayName } from '../utils' const logger = createLogger('IdentityCenterGetGroupAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - identityStoreId: z.string().min(1, 'Identity Store ID is required'), - displayName: z.string().min(1, 'Group display name is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -29,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIdentityCenterGetGroupContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `Looking up group "${params.displayName}" in identity store ${params.identityStoreId}` @@ -45,13 +36,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error('Failed to get group:', error) return NextResponse.json( { error: `Failed to get group: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/identity-center/get-user/route.ts b/apps/sim/app/api/tools/identity-center/get-user/route.ts index d7ab10e9feb..bc8d04c286c 100644 --- a/apps/sim/app/api/tools/identity-center/get-user/route.ts +++ b/apps/sim/app/api/tools/identity-center/get-user/route.ts @@ -1,27 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIdentityCenterGetUserContract } from '@/lib/api/contracts/tools/aws/identity-center-get-user' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIdentityStoreClient, getUserByEmail } from '../utils' const logger = createLogger('IdentityCenterGetUserAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - identityStoreId: z.string().min(1, 'Identity Store ID is required'), - email: z.string().email('Valid email address is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -29,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIdentityCenterGetUserContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Looking up user by email in identity store ${params.identityStoreId}`) @@ -43,13 +34,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error('Failed to get user:', error) return NextResponse.json( { error: `Failed to get user: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/identity-center/list-account-assignments/route.ts b/apps/sim/app/api/tools/identity-center/list-account-assignments/route.ts index bef649fac06..0ee8c1246a1 100644 --- a/apps/sim/app/api/tools/identity-center/list-account-assignments/route.ts +++ b/apps/sim/app/api/tools/identity-center/list-account-assignments/route.ts @@ -1,30 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIdentityCenterListAccountAssignmentsContract } from '@/lib/api/contracts/tools/aws/identity-center-list-account-assignments' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSOAdminClient, listAccountAssignmentsForPrincipal } from '../utils' const logger = createLogger('IdentityCenterListAccountAssignmentsAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - instanceArn: z.string().min(1, 'Instance ARN is required'), - principalId: z.string().min(1, 'Principal ID is required'), - principalType: z.enum(['USER', 'GROUP']), - maxResults: z.number().min(1).max(100).optional(), - nextToken: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -32,8 +16,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest( + awsIdentityCenterListAccountAssignmentsContract, + request, + { + errorFormat: 'details', + logger, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Listing account assignments for ${params.principalType} ${params.principalId}`) @@ -53,13 +45,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error('Failed to list account assignments:', error) return NextResponse.json( { error: `Failed to list account assignments: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/identity-center/list-accounts/route.ts b/apps/sim/app/api/tools/identity-center/list-accounts/route.ts index 40ac08033be..eecc6819274 100644 --- a/apps/sim/app/api/tools/identity-center/list-accounts/route.ts +++ b/apps/sim/app/api/tools/identity-center/list-accounts/route.ts @@ -1,27 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIdentityCenterListAccountsContract } from '@/lib/api/contracts/tools/aws/identity-center-list-accounts' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createOrganizationsClient, listAccounts } from '../utils' const logger = createLogger('IdentityCenterListAccountsAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - maxResults: z.number().min(1).max(20).optional(), - nextToken: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -29,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIdentityCenterListAccountsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info('Listing AWS accounts') @@ -43,13 +34,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error('Failed to list AWS accounts:', error) return NextResponse.json( { error: `Failed to list AWS accounts: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/identity-center/list-groups/route.ts b/apps/sim/app/api/tools/identity-center/list-groups/route.ts index 321ab4cec97..cecb0818883 100644 --- a/apps/sim/app/api/tools/identity-center/list-groups/route.ts +++ b/apps/sim/app/api/tools/identity-center/list-groups/route.ts @@ -1,28 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIdentityCenterListGroupsContract } from '@/lib/api/contracts/tools/aws/identity-center-list-groups' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createIdentityStoreClient, listGroups } from '../utils' const logger = createLogger('IdentityCenterListGroupsAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - identityStoreId: z.string().min(1, 'Identity Store ID is required'), - maxResults: z.number().min(1).max(100).optional(), - nextToken: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -30,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIdentityCenterListGroupsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Listing groups in identity store ${params.identityStoreId}`) @@ -49,13 +39,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error('Failed to list groups:', error) return NextResponse.json( { error: `Failed to list groups: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/identity-center/list-instances/route.ts b/apps/sim/app/api/tools/identity-center/list-instances/route.ts index 645044718c8..f29fbfb2e44 100644 --- a/apps/sim/app/api/tools/identity-center/list-instances/route.ts +++ b/apps/sim/app/api/tools/identity-center/list-instances/route.ts @@ -1,27 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIdentityCenterListInstancesContract } from '@/lib/api/contracts/tools/aws/identity-center-list-instances' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSOAdminClient, listInstances } from '../utils' const logger = createLogger('IdentityCenterListInstancesAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - maxResults: z.number().min(1).max(100).optional(), - nextToken: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -29,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIdentityCenterListInstancesContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info('Listing Identity Center instances') @@ -43,13 +34,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error('Failed to list Identity Center instances:', error) return NextResponse.json( { error: `Failed to list Identity Center instances: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/identity-center/list-permission-sets/route.ts b/apps/sim/app/api/tools/identity-center/list-permission-sets/route.ts index 72a07e20027..2cd029d78f3 100644 --- a/apps/sim/app/api/tools/identity-center/list-permission-sets/route.ts +++ b/apps/sim/app/api/tools/identity-center/list-permission-sets/route.ts @@ -1,28 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsIdentityCenterListPermissionSetsContract } from '@/lib/api/contracts/tools/aws/identity-center-list-permission-sets' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSOAdminClient, listPermissionSets } from '../utils' const logger = createLogger('IdentityCenterListPermissionSetsAPI') -const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - instanceArn: z.string().min(1, 'Instance ARN is required'), - maxResults: z.number().min(1).max(100).optional(), - nextToken: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -30,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseAwsToolRequest(awsIdentityCenterListPermissionSetsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Listing permission sets for instance ${params.instanceArn}`) @@ -49,13 +39,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } logger.error('Failed to list permission sets:', error) return NextResponse.json( { error: `Failed to list permission sets: ${toError(error).message}` }, diff --git a/apps/sim/app/api/tools/image/route.ts b/apps/sim/app/api/tools/image/route.ts index b3c9cfe0eee..01386220611 100644 --- a/apps/sim/app/api/tools/image/route.ts +++ b/apps/sim/app/api/tools/image/route.ts @@ -1,6 +1,12 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { imageProxyQuerySchema } from '@/lib/api/contracts/media-tools' +import { + getValidationErrorMessage, + searchParamsToObject, + validateSchema, +} from '@/lib/api/server/validation' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -16,8 +22,6 @@ const logger = createLogger('ImageProxyAPI') * This allows client-side requests to fetch images from various sources while avoiding CORS issues */ export const GET = withRouteHandler(async (request: NextRequest) => { - const url = new URL(request.url) - const imageUrl = url.searchParams.get('url') const requestId = generateRequestId() const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) @@ -26,10 +30,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return new NextResponse('Unauthorized', { status: 401 }) } - if (!imageUrl) { - logger.error(`[${requestId}] Missing 'url' parameter`) - return new NextResponse('Missing URL parameter', { status: 400 }) + const queryResult = validateSchema( + imageProxyQuerySchema, + searchParamsToObject(request.nextUrl.searchParams) + ) + if (!queryResult.success) { + const error = getValidationErrorMessage(queryResult.error, 'Missing URL parameter') + logger.error(`[${requestId}] ${error}`) + return new NextResponse(error, { status: 400 }) } + const { url: imageUrl } = queryResult.data const urlValidation = await validateUrlWithDNS(imageUrl, 'imageUrl') if (!urlValidation.isValid) { diff --git a/apps/sim/app/api/tools/imap/mailboxes/route.ts b/apps/sim/app/api/tools/imap/mailboxes/route.ts index 02a8b787a77..7230d440e25 100644 --- a/apps/sim/app/api/tools/imap/mailboxes/route.ts +++ b/apps/sim/app/api/tools/imap/mailboxes/route.ts @@ -1,37 +1,47 @@ import { createLogger } from '@sim/logger' import { ImapFlow } from 'imapflow' import { type NextRequest, NextResponse } from 'next/server' +import { imapMailboxesContract } from '@/lib/api/contracts/tools/imap' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('ImapMailboxesAPI') -interface ImapMailboxRequest { - host: string - port: number - secure: boolean - username: string - password: string -} - export const POST = withRouteHandler(async (request: NextRequest) => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ success: false, message: 'Unauthorized' }, { status: 401 }) } - try { - const body = (await request.json()) as ImapMailboxRequest - const { host, port, secure, username, password } = body - - if (!host || !username || !password) { - return NextResponse.json( - { success: false, message: 'Missing required fields: host, username, password' }, - { status: 400 } - ) + const parsed = await parseRequest( + imapMailboxesContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + success: false, + message: getValidationErrorMessage( + error, + 'Missing required fields: host, username, password' + ), + }, + { status: 400 } + ), + invalidJsonResponse: () => + NextResponse.json( + { success: false, message: 'Request body must be valid JSON' }, + { status: 400 } + ), } + ) + if (!parsed.success) return parsed.response + const { host, port, secure, username, password } = parsed.data.body + try { const hostValidation = await validateDatabaseHost(host, 'host') if (!hostValidation.isValid) { return NextResponse.json({ success: false, message: hostValidation.error }, { status: 400 }) @@ -40,8 +50,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const client = new ImapFlow({ host: hostValidation.resolvedIP!, servername: host, - port: port || 993, - secure: secure ?? true, + port, + secure, auth: { user: username, pass: password, diff --git a/apps/sim/app/api/tools/jira/add-attachment/route.ts b/apps/sim/app/api/tools/jira/add-attachment/route.ts index 1c036e21d25..e889ef13745 100644 --- a/apps/sim/app/api/tools/jira/add-attachment/route.ts +++ b/apps/sim/app/api/tools/jira/add-attachment/route.ts @@ -1,9 +1,9 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { jiraAddAttachmentContract } from '@/lib/api/contracts/selectors/jira' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -12,14 +12,6 @@ const logger = createLogger('JiraAddAttachmentAPI') export const dynamic = 'force-dynamic' -const JiraAddAttachmentSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - domain: z.string().min(1, 'Domain is required'), - issueKey: z.string().min(1, 'Issue key is required'), - files: RawFileInputArraySchema, - cloudId: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = `jira-attach-${Date.now()}` @@ -32,8 +24,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = JiraAddAttachmentSchema.parse(body) + const parsed = await parseRequest(jiraAddAttachmentContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const userFiles = processFilesToUserFiles(validatedData.files, requestId, logger) if (userFiles.length === 0) { @@ -107,13 +100,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { success: false, error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Jira attachment upload error`, error) return NextResponse.json( { success: false, error: error instanceof Error ? error.message : 'Internal server error' }, diff --git a/apps/sim/app/api/tools/jira/issues/route.ts b/apps/sim/app/api/tools/jira/issues/route.ts index 897719c0528..93d1479445d 100644 --- a/apps/sim/app/api/tools/jira/issues/route.ts +++ b/apps/sim/app/api/tools/jira/issues/route.ts @@ -1,5 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { + jiraIssueSelectorContract, + jiraIssuesSelectorContract, +} from '@/lib/api/contracts/selectors/jira' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -14,16 +19,6 @@ const createErrorResponse = async (response: Response) => { return parseAtlassianErrorMessage(response.status, response.statusText, errorText) } -const validateRequiredParams = (domain: string | null, accessToken: string | null) => { - if (!domain) { - return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) - } - if (!accessToken) { - return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) - } - return null -} - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) @@ -31,17 +26,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { domain, accessToken, issueKeys = [], cloudId: providedCloudId } = await request.json() + const parsed = await parseRequest(jiraIssueSelectorContract, request, {}) + if (!parsed.success) return parsed.response - const validationError = validateRequiredParams(domain || null, accessToken || null) - if (validationError) return validationError + const { domain, accessToken, issueKeys, cloudId: providedCloudId } = parsed.data.body if (issueKeys.length === 0) { logger.info('No issue keys provided, returning empty result') return NextResponse.json({ issues: [] }) } - const cloudId = providedCloudId || (await getJiraCloudId(domain!, accessToken!)) + const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken)) const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') if (!cloudIdValidation.isValid) { @@ -108,21 +103,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const url = new URL(request.url) - const domain = url.searchParams.get('domain')?.trim() - const accessToken = url.searchParams.get('accessToken') - const providedCloudId = url.searchParams.get('cloudId') - const query = url.searchParams.get('query') || '' - const projectId = url.searchParams.get('projectId') || '' - const manualProjectId = url.searchParams.get('manualProjectId') || '' - const all = url.searchParams.get('all')?.toLowerCase() === 'true' - const limitParam = Number.parseInt(url.searchParams.get('limit') || '', 10) - const limit = Number.isFinite(limitParam) && limitParam > 0 ? limitParam : 0 - - const validationError = validateRequiredParams(domain || null, accessToken || null) - if (validationError) return validationError - - const cloudId = providedCloudId || (await getJiraCloudId(domain!, accessToken!)) + const parsed = await parseRequest(jiraIssuesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: providedCloudId, + query = '', + projectId = '', + manualProjectId = '', + all, + limit, + } = parsed.data.query + + const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken)) const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') if (!cloudIdValidation.isValid) { diff --git a/apps/sim/app/api/tools/jira/projects/route.ts b/apps/sim/app/api/tools/jira/projects/route.ts index 8a933467f0d..2c99f2e447a 100644 --- a/apps/sim/app/api/tools/jira/projects/route.ts +++ b/apps/sim/app/api/tools/jira/projects/route.ts @@ -1,5 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { + jiraProjectSelectorContract, + jiraProjectsSelectorContract, +} from '@/lib/api/contracts/selectors/jira' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -16,11 +21,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const url = new URL(request.url) - const domain = url.searchParams.get('domain')?.trim() - const accessToken = url.searchParams.get('accessToken') - const providedCloudId = url.searchParams.get('cloudId') - const query = url.searchParams.get('query') || '' + const parsed = await parseRequest(jiraProjectsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, cloudId: providedCloudId, query = '' } = parsed.data.query if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) @@ -108,7 +112,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const { domain, accessToken, projectId, cloudId: providedCloudId } = await request.json() + const parsed = await parseRequest(jiraProjectSelectorContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, projectId, cloudId: providedCloudId } = parsed.data.body if (!domain) { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/jira/update/route.ts b/apps/sim/app/api/tools/jira/update/route.ts index 7945b2d29bc..54e1f846afb 100644 --- a/apps/sim/app/api/tools/jira/update/route.ts +++ b/apps/sim/app/api/tools/jira/update/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { jiraUpdateContract } from '@/lib/api/contracts/selectors/jira' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,26 +12,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('JiraUpdateAPI') -const jiraUpdateSchema = z.object({ - domain: z.string().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - issueKey: z.string().min(1, 'Issue key is required'), - summary: z.string().optional(), - title: z.string().optional(), - description: z.union([z.string(), z.record(z.unknown())]).optional(), - priority: z.string().optional(), - assignee: z.string().optional(), - labels: z.array(z.string()).optional(), - components: z.array(z.string()).optional(), - duedate: z.string().optional(), - fixVersions: z.array(z.string()).optional(), - environment: z.union([z.string(), z.record(z.unknown())]).optional(), - customFieldId: z.string().optional(), - customFieldValue: z.string().optional(), - notifyUsers: z.boolean().optional(), - cloudId: z.string().optional(), -}) - export const PUT = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) @@ -38,14 +19,8 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validation = jiraUpdateSchema.safeParse(body) - - if (!validation.success) { - const firstError = validation.error.errors[0] - logger.error('Validation error:', firstError) - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } + const parsed = await parseRequest(jiraUpdateContract, request, {}) + if (!parsed.success) return parsed.response const { domain, @@ -65,7 +40,7 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { customFieldValue, notifyUsers, cloudId: providedCloudId, - } = validation.data + } = parsed.data.body const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken)) logger.info('Using cloud ID:', cloudId) diff --git a/apps/sim/app/api/tools/jira/write/route.ts b/apps/sim/app/api/tools/jira/write/route.ts index be0bb063ef6..3c1ce3c491f 100644 --- a/apps/sim/app/api/tools/jira/write/route.ts +++ b/apps/sim/app/api/tools/jira/write/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jiraWriteContract } from '@/lib/api/contracts/selectors/jira' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -17,6 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } + const parsed = await parseRequest(jiraWriteContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -36,7 +41,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { customFieldValue, components, fixVersions, - } = await request.json() + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/approvals/route.ts b/apps/sim/app/api/tools/jsm/approvals/route.ts index ef9576f52da..76a65b7a4cb 100644 --- a/apps/sim/app/api/tools/jsm/approvals/route.ts +++ b/apps/sim/app/api/tools/jsm/approvals/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmApprovalsContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, @@ -26,7 +28,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() + const parsed = await parseRequest(jsmApprovalsContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -37,7 +41,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { decision, start, limit, - } = body + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/comment/route.ts b/apps/sim/app/api/tools/jsm/comment/route.ts index 4282f3655cc..3f464ad1ce6 100644 --- a/apps/sim/app/api/tools/jsm/comment/route.ts +++ b/apps/sim/app/api/tools/jsm/comment/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmCommentContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,6 +20,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { + const parsed = await parseRequest(jsmCommentContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -25,7 +30,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { issueIdOrKey, body: commentBody, isPublic, - } = await request.json() + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/comments/route.ts b/apps/sim/app/api/tools/jsm/comments/route.ts index ad5f0a58bbd..a918bbe89d6 100644 --- a/apps/sim/app/api/tools/jsm/comments/route.ts +++ b/apps/sim/app/api/tools/jsm/comments/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmCommentsContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,7 +20,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() + const parsed = await parseRequest(jsmCommentsContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -29,7 +33,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { expand, start, limit, - } = body + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/customers/route.ts b/apps/sim/app/api/tools/jsm/customers/route.ts index 5924cada69c..87d1aae4843 100644 --- a/apps/sim/app/api/tools/jsm/customers/route.ts +++ b/apps/sim/app/api/tools/jsm/customers/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmCustomersContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,7 +20,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() + const parsed = await parseRequest(jsmCustomersContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -29,7 +33,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { limit, accountIds, emails, - } = body + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/forms/answers/route.ts b/apps/sim/app/api/tools/jsm/forms/answers/route.ts index d37680801a9..aa204cc6f09 100644 --- a/apps/sim/app/api/tools/jsm/forms/answers/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/answers/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmFormAnswersContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,8 +20,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, formId } = body + const parsed = await parseRequest(jsmFormAnswersContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, formId } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/forms/attach/route.ts b/apps/sim/app/api/tools/jsm/forms/attach/route.ts index a9399a68124..a7ee4cc9d13 100644 --- a/apps/sim/app/api/tools/jsm/forms/attach/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/attach/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmAttachFormContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,8 +20,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, formTemplateId } = body + const parsed = await parseRequest(jsmAttachFormContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: cloudIdParam, + issueIdOrKey, + formTemplateId, + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/forms/copy/route.ts b/apps/sim/app/api/tools/jsm/forms/copy/route.ts index c1644d3faae..bf27d1d5c43 100644 --- a/apps/sim/app/api/tools/jsm/forms/copy/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/copy/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmCopyFormsContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,7 +20,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() + const parsed = await parseRequest(jsmCopyFormsContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -26,7 +30,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { sourceIssueIdOrKey, targetIssueIdOrKey, formIds, - } = body + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/forms/delete/route.ts b/apps/sim/app/api/tools/jsm/forms/delete/route.ts index c5bab3e2868..d0405ab9575 100644 --- a/apps/sim/app/api/tools/jsm/forms/delete/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/delete/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmDeleteFormContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,8 +20,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, formId } = body + const parsed = await parseRequest(jsmDeleteFormContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, formId } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/forms/externalise/route.ts b/apps/sim/app/api/tools/jsm/forms/externalise/route.ts index ccf23d9bb26..7e812406a87 100644 --- a/apps/sim/app/api/tools/jsm/forms/externalise/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/externalise/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmExternaliseFormContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,8 +20,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, formId } = body + const parsed = await parseRequest(jsmExternaliseFormContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, formId } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/forms/get/route.ts b/apps/sim/app/api/tools/jsm/forms/get/route.ts index 8517800edaa..f885a9cbd4f 100644 --- a/apps/sim/app/api/tools/jsm/forms/get/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/get/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmGetFormContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,8 +20,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, formId } = body + const parsed = await parseRequest(jsmGetFormContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, formId } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/forms/internalise/route.ts b/apps/sim/app/api/tools/jsm/forms/internalise/route.ts index 56830c579d9..6f695fab0ef 100644 --- a/apps/sim/app/api/tools/jsm/forms/internalise/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/internalise/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmInternaliseFormContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,8 +20,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, formId } = body + const parsed = await parseRequest(jsmInternaliseFormContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, formId } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/forms/issue/route.ts b/apps/sim/app/api/tools/jsm/forms/issue/route.ts index 6a21b5380d0..b47028d1703 100644 --- a/apps/sim/app/api/tools/jsm/forms/issue/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/issue/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmIssueFormsContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,8 +20,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey } = body + const parsed = await parseRequest(jsmIssueFormsContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/forms/reopen/route.ts b/apps/sim/app/api/tools/jsm/forms/reopen/route.ts index 912cee11f83..98335b431ea 100644 --- a/apps/sim/app/api/tools/jsm/forms/reopen/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/reopen/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmReopenFormContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,8 +20,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, formId } = body + const parsed = await parseRequest(jsmReopenFormContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, formId } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/forms/save/route.ts b/apps/sim/app/api/tools/jsm/forms/save/route.ts index e5d7722e926..b8bd7b54d37 100644 --- a/apps/sim/app/api/tools/jsm/forms/save/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/save/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmSaveFormAnswersContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,8 +20,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, formId, answers } = body + const parsed = await parseRequest(jsmSaveFormAnswersContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: cloudIdParam, + issueIdOrKey, + formId, + answers, + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/forms/structure/route.ts b/apps/sim/app/api/tools/jsm/forms/structure/route.ts index 5f07a0c04c4..c67ddb5f997 100644 --- a/apps/sim/app/api/tools/jsm/forms/structure/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/structure/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmProjectFormStructureContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,8 +20,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { domain, accessToken, cloudId: cloudIdParam, projectIdOrKey, formId } = body + const parsed = await parseRequest(jsmProjectFormStructureContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, cloudId: cloudIdParam, projectIdOrKey, formId } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/forms/submit/route.ts b/apps/sim/app/api/tools/jsm/forms/submit/route.ts index 5f2293cb02f..c7d8656c1d8 100644 --- a/apps/sim/app/api/tools/jsm/forms/submit/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/submit/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmSubmitFormContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,8 +20,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, formId } = body + const parsed = await parseRequest(jsmSubmitFormContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, formId } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/forms/templates/route.ts b/apps/sim/app/api/tools/jsm/forms/templates/route.ts index 15bb4677334..1f0c66b3972 100644 --- a/apps/sim/app/api/tools/jsm/forms/templates/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/templates/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmProjectFormTemplatesContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,8 +20,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { domain, accessToken, cloudId: cloudIdParam, projectIdOrKey } = body + const parsed = await parseRequest(jsmProjectFormTemplatesContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, cloudId: cloudIdParam, projectIdOrKey } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/organization/route.ts b/apps/sim/app/api/tools/jsm/organization/route.ts index 6fb3fe54f94..8368ea71918 100644 --- a/apps/sim/app/api/tools/jsm/organization/route.ts +++ b/apps/sim/app/api/tools/jsm/organization/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmOrganizationContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, @@ -24,7 +26,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() + const parsed = await parseRequest(jsmOrganizationContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -33,7 +37,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { name, serviceDeskId, organizationId, - } = body + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/organizations/route.ts b/apps/sim/app/api/tools/jsm/organizations/route.ts index 411160cb0ad..f06712a949f 100644 --- a/apps/sim/app/api/tools/jsm/organizations/route.ts +++ b/apps/sim/app/api/tools/jsm/organizations/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmOrganizationsContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,8 +20,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { domain, accessToken, cloudId: cloudIdParam, serviceDeskId, start, limit } = body + const parsed = await parseRequest(jsmOrganizationsContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: cloudIdParam, + serviceDeskId, + start, + limit, + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/participants/route.ts b/apps/sim/app/api/tools/jsm/participants/route.ts index 2b835d0de5d..581b1919683 100644 --- a/apps/sim/app/api/tools/jsm/participants/route.ts +++ b/apps/sim/app/api/tools/jsm/participants/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmParticipantsContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateEnum, @@ -24,7 +26,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() + const parsed = await parseRequest(jsmParticipantsContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -34,7 +38,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { accountIds, start, limit, - } = body + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/queues/route.ts b/apps/sim/app/api/tools/jsm/queues/route.ts index 4f53dba106a..790e550d801 100644 --- a/apps/sim/app/api/tools/jsm/queues/route.ts +++ b/apps/sim/app/api/tools/jsm/queues/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmQueuesContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,7 +20,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() + const parsed = await parseRequest(jsmQueuesContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -27,7 +31,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { includeCount, start, limit, - } = body + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/request/route.ts b/apps/sim/app/api/tools/jsm/request/route.ts index 6874c8e5a84..e833068187d 100644 --- a/apps/sim/app/api/tools/jsm/request/route.ts +++ b/apps/sim/app/api/tools/jsm/request/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmRequestContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, @@ -22,7 +24,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() + const parsed = await parseRequest(jsmRequestContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -38,7 +42,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { requestParticipants, channel, expand, - } = body + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/requests/route.ts b/apps/sim/app/api/tools/jsm/requests/route.ts index 8e18855cdf7..a73fb59646d 100644 --- a/apps/sim/app/api/tools/jsm/requests/route.ts +++ b/apps/sim/app/api/tools/jsm/requests/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmRequestsContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, @@ -22,7 +24,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() + const parsed = await parseRequest(jsmRequestsContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -35,7 +39,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { expand, start, limit, - } = body + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/requesttypefields/route.ts b/apps/sim/app/api/tools/jsm/requesttypefields/route.ts index 23a09e55b39..ffab68c310d 100644 --- a/apps/sim/app/api/tools/jsm/requesttypefields/route.ts +++ b/apps/sim/app/api/tools/jsm/requesttypefields/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmRequestTypeFieldsContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,8 +20,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { domain, accessToken, cloudId: cloudIdParam, serviceDeskId, requestTypeId } = body + const parsed = await parseRequest(jsmRequestTypeFieldsContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: cloudIdParam, + serviceDeskId, + requestTypeId, + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/requesttypes/route.ts b/apps/sim/app/api/tools/jsm/requesttypes/route.ts index b79eb42329f..56989b00497 100644 --- a/apps/sim/app/api/tools/jsm/requesttypes/route.ts +++ b/apps/sim/app/api/tools/jsm/requesttypes/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmRequestTypesContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,7 +20,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() + const parsed = await parseRequest(jsmRequestTypesContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -29,7 +33,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { expand, start, limit, - } = body + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') 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 0f20db9970c..01c1682e1e0 100644 --- a/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts +++ b/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { jsmRequestTypesSelectorContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -12,11 +14,13 @@ const logger = createLogger('JsmSelectorRequestTypesAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId, domain, serviceDeskId } = body + const parsed = await parseRequest(jsmRequestTypesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + + const { credential, workflowId, domain, serviceDeskId } = parsed.data.body if (!credential) { logger.error('Missing credential in request') @@ -36,7 +40,7 @@ export const POST = withRouteHandler(async (request: Request) => { return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 }) } - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) 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 3a6f8b2185b..c1efdb0f93e 100644 --- a/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts +++ b/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { jsmServiceDesksSelectorContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -12,11 +14,13 @@ const logger = createLogger('JsmSelectorServiceDesksAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId, domain } = body + const parsed = await parseRequest(jsmServiceDesksSelectorContract, request, {}) + if (!parsed.success) return parsed.response + + const { credential, workflowId, domain } = parsed.data.body if (!credential) { logger.error('Missing credential in request') @@ -27,7 +31,7 @@ export const POST = withRouteHandler(async (request: Request) => { return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) } - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) diff --git a/apps/sim/app/api/tools/jsm/servicedesks/route.ts b/apps/sim/app/api/tools/jsm/servicedesks/route.ts index bddbbc3ccc6..b7a8d6d8b4c 100644 --- a/apps/sim/app/api/tools/jsm/servicedesks/route.ts +++ b/apps/sim/app/api/tools/jsm/servicedesks/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmServiceDesksContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,8 +20,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { domain, accessToken, cloudId: cloudIdParam, expand, start, limit } = body + const parsed = await parseRequest(jsmServiceDesksContract, request, {}) + if (!parsed.success) return parsed.response + + const { domain, accessToken, cloudId: cloudIdParam, expand, start, limit } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/sla/route.ts b/apps/sim/app/api/tools/jsm/sla/route.ts index 9e8e22735d4..1ebf5613cab 100644 --- a/apps/sim/app/api/tools/jsm/sla/route.ts +++ b/apps/sim/app/api/tools/jsm/sla/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmSlaContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,8 +20,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, start, limit } = body + const parsed = await parseRequest(jsmSlaContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: cloudIdParam, + issueIdOrKey, + start, + limit, + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/transition/route.ts b/apps/sim/app/api/tools/jsm/transition/route.ts index 3614367c99a..8b1027d8c59 100644 --- a/apps/sim/app/api/tools/jsm/transition/route.ts +++ b/apps/sim/app/api/tools/jsm/transition/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmTransitionContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, @@ -22,6 +24,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { + const parsed = await parseRequest(jsmTransitionContract, request, {}) + if (!parsed.success) return parsed.response + const { domain, accessToken, @@ -29,7 +34,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { issueIdOrKey, transitionId, comment, - } = await request.json() + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/jsm/transitions/route.ts b/apps/sim/app/api/tools/jsm/transitions/route.ts index 67176399589..8d3199b9dd0 100644 --- a/apps/sim/app/api/tools/jsm/transitions/route.ts +++ b/apps/sim/app/api/tools/jsm/transitions/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { jsmTransitionsContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,8 +20,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, start, limit } = body + const parsed = await parseRequest(jsmTransitionsContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: cloudIdParam, + issueIdOrKey, + start, + limit, + } = parsed.data.body if (!domain) { logger.error('Missing domain in request') diff --git a/apps/sim/app/api/tools/linear/projects/route.ts b/apps/sim/app/api/tools/linear/projects/route.ts index b0370fd3793..abf3ad14135 100644 --- a/apps/sim/app/api/tools/linear/projects/route.ts +++ b/apps/sim/app/api/tools/linear/projects/route.ts @@ -1,7 +1,9 @@ import type { Project } from '@linear/sdk' import { LinearClient } from '@linear/sdk' import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { linearProjectsSelectorContract } from '@/lib/api/contracts/selectors' +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' @@ -11,18 +13,14 @@ export const dynamic = 'force-dynamic' const logger = createLogger('LinearProjectsAPI') -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { try { - const body = await request.json() - const { credential, teamId, workflowId } = body - - if (!credential || !teamId) { - logger.error('Missing credential or teamId in request') - return NextResponse.json({ error: 'Credential and teamId are required' }, { status: 400 }) - } + const parsed = await parseRequest(linearProjectsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, teamId, workflowId } = parsed.data.body const requestId = generateRequestId() - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) @@ -41,16 +39,13 @@ export const POST = withRouteHandler(async (request: Request) => { userId: authz.credentialOwnerUserId, }) return NextResponse.json( - { - error: 'Could not retrieve access token', - authRequired: true, - }, + { error: 'Could not retrieve access token', authRequired: true }, { status: 401 } ) } const linearClient = new LinearClient({ accessToken }) - let projects = [] + let projects: Array<{ id: string; name: string }> = [] const team = await linearClient.team(teamId) const projectsResult = await team.projects() diff --git a/apps/sim/app/api/tools/linear/teams/route.ts b/apps/sim/app/api/tools/linear/teams/route.ts index 05ac1132c6d..89b02a6e24a 100644 --- a/apps/sim/app/api/tools/linear/teams/route.ts +++ b/apps/sim/app/api/tools/linear/teams/route.ts @@ -1,7 +1,9 @@ import type { Team } from '@linear/sdk' import { LinearClient } from '@linear/sdk' import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { linearTeamsSelectorContract } from '@/lib/api/contracts/selectors' +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' @@ -11,18 +13,14 @@ export const dynamic = 'force-dynamic' const logger = createLogger('LinearTeamsAPI') -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const requestId = generateRequestId() - const body = await request.json() - const { credential, workflowId } = body + const parsed = await parseRequest(linearTeamsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId } = parsed.data.body - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) @@ -41,10 +39,7 @@ export const POST = withRouteHandler(async (request: Request) => { userId: authz.credentialOwnerUserId, }) return NextResponse.json( - { - error: 'Could not retrieve access token', - authRequired: true, - }, + { error: 'Could not retrieve access token', authRequired: true }, { status: 401 } ) } diff --git a/apps/sim/app/api/tools/mail/send/route.ts b/apps/sim/app/api/tools/mail/send/route.ts index e2413607a24..6ff45d5a0ed 100644 --- a/apps/sim/app/api/tools/mail/send/route.ts +++ b/apps/sim/app/api/tools/mail/send/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { Resend } from 'resend' -import { z } from 'zod' +import { mailSendContract } from '@/lib/api/contracts/tools/mail' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,29 +11,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('MailSendAPI') -const MailSendSchema = z.object({ - fromAddress: z.string().min(1, 'From address is required'), - to: z.string().min(1, 'To email is required'), - subject: z.string().min(1, 'Subject is required'), - body: z.string().min(1, 'Email body is required'), - contentType: z.enum(['text', 'html']).optional().nullable(), - resendApiKey: z.string().min(1, 'Resend API key is required'), - cc: z - .union([z.string().min(1), z.array(z.string().min(1))]) - .optional() - .nullable(), - bcc: z - .union([z.string().min(1), z.array(z.string().min(1))]) - .optional() - .nullable(), - replyTo: z - .union([z.string().min(1), z.array(z.string().min(1))]) - .optional() - .nullable(), - scheduledAt: z.string().datetime().optional().nullable(), - tags: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -54,8 +32,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = MailSendSchema.parse(body) + const parsed = await parseRequest( + mailSendContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + message: getValidationErrorMessage(error, 'Invalid request data'), + errors: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Sending email with user-provided Resend API key`, { to: validatedData.to, @@ -67,17 +63,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const resend = new Resend(validatedData.resendApiKey) const contentType = validatedData.contentType || 'text' - const emailData: Record = { + const emailBase = { from: validatedData.fromAddress, to: validatedData.to, subject: validatedData.subject, } + let emailData: Parameters[0] if (contentType === 'html') { - emailData.html = validatedData.body - emailData.text = validatedData.body.replace(/<[^>]*>/g, '') + emailData = { + ...emailBase, + html: validatedData.body, + text: validatedData.body.replace(/<[^>]*>/g, ''), + } } else { - emailData.text = validatedData.body + emailData = { + ...emailBase, + text: validatedData.body, + } } if (validatedData.cc) { @@ -110,9 +113,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const { data, error } = await resend.emails.send( - emailData as unknown as Parameters[0] - ) + const { data, error } = await resend.emails.send(emailData) if (error) { logger.error(`[${requestId}] Email sending failed:`, error) @@ -138,18 +139,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(result) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - message: 'Invalid request data', - errors: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error sending email via API:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts b/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts index bc67c5921e0..a46edab1251 100644 --- a/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts +++ b/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts @@ -1,10 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { dataverseUploadFileBodySchema } from '@/lib/api/contracts/tools/microsoft' +import { validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -12,17 +12,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('DataverseUploadFileAPI') -const DataverseUploadFileSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - environmentUrl: z.string().min(1, 'Environment URL is required'), - entitySetName: z.string().min(1, 'Entity set name is required'), - recordId: z.string().min(1, 'Record ID is required'), - fileColumn: z.string().min(1, 'File column is required'), - fileName: z.string().min(1, 'File name is required'), - file: RawFileInputSchema.optional().nullable(), - fileContent: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -44,8 +33,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = DataverseUploadFileSchema.parse(body) + const validation = await validateJsonBody(request, dataverseUploadFileBodySchema) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request data`, { errors: validation.error?.issues ?? [] }) + return NextResponse.json( + { + success: false, + error: 'Invalid request data', + details: validation.error?.issues ?? [], + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Uploading file to Dataverse`, { entitySetName: validatedData.entitySetName, @@ -128,14 +128,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { success: false, error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error uploading file to Dataverse:`, error) return NextResponse.json( 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 2adc8713ef2..6570d5fdd42 100644 --- a/apps/sim/app/api/tools/microsoft-teams/channels/route.ts +++ b/apps/sim/app/api/tools/microsoft-teams/channels/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { microsoftChannelsSelectorContract } from '@/lib/api/contracts/selectors/microsoft' +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' @@ -10,21 +12,11 @@ export const dynamic = 'force-dynamic' const logger = createLogger('TeamsChannelsAPI') -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { try { - const body = await request.json() - - const { credential, teamId, workflowId } = body - - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } - - if (!teamId) { - logger.error('Missing team ID in request') - return NextResponse.json({ error: 'Team ID is required' }, { status: 400 }) - } + const parsed = await parseRequest(microsoftChannelsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, teamId, workflowId } = parsed.data.body const teamIdValidation = validateMicrosoftGraphId(teamId, 'Team ID') if (!teamIdValidation.isValid) { @@ -33,7 +25,7 @@ export const POST = withRouteHandler(async (request: Request) => { } try { - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) 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 bd016d07bb5..832c7200141 100644 --- a/apps/sim/app/api/tools/microsoft-teams/chats/route.ts +++ b/apps/sim/app/api/tools/microsoft-teams/chats/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { microsoftChatsSelectorContract } from '@/lib/api/contracts/selectors/microsoft' +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' @@ -123,19 +125,14 @@ const getChatDisplayName = async ( } } -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { try { - const body = await request.json() - - const { credential, workflowId } = body - - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } + const parsed = await parseRequest(microsoftChatsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId } = parsed.data.body try { - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) 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 9334ab937c4..44c1d997060 100644 --- a/apps/sim/app/api/tools/microsoft-teams/teams/route.ts +++ b/apps/sim/app/api/tools/microsoft-teams/teams/route.ts @@ -1,8 +1,9 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { microsoftTeamsSelectorContract } from '@/lib/api/contracts/selectors/microsoft' +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' @@ -10,20 +11,14 @@ export const dynamic = 'force-dynamic' const logger = createLogger('TeamsTeamsAPI') -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { try { - const body = await request.json() - - const { credential, workflowId } = body - - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } + const parsed = await parseRequest(microsoftTeamsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId } = parsed.data.body try { - const requestId = generateRequestId() - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) 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 d00e8db13b4..df884bea928 100644 --- a/apps/sim/app/api/tools/microsoft_excel/drives/route.ts +++ b/apps/sim/app/api/tools/microsoft_excel/drives/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { microsoftExcelDrivesSelectorContract } from '@/lib/api/contracts/selectors/microsoft' +import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validatePathSegment, validateSharePointSiteId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -27,18 +29,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId, siteId, driveId } = body - - if (!credential) { - logger.warn(`[${requestId}] Missing credential in request`) - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } - - if (!siteId) { - logger.warn(`[${requestId}] Missing siteId in request`) - return NextResponse.json({ error: 'Site ID is required' }, { status: 400 }) - } + const parsed = await parseRequest(microsoftExcelDrivesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, siteId, driveId } = parsed.data.body const siteIdValidation = validateSharePointSiteId(siteId, 'siteId') if (!siteIdValidation.isValid) { 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 fc8acb9bcea..7a2c64cf6c3 100644 --- a/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts +++ b/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { microsoftExcelSheetsSelectorContract } from '@/lib/api/contracts/selectors/microsoft' +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' @@ -29,21 +31,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Microsoft Excel sheets request received`) try { - const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const spreadsheetId = searchParams.get('spreadsheetId') - const driveId = searchParams.get('driveId') || undefined - const workflowId = searchParams.get('workflowId') || undefined - - if (!credentialId) { - logger.warn(`[${requestId}] Missing credentialId parameter`) - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) - } - - if (!spreadsheetId) { - logger.warn(`[${requestId}] Missing spreadsheetId parameter`) - return NextResponse.json({ error: 'Spreadsheet ID is required' }, { status: 400 }) - } + const parsed = await parseRequest(microsoftExcelSheetsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credentialId, spreadsheetId, driveId, workflowId } = parsed.data.query const authz = await authorizeCredentialUse(request, { credentialId, workflowId }) if (!authz.ok || !authz.credentialOwnerUserId) { 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 a298ef1dc9a..a710f845251 100644 --- a/apps/sim/app/api/tools/microsoft_planner/plans/route.ts +++ b/apps/sim/app/api/tools/microsoft_planner/plans/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { microsoftPlannerPlansSelectorContract } from '@/lib/api/contracts/selectors/microsoft' +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' @@ -9,19 +11,15 @@ const logger = createLogger('MicrosoftPlannerPlansAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId } = body + const parsed = await parseRequest(microsoftPlannerPlansSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId } = parsed.data.body - if (!credential) { - logger.error(`[${requestId}] Missing credential in request`) - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) 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 430e291407c..94bf43e8322 100644 --- a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts +++ b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { microsoftPlannerTasksSelectorContract } from '@/lib/api/contracts/selectors/microsoft' +import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -11,22 +13,13 @@ const logger = createLogger('MicrosoftPlannerTasksAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId, planId } = body - - if (!credential) { - logger.error(`[${requestId}] Missing credential in request`) - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } - - if (!planId) { - logger.error(`[${requestId}] Missing planId in request`) - return NextResponse.json({ error: 'Plan ID is required' }, { status: 400 }) - } + const parsed = await parseRequest(microsoftPlannerTasksSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, planId } = parsed.data.body const planIdValidation = validateMicrosoftGraphId(planId, 'planId') if (!planIdValidation.isValid) { @@ -34,7 +27,7 @@ export const POST = withRouteHandler(async (request: Request) => { return NextResponse.json({ error: planIdValidation.error }, { status: 400 }) } - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) diff --git a/apps/sim/app/api/tools/microsoft_teams/delete_chat_message/route.ts b/apps/sim/app/api/tools/microsoft_teams/delete_chat_message/route.ts index aec29f546de..fb0584e06b4 100644 --- a/apps/sim/app/api/tools/microsoft_teams/delete_chat_message/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/delete_chat_message/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { teamsDeleteChatMessageBodySchema } from '@/lib/api/contracts/tools/microsoft' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -9,12 +10,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('TeamsDeleteChatMessageAPI') -const TeamsDeleteChatMessageSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - chatId: z.string().min(1, 'Chat ID is required'), - messageId: z.string().min(1, 'Message ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -39,8 +34,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = TeamsDeleteChatMessageSchema.parse(body) + const validation = await validateJsonBody(request, teamsDeleteChatMessageBodySchema) + if (!validation.success) { + if (!validation.error) return validation.response + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Deleting Teams chat message`, { chatId: validatedData.chatId, diff --git a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts index 7fd864e24cc..92b19143ac5 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts @@ -1,11 +1,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { teamsWriteChannelBodySchema } from '@/lib/api/contracts/tools/microsoft' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { uploadFilesForTeamsMessage } from '@/tools/microsoft_teams/server-utils' import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types' import { resolveMentionsForChannel, type TeamsMention } from '@/tools/microsoft_teams/utils' @@ -14,14 +14,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('TeamsWriteChannelAPI') -const TeamsWriteChannelSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - teamId: z.string().min(1, 'Team ID is required'), - channelId: z.string().min(1, 'Channel ID is required'), - content: z.string().min(1, 'Message content is required'), - files: RawFileInputArraySchema.optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -46,8 +38,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = TeamsWriteChannelSchema.parse(body) + const validation = await validateJsonBody(request, teamsWriteChannelBodySchema) + if (!validation.success) { + if (!validation.error) return validation.response + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Sending Teams channel message`, { teamId: validatedData.teamId, diff --git a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts index 1f4399c8932..73e9fee1cc8 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts @@ -1,11 +1,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { teamsWriteChatBodySchema } from '@/lib/api/contracts/tools/microsoft' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { uploadFilesForTeamsMessage } from '@/tools/microsoft_teams/server-utils' import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types' import { resolveMentionsForChat, type TeamsMention } from '@/tools/microsoft_teams/utils' @@ -14,13 +14,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('TeamsWriteChatAPI') -const TeamsWriteChatSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - chatId: z.string().min(1, 'Chat ID is required'), - content: z.string().min(1, 'Message content is required'), - files: RawFileInputArraySchema.optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -45,8 +38,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = TeamsWriteChatSchema.parse(body) + const validation = await validateJsonBody(request, teamsWriteChatBodySchema) + if (!validation.success) { + if (!validation.error) return validation.response + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Sending Teams chat message`, { chatId: validatedData.chatId, diff --git a/apps/sim/app/api/tools/mistral/parse/route.ts b/apps/sim/app/api/tools/mistral/parse/route.ts index 984d74963ad..96432fa33f2 100644 --- a/apps/sim/app/api/tools/mistral/parse/route.ts +++ b/apps/sim/app/api/tools/mistral/parse/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mistralParseBodySchema } from '@/lib/api/contracts/media-tools' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -8,7 +9,6 @@ import { } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage, @@ -19,18 +19,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('MistralParseAPI') -const MistralParseSchema = z.object({ - apiKey: z.string().min(1, 'API key is required'), - filePath: z.string().min(1, 'File path is required').optional(), - fileData: FileInputSchema.optional(), - file: FileInputSchema.optional(), - resultType: z.string().optional(), - pages: z.array(z.number()).optional(), - includeImageBase64: z.boolean().optional(), - imageLimit: z.number().optional(), - imageMinSize: z.number().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -52,7 +40,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userId = authResult.userId const body = await request.json() - const validatedData = MistralParseSchema.parse(body) + const validation = validateSchema(mistralParseBodySchema, body) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request data`, { errors: validation.error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request data'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data const fileData = validatedData.file || validatedData.fileData const filePath = typeof fileData === 'string' ? fileData : validatedData.filePath @@ -253,18 +253,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: mistralData, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error in Mistral parse:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/monday/boards/route.ts b/apps/sim/app/api/tools/monday/boards/route.ts index 23d8ef71412..d20634b0111 100644 --- a/apps/sim/app/api/tools/monday/boards/route.ts +++ b/apps/sim/app/api/tools/monday/boards/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { mondayBoardsSelectorContract } from '@/lib/api/contracts/selectors' +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' @@ -9,18 +11,29 @@ export const dynamic = 'force-dynamic' const logger = createLogger('MondayBoardsAPI') -export const POST = withRouteHandler(async (request: Request) => { +interface MondayGraphQLError { + message?: string +} + +interface MondayBoardsResponse { + errors?: MondayGraphQLError[] + error_message?: string + data?: { + boards?: Array<{ + id: string + name: string + }> + } +} + +export const POST = withRouteHandler(async (request: NextRequest) => { try { const requestId = generateRequestId() - const body = await request.json() - const { credential, workflowId } = body - - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } + const parsed = await parseRequest(mondayBoardsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId } = parsed.data.body - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) @@ -56,7 +69,7 @@ export const POST = withRouteHandler(async (request: Request) => { }), }) - const data = await response.json() + const data = (await response.json()) as MondayBoardsResponse if (data.errors?.length) { logger.error('Monday.com API error', { errors: data.errors }) @@ -71,7 +84,7 @@ export const POST = withRouteHandler(async (request: Request) => { return NextResponse.json({ error: data.error_message }, { status: 500 }) } - const boards = (data.data?.boards || []).map((board: { id: string; name: string }) => ({ + const boards = (data.data?.boards || []).map((board) => ({ id: board.id, name: board.name, })) diff --git a/apps/sim/app/api/tools/monday/groups/route.ts b/apps/sim/app/api/tools/monday/groups/route.ts index 8fef3b2a809..49021443e64 100644 --- a/apps/sim/app/api/tools/monday/groups/route.ts +++ b/apps/sim/app/api/tools/monday/groups/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { mondayGroupsSelectorContract } from '@/lib/api/contracts/selectors' +import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMondayNumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -10,23 +12,36 @@ export const dynamic = 'force-dynamic' const logger = createLogger('MondayGroupsAPI') -export const POST = withRouteHandler(async (request: Request) => { +interface MondayGraphQLError { + message?: string +} + +interface MondayGroupsResponse { + errors?: MondayGraphQLError[] + error_message?: string + data?: { + boards?: Array<{ + groups?: Array<{ + id: string + title: string + }> + }> + } +} + +export const POST = withRouteHandler(async (request: NextRequest) => { try { const requestId = generateRequestId() - const body = await request.json() - const { credential, boardId, workflowId } = body - - if (!credential || !boardId) { - logger.error('Missing credential or boardId in request') - return NextResponse.json({ error: 'Credential and boardId are required' }, { status: 400 }) - } + const parsed = await parseRequest(mondayGroupsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, boardId, workflowId } = parsed.data.body const boardIdValidation = validateMondayNumericId(boardId, 'boardId') if (!boardIdValidation.isValid) { return NextResponse.json({ error: boardIdValidation.error }, { status: 400 }) } - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) @@ -62,7 +77,7 @@ export const POST = withRouteHandler(async (request: Request) => { }), }) - const data = await response.json() + const data = (await response.json()) as MondayGroupsResponse if (data.errors?.length) { logger.error('Monday.com API error', { errors: data.errors }) @@ -78,7 +93,7 @@ export const POST = withRouteHandler(async (request: Request) => { } const board = data.data?.boards?.[0] - const groups = (board?.groups || []).map((group: { id: string; title: string }) => ({ + const groups = (board?.groups || []).map((group) => ({ id: group.id, name: group.title, })) diff --git a/apps/sim/app/api/tools/mongodb/delete/route.ts b/apps/sim/app/api/tools/mongodb/delete/route.ts index db8b1ed6209..d1e466bab3a 100644 --- a/apps/sim/app/api/tools/mongodb/delete/route.ts +++ b/apps/sim/app/api/tools/mongodb/delete/route.ts @@ -1,43 +1,18 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mongodbDeleteContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils' +import { + createMongoDBConnection, + sanitizeCollectionName, + validateFilter, +} from '@/app/api/tools/mongodb/utils' const logger = createLogger('MongoDBDeleteAPI') -const DeleteSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - authSource: z.string().optional(), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), - collection: z.string().min(1, 'Collection name is required'), - filter: z - .union([z.string(), z.object({}).passthrough()]) - .transform((val) => { - if (typeof val === 'object' && val !== null) { - return JSON.stringify(val) - } - return val - }) - .refine((val) => val && val.trim() !== '' && val !== '{}', { - message: 'Filter is required for MongoDB Delete', - }), - multi: z - .union([z.boolean(), z.string(), z.undefined()]) - .optional() - .transform((val) => { - if (val === 'true' || val === true) return true - if (val === 'false' || val === false) return false - return false // Default to false - }), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let client = null @@ -49,8 +24,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = DeleteSchema.parse(body) + const parsed = await parseDatabaseToolRequest(mongodbDeleteContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Deleting document(s) from ${params.host}:${params.port}/${params.database}.${params.collection} (multi: ${params.multi})` @@ -102,14 +78,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { deletedCount: result.deletedCount, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] MongoDB delete failed:`, error) diff --git a/apps/sim/app/api/tools/mongodb/execute/route.ts b/apps/sim/app/api/tools/mongodb/execute/route.ts index 64c4a73484e..8fbddcff36a 100644 --- a/apps/sim/app/api/tools/mongodb/execute/route.ts +++ b/apps/sim/app/api/tools/mongodb/execute/route.ts @@ -1,35 +1,18 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mongodbExecuteContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createMongoDBConnection, sanitizeCollectionName, validatePipeline } from '../utils' +import { + createMongoDBConnection, + sanitizeCollectionName, + validatePipeline, +} from '@/app/api/tools/mongodb/utils' const logger = createLogger('MongoDBExecuteAPI') -const ExecuteSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - authSource: z.string().optional(), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), - collection: z.string().min(1, 'Collection name is required'), - pipeline: z - .union([z.string(), z.array(z.object({}).passthrough())]) - .transform((val) => { - if (Array.isArray(val)) { - return JSON.stringify(val) - } - return val - }) - .refine((val) => val && val.trim() !== '', { - message: 'Pipeline is required', - }), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let client = null @@ -41,8 +24,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = ExecuteSchema.parse(body) + const parsed = await parseDatabaseToolRequest(mongodbExecuteContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Executing aggregation pipeline on ${params.host}:${params.port}/${params.database}.${params.collection}` @@ -87,14 +71,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { documentCount: documents.length, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] MongoDB aggregation failed:`, error) diff --git a/apps/sim/app/api/tools/mongodb/insert/route.ts b/apps/sim/app/api/tools/mongodb/insert/route.ts index e987ad50af7..f1a82a55380 100644 --- a/apps/sim/app/api/tools/mongodb/insert/route.ts +++ b/apps/sim/app/api/tools/mongodb/insert/route.ts @@ -1,40 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mongodbInsertContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createMongoDBConnection, sanitizeCollectionName } from '../utils' +import { createMongoDBConnection, sanitizeCollectionName } from '@/app/api/tools/mongodb/utils' const logger = createLogger('MongoDBInsertAPI') -const InsertSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - authSource: z.string().optional(), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), - collection: z.string().min(1, 'Collection name is required'), - documents: z - .union([z.array(z.record(z.unknown())), z.string()]) - .transform((val) => { - if (typeof val === 'string') { - try { - const parsed = JSON.parse(val) - return Array.isArray(parsed) ? parsed : [parsed] - } catch { - throw new Error('Invalid JSON in documents field') - } - } - return val - }) - .refine((val) => Array.isArray(val) && val.length > 0, { - message: 'At least one document is required', - }), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let client = null @@ -46,8 +20,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = InsertSchema.parse(body) + const parsed = await parseDatabaseToolRequest(mongodbInsertContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Inserting ${params.documents.length} document(s) into ${params.host}:${params.port}/${params.database}.${params.collection}` @@ -86,14 +61,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { documentCount: insertedCount, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] MongoDB insert failed:`, error) diff --git a/apps/sim/app/api/tools/mongodb/introspect/route.ts b/apps/sim/app/api/tools/mongodb/introspect/route.ts index 40cefb7aedb..2a1f242752d 100644 --- a/apps/sim/app/api/tools/mongodb/introspect/route.ts +++ b/apps/sim/app/api/tools/mongodb/introspect/route.ts @@ -1,23 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mongodbIntrospectContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createMongoDBConnection, executeIntrospect } from '../utils' +import { createMongoDBConnection, executeIntrospect } from '@/app/api/tools/mongodb/utils' const logger = createLogger('MongoDBIntrospectAPI') -const IntrospectSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().optional(), - username: z.string().optional(), - password: z.string().optional(), - authSource: z.string().optional(), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let client = null @@ -29,8 +20,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = IntrospectSchema.parse(body) + const parsed = await parseDatabaseToolRequest(mongodbIntrospectContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Introspecting MongoDB at ${params.host}:${params.port}${params.database ? `/${params.database}` : ''}` @@ -58,14 +50,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { collections: result.collections, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] MongoDB introspect failed:`, error) diff --git a/apps/sim/app/api/tools/mongodb/query/route.ts b/apps/sim/app/api/tools/mongodb/query/route.ts index 5a365472968..3c143f2fa1c 100644 --- a/apps/sim/app/api/tools/mongodb/query/route.ts +++ b/apps/sim/app/api/tools/mongodb/query/route.ts @@ -1,52 +1,18 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mongodbQueryContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils' +import { + createMongoDBConnection, + sanitizeCollectionName, + validateFilter, +} from '@/app/api/tools/mongodb/utils' const logger = createLogger('MongoDBQueryAPI') -const QuerySchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - authSource: z.string().optional(), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), - collection: z.string().min(1, 'Collection name is required'), - query: z - .union([z.string(), z.object({}).passthrough()]) - .optional() - .default('{}') - .transform((val) => { - if (typeof val === 'object' && val !== null) { - return JSON.stringify(val) - } - return val || '{}' - }), - limit: z - .union([z.coerce.number().int().positive(), z.literal(''), z.undefined()]) - .optional() - .transform((val) => { - if (val === '' || val === undefined || val === null) { - return 100 - } - return val - }), - sort: z - .union([z.string(), z.object({}).passthrough(), z.null()]) - .optional() - .transform((val) => { - if (typeof val === 'object' && val !== null) { - return JSON.stringify(val) - } - return val - }), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let client = null @@ -58,8 +24,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = QuerySchema.parse(body) + const parsed = await parseDatabaseToolRequest(mongodbQueryContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Executing MongoDB query on ${params.host}:${params.port}/${params.database}.${params.collection}` @@ -124,14 +91,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { documentCount: documents.length, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] MongoDB query failed:`, error) diff --git a/apps/sim/app/api/tools/mongodb/update/route.ts b/apps/sim/app/api/tools/mongodb/update/route.ts index c4038f5dc23..1237622c855 100644 --- a/apps/sim/app/api/tools/mongodb/update/route.ts +++ b/apps/sim/app/api/tools/mongodb/update/route.ts @@ -1,62 +1,18 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mongodbUpdateContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils' +import { + createMongoDBConnection, + sanitizeCollectionName, + validateFilter, +} from '@/app/api/tools/mongodb/utils' const logger = createLogger('MongoDBUpdateAPI') -const UpdateSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - authSource: z.string().optional(), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), - collection: z.string().min(1, 'Collection name is required'), - filter: z - .union([z.string(), z.object({}).passthrough()]) - .transform((val) => { - if (typeof val === 'object' && val !== null) { - return JSON.stringify(val) - } - return val - }) - .refine((val) => val && val.trim() !== '' && val !== '{}', { - message: 'Filter is required for MongoDB Update', - }), - update: z - .union([z.string(), z.object({}).passthrough()]) - .transform((val) => { - if (typeof val === 'object' && val !== null) { - return JSON.stringify(val) - } - return val - }) - .refine((val) => val && val.trim() !== '', { - message: 'Update is required', - }), - upsert: z - .union([z.boolean(), z.string(), z.undefined()]) - .optional() - .transform((val) => { - if (val === 'true' || val === true) return true - if (val === 'false' || val === false) return false - return false - }), - multi: z - .union([z.boolean(), z.string(), z.undefined()]) - .optional() - .transform((val) => { - if (val === 'true' || val === true) return true - if (val === 'false' || val === false) return false - return false - }), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let client = null @@ -68,8 +24,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = UpdateSchema.parse(body) + const parsed = await parseDatabaseToolRequest(mongodbUpdateContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Updating document(s) in ${params.host}:${params.port}/${params.database}.${params.collection} (multi: ${params.multi}, upsert: ${params.upsert})` @@ -131,14 +88,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ...(result.upsertedId && { insertedId: result.upsertedId.toString() }), }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] MongoDB update failed:`, error) diff --git a/apps/sim/app/api/tools/mysql/delete/route.ts b/apps/sim/app/api/tools/mysql/delete/route.ts index 4146bb49ba1..c6991b1434e 100644 --- a/apps/sim/app/api/tools/mysql/delete/route.ts +++ b/apps/sim/app/api/tools/mysql/delete/route.ts @@ -1,24 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mysqlDeleteContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildDeleteQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils' const logger = createLogger('MySQLDeleteAPI') -const DeleteSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), - table: z.string().min(1, 'Table name is required'), - where: z.string().min(1, 'WHERE clause is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -29,8 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = DeleteSchema.parse(body) + const parsed = await parseDatabaseToolRequest(mysqlDeleteContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}` @@ -60,14 +51,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await connection.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] MySQL delete failed:`, error) diff --git a/apps/sim/app/api/tools/mysql/execute/route.ts b/apps/sim/app/api/tools/mysql/execute/route.ts index 0f20e8bed9e..defd5c4197e 100644 --- a/apps/sim/app/api/tools/mysql/execute/route.ts +++ b/apps/sim/app/api/tools/mysql/execute/route.ts @@ -1,23 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mysqlExecuteContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils' const logger = createLogger('MySQLExecuteAPI') -const ExecuteSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), - query: z.string().min(1, 'Query is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -28,8 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = ExecuteSchema.parse(body) + const parsed = await parseDatabaseToolRequest(mysqlExecuteContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Executing raw SQL on ${params.host}:${params.port}/${params.database}` @@ -67,14 +59,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await connection.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] MySQL execute failed:`, error) diff --git a/apps/sim/app/api/tools/mysql/insert/route.ts b/apps/sim/app/api/tools/mysql/insert/route.ts index 013e8cc650c..5f512fdd498 100644 --- a/apps/sim/app/api/tools/mysql/insert/route.ts +++ b/apps/sim/app/api/tools/mysql/insert/route.ts @@ -1,45 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mysqlInsertContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildInsertQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils' const logger = createLogger('MySQLInsertAPI') -const InsertSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), - table: z.string().min(1, 'Table name is required'), - data: z.union([ - z - .record(z.unknown()) - .refine((obj) => Object.keys(obj).length > 0, 'Data object cannot be empty'), - z - .string() - .min(1) - .transform((str) => { - try { - const parsed = JSON.parse(str) - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('Data must be a JSON object') - } - return parsed - } catch (e) { - const errorMsg = e instanceof Error ? e.message : 'Unknown error' - throw new Error( - `Invalid JSON format in data field: ${errorMsg}. Received: ${str.substring(0, 100)}...` - ) - } - }), - ]), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -50,8 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = InsertSchema.parse(body) + const parsed = await parseDatabaseToolRequest(mysqlInsertContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}` @@ -81,14 +51,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await connection.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] MySQL insert failed:`, error) diff --git a/apps/sim/app/api/tools/mysql/introspect/route.ts b/apps/sim/app/api/tools/mysql/introspect/route.ts index e22ecf5444d..56d02efce87 100644 --- a/apps/sim/app/api/tools/mysql/introspect/route.ts +++ b/apps/sim/app/api/tools/mysql/introspect/route.ts @@ -1,22 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mysqlIntrospectContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createMySQLConnection, executeIntrospect } from '@/app/api/tools/mysql/utils' const logger = createLogger('MySQLIntrospectAPI') -const IntrospectSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -27,8 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = IntrospectSchema.parse(body) + const parsed = await parseDatabaseToolRequest(mysqlIntrospectContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Introspecting MySQL schema on ${params.host}:${params.port}/${params.database}` @@ -59,14 +52,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await connection.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] MySQL introspection failed:`, error) diff --git a/apps/sim/app/api/tools/mysql/query/route.ts b/apps/sim/app/api/tools/mysql/query/route.ts index 0950b2be1d1..87db1ed53b5 100644 --- a/apps/sim/app/api/tools/mysql/query/route.ts +++ b/apps/sim/app/api/tools/mysql/query/route.ts @@ -1,23 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mysqlQueryContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils' const logger = createLogger('MySQLQueryAPI') -const QuerySchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), - query: z.string().min(1, 'Query is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -28,8 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = QuerySchema.parse(body) + const parsed = await parseDatabaseToolRequest(mysqlQueryContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Executing MySQL query on ${params.host}:${params.port}/${params.database}` @@ -67,14 +59,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await connection.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] MySQL query failed:`, error) diff --git a/apps/sim/app/api/tools/mysql/update/route.ts b/apps/sim/app/api/tools/mysql/update/route.ts index bfcad56bbc8..b7ac5f422d6 100644 --- a/apps/sim/app/api/tools/mysql/update/route.ts +++ b/apps/sim/app/api/tools/mysql/update/route.ts @@ -1,43 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mysqlUpdateContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildUpdateQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils' const logger = createLogger('MySQLUpdateAPI') -const UpdateSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), - table: z.string().min(1, 'Table name is required'), - data: z.union([ - z - .record(z.unknown()) - .refine((obj) => Object.keys(obj).length > 0, 'Data object cannot be empty'), - z - .string() - .min(1) - .transform((str) => { - try { - const parsed = JSON.parse(str) - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('Data must be a JSON object') - } - return parsed - } catch (e) { - throw new Error('Invalid JSON format in data field') - } - }), - ]), - where: z.string().min(1, 'WHERE clause is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -48,8 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = UpdateSchema.parse(body) + const parsed = await parseDatabaseToolRequest(mysqlUpdateContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}` @@ -79,14 +51,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await connection.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] MySQL update failed:`, error) diff --git a/apps/sim/app/api/tools/neo4j/create/route.ts b/apps/sim/app/api/tools/neo4j/create/route.ts index 4ee7bbfd336..282751db79d 100644 --- a/apps/sim/app/api/tools/neo4j/create/route.ts +++ b/apps/sim/app/api/tools/neo4j/create/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { neo4jCreateContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -12,17 +13,6 @@ import { const logger = createLogger('Neo4jCreateAPI') -const CreateSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - encryption: z.enum(['enabled', 'disabled']).default('disabled'), - cypherQuery: z.string().min(1, 'Cypher query is required'), - parameters: z.record(z.unknown()).nullable().optional().default({}), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let driver = null @@ -35,8 +25,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = CreateSchema.parse(body) + const parsed = await parseDatabaseToolRequest(neo4jCreateContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Executing Neo4j create on ${params.host}:${params.port}/${params.database}` @@ -103,14 +94,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { summary, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] Neo4j create failed:`, error) diff --git a/apps/sim/app/api/tools/neo4j/delete/route.ts b/apps/sim/app/api/tools/neo4j/delete/route.ts index 2338db5e843..195fd293f91 100644 --- a/apps/sim/app/api/tools/neo4j/delete/route.ts +++ b/apps/sim/app/api/tools/neo4j/delete/route.ts @@ -1,25 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { neo4jDeleteContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createNeo4jDriver, validateCypherQuery } from '@/app/api/tools/neo4j/utils' const logger = createLogger('Neo4jDeleteAPI') -const DeleteSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - encryption: z.enum(['enabled', 'disabled']).default('disabled'), - cypherQuery: z.string().min(1, 'Cypher query is required'), - parameters: z.record(z.unknown()).nullable().optional().default({}), - detach: z.boolean().optional().default(false), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let driver = null @@ -32,8 +21,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = DeleteSchema.parse(body) + const parsed = await parseDatabaseToolRequest(neo4jDeleteContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Executing Neo4j delete on ${params.host}:${params.port}/${params.database}` @@ -88,14 +78,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { summary, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] Neo4j delete failed:`, error) diff --git a/apps/sim/app/api/tools/neo4j/execute/route.ts b/apps/sim/app/api/tools/neo4j/execute/route.ts index 9e74c736bc1..36f69f11174 100644 --- a/apps/sim/app/api/tools/neo4j/execute/route.ts +++ b/apps/sim/app/api/tools/neo4j/execute/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { neo4jExecuteContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -12,17 +13,6 @@ import { const logger = createLogger('Neo4jExecuteAPI') -const ExecuteSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - encryption: z.enum(['enabled', 'disabled']).default('disabled'), - cypherQuery: z.string().min(1, 'Cypher query is required'), - parameters: z.record(z.unknown()).nullable().optional().default({}), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let driver = null @@ -35,8 +25,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = ExecuteSchema.parse(body) + const parsed = await parseDatabaseToolRequest(neo4jExecuteContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Executing Neo4j query on ${params.host}:${params.port}/${params.database}` @@ -101,14 +92,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { summary, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] Neo4j execute failed:`, error) diff --git a/apps/sim/app/api/tools/neo4j/introspect/route.ts b/apps/sim/app/api/tools/neo4j/introspect/route.ts index 838d28377af..17a66ea0823 100644 --- a/apps/sim/app/api/tools/neo4j/introspect/route.ts +++ b/apps/sim/app/api/tools/neo4j/introspect/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { neo4jIntrospectContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createNeo4jDriver } from '@/app/api/tools/neo4j/utils' @@ -9,15 +10,6 @@ import type { Neo4jNodeSchema, Neo4jRelationshipSchema } from '@/tools/neo4j/typ const logger = createLogger('Neo4jIntrospectAPI') -const IntrospectSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - encryption: z.enum(['enabled', 'disabled']).default('disabled'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let driver = null @@ -30,8 +22,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = IntrospectSchema.parse(body) + const parsed = await parseDatabaseToolRequest(neo4jIntrospectContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Introspecting Neo4j database at ${params.host}:${params.port}/${params.database}` @@ -181,14 +174,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { indexes, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] Neo4j introspection failed:`, error) diff --git a/apps/sim/app/api/tools/neo4j/merge/route.ts b/apps/sim/app/api/tools/neo4j/merge/route.ts index 1c8163876f7..9db6d95e9e7 100644 --- a/apps/sim/app/api/tools/neo4j/merge/route.ts +++ b/apps/sim/app/api/tools/neo4j/merge/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { neo4jMergeContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -12,17 +13,6 @@ import { const logger = createLogger('Neo4jMergeAPI') -const MergeSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - encryption: z.enum(['enabled', 'disabled']).default('disabled'), - cypherQuery: z.string().min(1, 'Cypher query is required'), - parameters: z.record(z.unknown()).nullable().optional().default({}), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let driver = null @@ -35,8 +25,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = MergeSchema.parse(body) + const parsed = await parseDatabaseToolRequest(neo4jMergeContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Executing Neo4j merge on ${params.host}:${params.port}/${params.database}` @@ -103,14 +94,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { summary, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] Neo4j merge failed:`, error) diff --git a/apps/sim/app/api/tools/neo4j/query/route.ts b/apps/sim/app/api/tools/neo4j/query/route.ts index f578ffdfa11..2790bb3038b 100644 --- a/apps/sim/app/api/tools/neo4j/query/route.ts +++ b/apps/sim/app/api/tools/neo4j/query/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { neo4jQueryContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -12,17 +13,6 @@ import { const logger = createLogger('Neo4jQueryAPI') -const QuerySchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - encryption: z.enum(['enabled', 'disabled']).default('disabled'), - cypherQuery: z.string().min(1, 'Cypher query is required'), - parameters: z.record(z.unknown()).nullable().optional().default({}), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let driver = null @@ -35,8 +25,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = QuerySchema.parse(body) + const parsed = await parseDatabaseToolRequest(neo4jQueryContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Executing Neo4j query on ${params.host}:${params.port}/${params.database}` @@ -101,14 +92,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { summary, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] Neo4j query failed:`, error) diff --git a/apps/sim/app/api/tools/neo4j/update/route.ts b/apps/sim/app/api/tools/neo4j/update/route.ts index 8b910e887eb..ea7f2602c38 100644 --- a/apps/sim/app/api/tools/neo4j/update/route.ts +++ b/apps/sim/app/api/tools/neo4j/update/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { neo4jUpdateContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -12,17 +13,6 @@ import { const logger = createLogger('Neo4jUpdateAPI') -const UpdateSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - encryption: z.enum(['enabled', 'disabled']).default('disabled'), - cypherQuery: z.string().min(1, 'Cypher query is required'), - parameters: z.record(z.unknown()).nullable().optional().default({}), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let driver = null @@ -35,8 +25,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = UpdateSchema.parse(body) + const parsed = await parseDatabaseToolRequest(neo4jUpdateContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Executing Neo4j update on ${params.host}:${params.port}/${params.database}` @@ -103,14 +94,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { summary, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] Neo4j update failed:`, error) diff --git a/apps/sim/app/api/tools/notion/databases/route.ts b/apps/sim/app/api/tools/notion/databases/route.ts index 2448f067d98..6ab772afa99 100644 --- a/apps/sim/app/api/tools/notion/databases/route.ts +++ b/apps/sim/app/api/tools/notion/databases/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { notionDatabasesSelectorContract } from '@/lib/api/contracts/selectors' +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' @@ -10,20 +12,16 @@ const logger = createLogger('NotionDatabasesAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId } = body + const parsed = await parseRequest(notionDatabasesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId } = parsed.data.body - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, - workflowId, + workflowId: workflowId || undefined, }) if (!authz.ok || !authz.credentialOwnerUserId) { return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) diff --git a/apps/sim/app/api/tools/notion/pages/route.ts b/apps/sim/app/api/tools/notion/pages/route.ts index 3c01c36b834..419193fdc7c 100644 --- a/apps/sim/app/api/tools/notion/pages/route.ts +++ b/apps/sim/app/api/tools/notion/pages/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { notionPagesSelectorContract } from '@/lib/api/contracts/selectors' +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' @@ -10,20 +12,16 @@ const logger = createLogger('NotionPagesAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId } = body + const parsed = await parseRequest(notionPagesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId } = parsed.data.body - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, - workflowId, + workflowId: workflowId || undefined, }) if (!authz.ok || !authz.credentialOwnerUserId) { return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) diff --git a/apps/sim/app/api/tools/onedrive/download/route.ts b/apps/sim/app/api/tools/onedrive/download/route.ts index 8afe7b7ff8d..ef66c64d04b 100644 --- a/apps/sim/app/api/tools/onedrive/download/route.ts +++ b/apps/sim/app/api/tools/onedrive/download/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onedriveDownloadBodySchema } from '@/lib/api/contracts/tools/microsoft' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -31,12 +32,6 @@ interface DriveItemMetadata { const logger = createLogger('OneDriveDownloadAPI') -const OneDriveDownloadSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - fileId: z.string().min(1, 'File ID is required'), - fileName: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -54,8 +49,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = OneDriveDownloadSchema.parse(body) + const validation = await validateJsonBody(request, onedriveDownloadBodySchema) + if (!validation.success) { + if (!validation.error) return validation.response + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data const { accessToken, fileId, fileName } = validatedData const authHeader = `Bearer ${accessToken}` @@ -166,12 +172,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { success: false, error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } logger.error(`[${requestId}] Error downloading OneDrive file:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/onedrive/files/route.ts b/apps/sim/app/api/tools/onedrive/files/route.ts index 4dadb9d01d0..1a8c2d0977a 100644 --- a/apps/sim/app/api/tools/onedrive/files/route.ts +++ b/apps/sim/app/api/tools/onedrive/files/route.ts @@ -4,17 +4,18 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { onedriveFilesQuerySchema } from '@/lib/api/contracts/selectors/microsoft' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' export const dynamic = 'force-dynamic' const logger = createLogger('OneDriveFilesAPI') -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' - /** * Get files (not folders) from Microsoft OneDrive */ @@ -30,13 +31,19 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const query = searchParams.get('query') || '' - - if (!credentialId) { - logger.warn(`[${requestId}] Missing credential ID`) - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + const validation = validateSchema(onedriveFilesQuerySchema, { + credentialId: searchParams.get('credentialId') ?? '', + query: searchParams.get('query') ?? undefined, + }) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid files request data`, { errors: validation.error.issues }) + return NextResponse.json( + { error: getValidationErrorMessage(validation.error, 'Invalid request') }, + { status: 400 } + ) } + const { credentialId } = validation.data + const query = validation.data.query ?? '' const credentialIdValidation = validateMicrosoftGraphId(credentialId, 'credentialId') if (!credentialIdValidation.isValid) { diff --git a/apps/sim/app/api/tools/onedrive/folder/route.ts b/apps/sim/app/api/tools/onedrive/folder/route.ts index a499c06810f..8a8e8ff086e 100644 --- a/apps/sim/app/api/tools/onedrive/folder/route.ts +++ b/apps/sim/app/api/tools/onedrive/folder/route.ts @@ -4,6 +4,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { onedriveFolderQuerySchema } from '@/lib/api/contracts/selectors/microsoft' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -23,12 +25,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const fileId = searchParams.get('fileId') - - if (!credentialId || !fileId) { - return NextResponse.json({ error: 'Credential ID and File ID are required' }, { status: 400 }) + const validation = validateSchema(onedriveFolderQuerySchema, { + credentialId: searchParams.get('credentialId') ?? '', + fileId: searchParams.get('fileId') ?? '', + }) + if (!validation.success) { + return NextResponse.json( + { error: getValidationErrorMessage(validation.error, 'Invalid request') }, + { status: 400 } + ) } + const { credentialId, fileId } = validation.data const fileIdValidation = validateMicrosoftGraphId(fileId, 'fileId') if (!fileIdValidation.isValid) { diff --git a/apps/sim/app/api/tools/onedrive/folders/route.ts b/apps/sim/app/api/tools/onedrive/folders/route.ts index d95353de1e3..f00ea4fc1f0 100644 --- a/apps/sim/app/api/tools/onedrive/folders/route.ts +++ b/apps/sim/app/api/tools/onedrive/folders/route.ts @@ -4,17 +4,18 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { onedriveFoldersQuerySchema } from '@/lib/api/contracts/selectors/microsoft' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' export const dynamic = 'force-dynamic' const logger = createLogger('OneDriveFoldersAPI') -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' - /** * Get folders from Microsoft OneDrive */ @@ -28,12 +29,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const query = searchParams.get('query') || '' - - if (!credentialId) { - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + const validation = validateSchema(onedriveFoldersQuerySchema, { + credentialId: searchParams.get('credentialId') ?? '', + query: searchParams.get('query') ?? undefined, + }) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid folders request data`, { + errors: validation.error.issues, + }) + return NextResponse.json( + { error: getValidationErrorMessage(validation.error, 'Invalid request') }, + { status: 400 } + ) } + const { credentialId } = validation.data + const query = validation.data.query ?? '' const credentialIdValidation = validateMicrosoftGraphId(credentialId, 'credentialId') if (!credentialIdValidation.isValid) { diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts index 1af24e81e0e..a4c9e1d38ef 100644 --- a/apps/sim/app/api/tools/onedrive/upload/route.ts +++ b/apps/sim/app/api/tools/onedrive/upload/route.ts @@ -1,13 +1,13 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import * as XLSX from 'xlsx' -import { z } from 'zod' +import { onedriveUploadBodySchema } from '@/lib/api/contracts/tools/microsoft' +import { validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { getExtensionFromMimeType, processSingleFileToUserFile, @@ -21,24 +21,6 @@ const logger = createLogger('OneDriveUploadAPI') const MICROSOFT_GRAPH_BASE = 'https://graph.microsoft.com/v1.0' -const ExcelCellSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]) -const ExcelRowSchema = z.array(ExcelCellSchema) -const ExcelValuesSchema = z.union([ - z.string(), - z.array(ExcelRowSchema), - z.array(z.record(ExcelCellSchema)), -]) - -const OneDriveUploadSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - fileName: z.string().min(1, 'File name is required'), - file: RawFileInputSchema.optional(), - folderId: z.string().optional().nullable(), - mimeType: z.string().nullish(), - values: ExcelValuesSchema.optional().nullable(), - conflictBehavior: z.enum(['fail', 'replace', 'rename']).optional().nullable(), -}) - /** Microsoft Graph DriveItem response */ interface OneDriveFileData { id: string @@ -80,8 +62,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = OneDriveUploadSchema.parse(body) + const validation = await validateJsonBody(request, onedriveUploadBodySchema) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request data`, { errors: validation.error?.issues ?? [] }) + return NextResponse.json( + { + success: false, + error: 'Invalid request data', + details: validation.error?.issues ?? [], + }, + { status: 400 } + ) + } + const validatedData = validation.data const excelValues = normalizeExcelValues(validatedData.values) let fileBuffer: Buffer @@ -416,18 +409,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error uploading file to OneDrive:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/onepassword/create-item/route.ts b/apps/sim/app/api/tools/onepassword/create-item/route.ts index e9b5aedc995..78c25c778e3 100644 --- a/apps/sim/app/api/tools/onepassword/create-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/create-item/route.ts @@ -2,7 +2,8 @@ import type { ItemCreateParams } from '@1password/sdk' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordCreateItemContract } from '@/lib/api/contracts/internal-tools' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -16,18 +17,6 @@ import { const logger = createLogger('OnePasswordCreateItemAPI') -const CreateItemSchema = z.object({ - connectionMode: z.enum(['service_account', 'connect']).nullish(), - serviceAccountToken: z.string().nullish(), - serverUrl: z.string().nullish(), - apiKey: z.string().nullish(), - vaultId: z.string().min(1, 'Vault ID is required'), - category: z.string().min(1, 'Category is required'), - title: z.string().nullish(), - tags: z.string().nullish(), - fields: z.string().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -38,8 +27,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = CreateItemSchema.parse(body) + const parsed = await parseRequest( + onePasswordCreateItemContract, + request, + {}, + { + validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'), + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body const creds = resolveCredentials(params) logger.info(`[${requestId}] Creating item in vault ${params.vaultId} (${creds.mode} mode)`) @@ -101,12 +98,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(data) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } const message = error instanceof Error ? error.message : 'Unknown error' logger.error(`[${requestId}] Create item failed:`, error) return NextResponse.json({ error: `Failed to create item: ${message}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/onepassword/delete-item/route.ts b/apps/sim/app/api/tools/onepassword/delete-item/route.ts index bde63915323..37436ce41d3 100644 --- a/apps/sim/app/api/tools/onepassword/delete-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/delete-item/route.ts @@ -1,22 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordDeleteItemContract } from '@/lib/api/contracts/internal-tools' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { connectRequest, createOnePasswordClient, resolveCredentials } from '../utils' const logger = createLogger('OnePasswordDeleteItemAPI') -const DeleteItemSchema = z.object({ - connectionMode: z.enum(['service_account', 'connect']).nullish(), - serviceAccountToken: z.string().nullish(), - serverUrl: z.string().nullish(), - apiKey: z.string().nullish(), - vaultId: z.string().min(1, 'Vault ID is required'), - itemId: z.string().min(1, 'Item ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -27,8 +19,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = DeleteItemSchema.parse(body) + const parsed = await parseRequest( + onePasswordDeleteItemContract, + request, + {}, + { + validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'), + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body const creds = resolveCredentials(params) logger.info( @@ -58,12 +58,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } const message = error instanceof Error ? error.message : 'Unknown error' logger.error(`[${requestId}] Delete item failed:`, error) return NextResponse.json({ error: `Failed to delete item: ${message}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/onepassword/get-item/route.ts b/apps/sim/app/api/tools/onepassword/get-item/route.ts index aaf2276d8da..d8b901c4897 100644 --- a/apps/sim/app/api/tools/onepassword/get-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/get-item/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordGetItemContract } from '@/lib/api/contracts/internal-tools' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -13,15 +14,6 @@ import { const logger = createLogger('OnePasswordGetItemAPI') -const GetItemSchema = z.object({ - connectionMode: z.enum(['service_account', 'connect']).nullish(), - serviceAccountToken: z.string().nullish(), - serverUrl: z.string().nullish(), - apiKey: z.string().nullish(), - vaultId: z.string().min(1, 'Vault ID is required'), - itemId: z.string().min(1, 'Item ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -32,8 +24,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = GetItemSchema.parse(body) + const parsed = await parseRequest( + onePasswordGetItemContract, + request, + {}, + { + validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'), + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body const creds = resolveCredentials(params) logger.info( @@ -63,12 +63,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(data) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } const message = error instanceof Error ? error.message : 'Unknown error' logger.error(`[${requestId}] Get item failed:`, error) return NextResponse.json({ error: `Failed to get item: ${message}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/onepassword/get-vault/route.ts b/apps/sim/app/api/tools/onepassword/get-vault/route.ts index 0a647a8c1f8..ab9a0809d36 100644 --- a/apps/sim/app/api/tools/onepassword/get-vault/route.ts +++ b/apps/sim/app/api/tools/onepassword/get-vault/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordGetVaultContract } from '@/lib/api/contracts/internal-tools' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -13,14 +14,6 @@ import { const logger = createLogger('OnePasswordGetVaultAPI') -const GetVaultSchema = z.object({ - connectionMode: z.enum(['service_account', 'connect']).nullish(), - serviceAccountToken: z.string().nullish(), - serverUrl: z.string().nullish(), - apiKey: z.string().nullish(), - vaultId: z.string().min(1, 'Vault ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -31,8 +24,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = GetVaultSchema.parse(body) + const parsed = await parseRequest( + onePasswordGetVaultContract, + request, + {}, + { + validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'), + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body const creds = resolveCredentials(params) logger.info(`[${requestId}] Getting 1Password vault ${params.vaultId} (${creds.mode} mode)`) @@ -66,12 +67,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(data) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } const message = error instanceof Error ? error.message : 'Unknown error' logger.error(`[${requestId}] Get vault failed:`, error) return NextResponse.json({ error: `Failed to get vault: ${message}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/onepassword/list-items/route.ts b/apps/sim/app/api/tools/onepassword/list-items/route.ts index 63343675c4f..f0557c79bad 100644 --- a/apps/sim/app/api/tools/onepassword/list-items/route.ts +++ b/apps/sim/app/api/tools/onepassword/list-items/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordListItemsContract } from '@/lib/api/contracts/internal-tools' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -13,15 +14,6 @@ import { const logger = createLogger('OnePasswordListItemsAPI') -const ListItemsSchema = z.object({ - connectionMode: z.enum(['service_account', 'connect']).nullish(), - serviceAccountToken: z.string().nullish(), - serverUrl: z.string().nullish(), - apiKey: z.string().nullish(), - vaultId: z.string().min(1, 'Vault ID is required'), - filter: z.string().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -32,8 +24,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = ListItemsSchema.parse(body) + const parsed = await parseRequest( + onePasswordListItemsContract, + request, + {}, + { + validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'), + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body const creds = resolveCredentials(params) logger.info(`[${requestId}] Listing items in vault ${params.vaultId} (${creds.mode} mode)`) @@ -75,12 +75,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(data) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } const message = error instanceof Error ? error.message : 'Unknown error' logger.error(`[${requestId}] List items failed:`, error) return NextResponse.json({ error: `Failed to list items: ${message}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/onepassword/list-vaults/route.ts b/apps/sim/app/api/tools/onepassword/list-vaults/route.ts index 60c9e71e922..fbb8722ef27 100644 --- a/apps/sim/app/api/tools/onepassword/list-vaults/route.ts +++ b/apps/sim/app/api/tools/onepassword/list-vaults/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordListVaultsContract } from '@/lib/api/contracts/internal-tools' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -13,14 +14,6 @@ import { const logger = createLogger('OnePasswordListVaultsAPI') -const ListVaultsSchema = z.object({ - connectionMode: z.enum(['service_account', 'connect']).nullish(), - serviceAccountToken: z.string().nullish(), - serverUrl: z.string().nullish(), - apiKey: z.string().nullish(), - filter: z.string().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -31,8 +24,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = ListVaultsSchema.parse(body) + const parsed = await parseRequest( + onePasswordListVaultsContract, + request, + {}, + { + validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'), + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body const creds = resolveCredentials(params) logger.info(`[${requestId}] Listing 1Password vaults (${creds.mode} mode)`) @@ -73,12 +74,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(data) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } const message = error instanceof Error ? error.message : 'Unknown error' logger.error(`[${requestId}] List vaults failed:`, error) return NextResponse.json({ error: `Failed to list vaults: ${message}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/onepassword/replace-item/route.ts b/apps/sim/app/api/tools/onepassword/replace-item/route.ts index d620b545f3e..1e4407362bd 100644 --- a/apps/sim/app/api/tools/onepassword/replace-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/replace-item/route.ts @@ -2,7 +2,8 @@ import type { Item } from '@1password/sdk' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordReplaceItemContract } from '@/lib/api/contracts/internal-tools' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -16,16 +17,6 @@ import { const logger = createLogger('OnePasswordReplaceItemAPI') -const ReplaceItemSchema = z.object({ - connectionMode: z.enum(['service_account', 'connect']).nullish(), - serviceAccountToken: z.string().nullish(), - serverUrl: z.string().nullish(), - apiKey: z.string().nullish(), - vaultId: z.string().min(1, 'Vault ID is required'), - itemId: z.string().min(1, 'Item ID is required'), - item: z.string().min(1, 'Item JSON is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -36,8 +27,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = ReplaceItemSchema.parse(body) + const parsed = await parseRequest( + onePasswordReplaceItemContract, + request, + {}, + { + validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'), + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body const creds = resolveCredentials(params) const itemData = JSON.parse(params.item) @@ -105,12 +104,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(data) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } const message = error instanceof Error ? error.message : 'Unknown error' logger.error(`[${requestId}] Replace item failed:`, error) return NextResponse.json({ error: `Failed to replace item: ${message}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/onepassword/resolve-secret/route.ts b/apps/sim/app/api/tools/onepassword/resolve-secret/route.ts index 37c31ece818..f8bd29d3f6d 100644 --- a/apps/sim/app/api/tools/onepassword/resolve-secret/route.ts +++ b/apps/sim/app/api/tools/onepassword/resolve-secret/route.ts @@ -1,21 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordResolveSecretContract } from '@/lib/api/contracts/internal-tools' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createOnePasswordClient, resolveCredentials } from '../utils' const logger = createLogger('OnePasswordResolveSecretAPI') -const ResolveSecretSchema = z.object({ - connectionMode: z.enum(['service_account', 'connect']).nullish(), - serviceAccountToken: z.string().nullish(), - serverUrl: z.string().nullish(), - apiKey: z.string().nullish(), - secretReference: z.string().min(1, 'Secret reference is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -26,8 +19,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = ResolveSecretSchema.parse(body) + const parsed = await parseRequest( + onePasswordResolveSecretContract, + request, + {}, + { + validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'), + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body const creds = resolveCredentials(params) if (creds.mode !== 'service_account') { @@ -47,12 +48,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { reference: params.secretReference, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } const message = error instanceof Error ? error.message : 'Unknown error' logger.error(`[${requestId}] Resolve secret failed:`, error) return NextResponse.json({ error: `Failed to resolve secret: ${message}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/onepassword/update-item/route.ts b/apps/sim/app/api/tools/onepassword/update-item/route.ts index d9347865898..1ec9a9d4487 100644 --- a/apps/sim/app/api/tools/onepassword/update-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/update-item/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordUpdateItemContract } from '@/lib/api/contracts/internal-tools' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -13,16 +14,6 @@ import { const logger = createLogger('OnePasswordUpdateItemAPI') -const UpdateItemSchema = z.object({ - connectionMode: z.enum(['service_account', 'connect']).nullish(), - serviceAccountToken: z.string().nullish(), - serverUrl: z.string().nullish(), - apiKey: z.string().nullish(), - vaultId: z.string().min(1, 'Vault ID is required'), - itemId: z.string().min(1, 'Item ID is required'), - operations: z.string().min(1, 'Patch operations are required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -33,8 +24,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = UpdateItemSchema.parse(body) + const parsed = await parseRequest( + onePasswordUpdateItemContract, + request, + {}, + { + validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'), + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body const creds = resolveCredentials(params) const ops = JSON.parse(params.operations) as JsonPatchOperation[] @@ -73,12 +72,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(data) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } const message = error instanceof Error ? error.message : 'Unknown error' logger.error(`[${requestId}] Update item failed:`, error) return NextResponse.json({ error: `Failed to update item: ${message}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/outlook/copy/route.ts b/apps/sim/app/api/tools/outlook/copy/route.ts index 8bb47a0b5dc..247b2d21990 100644 --- a/apps/sim/app/api/tools/outlook/copy/route.ts +++ b/apps/sim/app/api/tools/outlook/copy/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { outlookCopyMoveBodySchema } from '@/lib/api/contracts/tools/microsoft' +import { validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -9,12 +10,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('OutlookCopyAPI') -const OutlookCopySchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - messageId: z.string().min(1, 'Message ID is required'), - destinationId: z.string().min(1, 'Destination folder ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -36,8 +31,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = OutlookCopySchema.parse(body) + const validation = await validateJsonBody(request, outlookCopyMoveBodySchema) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request data`, { errors: validation.error?.issues ?? [] }) + return NextResponse.json( + { + success: false, + error: 'Invalid request data', + details: validation.error?.issues ?? [], + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Copying Outlook email`, { messageId: validatedData.messageId, @@ -89,18 +95,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error copying Outlook email:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/outlook/delete/route.ts b/apps/sim/app/api/tools/outlook/delete/route.ts index f697c571280..3c6f189e4bc 100644 --- a/apps/sim/app/api/tools/outlook/delete/route.ts +++ b/apps/sim/app/api/tools/outlook/delete/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { outlookDeleteBodySchema } from '@/lib/api/contracts/tools/microsoft' +import { validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -9,11 +10,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('OutlookDeleteAPI') -const OutlookDeleteSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - messageId: z.string().min(1, 'Message ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -35,8 +31,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = OutlookDeleteSchema.parse(body) + const validation = await validateJsonBody(request, outlookDeleteBodySchema) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request data`, { errors: validation.error?.issues ?? [] }) + return NextResponse.json( + { + success: false, + error: 'Invalid request data', + details: validation.error?.issues ?? [], + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Deleting Outlook email`, { messageId: validatedData.messageId, @@ -78,18 +85,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error deleting Outlook email:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/outlook/draft/route.ts b/apps/sim/app/api/tools/outlook/draft/route.ts index f58386af86e..8ef9c4b1612 100644 --- a/apps/sim/app/api/tools/outlook/draft/route.ts +++ b/apps/sim/app/api/tools/outlook/draft/route.ts @@ -1,10 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { outlookDraftBodySchema } from '@/lib/api/contracts/tools/microsoft' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -12,17 +12,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('OutlookDraftAPI') -const OutlookDraftSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - to: z.string().min(1, 'Recipient email is required'), - subject: z.string().min(1, 'Subject is required'), - body: z.string().min(1, 'Email body is required'), - contentType: z.enum(['text', 'html']).optional().nullable(), - cc: z.string().optional().nullable(), - bcc: z.string().optional().nullable(), - attachments: RawFileInputArraySchema.optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -44,8 +33,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = OutlookDraftSchema.parse(body) + const validation = await validateJsonBody(request, outlookDraftBodySchema) + if (!validation.success) { + if (!validation.error) return validation.response + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Creating Outlook draft`, { to: validatedData.to, @@ -177,12 +177,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { success: false, error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } logger.error(`[${requestId}] Error creating Outlook draft:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/outlook/folders/route.ts b/apps/sim/app/api/tools/outlook/folders/route.ts index 12ed5e067f2..4e936986354 100644 --- a/apps/sim/app/api/tools/outlook/folders/route.ts +++ b/apps/sim/app/api/tools/outlook/folders/route.ts @@ -4,6 +4,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { outlookFoldersQuerySchema } from '@/lib/api/contracts/selectors/microsoft' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -25,12 +27,17 @@ export const GET = withRouteHandler(async (request: Request) => { try { const session = await getSession() const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - - if (!credentialId) { - logger.error('Missing credentialId in request') - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + const validation = validateSchema(outlookFoldersQuerySchema, { + credentialId: searchParams.get('credentialId') ?? '', + }) + if (!validation.success) { + logger.warn('Invalid Outlook folders request data', { errors: validation.error.issues }) + return NextResponse.json( + { error: getValidationErrorMessage(validation.error, 'Invalid request') }, + { status: 400 } + ) } + const { credentialId } = validation.data const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId') if (!credentialIdValidation.isValid) { diff --git a/apps/sim/app/api/tools/outlook/mark-read/route.ts b/apps/sim/app/api/tools/outlook/mark-read/route.ts index f393c9b8f5d..25e8327241b 100644 --- a/apps/sim/app/api/tools/outlook/mark-read/route.ts +++ b/apps/sim/app/api/tools/outlook/mark-read/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { outlookDeleteBodySchema } from '@/lib/api/contracts/tools/microsoft' +import { validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -9,11 +10,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('OutlookMarkReadAPI') -const OutlookMarkReadSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - messageId: z.string().min(1, 'Message ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -38,8 +34,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = OutlookMarkReadSchema.parse(body) + const validation = await validateJsonBody(request, outlookDeleteBodySchema) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request data`, { errors: validation.error?.issues ?? [] }) + return NextResponse.json( + { + success: false, + error: 'Invalid request data', + details: validation.error?.issues ?? [], + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Marking Outlook email as read`, { messageId: validatedData.messageId, @@ -88,18 +95,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error marking Outlook email as read:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/outlook/mark-unread/route.ts b/apps/sim/app/api/tools/outlook/mark-unread/route.ts index 1e1078402b9..321a095c5e6 100644 --- a/apps/sim/app/api/tools/outlook/mark-unread/route.ts +++ b/apps/sim/app/api/tools/outlook/mark-unread/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { outlookDeleteBodySchema } from '@/lib/api/contracts/tools/microsoft' +import { validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -9,11 +10,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('OutlookMarkUnreadAPI') -const OutlookMarkUnreadSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - messageId: z.string().min(1, 'Message ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -38,8 +34,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = OutlookMarkUnreadSchema.parse(body) + const validation = await validateJsonBody(request, outlookDeleteBodySchema) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request data`, { errors: validation.error?.issues ?? [] }) + return NextResponse.json( + { + success: false, + error: 'Invalid request data', + details: validation.error?.issues ?? [], + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Marking Outlook email as unread`, { messageId: validatedData.messageId, @@ -88,18 +95,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error marking Outlook email as unread:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/outlook/move/route.ts b/apps/sim/app/api/tools/outlook/move/route.ts index 24b04ed252e..1065905ccf8 100644 --- a/apps/sim/app/api/tools/outlook/move/route.ts +++ b/apps/sim/app/api/tools/outlook/move/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { outlookCopyMoveBodySchema } from '@/lib/api/contracts/tools/microsoft' +import { validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -9,12 +10,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('OutlookMoveAPI') -const OutlookMoveSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - messageId: z.string().min(1, 'Message ID is required'), - destinationId: z.string().min(1, 'Destination folder ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -36,8 +31,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = OutlookMoveSchema.parse(body) + const validation = await validateJsonBody(request, outlookCopyMoveBodySchema) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request data`, { errors: validation.error?.issues ?? [] }) + return NextResponse.json( + { + success: false, + error: 'Invalid request data', + details: validation.error?.issues ?? [], + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Moving Outlook email`, { messageId: validatedData.messageId, @@ -87,18 +93,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error moving Outlook email:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/outlook/send/route.ts b/apps/sim/app/api/tools/outlook/send/route.ts index 6d8eac4cfcc..def0e508025 100644 --- a/apps/sim/app/api/tools/outlook/send/route.ts +++ b/apps/sim/app/api/tools/outlook/send/route.ts @@ -1,10 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { outlookSendBodySchema } from '@/lib/api/contracts/tools/microsoft' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -12,19 +12,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('OutlookSendAPI') -const OutlookSendSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - to: z.string().min(1, 'Recipient email is required'), - subject: z.string().min(1, 'Subject is required'), - body: z.string().min(1, 'Email body is required'), - contentType: z.enum(['text', 'html']).optional().nullable(), - cc: z.string().optional().nullable(), - bcc: z.string().optional().nullable(), - replyToMessageId: z.string().optional().nullable(), - conversationId: z.string().optional().nullable(), - attachments: RawFileInputArraySchema.optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -46,8 +33,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = OutlookSendSchema.parse(body) + const validation = await validateJsonBody(request, outlookSendBodySchema) + if (!validation.success) { + if (!validation.error) return validation.response + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Sending Outlook email`, { to: validatedData.to, @@ -190,12 +188,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { success: false, error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } logger.error(`[${requestId}] Error sending Outlook email:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/pipedrive/get-files/route.ts b/apps/sim/app/api/tools/pipedrive/get-files/route.ts index 60120daec7a..20a8de641d0 100644 --- a/apps/sim/app/api/tools/pipedrive/get-files/route.ts +++ b/apps/sim/app/api/tools/pipedrive/get-files/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { pipedriveGetFilesContract } from '@/lib/api/contracts/tools/pipedrive' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -32,14 +33,6 @@ interface PipedriveApiResponse { error?: string } -const PipedriveGetFilesSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - sort: z.enum(['id', 'update_time']).optional().nullable(), - limit: z.string().optional().nullable(), - start: z.string().optional().nullable(), - downloadFiles: z.boolean().optional().default(false), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -57,10 +50,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = PipedriveGetFilesSchema.parse(body) + const parsed = await parseRequest(pipedriveGetFilesContract, request, {}) + if (!parsed.success) return parsed.response - const { accessToken, sort, limit, start, downloadFiles } = validatedData + const { accessToken, sort, limit, start, downloadFiles } = parsed.data.body const baseUrl = 'https://api.pipedrive.com/v1/files' const queryParams = new URLSearchParams() diff --git a/apps/sim/app/api/tools/pipedrive/pipelines/route.ts b/apps/sim/app/api/tools/pipedrive/pipelines/route.ts index bfb39961b08..8e3900fe113 100644 --- a/apps/sim/app/api/tools/pipedrive/pipelines/route.ts +++ b/apps/sim/app/api/tools/pipedrive/pipelines/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { pipedrivePipelinesSelectorContract } from '@/lib/api/contracts/selectors' +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' @@ -9,18 +11,14 @@ const logger = createLogger('PipedrivePipelinesAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId } = body + const parsed = await parseRequest(pipedrivePipelinesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId } = parsed.data.body - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) diff --git a/apps/sim/app/api/tools/postgresql/delete/route.ts b/apps/sim/app/api/tools/postgresql/delete/route.ts index ca15a1f0f81..efd0f4e1c0b 100644 --- a/apps/sim/app/api/tools/postgresql/delete/route.ts +++ b/apps/sim/app/api/tools/postgresql/delete/route.ts @@ -1,24 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { postgresqlDeleteContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createPostgresConnection, executeDelete } from '@/app/api/tools/postgresql/utils' const logger = createLogger('PostgreSQLDeleteAPI') -const DeleteSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), - table: z.string().min(1, 'Table name is required'), - where: z.string().min(1, 'WHERE clause is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -29,8 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = DeleteSchema.parse(body) + const parsed = await parseDatabaseToolRequest(postgresqlDeleteContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}` @@ -59,14 +50,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await sql.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] PostgreSQL delete failed:`, error) diff --git a/apps/sim/app/api/tools/postgresql/execute/route.ts b/apps/sim/app/api/tools/postgresql/execute/route.ts index 373c749be45..44d05edb6dc 100644 --- a/apps/sim/app/api/tools/postgresql/execute/route.ts +++ b/apps/sim/app/api/tools/postgresql/execute/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { postgresqlExecuteContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -12,16 +13,6 @@ import { const logger = createLogger('PostgreSQLExecuteAPI') -const ExecuteSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), - query: z.string().min(1, 'Query is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -32,8 +23,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = ExecuteSchema.parse(body) + const parsed = await parseDatabaseToolRequest(postgresqlExecuteContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Executing raw SQL on ${params.host}:${params.port}/${params.database}` @@ -71,14 +63,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await sql.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] PostgreSQL execute failed:`, error) diff --git a/apps/sim/app/api/tools/postgresql/insert/route.ts b/apps/sim/app/api/tools/postgresql/insert/route.ts index 447b098895c..b079680d207 100644 --- a/apps/sim/app/api/tools/postgresql/insert/route.ts +++ b/apps/sim/app/api/tools/postgresql/insert/route.ts @@ -1,45 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { postgresqlInsertContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createPostgresConnection, executeInsert } from '@/app/api/tools/postgresql/utils' const logger = createLogger('PostgreSQLInsertAPI') -const InsertSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), - table: z.string().min(1, 'Table name is required'), - data: z.union([ - z - .record(z.unknown()) - .refine((obj) => Object.keys(obj).length > 0, 'Data object cannot be empty'), - z - .string() - .min(1) - .transform((str) => { - try { - const parsed = JSON.parse(str) - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('Data must be a JSON object') - } - return parsed - } catch (e) { - const errorMsg = e instanceof Error ? e.message : 'Unknown error' - throw new Error( - `Invalid JSON format in data field: ${errorMsg}. Received: ${str.substring(0, 100)}...` - ) - } - }), - ]), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -50,9 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - - const params = InsertSchema.parse(body) + const parsed = await parseDatabaseToolRequest(postgresqlInsertContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}` @@ -81,14 +50,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await sql.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] PostgreSQL insert failed:`, error) diff --git a/apps/sim/app/api/tools/postgresql/introspect/route.ts b/apps/sim/app/api/tools/postgresql/introspect/route.ts index 462ae3f88fc..e92f329c18f 100644 --- a/apps/sim/app/api/tools/postgresql/introspect/route.ts +++ b/apps/sim/app/api/tools/postgresql/introspect/route.ts @@ -1,23 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { postgresqlIntrospectContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createPostgresConnection, executeIntrospect } from '@/app/api/tools/postgresql/utils' const logger = createLogger('PostgreSQLIntrospectAPI') -const IntrospectSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), - schema: z.string().default('public'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -28,8 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = IntrospectSchema.parse(body) + const parsed = await parseDatabaseToolRequest(postgresqlIntrospectContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Introspecting PostgreSQL schema on ${params.host}:${params.port}/${params.database}` @@ -60,14 +52,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await sql.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] PostgreSQL introspection failed:`, error) diff --git a/apps/sim/app/api/tools/postgresql/query/route.ts b/apps/sim/app/api/tools/postgresql/query/route.ts index 7726a8d5742..0feeea7ecb3 100644 --- a/apps/sim/app/api/tools/postgresql/query/route.ts +++ b/apps/sim/app/api/tools/postgresql/query/route.ts @@ -1,23 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { postgresqlQueryContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createPostgresConnection, executeQuery } from '@/app/api/tools/postgresql/utils' const logger = createLogger('PostgreSQLQueryAPI') -const QuerySchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), - query: z.string().min(1, 'Query is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -28,8 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = QuerySchema.parse(body) + const parsed = await parseDatabaseToolRequest(postgresqlQueryContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Executing PostgreSQL query on ${params.host}:${params.port}/${params.database}` @@ -58,14 +50,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await sql.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] PostgreSQL query failed:`, error) diff --git a/apps/sim/app/api/tools/postgresql/update/route.ts b/apps/sim/app/api/tools/postgresql/update/route.ts index 2e0dcf4feb3..40effe196d8 100644 --- a/apps/sim/app/api/tools/postgresql/update/route.ts +++ b/apps/sim/app/api/tools/postgresql/update/route.ts @@ -1,43 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { postgresqlUpdateContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createPostgresConnection, executeUpdate } from '@/app/api/tools/postgresql/utils' const logger = createLogger('PostgreSQLUpdateAPI') -const UpdateSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), - ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), - table: z.string().min(1, 'Table name is required'), - data: z.union([ - z - .record(z.unknown()) - .refine((obj) => Object.keys(obj).length > 0, 'Data object cannot be empty'), - z - .string() - .min(1) - .transform((str) => { - try { - const parsed = JSON.parse(str) - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('Data must be a JSON object') - } - return parsed - } catch (e) { - throw new Error('Invalid JSON format in data field') - } - }), - ]), - where: z.string().min(1, 'WHERE clause is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -48,8 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = UpdateSchema.parse(body) + const parsed = await parseDatabaseToolRequest(postgresqlUpdateContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}` @@ -78,14 +50,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await sql.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] PostgreSQL update failed:`, error) diff --git a/apps/sim/app/api/tools/pulse/parse/route.ts b/apps/sim/app/api/tools/pulse/parse/route.ts index 30b5a198803..468378801d3 100644 --- a/apps/sim/app/api/tools/pulse/parse/route.ts +++ b/apps/sim/app/api/tools/pulse/parse/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { pulseParseBodySchema } from '@/lib/api/contracts/media-tools' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -8,7 +9,6 @@ import { } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server' @@ -16,18 +16,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('PulseParseAPI') -const PulseParseSchema = z.object({ - apiKey: z.string().min(1, 'API key is required'), - filePath: z.string().optional(), - file: RawFileInputSchema.optional(), - pages: z.string().optional(), - extractFigure: z.boolean().optional(), - figureDescription: z.boolean().optional(), - returnHtml: z.boolean().optional(), - chunking: z.string().optional(), - chunkSize: z.number().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -49,7 +37,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userId = authResult.userId const body = await request.json() - const validatedData = PulseParseSchema.parse(body) + const validation = validateSchema(pulseParseBodySchema, body) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request data`, { errors: validation.error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request data'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Pulse parse request`, { fileName: validatedData.file?.name, @@ -152,18 +152,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: pulseData, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error in Pulse parse:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/quiver/image-to-svg/route.ts b/apps/sim/app/api/tools/quiver/image-to-svg/route.ts index 9d1cc21ebff..9e190899287 100644 --- a/apps/sim/app/api/tools/quiver/image-to-svg/route.ts +++ b/apps/sim/app/api/tools/quiver/image-to-svg/route.ts @@ -1,27 +1,16 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { quiverImageToSvgContract } from '@/lib/api/contracts/internal-tools' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { FileInputSchema, type RawFileInput } from '@/lib/uploads/utils/file-schemas' +import type { RawFileInput } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' const logger = createLogger('QuiverImageToSvgAPI') -const RequestSchema = z.object({ - apiKey: z.string().min(1), - model: z.string().min(1), - image: z.union([FileInputSchema, z.string()]), - temperature: z.number().min(0).max(2).optional().nullable(), - top_p: z.number().min(0).max(1).optional().nullable(), - max_output_tokens: z.number().int().min(1).max(131072).optional().nullable(), - presence_penalty: z.number().min(-2).max(2).optional().nullable(), - auto_crop: z.boolean().optional().nullable(), - target_size: z.number().int().min(128).max(4096).optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -31,8 +20,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const data = RequestSchema.parse(body) + const parsed = await parseRequest( + quiverImageToSvgContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + const data = parsed.data.body let apiImage: { url: string } | { base64: string } diff --git a/apps/sim/app/api/tools/quiver/text-to-svg/route.ts b/apps/sim/app/api/tools/quiver/text-to-svg/route.ts index c591e626fed..bf0343bdb4b 100644 --- a/apps/sim/app/api/tools/quiver/text-to-svg/route.ts +++ b/apps/sim/app/api/tools/quiver/text-to-svg/route.ts @@ -1,31 +1,16 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { quiverTextToSvgContract } from '@/lib/api/contracts/internal-tools' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { FileInputSchema, type RawFileInput } from '@/lib/uploads/utils/file-schemas' +import type { RawFileInput } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' const logger = createLogger('QuiverTextToSvgAPI') -const RequestSchema = z.object({ - apiKey: z.string().min(1), - prompt: z.string().min(1), - model: z.string().min(1), - instructions: z.string().optional().nullable(), - references: z - .union([z.array(FileInputSchema), FileInputSchema, z.string()]) - .optional() - .nullable(), - n: z.number().int().min(1).max(16).optional().nullable(), - temperature: z.number().min(0).max(2).optional().nullable(), - top_p: z.number().min(0).max(1).optional().nullable(), - max_output_tokens: z.number().int().min(1).max(131072).optional().nullable(), - presence_penalty: z.number().min(-2).max(2).optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -35,8 +20,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const data = RequestSchema.parse(body) + const parsed = await parseRequest( + quiverTextToSvgContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + const data = parsed.data.body const apiReferences: Array<{ url: string } | { base64: string }> = [] diff --git a/apps/sim/app/api/tools/rds/delete/route.ts b/apps/sim/app/api/tools/rds/delete/route.ts index a1921d5a186..275cf812610 100644 --- a/apps/sim/app/api/tools/rds/delete/route.ts +++ b/apps/sim/app/api/tools/rds/delete/route.ts @@ -1,26 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { rdsDeleteContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createRdsClient, executeDelete } from '@/app/api/tools/rds/utils' const logger = createLogger('RDSDeleteAPI') -const DeleteSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - resourceArn: z.string().min(1, 'Resource ARN is required'), - secretArn: z.string().min(1, 'Secret ARN is required'), - database: z.string().optional(), - table: z.string().min(1, 'Table name is required'), - conditions: z.record(z.unknown()).refine((obj) => Object.keys(obj).length > 0, { - message: 'At least one condition is required', - }), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -30,8 +18,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = DeleteSchema.parse(body) + const parsed = await parseDatabaseToolRequest(rdsDeleteContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Deleting from RDS table ${params.table} in ${params.database}`) @@ -65,14 +54,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] RDS delete failed:`, error) diff --git a/apps/sim/app/api/tools/rds/execute/route.ts b/apps/sim/app/api/tools/rds/execute/route.ts index 3e3dfdac2fe..027435cb7bc 100644 --- a/apps/sim/app/api/tools/rds/execute/route.ts +++ b/apps/sim/app/api/tools/rds/execute/route.ts @@ -1,23 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { rdsExecuteContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createRdsClient, executeStatement } from '@/app/api/tools/rds/utils' const logger = createLogger('RDSExecuteAPI') -const ExecuteSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - resourceArn: z.string().min(1, 'Resource ARN is required'), - secretArn: z.string().min(1, 'Secret ARN is required'), - database: z.string().optional(), - query: z.string().min(1, 'Query is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -27,8 +18,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = ExecuteSchema.parse(body) + const parsed = await parseDatabaseToolRequest(rdsExecuteContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Executing raw SQL on RDS database ${params.database}`) @@ -61,14 +53,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] RDS execute failed:`, error) diff --git a/apps/sim/app/api/tools/rds/insert/route.ts b/apps/sim/app/api/tools/rds/insert/route.ts index 899a657937c..c21d7cfa986 100644 --- a/apps/sim/app/api/tools/rds/insert/route.ts +++ b/apps/sim/app/api/tools/rds/insert/route.ts @@ -1,26 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { rdsInsertContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createRdsClient, executeInsert } from '@/app/api/tools/rds/utils' const logger = createLogger('RDSInsertAPI') -const InsertSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - resourceArn: z.string().min(1, 'Resource ARN is required'), - secretArn: z.string().min(1, 'Secret ARN is required'), - database: z.string().optional(), - table: z.string().min(1, 'Table name is required'), - data: z.record(z.unknown()).refine((obj) => Object.keys(obj).length > 0, { - message: 'Data object must have at least one field', - }), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -30,8 +18,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = InsertSchema.parse(body) + const parsed = await parseDatabaseToolRequest(rdsInsertContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Inserting into RDS table ${params.table} in ${params.database}`) @@ -65,14 +54,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] RDS insert failed:`, error) diff --git a/apps/sim/app/api/tools/rds/introspect/route.ts b/apps/sim/app/api/tools/rds/introspect/route.ts index 8f983033fac..7f752dd535b 100644 --- a/apps/sim/app/api/tools/rds/introspect/route.ts +++ b/apps/sim/app/api/tools/rds/introspect/route.ts @@ -1,24 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { rdsIntrospectContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createRdsClient, executeIntrospect, type RdsEngine } from '@/app/api/tools/rds/utils' const logger = createLogger('RDSIntrospectAPI') -const IntrospectSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - resourceArn: z.string().min(1, 'Resource ARN is required'), - secretArn: z.string().min(1, 'Secret ARN is required'), - database: z.string().optional(), - schema: z.string().optional(), - engine: z.enum(['aurora-postgresql', 'aurora-mysql']).optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -28,8 +18,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = IntrospectSchema.parse(body) + const parsed = await parseDatabaseToolRequest(rdsIntrospectContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Introspecting RDS Aurora database${params.database ? ` (${params.database})` : ''}` @@ -68,14 +59,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] RDS introspection failed:`, error) diff --git a/apps/sim/app/api/tools/rds/query/route.ts b/apps/sim/app/api/tools/rds/query/route.ts index 7fcc4bf8d30..1621f1c2d3f 100644 --- a/apps/sim/app/api/tools/rds/query/route.ts +++ b/apps/sim/app/api/tools/rds/query/route.ts @@ -1,23 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { rdsQueryContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createRdsClient, executeStatement, validateQuery } from '@/app/api/tools/rds/utils' const logger = createLogger('RDSQueryAPI') -const QuerySchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - resourceArn: z.string().min(1, 'Resource ARN is required'), - secretArn: z.string().min(1, 'Secret ARN is required'), - database: z.string().optional(), - query: z.string().min(1, 'Query is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -27,8 +18,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = QuerySchema.parse(body) + const parsed = await parseDatabaseToolRequest(rdsQueryContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Executing RDS query on ${params.database}`) @@ -67,14 +59,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] RDS query failed:`, error) diff --git a/apps/sim/app/api/tools/rds/update/route.ts b/apps/sim/app/api/tools/rds/update/route.ts index fcdcb67c94e..d8efb0f4e84 100644 --- a/apps/sim/app/api/tools/rds/update/route.ts +++ b/apps/sim/app/api/tools/rds/update/route.ts @@ -1,29 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { rdsUpdateContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createRdsClient, executeUpdate } from '@/app/api/tools/rds/utils' const logger = createLogger('RDSUpdateAPI') -const UpdateSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - resourceArn: z.string().min(1, 'Resource ARN is required'), - secretArn: z.string().min(1, 'Secret ARN is required'), - database: z.string().optional(), - table: z.string().min(1, 'Table name is required'), - data: z.record(z.unknown()).refine((obj) => Object.keys(obj).length > 0, { - message: 'Data object must have at least one field', - }), - conditions: z.record(z.unknown()).refine((obj) => Object.keys(obj).length > 0, { - message: 'At least one condition is required', - }), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -33,8 +18,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = UpdateSchema.parse(body) + const parsed = await parseDatabaseToolRequest(rdsUpdateContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Updating RDS table ${params.table} in ${params.database}`) @@ -69,14 +55,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] RDS update failed:`, error) diff --git a/apps/sim/app/api/tools/redis/execute/route.ts b/apps/sim/app/api/tools/redis/execute/route.ts index 5482d0896d2..8df1b971257 100644 --- a/apps/sim/app/api/tools/redis/execute/route.ts +++ b/apps/sim/app/api/tools/redis/execute/route.ts @@ -1,19 +1,14 @@ import { createLogger } from '@sim/logger' import Redis from 'ioredis' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { redisExecuteContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('RedisAPI') -const RequestSchema = z.object({ - url: z.string().min(1, 'Redis connection URL is required'), - command: z.string().min(1, 'Redis command is required'), - args: z.array(z.union([z.string(), z.number()])).default([]), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { let client: Redis | null = null @@ -23,8 +18,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { url, command, args } = RequestSchema.parse(body) + const parsed = await parseDatabaseToolRequest(redisExecuteContract, request, { + errorFormat: 'firstError', + logger, + }) + if (!parsed.success) return parsed.response + const { url, command, args } = parsed.data.body const parsedUrl = new URL(url) const hostname = diff --git a/apps/sim/app/api/tools/reducto/parse/route.ts b/apps/sim/app/api/tools/reducto/parse/route.ts index dc92994a48f..adffa72bffc 100644 --- a/apps/sim/app/api/tools/reducto/parse/route.ts +++ b/apps/sim/app/api/tools/reducto/parse/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { reductoParseBodySchema } from '@/lib/api/contracts/media-tools' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -8,7 +9,6 @@ import { } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server' @@ -16,14 +16,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('ReductoParseAPI') -const ReductoParseSchema = z.object({ - apiKey: z.string().min(1, 'API key is required'), - filePath: z.string().optional(), - file: RawFileInputSchema.optional(), - pages: z.array(z.number()).optional(), - tableOutputFormat: z.enum(['html', 'md']).optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -45,7 +37,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userId = authResult.userId const body = await request.json() - const validatedData = ReductoParseSchema.parse(body) + const validation = validateSchema(reductoParseBodySchema, body) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request data`, { errors: validation.error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request data'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Reducto parse request`, { fileName: validatedData.file?.name, @@ -145,18 +149,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: reductoData, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error in Reducto parse:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/s3/copy-object/route.ts b/apps/sim/app/api/tools/s3/copy-object/route.ts index e8e3632a908..a8abc76f8e1 100644 --- a/apps/sim/app/api/tools/s3/copy-object/route.ts +++ b/apps/sim/app/api/tools/s3/copy-object/route.ts @@ -1,7 +1,8 @@ import { CopyObjectCommand, type ObjectCannedACL, S3Client } from '@aws-sdk/client-s3' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsS3CopyObjectContract } from '@/lib/api/contracts/tools/aws/s3-copy-object' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,17 +11,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('S3CopyObjectAPI') -const S3CopyObjectSchema = z.object({ - accessKeyId: z.string().min(1, 'Access Key ID is required'), - secretAccessKey: z.string().min(1, 'Secret Access Key is required'), - region: z.string().min(1, 'Region is required'), - sourceBucket: z.string().min(1, 'Source bucket name is required'), - sourceKey: z.string().min(1, 'Source object key is required'), - destinationBucket: z.string().min(1, 'Destination bucket name is required'), - destinationKey: z.string().min(1, 'Destination object key is required'), - acl: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -42,8 +32,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = S3CopyObjectSchema.parse(body) + const parsed = await parseAwsToolRequest(awsS3CopyObjectContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Copying S3 object`, { source: `${validatedData.sourceBucket}/${validatedData.sourceKey}`, @@ -93,18 +87,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error copying S3 object:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/s3/delete-object/route.ts b/apps/sim/app/api/tools/s3/delete-object/route.ts index 01305c0d7fe..488609c4d4b 100644 --- a/apps/sim/app/api/tools/s3/delete-object/route.ts +++ b/apps/sim/app/api/tools/s3/delete-object/route.ts @@ -1,7 +1,8 @@ import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsS3DeleteObjectContract } from '@/lib/api/contracts/tools/aws/s3-delete-object' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,14 +11,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('S3DeleteObjectAPI') -const S3DeleteObjectSchema = z.object({ - accessKeyId: z.string().min(1, 'Access Key ID is required'), - secretAccessKey: z.string().min(1, 'Secret Access Key is required'), - region: z.string().min(1, 'Region is required'), - bucketName: z.string().min(1, 'Bucket name is required'), - objectKey: z.string().min(1, 'Object key is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -42,8 +35,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = S3DeleteObjectSchema.parse(body) + const parsed = await parseAwsToolRequest(awsS3DeleteObjectContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Deleting S3 object`, { bucket: validatedData.bucketName, @@ -82,18 +79,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error deleting S3 object:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/s3/list-objects/route.ts b/apps/sim/app/api/tools/s3/list-objects/route.ts index 6c7f72f4a7e..267f40e875d 100644 --- a/apps/sim/app/api/tools/s3/list-objects/route.ts +++ b/apps/sim/app/api/tools/s3/list-objects/route.ts @@ -1,7 +1,8 @@ import { ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsS3ListObjectsContract } from '@/lib/api/contracts/tools/aws/s3-list-objects' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,16 +11,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('S3ListObjectsAPI') -const S3ListObjectsSchema = z.object({ - accessKeyId: z.string().min(1, 'Access Key ID is required'), - secretAccessKey: z.string().min(1, 'Secret Access Key is required'), - region: z.string().min(1, 'Region is required'), - bucketName: z.string().min(1, 'Bucket name is required'), - prefix: z.string().optional().nullable(), - maxKeys: z.number().optional().nullable(), - continuationToken: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -41,8 +32,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = S3ListObjectsSchema.parse(body) + const parsed = await parseAwsToolRequest(awsS3ListObjectsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Listing S3 objects`, { bucket: validatedData.bucketName, @@ -92,18 +87,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error listing S3 objects:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/s3/put-object/route.ts b/apps/sim/app/api/tools/s3/put-object/route.ts index 2e9dbbed909..7cb2f529302 100644 --- a/apps/sim/app/api/tools/s3/put-object/route.ts +++ b/apps/sim/app/api/tools/s3/put-object/route.ts @@ -1,11 +1,11 @@ import { type ObjectCannedACL, PutObjectCommand, S3Client } from '@aws-sdk/client-s3' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsS3PutObjectContract } from '@/lib/api/contracts/tools/aws/s3-put-object' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -13,18 +13,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('S3PutObjectAPI') -const S3PutObjectSchema = z.object({ - accessKeyId: z.string().min(1, 'Access Key ID is required'), - secretAccessKey: z.string().min(1, 'Secret Access Key is required'), - region: z.string().min(1, 'Region is required'), - bucketName: z.string().min(1, 'Bucket name is required'), - objectKey: z.string().min(1, 'Object key is required'), - file: RawFileInputSchema.optional().nullable(), - content: z.string().optional().nullable(), - contentType: z.string().optional().nullable(), - acl: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -46,8 +34,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = S3PutObjectSchema.parse(body) + const parsed = await parseAwsToolRequest(awsS3PutObjectContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Uploading to S3`, { bucket: validatedData.bucketName, @@ -133,18 +125,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error uploading to S3:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/sap_s4hana/proxy/route.ts b/apps/sim/app/api/tools/sap_s4hana/proxy/route.ts index 3ca70fb7f42..6980b975a5d 100644 --- a/apps/sim/app/api/tools/sap_s4hana/proxy/route.ts +++ b/apps/sim/app/api/tools/sap_s4hana/proxy/route.ts @@ -2,7 +2,12 @@ import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + assertSafeSapExternalUrl, + type SapS4HanaProxyRequest, + sapS4HanaProxyContract, +} from '@/lib/api/contracts/internal-tools' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,167 +16,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SapS4HanaProxyAPI') -const HttpMethod = z.enum(['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'MERGE']) -const DeploymentType = z.enum(['cloud_public', 'cloud_private', 'on_premise']) -const AuthType = z.enum(['oauth_client_credentials', 'basic']) - -const ServiceName = z - .string() - .min(1, 'service is required') - .regex( - /^[A-Z][A-Z0-9_]*(;v=\d+)?$/, - 'service must be an uppercase OData service name optionally suffixed with ";v=NNNN" (e.g., API_BUSINESS_PARTNER, API_OUTBOUND_DELIVERY_SRV;v=0002)' - ) - -const ServicePath = z - .string() - .min(1, 'path is required') - .refine( - (p) => - !p.split(/[/\\]/).some((seg) => seg === '..' || seg === '.') && - !p.includes('?') && - !p.includes('#') && - !/%(?:2[eEfF]|5[cC]|3[fF]|23)/.test(p), - { - message: - 'path must not contain ".." or "." segments, "?", "#", or percent-encoded path/query/fragment characters', - } - ) - -const Subdomain = z - .string() - .regex( - /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i, - 'subdomain must contain only letters, digits, and hyphens (1-63 chars)' - ) - -const ProxyRequestSchema = z - .object({ - deploymentType: DeploymentType.default('cloud_public'), - authType: AuthType.default('oauth_client_credentials'), - subdomain: Subdomain.optional(), - region: z - .string() - .regex(/^[a-z]{2,4}\d{1,3}$/i, 'region must be an SAP BTP region code (e.g., eu10, us30)') - .optional(), - baseUrl: z.string().optional(), - tokenUrl: z.string().optional(), - clientId: z.string().optional(), - clientSecret: z.string().optional(), - username: z.string().optional(), - password: z.string().optional(), - service: ServiceName, - path: ServicePath, - method: HttpMethod.default('GET'), - query: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(), - body: z.unknown().optional(), - ifMatch: z.string().optional(), - }) - .superRefine((req, ctx) => { - if (req.deploymentType === 'cloud_public') { - if (!req.subdomain) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['subdomain'], - message: 'subdomain is required for cloud_public deployment', - }) - } - if (!req.region) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['region'], - message: 'region is required for cloud_public deployment', - }) - } - if (req.authType !== 'oauth_client_credentials') { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['authType'], - message: 'cloud_public deployment only supports oauth_client_credentials', - }) - } - if (!req.clientId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['clientId'], - message: 'clientId is required', - }) - } - if (!req.clientSecret) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['clientSecret'], - message: 'clientSecret is required', - }) - } - } else { - if (!req.baseUrl) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['baseUrl'], - message: 'baseUrl is required for cloud_private and on_premise deployments', - }) - } else { - const baseUrlCheck = checkExternalUrlSafety(req.baseUrl, 'baseUrl') - if (!baseUrlCheck.ok) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['baseUrl'], - message: baseUrlCheck.message, - }) - } - } - if (req.authType === 'oauth_client_credentials') { - if (!req.tokenUrl) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['tokenUrl'], - message: 'tokenUrl is required for OAuth on cloud_private/on_premise', - }) - } else { - const tokenUrlCheck = checkExternalUrlSafety(req.tokenUrl, 'tokenUrl') - if (!tokenUrlCheck.ok) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['tokenUrl'], - message: tokenUrlCheck.message, - }) - } - } - if (!req.clientId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['clientId'], - message: 'clientId is required for OAuth', - }) - } - if (!req.clientSecret) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['clientSecret'], - message: 'clientSecret is required for OAuth', - }) - } - } else { - if (!req.username) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['username'], - message: 'username is required for Basic auth', - }) - } - if (!req.password) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['password'], - message: 'password is required for Basic auth', - }) - } - } - } - }) - -type ProxyRequest = z.infer +type ProxyRequest = SapS4HanaProxyRequest interface CachedToken { accessToken: string @@ -183,103 +28,6 @@ const TOKEN_CACHE_MAX_ENTRIES = 500 const TOKEN_SAFETY_WINDOW_MS = 60_000 const OUTBOUND_FETCH_TIMEOUT_MS = 30_000 -const FORBIDDEN_HOSTS = new Set([ - 'localhost', - '0.0.0.0', - '127.0.0.1', - '169.254.169.254', - 'metadata.google.internal', - 'metadata', - '[::1]', - '[::]', - '[::ffff:127.0.0.1]', - '[fd00:ec2::254]', -]) - -function isPrivateIPv4(host: string): boolean { - const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) - if (!match) return false - const octets = match.slice(1, 5).map(Number) as [number, number, number, number] - if (octets.some((o) => o < 0 || o > 255)) return false - const [a, b] = octets - if (a === 10) return true - if (a === 172 && b >= 16 && b <= 31) return true - if (a === 192 && b === 168) return true - if (a === 127) return true - if (a === 169 && b === 254) return true - if (a === 0) return true - return false -} - -function extractIPv4MappedHost(host: string): string | null { - const stripped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host - const lower = stripped.toLowerCase() - for (const prefix of ['::ffff:', '::']) { - if (lower.startsWith(prefix)) { - const candidate = lower.slice(prefix.length) - if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(candidate)) return candidate - } - } - const hexMatch = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/) - if (hexMatch) { - const high = Number.parseInt(hexMatch[1] as string, 16) - const low = Number.parseInt(hexMatch[2] as string, 16) - if (high >= 0 && high <= 0xffff && low >= 0 && low <= 0xffff) { - const a = (high >> 8) & 0xff - const b = high & 0xff - const c = (low >> 8) & 0xff - const d = low & 0xff - return `${a}.${b}.${c}.${d}` - } - } - return null -} - -function isPrivateOrLoopbackIPv6(host: string): boolean { - const stripped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host - const lower = stripped.toLowerCase() - if (lower === '::' || lower === '::1') return true - if (/^fc[0-9a-f]{2}:/.test(lower) || /^fd[0-9a-f]{2}:/.test(lower)) return true - if (lower.startsWith('fe80:')) return true - return false -} - -function checkExternalUrlSafety( - rawUrl: string, - label: string -): { ok: true; url: URL } | { ok: false; message: string } { - let parsed: URL - try { - parsed = new URL(rawUrl) - } catch { - return { ok: false, message: `${label} must be a valid URL` } - } - if (parsed.protocol !== 'https:') { - return { ok: false, message: `${label} must use https://` } - } - const host = parsed.hostname.toLowerCase() - if (FORBIDDEN_HOSTS.has(host) || FORBIDDEN_HOSTS.has(`[${host}]`)) { - return { ok: false, message: `${label} host is not allowed` } - } - if (isPrivateIPv4(host)) { - return { ok: false, message: `${label} host is not allowed (private/loopback range)` } - } - const mapped = extractIPv4MappedHost(host) - if (mapped && isPrivateIPv4(mapped)) { - return { ok: false, message: `${label} host is not allowed (IPv4-mapped private range)` } - } - if (isPrivateOrLoopbackIPv6(host)) { - return { ok: false, message: `${label} host is not allowed (IPv6 private/loopback)` } - } - return { ok: true, url: parsed } -} - -function assertSafeExternalUrl(rawUrl: string, label: string): URL { - const result = checkExternalUrlSafety(rawUrl, label) - if (!result.ok) throw new Error(result.message) - return result.url -} - function resolveTokenUrl(req: ProxyRequest): string { if (req.deploymentType === 'cloud_public') { return `https://${req.subdomain}.authentication.${req.region}.hana.ondemand.com/oauth/token` @@ -314,7 +62,7 @@ async function fetchAccessToken(req: ProxyRequest, requestId: string): Promise { ) } - const json = await request.json() - const proxyReq = ProxyRequestSchema.parse(json) + const parsed = await parseRequest( + sapS4HanaProxyContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Validation failed') }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + const proxyReq = parsed.data.body const isWrite = WRITE_METHODS.has(proxyReq.method) const accessToken = @@ -601,13 +361,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: invocation.status } ) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Validation error:`, error.errors) - return NextResponse.json( - { success: false, error: error.errors[0]?.message || 'Validation failed' }, - { status: 400 } - ) - } logger.error(`[${requestId}] Unexpected SAP proxy error:`, error) return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) } diff --git a/apps/sim/app/api/tools/search/route.ts b/apps/sim/app/api/tools/search/route.ts index 63d88ed0b93..41e79bc6c41 100644 --- a/apps/sim/app/api/tools/search/route.ts +++ b/apps/sim/app/api/tools/search/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { searchToolContract } from '@/lib/api/contracts/tools/search' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { SEARCH_TOOL_COST } from '@/lib/billing/constants' import { env } from '@/lib/core/config/env' @@ -10,10 +11,6 @@ import { executeTool } from '@/tools' const logger = createLogger('search') -const SearchRequestSchema = z.object({ - query: z.string().min(1), -}) - export const maxDuration = 60 export const dynamic = 'force-dynamic' @@ -38,8 +35,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId, }) - const body = await request.json() - const validated = SearchRequestSchema.parse(body) + const parsed = await parseRequest(searchToolContract, request, {}) + if (!parsed.success) return parsed.response + const validated = parsed.data.body const exaApiKey = env.EXA_API_KEY diff --git a/apps/sim/app/api/tools/secrets_manager/create-secret/route.ts b/apps/sim/app/api/tools/secrets_manager/create-secret/route.ts index 88f75174455..95fc64b2d5f 100644 --- a/apps/sim/app/api/tools/secrets_manager/create-secret/route.ts +++ b/apps/sim/app/api/tools/secrets_manager/create-secret/route.ts @@ -1,22 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsSecretsManagerCreateSecretContract } from '@/lib/api/contracts/tools/aws/secrets-manager-create-secret' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSecret, createSecretsManagerClient } from '../utils' const logger = createLogger('SecretsManagerCreateSecretAPI') -const CreateSecretSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - name: z.string().min(1, 'Secret name is required'), - secretValue: z.string().min(1, 'Secret value is required'), - description: z.string().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -26,8 +18,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = CreateSecretSchema.parse(body) + const parsed = await parseAwsToolRequest(awsSecretsManagerCreateSecretContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Creating secret ${params.name}`) @@ -50,14 +46,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] Failed to create secret:`, error) diff --git a/apps/sim/app/api/tools/secrets_manager/delete-secret/route.ts b/apps/sim/app/api/tools/secrets_manager/delete-secret/route.ts index 57efd4fc7db..124492a2fad 100644 --- a/apps/sim/app/api/tools/secrets_manager/delete-secret/route.ts +++ b/apps/sim/app/api/tools/secrets_manager/delete-secret/route.ts @@ -1,22 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsSecretsManagerDeleteSecretContract } from '@/lib/api/contracts/tools/aws/secrets-manager-delete-secret' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSecretsManagerClient, deleteSecret } from '../utils' const logger = createLogger('SecretsManagerDeleteSecretAPI') -const DeleteSecretSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - secretId: z.string().min(1, 'Secret ID is required'), - recoveryWindowInDays: z.number().min(7).max(30).nullish(), - forceDelete: z.boolean().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -26,8 +18,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = DeleteSecretSchema.parse(body) + const parsed = await parseAwsToolRequest(awsSecretsManagerDeleteSecretContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Deleting secret ${params.secretId}`) @@ -56,14 +52,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] Failed to delete secret:`, error) diff --git a/apps/sim/app/api/tools/secrets_manager/get-secret/route.ts b/apps/sim/app/api/tools/secrets_manager/get-secret/route.ts index ff88a00ed33..639cdb35440 100644 --- a/apps/sim/app/api/tools/secrets_manager/get-secret/route.ts +++ b/apps/sim/app/api/tools/secrets_manager/get-secret/route.ts @@ -1,22 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsSecretsManagerGetSecretContract } from '@/lib/api/contracts/tools/aws/secrets-manager-get-secret' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSecretsManagerClient, getSecretValue } from '../utils' const logger = createLogger('SecretsManagerGetSecretAPI') -const GetSecretSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - secretId: z.string().min(1, 'Secret ID is required'), - versionId: z.string().nullish(), - versionStage: z.string().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -26,8 +18,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = GetSecretSchema.parse(body) + const parsed = await parseAwsToolRequest(awsSecretsManagerGetSecretContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Retrieving secret ${params.secretId}`) @@ -52,14 +48,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] Failed to retrieve secret:`, error) diff --git a/apps/sim/app/api/tools/secrets_manager/list-secrets/route.ts b/apps/sim/app/api/tools/secrets_manager/list-secrets/route.ts index 3ed4b030f01..91d895b16e4 100644 --- a/apps/sim/app/api/tools/secrets_manager/list-secrets/route.ts +++ b/apps/sim/app/api/tools/secrets_manager/list-secrets/route.ts @@ -1,21 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsSecretsManagerListSecretsContract } from '@/lib/api/contracts/tools/aws/secrets-manager-list-secrets' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSecretsManagerClient, listSecrets } from '../utils' const logger = createLogger('SecretsManagerListSecretsAPI') -const ListSecretsSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - maxResults: z.number().min(1).max(100).nullish(), - nextToken: z.string().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -25,8 +18,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = ListSecretsSchema.parse(body) + const parsed = await parseAwsToolRequest(awsSecretsManagerListSecretsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Listing secrets`) @@ -46,14 +43,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] Failed to list secrets:`, error) diff --git a/apps/sim/app/api/tools/secrets_manager/update-secret/route.ts b/apps/sim/app/api/tools/secrets_manager/update-secret/route.ts index e7f1a8b7e1f..86b22647e86 100644 --- a/apps/sim/app/api/tools/secrets_manager/update-secret/route.ts +++ b/apps/sim/app/api/tools/secrets_manager/update-secret/route.ts @@ -1,22 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsSecretsManagerUpdateSecretContract } from '@/lib/api/contracts/tools/aws/secrets-manager-update-secret' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSecretsManagerClient, updateSecretValue } from '../utils' const logger = createLogger('SecretsManagerUpdateSecretAPI') -const UpdateSecretSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - secretId: z.string().min(1, 'Secret ID is required'), - secretValue: z.string().min(1, 'Secret value is required'), - description: z.string().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -26,8 +18,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = UpdateSecretSchema.parse(body) + const parsed = await parseAwsToolRequest(awsSecretsManagerUpdateSecretContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Updating secret ${params.secretId}`) @@ -55,14 +51,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] Failed to update secret:`, error) diff --git a/apps/sim/app/api/tools/sendgrid/send-mail/route.ts b/apps/sim/app/api/tools/sendgrid/send-mail/route.ts index ccb3bb477ae..bf2cf1c6558 100644 --- a/apps/sim/app/api/tools/sendgrid/send-mail/route.ts +++ b/apps/sim/app/api/tools/sendgrid/send-mail/route.ts @@ -1,10 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { sendGridSendMailBodySchema } from '@/lib/api/contracts' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -12,24 +12,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SendGridSendMailAPI') -const SendGridSendMailSchema = z.object({ - apiKey: z.string().min(1, 'API key is required'), - from: z.string().min(1, 'From email is required'), - fromName: z.string().optional().nullable(), - to: z.string().min(1, 'To email is required'), - toName: z.string().optional().nullable(), - subject: z.string().optional().nullable(), - content: z.string().optional().nullable(), - contentType: z.string().optional().nullable(), - cc: z.string().optional().nullable(), - bcc: z.string().optional().nullable(), - replyTo: z.string().optional().nullable(), - replyToName: z.string().optional().nullable(), - templateId: z.string().optional().nullable(), - dynamicTemplateData: z.any().optional().nullable(), - attachments: RawFileInputArraySchema.optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -46,8 +28,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Authenticated SendGrid send request via ${authResult.authType}`) - const body = await request.json() - const validatedData = SendGridSendMailSchema.parse(body) + const validation = await validateJsonBody(request, sendGridSendMailBodySchema) + if (!validation.success) { + logger.warn(`[${requestId}] Validation error:`, validation.error?.issues ?? []) + if (!validation.error) return validation.response + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Validation failed'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Sending SendGrid email`, { to: validatedData.to, @@ -172,14 +166,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Validation error:`, error.errors) - return NextResponse.json( - { success: false, error: error.errors[0]?.message || 'Validation failed' }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Unexpected error:`, error) return NextResponse.json( { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, diff --git a/apps/sim/app/api/tools/ses/create-template/route.ts b/apps/sim/app/api/tools/ses/create-template/route.ts index 1632d274f3c..7080089dc22 100644 --- a/apps/sim/app/api/tools/ses/create-template/route.ts +++ b/apps/sim/app/api/tools/ses/create-template/route.ts @@ -1,34 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsSesCreateTemplateContract } from '@/lib/api/contracts/tools/aws/ses-create-template' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSESClient, createTemplate } from '../utils' const logger = createLogger('SESCreateTemplateAPI') -const CreateTemplateSchema = z - .object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - templateName: z.string().min(1, 'Template name is required'), - subjectPart: z.string().min(1, 'Subject is required'), - textPart: z.string().nullish(), - htmlPart: z.string().nullish(), - }) - .refine((data) => data.textPart || data.htmlPart, { - message: 'At least one of textPart or htmlPart is required', - path: ['textPart'], - }) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -36,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = CreateTemplateSchema.parse(body) + const parsed = await parseAwsToolRequest(awsSesCreateTemplateContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Creating SES template '${params.templateName}'`) @@ -62,14 +46,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error('Failed to create template:', error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/ses/delete-template/route.ts b/apps/sim/app/api/tools/ses/delete-template/route.ts index fe81de2f288..25b7033d1b6 100644 --- a/apps/sim/app/api/tools/ses/delete-template/route.ts +++ b/apps/sim/app/api/tools/ses/delete-template/route.ts @@ -1,26 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsSesDeleteTemplateContract } from '@/lib/api/contracts/tools/aws/ses-delete-template' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSESClient, deleteTemplate } from '../utils' const logger = createLogger('SESDeleteTemplateAPI') -const DeleteTemplateSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - templateName: z.string().min(1, 'Template name is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -28,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = DeleteTemplateSchema.parse(body) + const parsed = await parseAwsToolRequest(awsSesDeleteTemplateContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Deleting SES template '${params.templateName}'`) @@ -49,14 +41,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error('Failed to delete template:', error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/ses/get-account/route.ts b/apps/sim/app/api/tools/ses/get-account/route.ts index 71f310ed297..6c01f096d3d 100644 --- a/apps/sim/app/api/tools/ses/get-account/route.ts +++ b/apps/sim/app/api/tools/ses/get-account/route.ts @@ -1,25 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsSesGetAccountContract } from '@/lib/api/contracts/tools/aws/ses-get-account' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSESClient, getAccount } from '../utils' const logger = createLogger('SESGetAccountAPI') -const GetAccountSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -27,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = GetAccountSchema.parse(body) + const parsed = await parseAwsToolRequest(awsSesGetAccountContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info('Getting SES account information') @@ -48,14 +41,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error('Failed to get account information:', error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/ses/get-template/route.ts b/apps/sim/app/api/tools/ses/get-template/route.ts index 4d7c4b687bb..8ecf537372a 100644 --- a/apps/sim/app/api/tools/ses/get-template/route.ts +++ b/apps/sim/app/api/tools/ses/get-template/route.ts @@ -1,26 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsSesGetTemplateContract } from '@/lib/api/contracts/tools/aws/ses-get-template' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSESClient, getTemplate } from '../utils' const logger = createLogger('SESGetTemplateAPI') -const GetTemplateSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - templateName: z.string().min(1, 'Template name is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -28,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = GetTemplateSchema.parse(body) + const parsed = await parseAwsToolRequest(awsSesGetTemplateContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Getting SES template '${params.templateName}'`) @@ -49,14 +41,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error('Failed to get template:', error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/ses/list-identities/route.ts b/apps/sim/app/api/tools/ses/list-identities/route.ts index caac028d66b..c3e5373c7d9 100644 --- a/apps/sim/app/api/tools/ses/list-identities/route.ts +++ b/apps/sim/app/api/tools/ses/list-identities/route.ts @@ -1,27 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsSesListIdentitiesContract } from '@/lib/api/contracts/tools/aws/ses-list-identities' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSESClient, listIdentities } from '../utils' const logger = createLogger('SESListIdentitiesAPI') -const ListIdentitiesSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - pageSize: z.number().int().min(0).max(1000).nullish(), - nextToken: z.string().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -29,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = ListIdentitiesSchema.parse(body) + const parsed = await parseAwsToolRequest(awsSesListIdentitiesContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info('Listing SES email identities') @@ -53,14 +44,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error('Failed to list identities:', error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/ses/list-templates/route.ts b/apps/sim/app/api/tools/ses/list-templates/route.ts index 52bcf00cb2f..f19e8b5552f 100644 --- a/apps/sim/app/api/tools/ses/list-templates/route.ts +++ b/apps/sim/app/api/tools/ses/list-templates/route.ts @@ -1,27 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsSesListTemplatesContract } from '@/lib/api/contracts/tools/aws/ses-list-templates' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSESClient, listTemplates } from '../utils' const logger = createLogger('SESListTemplatesAPI') -const ListTemplatesSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - pageSize: z.number().int().min(1).max(100).nullish(), - nextToken: z.string().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -29,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = ListTemplatesSchema.parse(body) + const parsed = await parseAwsToolRequest(awsSesListTemplatesContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info('Listing SES email templates') @@ -53,14 +44,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error('Failed to list templates:', error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/ses/send-bulk-email/route.ts b/apps/sim/app/api/tools/ses/send-bulk-email/route.ts index 40b357a45c7..35f0bdcbc8e 100644 --- a/apps/sim/app/api/tools/ses/send-bulk-email/route.ts +++ b/apps/sim/app/api/tools/ses/send-bulk-email/route.ts @@ -1,35 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsSesSendBulkEmailContract } from '@/lib/api/contracts/tools/aws/ses-send-bulk-email' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createSESClient, sendBulkEmail } from '../utils' +import { createSESClient, parseBulkEmailDestinations, sendBulkEmail } from '../utils' const logger = createLogger('SESSendBulkEmailAPI') -const DestinationSchema = z.object({ - toAddresses: z.array(z.string().email()), - templateData: z.string().optional(), -}) - -const SendBulkEmailSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - fromAddress: z.string().email('Valid sender email address is required'), - templateName: z.string().min(1, 'Template name is required'), - destinations: z.string().min(1, 'Destinations JSON array is required'), - defaultTemplateData: z.string().nullish(), - configurationSetName: z.string().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -37,13 +16,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = SendBulkEmailSchema.parse(body) + const parsed = await parseAwsToolRequest(awsSesSendBulkEmailContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body - let destinations: Array<{ toAddresses: string[]; templateData?: string }> + let destinations: ReturnType try { - const parsed = JSON.parse(params.destinations) - destinations = z.array(DestinationSchema).parse(parsed) + destinations = parseBulkEmailDestinations(params.destinations) } catch { return NextResponse.json( { error: 'destinations must be a valid JSON array of destination objects' }, @@ -79,14 +61,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error('Failed to send bulk email:', error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/ses/send-email/route.ts b/apps/sim/app/api/tools/ses/send-email/route.ts index 5328bf0d741..8059ce67217 100644 --- a/apps/sim/app/api/tools/ses/send-email/route.ts +++ b/apps/sim/app/api/tools/ses/send-email/route.ts @@ -1,39 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsSesSendEmailContract } from '@/lib/api/contracts/tools/aws/ses-send-email' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSESClient, sendEmail } from '../utils' const logger = createLogger('SESSendEmailAPI') -const SendEmailSchema = z - .object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - fromAddress: z.string().email('Valid sender email address is required'), - toAddresses: z.string().min(1, 'At least one recipient address is required'), - subject: z.string().min(1, 'Email subject is required'), - bodyText: z.string().nullish(), - bodyHtml: z.string().nullish(), - ccAddresses: z.string().nullish(), - bccAddresses: z.string().nullish(), - replyToAddresses: z.string().nullish(), - configurationSetName: z.string().nullish(), - }) - .refine((data) => data.bodyText || data.bodyHtml, { - message: 'At least one of bodyText or bodyHtml is required', - path: ['bodyText'], - }) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -41,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = SendEmailSchema.parse(body) + const parsed = await parseAwsToolRequest(awsSesSendEmailContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body const toList = params.toAddresses .split(',') @@ -92,14 +71,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error('Failed to send email:', error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/ses/send-templated-email/route.ts b/apps/sim/app/api/tools/ses/send-templated-email/route.ts index 0efc9cf5ce6..8d40dcea484 100644 --- a/apps/sim/app/api/tools/ses/send-templated-email/route.ts +++ b/apps/sim/app/api/tools/ses/send-templated-email/route.ts @@ -1,32 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsSesSendTemplatedEmailContract } from '@/lib/api/contracts/tools/aws/ses-send-templated-email' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSESClient, sendTemplatedEmail } from '../utils' const logger = createLogger('SESSendTemplatedEmailAPI') -const SendTemplatedEmailSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - fromAddress: z.string().email('Valid sender email address is required'), - toAddresses: z.string().min(1, 'At least one recipient address is required'), - templateName: z.string().min(1, 'Template name is required'), - templateData: z.string().min(1, 'Template data is required'), - ccAddresses: z.string().nullish(), - bccAddresses: z.string().nullish(), - configurationSetName: z.string().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -34,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = SendTemplatedEmailSchema.parse(body) + const parsed = await parseAwsToolRequest(awsSesSendTemplatedEmailContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body const toList = params.toAddresses .split(',') @@ -80,14 +66,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error('Failed to send templated email:', error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/ses/utils.ts b/apps/sim/app/api/tools/ses/utils.ts index cc34cd79e24..8e15e2d3d84 100644 --- a/apps/sim/app/api/tools/ses/utils.ts +++ b/apps/sim/app/api/tools/ses/utils.ts @@ -9,8 +9,16 @@ import { SendBulkEmailCommand, SendEmailCommand, } from '@aws-sdk/client-sesv2' +import { z } from 'zod' import type { SESConnectionConfig } from '@/tools/ses/types' +const SesBulkEmailDestinationSchema = z.object({ + toAddresses: z.array(z.string().email()), + templateData: z.string().optional(), +}) + +type SesBulkEmailDestination = z.infer + export function createSESClient(config: SESConnectionConfig): SESv2Client { return new SESv2Client({ region: config.region, @@ -97,12 +105,17 @@ export async function sendTemplatedEmail( } } +export function parseBulkEmailDestinations(destinationsJson: string): SesBulkEmailDestination[] { + const destinations = JSON.parse(destinationsJson) + return z.array(SesBulkEmailDestinationSchema).parse(destinations) +} + export async function sendBulkEmail( client: SESv2Client, params: { fromAddress: string templateName: string - destinations: Array<{ toAddresses: string[]; templateData?: string }> + destinations: SesBulkEmailDestination[] defaultTemplateData?: string | null configurationSetName?: string | null } diff --git a/apps/sim/app/api/tools/sftp/delete/route.ts b/apps/sim/app/api/tools/sftp/delete/route.ts index ed6c77451ca..5109230a6a9 100644 --- a/apps/sim/app/api/tools/sftp/delete/route.ts +++ b/apps/sim/app/api/tools/sftp/delete/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import type { SFTPWrapper } from 'ssh2' -import { z } from 'zod' +import { sftpDeleteContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,17 +19,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SftpDeleteAPI') -const DeleteSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), - remotePath: z.string().min(1, 'Remote path is required'), - recursive: z.boolean().default(false), -}) - /** * Recursively deletes a directory and all its contents */ @@ -87,15 +77,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const params = DeleteSchema.parse(body) - - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } + const parsed = await parseRequest(sftpDeleteContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body if (!isPathSafe(params.remotePath)) { logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`) @@ -173,14 +157,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SFTP delete failed:`, error) diff --git a/apps/sim/app/api/tools/sftp/download/route.ts b/apps/sim/app/api/tools/sftp/download/route.ts index a430fd679c3..73aef7522e9 100644 --- a/apps/sim/app/api/tools/sftp/download/route.ts +++ b/apps/sim/app/api/tools/sftp/download/route.ts @@ -1,7 +1,8 @@ import path from 'path' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { sftpDownloadContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -12,17 +13,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SftpDownloadAPI') -const DownloadSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), - remotePath: z.string().min(1, 'Remote path is required'), - encoding: z.enum(['utf-8', 'base64']).default('utf-8'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -41,15 +31,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const params = DownloadSchema.parse(body) - - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } + const parsed = await parseRequest(sftpDownloadContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body if (!isPathSafe(params.remotePath)) { logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`) @@ -143,14 +127,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SFTP download failed:`, error) diff --git a/apps/sim/app/api/tools/sftp/list/route.ts b/apps/sim/app/api/tools/sftp/list/route.ts deleted file mode 100644 index bb1e5404ab2..00000000000 --- a/apps/sim/app/api/tools/sftp/list/route.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { checkInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - createSftpConnection, - getFileType, - getSftp, - isPathSafe, - parsePermissions, - sanitizePath, -} from '@/app/api/tools/sftp/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('SftpListAPI') - -const ListSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), - remotePath: z.string().min(1, 'Remote path is required'), - detailed: z.boolean().default(false), -}) - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - - if (!authResult.success) { - logger.warn(`[${requestId}] Unauthorized SFTP list attempt: ${authResult.error}`) - return NextResponse.json( - { success: false, error: authResult.error || 'Authentication required' }, - { status: 401 } - ) - } - - logger.info(`[${requestId}] Authenticated SFTP list request via ${authResult.authType}`, { - userId: authResult.userId, - }) - - const body = await request.json() - const params = ListSchema.parse(body) - - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } - - if (!isPathSafe(params.remotePath)) { - logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`) - return NextResponse.json( - { error: 'Invalid remote path: path traversal sequences are not allowed' }, - { status: 400 } - ) - } - - logger.info(`[${requestId}] Connecting to SFTP server ${params.host}:${params.port}`) - - const client = await createSftpConnection({ - host: params.host, - port: params.port, - username: params.username, - password: params.password, - privateKey: params.privateKey, - passphrase: params.passphrase, - }) - - try { - const sftp = await getSftp(client) - const remotePath = sanitizePath(params.remotePath) - - logger.info(`[${requestId}] Listing directory ${remotePath}`) - - const fileList = await new Promise>( - (resolve, reject) => { - sftp.readdir(remotePath, (err, list) => { - if (err) { - if (err.message.includes('No such file')) { - reject(new Error(`Directory not found: ${remotePath}`)) - } else { - reject(err) - } - } else { - resolve(list) - } - }) - } - ) - - const entries = fileList - .filter((item) => item.filename !== '.' && item.filename !== '..') - .map((item) => { - const entry: { - name: string - type: 'file' | 'directory' | 'symlink' | 'other' - size?: number - permissions?: string - modifiedAt?: string - } = { - name: item.filename, - type: getFileType(item.attrs), - } - - if (params.detailed) { - entry.size = item.attrs.size - entry.permissions = parsePermissions(item.attrs.mode) - if (item.attrs.mtime) { - entry.modifiedAt = new Date(item.attrs.mtime * 1000).toISOString() - } - } - - return entry - }) - - entries.sort((a, b) => { - if (a.type === 'directory' && b.type !== 'directory') return -1 - if (a.type !== 'directory' && b.type === 'directory') return 1 - return a.name.localeCompare(b.name) - }) - - logger.info(`[${requestId}] Listed ${entries.length} entries in ${remotePath}`) - - return NextResponse.json({ - success: true, - path: remotePath, - entries, - count: entries.length, - message: `Found ${entries.length} entries in ${remotePath}`, - }) - } finally { - client.end() - } - } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' - logger.error(`[${requestId}] SFTP list failed:`, error) - - return NextResponse.json({ error: `SFTP list failed: ${errorMessage}` }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/sftp/mkdir/route.ts b/apps/sim/app/api/tools/sftp/mkdir/route.ts index c9a2905efd5..6dbb16c49b5 100644 --- a/apps/sim/app/api/tools/sftp/mkdir/route.ts +++ b/apps/sim/app/api/tools/sftp/mkdir/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import type { SFTPWrapper } from 'ssh2' -import { z } from 'zod' +import { sftpMkdirContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -17,17 +18,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SftpMkdirAPI') -const MkdirSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), - remotePath: z.string().min(1, 'Remote path is required'), - recursive: z.boolean().default(false), -}) - /** * Creates directory recursively (like mkdir -p) */ @@ -75,15 +65,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const params = MkdirSchema.parse(body) - - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } + const parsed = await parseRequest(sftpMkdirContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body if (!isPathSafe(params.remotePath)) { logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`) @@ -153,14 +137,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SFTP mkdir failed:`, error) diff --git a/apps/sim/app/api/tools/sftp/upload/route.ts b/apps/sim/app/api/tools/sftp/upload/route.ts index c915ec22e9b..a0dbbbbbdb9 100644 --- a/apps/sim/app/api/tools/sftp/upload/route.ts +++ b/apps/sim/app/api/tools/sftp/upload/route.ts @@ -1,10 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { sftpUploadContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { @@ -20,21 +20,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SftpUploadAPI') -const UploadSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), - remotePath: z.string().min(1, 'Remote path is required'), - files: RawFileInputArraySchema.optional().nullable(), - fileContent: z.string().nullish(), - fileName: z.string().nullish(), - overwrite: z.boolean().default(true), - permissions: z.string().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -53,15 +38,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const params = UploadSchema.parse(body) - - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } + const parsed = await parseRequest(sftpUploadContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body const hasFiles = params.files && params.files.length > 0 const hasDirectContent = params.fileContent && params.fileName @@ -156,7 +135,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] Failed to upload file ${file.name}:`, error) throw new Error( - `Failed to upload file "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` + `Failed to upload file "${file.name}": ${ + error instanceof Error ? error.message : 'Unknown error' + }` ) } } @@ -221,14 +202,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SFTP upload failed:`, error) diff --git a/apps/sim/app/api/tools/sharepoint/lists/route.ts b/apps/sim/app/api/tools/sharepoint/lists/route.ts index 9265d8aff61..26735a1a8ee 100644 --- a/apps/sim/app/api/tools/sharepoint/lists/route.ts +++ b/apps/sim/app/api/tools/sharepoint/lists/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' +import { selectorContractsByPath } from '@/lib/api/contracts/selectors' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateSharePointSiteId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -24,13 +26,24 @@ export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId, siteId } = body - - if (!credential) { - logger.error(`[${requestId}] Missing credential in request`) - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + const validation = await validateJsonBody( + request, + selectorContractsByPath['/api/tools/sharepoint/lists'].body! + ) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid lists request data`, { + errors: validation.error?.issues ?? [], + }) + if (!validation.error) return validation.response + return NextResponse.json( + { + error: getValidationErrorMessage(validation.error, 'Invalid request'), + details: validation.error.issues, + }, + { status: 400 } + ) } + const { credential, workflowId, siteId } = validation.data const siteIdValidation = validateSharePointSiteId(siteId) if (!siteIdValidation.isValid) { diff --git a/apps/sim/app/api/tools/sharepoint/site/route.ts b/apps/sim/app/api/tools/sharepoint/site/route.ts index be3da5174bb..e0835c0a9e9 100644 --- a/apps/sim/app/api/tools/sharepoint/site/route.ts +++ b/apps/sim/app/api/tools/sharepoint/site/route.ts @@ -4,6 +4,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { sharepointSiteQuerySchema } from '@/lib/api/contracts/selectors/sharepoint' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -23,12 +25,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const siteId = searchParams.get('siteId') - - if (!credentialId || !siteId) { - return NextResponse.json({ error: 'Credential ID and Site ID are required' }, { status: 400 }) + const validation = validateSchema(sharepointSiteQuerySchema, { + credentialId: searchParams.get('credentialId') ?? '', + siteId: searchParams.get('siteId') ?? '', + }) + if (!validation.success) { + return NextResponse.json( + { error: getValidationErrorMessage(validation.error, 'Invalid request') }, + { status: 400 } + ) } + const { credentialId, siteId } = validation.data const siteIdValidation = validateMicrosoftGraphId(siteId, 'siteId') if (!siteIdValidation.isValid) { diff --git a/apps/sim/app/api/tools/sharepoint/sites/route.ts b/apps/sim/app/api/tools/sharepoint/sites/route.ts index 14fd022fe42..1e6163e8014 100644 --- a/apps/sim/app/api/tools/sharepoint/sites/route.ts +++ b/apps/sim/app/api/tools/sharepoint/sites/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' +import { selectorContractsByPath } from '@/lib/api/contracts/selectors' +import { getValidationErrorMessage, validateJsonBody } 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' @@ -14,13 +16,24 @@ export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId, query } = body - - if (!credential) { - logger.error(`[${requestId}] Missing credential in request`) - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + const validation = await validateJsonBody( + request, + selectorContractsByPath['/api/tools/sharepoint/sites'].body! + ) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid sites request data`, { + errors: validation.error?.issues ?? [], + }) + if (!validation.error) return validation.response + return NextResponse.json( + { + error: getValidationErrorMessage(validation.error, 'Invalid request'), + details: validation.error.issues, + }, + { status: 400 } + ) } + const { credential, workflowId, query } = validation.data const authz = await authorizeCredentialUse(request as any, { credentialId: credential, diff --git a/apps/sim/app/api/tools/sharepoint/upload/route.ts b/apps/sim/app/api/tools/sharepoint/upload/route.ts index b7c08dd7a32..e6fdc6792a1 100644 --- a/apps/sim/app/api/tools/sharepoint/upload/route.ts +++ b/apps/sim/app/api/tools/sharepoint/upload/route.ts @@ -1,11 +1,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { sharepointUploadBodySchema } from '@/lib/api/contracts/tools/microsoft' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' @@ -14,15 +14,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SharepointUploadAPI') -const SharepointUploadSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - siteId: z.string().default('root'), - driveId: z.string().optional().nullable(), - folderPath: z.string().optional().nullable(), - fileName: z.string().optional().nullable(), - files: RawFileInputArraySchema.optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -47,8 +38,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = SharepointUploadSchema.parse(body) + const validation = await validateJsonBody(request, sharepointUploadBodySchema) + if (!validation.success) { + if (!validation.error) return validation.response + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Uploading files to SharePoint`, { siteId: validatedData.siteId, diff --git a/apps/sim/app/api/tools/slack/add-reaction/route.ts b/apps/sim/app/api/tools/slack/add-reaction/route.ts index f9665cb6b4c..12fbdbb74ab 100644 --- a/apps/sim/app/api/tools/slack/add-reaction/route.ts +++ b/apps/sim/app/api/tools/slack/add-reaction/route.ts @@ -1,17 +1,11 @@ import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { slackReactionBodySchema } from '@/lib/api/contracts' +import { validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' -const SlackAddReactionSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - channel: z.string().min(1, 'Channel is required'), - timestamp: z.string().min(1, 'Message timestamp is required'), - name: z.string().min(1, 'Emoji name is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) @@ -26,8 +20,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = SlackAddReactionSchema.parse(body) + const validation = await validateJsonBody(request, slackReactionBodySchema) + if (!validation.success) { + return NextResponse.json( + { + success: false, + error: 'Invalid request data', + details: validation.error?.issues ?? [], + }, + { status: 400 } + ) + } + const validatedData = validation.data const slackResponse = await fetch('https://slack.com/api/reactions.add', { method: 'POST', @@ -66,17 +70,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - return NextResponse.json( { success: false, diff --git a/apps/sim/app/api/tools/slack/channels/route.ts b/apps/sim/app/api/tools/slack/channels/route.ts index 7d37f4197d2..3065a4a4164 100644 --- a/apps/sim/app/api/tools/slack/channels/route.ts +++ b/apps/sim/app/api/tools/slack/channels/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' +import { selectorContractsByPath } from '@/lib/api/contracts/selectors' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -21,13 +23,22 @@ interface SlackChannel { export const POST = withRouteHandler(async (request: Request) => { try { const requestId = generateRequestId() - const body = await request.json() - const { credential, workflowId } = body - - if (!credential) { + const validation = await validateJsonBody( + request, + selectorContractsByPath['/api/tools/slack/channels'].body! + ) + if (!validation.success) { logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + if (!validation.error) return validation.response + return NextResponse.json( + { + error: getValidationErrorMessage(validation.error, 'Invalid request data'), + details: validation.error.issues, + }, + { status: 400 } + ) } + const { credential, workflowId } = validation.data let accessToken: string let isBotToken = false @@ -39,7 +50,7 @@ export const POST = withRouteHandler(async (request: Request) => { } else { const authz = await authorizeCredentialUse(request as any, { credentialId: credential, - workflowId, + workflowId: workflowId ?? undefined, }) if (!authz.ok || !authz.credentialOwnerUserId) { return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) diff --git a/apps/sim/app/api/tools/slack/delete-message/route.ts b/apps/sim/app/api/tools/slack/delete-message/route.ts index dc6ecb4b071..6f04c158a9e 100644 --- a/apps/sim/app/api/tools/slack/delete-message/route.ts +++ b/apps/sim/app/api/tools/slack/delete-message/route.ts @@ -1,16 +1,11 @@ import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { slackDeleteMessageBodySchema } from '@/lib/api/contracts' +import { validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' -const SlackDeleteMessageSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - channel: z.string().min(1, 'Channel is required'), - timestamp: z.string().min(1, 'Message timestamp is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) @@ -25,8 +20,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = SlackDeleteMessageSchema.parse(body) + const validation = await validateJsonBody(request, slackDeleteMessageBodySchema) + if (!validation.success) { + return NextResponse.json( + { + success: false, + error: 'Invalid request data', + details: validation.error?.issues ?? [], + }, + { status: 400 } + ) + } + const validatedData = validation.data const slackResponse = await fetch('https://slack.com/api/chat.delete', { method: 'POST', @@ -63,17 +68,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - return NextResponse.json( { success: false, diff --git a/apps/sim/app/api/tools/slack/download/route.ts b/apps/sim/app/api/tools/slack/download/route.ts index 5885206bd2a..1b5fa87d26d 100644 --- a/apps/sim/app/api/tools/slack/download/route.ts +++ b/apps/sim/app/api/tools/slack/download/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { slackDownloadBodySchema } from '@/lib/api/contracts' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -13,12 +14,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SlackDownloadAPI') -const SlackDownloadSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - fileId: z.string().min(1, 'File ID is required'), - fileName: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -40,8 +35,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = SlackDownloadSchema.parse(body) + const validation = await validateJsonBody(request, slackDownloadBodySchema) + if (!validation.success) { + if (!validation.error) return validation.response + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data const { accessToken, fileId, fileName } = validatedData @@ -159,12 +165,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { success: false, error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } logger.error(`[${requestId}] Error downloading Slack file:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/slack/read-messages/route.ts b/apps/sim/app/api/tools/slack/read-messages/route.ts index 383bd11bde6..41b7787c277 100644 --- a/apps/sim/app/api/tools/slack/read-messages/route.ts +++ b/apps/sim/app/api/tools/slack/read-messages/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { slackReadMessagesBodySchema } from '@/lib/api/contracts' +import { validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,24 +11,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SlackReadMessagesAPI') -const SlackReadMessagesSchema = z - .object({ - accessToken: z.string().min(1, 'Access token is required'), - channel: z.string().optional().nullable(), - userId: z.string().optional().nullable(), - limit: z.coerce - .number() - .min(1, 'Limit must be at least 1') - .max(15, 'Limit cannot exceed 15') - .optional() - .nullable(), - oldest: z.string().optional().nullable(), - latest: z.string().optional().nullable(), - }) - .refine((data) => data.channel || data.userId, { - message: 'Either channel or userId is required', - }) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -52,8 +35,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = SlackReadMessagesSchema.parse(body) + const validation = await validateJsonBody(request, slackReadMessagesBodySchema) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request data`, { errors: validation.error?.issues ?? [] }) + return NextResponse.json( + { + success: false, + error: 'Invalid request data', + details: validation.error?.issues ?? [], + }, + { status: 400 } + ) + } + const validatedData = validation.data let channel = validatedData.channel if (!channel && validatedData.userId) { @@ -189,18 +183,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error reading Slack messages:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/slack/remove-reaction/route.ts b/apps/sim/app/api/tools/slack/remove-reaction/route.ts index bdb1a8ef9e9..b5a4f189a70 100644 --- a/apps/sim/app/api/tools/slack/remove-reaction/route.ts +++ b/apps/sim/app/api/tools/slack/remove-reaction/route.ts @@ -1,17 +1,11 @@ import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { slackReactionBodySchema } from '@/lib/api/contracts' +import { validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' -const SlackRemoveReactionSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - channel: z.string().min(1, 'Channel is required'), - timestamp: z.string().min(1, 'Message timestamp is required'), - name: z.string().min(1, 'Emoji name is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) @@ -26,8 +20,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = SlackRemoveReactionSchema.parse(body) + const validation = await validateJsonBody(request, slackReactionBodySchema) + if (!validation.success) { + return NextResponse.json( + { + success: false, + error: 'Invalid request data', + details: validation.error?.issues ?? [], + }, + { status: 400 } + ) + } + const validatedData = validation.data const slackResponse = await fetch('https://slack.com/api/reactions.remove', { method: 'POST', @@ -66,17 +70,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - return NextResponse.json( { success: false, diff --git a/apps/sim/app/api/tools/slack/send-ephemeral/route.ts b/apps/sim/app/api/tools/slack/send-ephemeral/route.ts index c06b1790284..bb063fa2f04 100644 --- a/apps/sim/app/api/tools/slack/send-ephemeral/route.ts +++ b/apps/sim/app/api/tools/slack/send-ephemeral/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { slackSendEphemeralBodySchema } from '@/lib/api/contracts' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -9,15 +10,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SlackSendEphemeralAPI') -const SlackSendEphemeralSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - channel: z.string().min(1, 'Channel ID is required'), - user: z.string().min(1, 'User ID is required'), - text: z.string().min(1, 'Message text is required'), - thread_ts: z.string().optional().nullable(), - blocks: z.array(z.record(z.unknown())).optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -40,8 +32,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { userId: authResult.userId } ) - const body = await request.json() - const validatedData = SlackSendEphemeralSchema.parse(body) + const validation = await validateJsonBody(request, slackSendEphemeralBodySchema) + if (!validation.success) { + if (!validation.error) return validation.response + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Sending ephemeral message`, { channel: validatedData.channel, @@ -85,12 +88,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { success: false, error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } logger.error(`[${requestId}] Error sending ephemeral message:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/slack/send-message/route.ts b/apps/sim/app/api/tools/slack/send-message/route.ts index 1c227db0ec9..e7bc24263ac 100644 --- a/apps/sim/app/api/tools/slack/send-message/route.ts +++ b/apps/sim/app/api/tools/slack/send-message/route.ts @@ -1,30 +1,16 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { slackSendMessageBodySchema } from '@/lib/api/contracts' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { sendSlackMessage } from '../utils' export const dynamic = 'force-dynamic' const logger = createLogger('SlackSendMessageAPI') -const SlackSendMessageSchema = z - .object({ - accessToken: z.string().min(1, 'Access token is required'), - channel: z.string().optional().nullable(), - userId: z.string().optional().nullable(), - text: z.string().min(1, 'Message text is required'), - thread_ts: z.string().optional().nullable(), - blocks: z.array(z.record(z.unknown())).optional().nullable(), - files: RawFileInputArraySchema.optional().nullable(), - }) - .refine((data) => data.channel || data.userId, { - message: 'Either channel or userId is required', - }) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -46,8 +32,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = SlackSendMessageSchema.parse(body) + const validation = await validateJsonBody(request, slackSendMessageBodySchema) + if (!validation.success) { + if (!validation.error) return validation.response + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data const isDM = !!validatedData.userId logger.info(`[${requestId}] Sending Slack message`, { @@ -78,12 +75,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, output: result.output }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { success: false, error: error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } logger.error(`[${requestId}] Error sending Slack message:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/slack/update-message/route.ts b/apps/sim/app/api/tools/slack/update-message/route.ts index e38d9c3cb7f..fdf851f9a4b 100644 --- a/apps/sim/app/api/tools/slack/update-message/route.ts +++ b/apps/sim/app/api/tools/slack/update-message/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { slackUpdateMessageBodySchema } from '@/lib/api/contracts' +import { validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -9,14 +10,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SlackUpdateMessageAPI') -const SlackUpdateMessageSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - channel: z.string().min(1, 'Channel is required'), - timestamp: z.string().min(1, 'Message timestamp is required'), - text: z.string().min(1, 'Message text is required'), - blocks: z.array(z.record(z.unknown())).optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -41,8 +34,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = SlackUpdateMessageSchema.parse(body) + const validation = await validateJsonBody(request, slackUpdateMessageBodySchema) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request data`, { errors: validation.error?.issues ?? [] }) + return NextResponse.json( + { + success: false, + error: 'Invalid request data', + details: validation.error?.issues ?? [], + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Updating Slack message`, { channel: validatedData.channel, @@ -102,18 +106,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error updating Slack message:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/slack/users/route.ts b/apps/sim/app/api/tools/slack/users/route.ts index 6accc49d91f..bab9ade3297 100644 --- a/apps/sim/app/api/tools/slack/users/route.ts +++ b/apps/sim/app/api/tools/slack/users/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' +import { slackUsersBodySchema } from '@/lib/api/contracts/selectors/slack' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -21,13 +23,19 @@ interface SlackUser { export const POST = withRouteHandler(async (request: Request) => { try { const requestId = generateRequestId() - const body = await request.json() - const { credential, workflowId, userId } = body - - if (!credential) { + const validation = await validateJsonBody(request, slackUsersBodySchema) + if (!validation.success) { logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + if (!validation.error) return validation.response + return NextResponse.json( + { + error: getValidationErrorMessage(validation.error, 'Invalid request data'), + details: validation.error.issues, + }, + { status: 400 } + ) } + const { credential, workflowId, userId } = validation.data if (userId !== undefined && userId !== null) { const validation = validateAlphanumericId(userId, 'userId', 100) diff --git a/apps/sim/app/api/tools/sms/send/route.ts b/apps/sim/app/api/tools/sms/send/route.ts index 5a1c6701b60..80e1c01a9a8 100644 --- a/apps/sim/app/api/tools/sms/send/route.ts +++ b/apps/sim/app/api/tools/sms/send/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { smsSendBodySchema } from '@/lib/api/contracts' +import { validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' @@ -11,11 +12,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SMSSendAPI') -const SMSSendSchema = z.object({ - to: z.string().min(1, 'To phone number is required'), - body: z.string().min(1, 'SMS body is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -37,8 +33,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = SMSSendSchema.parse(body) + const validation = await validateJsonBody(request, smsSendBodySchema) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request data`, { errors: validation.error?.issues ?? [] }) + return NextResponse.json( + { + success: false, + message: 'Invalid request data', + errors: validation.error?.issues ?? [], + }, + { status: 400 } + ) + } + const validatedData = validation.data const fromNumber = env.TWILIO_PHONE_NUMBER @@ -74,18 +81,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(result) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - message: 'Invalid request data', - errors: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error sending SMS via API:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/smtp/send/route.ts b/apps/sim/app/api/tools/smtp/send/route.ts index 3fe0753a913..ae9e850fea0 100644 --- a/apps/sim/app/api/tools/smtp/send/route.ts +++ b/apps/sim/app/api/tools/smtp/send/route.ts @@ -2,12 +2,12 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import nodemailer from 'nodemailer' -import { z } from 'zod' +import { smtpSendBodySchema } from '@/lib/api/contracts' +import { validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -15,26 +15,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SmtpSendAPI') -const SmtpSendSchema = z.object({ - smtpHost: z.string().min(1, 'SMTP host is required'), - smtpPort: z.number().min(1).max(65535, 'Port must be between 1 and 65535'), - smtpUsername: z.string().min(1, 'SMTP username is required'), - smtpPassword: z.string().min(1, 'SMTP password is required'), - smtpSecure: z.enum(['TLS', 'SSL', 'None']), - - from: z.string().email('Invalid from email address').min(1, 'From address is required'), - to: z.string().min(1, 'To email is required'), - subject: z.string().min(1, 'Subject is required'), - body: z.string().min(1, 'Email body is required'), - contentType: z.enum(['text', 'html']).optional().nullable(), - - fromName: z.string().optional().nullable(), - cc: z.string().optional().nullable(), - bcc: z.string().optional().nullable(), - replyTo: z.string().optional().nullable(), - attachments: RawFileInputArraySchema.optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -56,8 +36,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = SmtpSendSchema.parse(body) + const validation = await validateJsonBody(request, smtpSendBodySchema) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request data`, { errors: validation.error?.issues ?? [] }) + return NextResponse.json( + { + success: false, + error: 'Invalid request data', + details: validation.error?.issues ?? [], + }, + { status: 400 } + ) + } + const validatedData = validation.data const hostValidation = await validateDatabaseHost(validatedData.smtpHost, 'smtpHost') if (!hostValidation.isValid) { @@ -180,18 +171,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { subject: validatedData.subject, }) } catch (error: unknown) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - // Type guard for error objects with code property const isNodeError = (err: unknown): err is NodeJS.ErrnoException => { return err instanceof Error && 'code' in err diff --git a/apps/sim/app/api/tools/sqs/send/route.ts b/apps/sim/app/api/tools/sqs/send/route.ts index 634a7d097a3..1d563ae5783 100644 --- a/apps/sim/app/api/tools/sqs/send/route.ts +++ b/apps/sim/app/api/tools/sqs/send/route.ts @@ -1,25 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsSqsSendContract } from '@/lib/api/contracts/tools/aws/sqs-send' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSqsClient, sendMessage } from '../utils' const logger = createLogger('SQSSendMessageAPI') -const SendMessageSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - queueUrl: z.string().min(1, 'Queue URL is required'), - messageGroupId: z.string().nullish(), - messageDeduplicationId: z.string().nullish(), - data: z.record(z.unknown()).refine((obj) => Object.keys(obj).length > 0, { - message: 'Data object must have at least one field', - }), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -29,8 +18,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = SendMessageSchema.parse(body) + const parsed = await parseAwsToolRequest(awsSqsSendContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Sending message to SQS queue ${params.queueUrl}`) @@ -59,16 +52,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { - errors: error.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SQS send message failed:`, error) diff --git a/apps/sim/app/api/tools/ssh/check-command-exists/route.ts b/apps/sim/app/api/tools/ssh/check-command-exists/route.ts index 148471b101c..c297342db79 100644 --- a/apps/sim/app/api/tools/ssh/check-command-exists/route.ts +++ b/apps/sim/app/api/tools/ssh/check-command-exists/route.ts @@ -1,23 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { sshCheckCommandExistsContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, escapeShellArg, executeSSHCommand } from '@/app/api/tools/ssh/utils' const logger = createLogger('SSHCheckCommandExistsAPI') -const CheckCommandExistsSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), - commandName: z.string().min(1, 'Command name is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -28,15 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = CheckCommandExistsSchema.parse(body) - - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } + const parsed = await parseRequest(sshCheckCommandExistsContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Checking if command '${params.commandName}' exists on ${params.host}:${params.port}` @@ -93,14 +78,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SSH check command exists failed:`, error) diff --git a/apps/sim/app/api/tools/ssh/check-file-exists/route.ts b/apps/sim/app/api/tools/ssh/check-file-exists/route.ts index 969b3edd22d..fc2a046e763 100644 --- a/apps/sim/app/api/tools/ssh/check-file-exists/route.ts +++ b/apps/sim/app/api/tools/ssh/check-file-exists/route.ts @@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import type { Client, SFTPWrapper, Stats } from 'ssh2' -import { z } from 'zod' +import { sshCheckFileExistsContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -14,17 +15,6 @@ import { const logger = createLogger('SSHCheckFileExistsAPI') -const CheckFileExistsSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), - path: z.string().min(1, 'Path is required'), - type: z.enum(['file', 'directory', 'any']).default('any'), -}) - function getSFTP(client: Client): Promise { return new Promise((resolve, reject) => { client.sftp((err, sftp) => { @@ -47,15 +37,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = CheckFileExistsSchema.parse(body) - - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } + const parsed = await parseRequest(sshCheckFileExistsContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Checking if path exists: ${params.path} on ${params.host}:${params.port}` @@ -122,14 +106,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SSH check file exists failed:`, error) diff --git a/apps/sim/app/api/tools/ssh/create-directory/route.ts b/apps/sim/app/api/tools/ssh/create-directory/route.ts index dc89f714ef4..7dcb94522e1 100644 --- a/apps/sim/app/api/tools/ssh/create-directory/route.ts +++ b/apps/sim/app/api/tools/ssh/create-directory/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { sshCreateDirectoryContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -13,18 +14,6 @@ import { const logger = createLogger('SSHCreateDirectoryAPI') -const CreateDirectorySchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), - path: z.string().min(1, 'Path is required'), - recursive: z.boolean().default(true), - permissions: z.string().default('0755'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -35,15 +24,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = CreateDirectorySchema.parse(body) - - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } + const parsed = await parseRequest(sshCreateDirectoryContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Creating directory ${params.path} on ${params.host}:${params.port}`) @@ -96,14 +79,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SSH create directory failed:`, error) diff --git a/apps/sim/app/api/tools/ssh/delete-file/route.ts b/apps/sim/app/api/tools/ssh/delete-file/route.ts index 765bcaf28ec..a08ea88543f 100644 --- a/apps/sim/app/api/tools/ssh/delete-file/route.ts +++ b/apps/sim/app/api/tools/ssh/delete-file/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { sshDeleteFileContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -13,18 +14,6 @@ import { const logger = createLogger('SSHDeleteFileAPI') -const DeleteFileSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), - path: z.string().min(1, 'Path is required'), - recursive: z.boolean().default(false), - force: z.boolean().default(false), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -35,15 +24,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = DeleteFileSchema.parse(body) - - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } + const parsed = await parseRequest(sshDeleteFileContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Deleting ${params.path} on ${params.host}:${params.port}`) @@ -92,14 +75,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SSH delete file failed:`, error) diff --git a/apps/sim/app/api/tools/ssh/download-file/route.ts b/apps/sim/app/api/tools/ssh/download-file/route.ts index ac8bf86b986..01f2848182e 100644 --- a/apps/sim/app/api/tools/ssh/download-file/route.ts +++ b/apps/sim/app/api/tools/ssh/download-file/route.ts @@ -3,7 +3,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import type { Client, SFTPWrapper } from 'ssh2' -import { z } from 'zod' +import { sshDownloadFileContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' @@ -11,16 +12,6 @@ import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils' const logger = createLogger('SSHDownloadFileAPI') -const DownloadFileSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), - remotePath: z.string().min(1, 'Remote path is required'), -}) - function getSFTP(client: Client): Promise { return new Promise((resolve, reject) => { client.sftp((err, sftp) => { @@ -43,15 +34,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = DownloadFileSchema.parse(body) - - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } + const parsed = await parseRequest(sshDownloadFileContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Downloading file from ${params.host}:${params.port}${params.remotePath}` @@ -134,14 +119,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SSH file download failed:`, error) diff --git a/apps/sim/app/api/tools/ssh/execute-command/route.ts b/apps/sim/app/api/tools/ssh/execute-command/route.ts index c2852a84e4e..571c71bb30a 100644 --- a/apps/sim/app/api/tools/ssh/execute-command/route.ts +++ b/apps/sim/app/api/tools/ssh/execute-command/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { sshExecuteCommandContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -13,17 +14,6 @@ import { const logger = createLogger('SSHExecuteCommandAPI') -const ExecuteCommandSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), - command: z.string().min(1, 'Command is required'), - workingDirectory: z.string().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -34,15 +24,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = ExecuteCommandSchema.parse(body) - - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } + const parsed = await parseRequest(sshExecuteCommandContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Executing SSH command on ${params.host}:${params.port}`) @@ -77,14 +61,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SSH command execution failed:`, error) diff --git a/apps/sim/app/api/tools/ssh/execute-script/route.ts b/apps/sim/app/api/tools/ssh/execute-script/route.ts index 863df1a979e..9da4f51027b 100644 --- a/apps/sim/app/api/tools/ssh/execute-script/route.ts +++ b/apps/sim/app/api/tools/ssh/execute-script/route.ts @@ -1,25 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { sshExecuteScriptContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, escapeShellArg, executeSSHCommand } from '@/app/api/tools/ssh/utils' const logger = createLogger('SSHExecuteScriptAPI') -const ExecuteScriptSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), - script: z.string().min(1, 'Script content is required'), - interpreter: z.string().default('/bin/bash'), - workingDirectory: z.string().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -30,15 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = ExecuteScriptSchema.parse(body) - - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } + const parsed = await parseRequest(sshExecuteScriptContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Executing SSH script on ${params.host}:${params.port}`) @@ -90,14 +73,6 @@ exit $exit_code` client.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SSH script execution failed:`, error) diff --git a/apps/sim/app/api/tools/ssh/get-system-info/route.ts b/apps/sim/app/api/tools/ssh/get-system-info/route.ts index fde26cd3f31..77b148a70d5 100644 --- a/apps/sim/app/api/tools/ssh/get-system-info/route.ts +++ b/apps/sim/app/api/tools/ssh/get-system-info/route.ts @@ -1,22 +1,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { sshGetSystemInfoContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, executeSSHCommand } from '@/app/api/tools/ssh/utils' const logger = createLogger('SSHGetSystemInfoAPI') -const GetSystemInfoSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -27,15 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = GetSystemInfoSchema.parse(body) - - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } + const parsed = await parseRequest(sshGetSystemInfoContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Getting system info from ${params.host}:${params.port}`) @@ -113,14 +99,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SSH get system info failed:`, error) diff --git a/apps/sim/app/api/tools/ssh/list-directory/route.ts b/apps/sim/app/api/tools/ssh/list-directory/route.ts index caee5d6ad24..c3bb0e32d1a 100644 --- a/apps/sim/app/api/tools/ssh/list-directory/route.ts +++ b/apps/sim/app/api/tools/ssh/list-directory/route.ts @@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import type { Client, FileEntry, SFTPWrapper } from 'ssh2' -import { z } from 'zod' +import { sshListDirectoryContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -14,18 +15,6 @@ import { const logger = createLogger('SSHListDirectoryAPI') -const ListDirectorySchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), - path: z.string().min(1, 'Path is required'), - detailed: z.boolean().default(true), - recursive: z.boolean().default(false), -}) - function getSFTP(client: Client): Promise { return new Promise((resolve, reject) => { client.sftp((err, sftp) => { @@ -68,15 +57,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = ListDirectorySchema.parse(body) - - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } + const parsed = await parseRequest(sshListDirectoryContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Listing directory ${params.path} on ${params.host}:${params.port}`) @@ -120,14 +103,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SSH list directory failed:`, error) diff --git a/apps/sim/app/api/tools/ssh/move-rename/route.ts b/apps/sim/app/api/tools/ssh/move-rename/route.ts index b6639479637..cf9114ea205 100644 --- a/apps/sim/app/api/tools/ssh/move-rename/route.ts +++ b/apps/sim/app/api/tools/ssh/move-rename/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { sshMoveRenameContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -13,18 +14,6 @@ import { const logger = createLogger('SSHMoveRenameAPI') -const MoveRenameSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), - sourcePath: z.string().min(1, 'Source path is required'), - destinationPath: z.string().min(1, 'Destination path is required'), - overwrite: z.boolean().default(false), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -35,16 +24,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = MoveRenameSchema.parse(body) - - // Validate SSH authentication - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } + const parsed = await parseRequest(sshMoveRenameContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Moving ${params.sourcePath} to ${params.destinationPath} on ${params.host}:${params.port}` @@ -110,14 +92,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SSH move/rename failed:`, error) diff --git a/apps/sim/app/api/tools/ssh/read-file-content/route.ts b/apps/sim/app/api/tools/ssh/read-file-content/route.ts index f88e374ccd6..8a91bf6edd5 100644 --- a/apps/sim/app/api/tools/ssh/read-file-content/route.ts +++ b/apps/sim/app/api/tools/ssh/read-file-content/route.ts @@ -2,25 +2,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import type { Client, SFTPWrapper } from 'ssh2' -import { z } from 'zod' +import { sshReadFileContentContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils' const logger = createLogger('SSHReadFileContentAPI') -const ReadFileContentSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), - path: z.string().min(1, 'Path is required'), - encoding: z.string().default('utf-8'), - maxSize: z.coerce.number().default(10), // MB -}) - function getSFTP(client: Client): Promise { return new Promise((resolve, reject) => { client.sftp((err, sftp) => { @@ -43,15 +32,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = ReadFileContentSchema.parse(body) - - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } + const parsed = await parseRequest(sshReadFileContentContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Reading file content from ${params.path} on ${params.host}:${params.port}` @@ -121,14 +104,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SSH read file content failed:`, error) diff --git a/apps/sim/app/api/tools/ssh/upload-file/route.ts b/apps/sim/app/api/tools/ssh/upload-file/route.ts index a406b7f2ef3..c85e6418c04 100644 --- a/apps/sim/app/api/tools/ssh/upload-file/route.ts +++ b/apps/sim/app/api/tools/ssh/upload-file/route.ts @@ -2,27 +2,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import type { Client, SFTPWrapper } from 'ssh2' -import { z } from 'zod' +import { sshUploadFileContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils' const logger = createLogger('SSHUploadFileAPI') -const UploadFileSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), - fileContent: z.string().min(1, 'File content is required'), - fileName: z.string().min(1, 'File name is required'), - remotePath: z.string().min(1, 'Remote path is required'), - permissions: z.string().nullish(), - overwrite: z.boolean().default(true), -}) - function getSFTP(client: Client): Promise { return new Promise((resolve, reject) => { client.sftp((err, sftp) => { @@ -45,15 +32,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = UploadFileSchema.parse(body) - - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } + const parsed = await parseRequest(sshUploadFileContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Uploading file to ${params.host}:${params.port}${params.remotePath}` @@ -121,14 +102,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SSH file upload failed:`, error) diff --git a/apps/sim/app/api/tools/ssh/write-file-content/route.ts b/apps/sim/app/api/tools/ssh/write-file-content/route.ts index 58500e76c77..4dcab77ea2e 100644 --- a/apps/sim/app/api/tools/ssh/write-file-content/route.ts +++ b/apps/sim/app/api/tools/ssh/write-file-content/route.ts @@ -2,26 +2,14 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import type { Client, SFTPWrapper } from 'ssh2' -import { z } from 'zod' +import { sshWriteFileContentContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils' const logger = createLogger('SSHWriteFileContentAPI') -const WriteFileContentSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive().default(22), - username: z.string().min(1, 'Username is required'), - password: z.string().nullish(), - privateKey: z.string().nullish(), - passphrase: z.string().nullish(), - path: z.string().min(1, 'Path is required'), - content: z.string(), - mode: z.enum(['overwrite', 'append', 'create']).default('overwrite'), - permissions: z.string().nullish(), -}) - function getSFTP(client: Client): Promise { return new Promise((resolve, reject) => { client.sftp((err, sftp) => { @@ -44,15 +32,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const params = WriteFileContentSchema.parse(body) - - if (!params.password && !params.privateKey) { - return NextResponse.json( - { error: 'Either password or privateKey must be provided' }, - { status: 400 } - ) - } + const parsed = await parseRequest(sshWriteFileContentContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info( `[${requestId}] Writing file content to ${params.path} on ${params.host}:${params.port} (mode: ${params.mode})` @@ -140,14 +122,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.end() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.error(`[${requestId}] SSH write file content failed:`, error) diff --git a/apps/sim/app/api/tools/stagehand/agent/route.ts b/apps/sim/app/api/tools/stagehand/agent/route.ts index 3c17d60eeb4..e6a321c59b7 100644 --- a/apps/sim/app/api/tools/stagehand/agent/route.ts +++ b/apps/sim/app/api/tools/stagehand/agent/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { stagehandAgentContract } from '@/lib/api/contracts/internal-tools' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/core/config/env' import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' @@ -15,17 +16,6 @@ type StagehandType = import('@browserbasehq/stagehand').Stagehand const BROWSERBASE_API_KEY = env.BROWSERBASE_API_KEY const BROWSERBASE_PROJECT_ID = env.BROWSERBASE_PROJECT_ID -const requestSchema = z.object({ - task: z.string().min(1), - startUrl: z.string().url(), - outputSchema: z.any(), - variables: z.any(), - provider: z.enum(['openai', 'anthropic']).optional().default('openai'), - apiKey: z.string(), - mode: z.enum(['dom', 'hybrid', 'cua']).optional().default('dom'), - maxSteps: z.number().int().min(1).max(200).optional().default(20), -}) - /** * Extracts the inner schema object from a potentially nested schema structure */ @@ -104,25 +94,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let stagehand: StagehandType | null = null try { - const body = await request.json() + const parsed = await parseRequest( + stagehandAgentContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.error('Invalid request body', { errors: error.issues }) + return NextResponse.json( + { + error: getValidationErrorMessage(error, 'Invalid request parameters'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + logger.info('Received Stagehand agent request', { - startUrl: body.startUrl, - hasTask: !!body.task, - hasVariables: !!body.variables, - hasSchema: !!body.outputSchema, + startUrl: params.startUrl, + hasTask: !!params.task, + hasVariables: !!params.variables, + hasSchema: !!params.outputSchema, }) - const validationResult = requestSchema.safeParse(body) - - if (!validationResult.success) { - logger.error('Invalid request body', { errors: validationResult.error.errors }) - return NextResponse.json( - { error: 'Invalid request parameters', details: validationResult.error.errors }, - { status: 400 } - ) - } - - const params = validationResult.data const { task, startUrl: rawStartUrl, outputSchema, provider, apiKey, mode, maxSteps } = params const variablesObject = processVariables(params.variables) diff --git a/apps/sim/app/api/tools/stagehand/extract/route.ts b/apps/sim/app/api/tools/stagehand/extract/route.ts index 1ec99a182d9..3d18965541e 100644 --- a/apps/sim/app/api/tools/stagehand/extract/route.ts +++ b/apps/sim/app/api/tools/stagehand/extract/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { stagehandExtractContract } from '@/lib/api/contracts/internal-tools' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/core/config/env' import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' @@ -14,14 +15,6 @@ type StagehandType = import('@browserbasehq/stagehand').Stagehand const BROWSERBASE_API_KEY = env.BROWSERBASE_API_KEY const BROWSERBASE_PROJECT_ID = env.BROWSERBASE_PROJECT_ID -const requestSchema = z.object({ - instruction: z.string(), - schema: z.record(z.any()), - provider: z.enum(['openai', 'anthropic']).optional().default('openai'), - apiKey: z.string(), - url: z.string().url(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -31,24 +24,32 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let stagehand: StagehandType | null = null try { - const body = await request.json() + const parsed = await parseRequest( + stagehandExtractContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.error('Invalid request body', { errors: error.issues }) + return NextResponse.json( + { + error: getValidationErrorMessage(error, 'Invalid request parameters'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + logger.info('Received extraction request', { - url: body.url, - hasInstruction: !!body.instruction, - schema: body.schema ? typeof body.schema : 'none', + url: params.url, + hasInstruction: !!params.instruction, + schema: params.schema ? typeof params.schema : 'none', }) - const validationResult = requestSchema.safeParse(body) - - if (!validationResult.success) { - logger.error('Invalid request body', { errors: validationResult.error.errors }) - return NextResponse.json( - { error: 'Invalid request parameters', details: validationResult.error.errors }, - { status: 400 } - ) - } - - const params = validationResult.data const { url: rawUrl, instruction, provider, apiKey, schema } = params const url = normalizeUrl(rawUrl) const urlValidation = await validateUrlWithDNS(url, 'url') diff --git a/apps/sim/app/api/tools/sts/assume-role/route.ts b/apps/sim/app/api/tools/sts/assume-role/route.ts index fb3cf6c31ee..62b49606874 100644 --- a/apps/sim/app/api/tools/sts/assume-role/route.ts +++ b/apps/sim/app/api/tools/sts/assume-role/route.ts @@ -1,32 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsStsAssumeRoleContract } from '@/lib/api/contracts/tools/aws/sts-assume-role' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { assumeRole, createSTSClient } from '../utils' const logger = createLogger('STSAssumeRoleAPI') -const AssumeRoleSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - roleArn: z.string().min(1, 'Role ARN is required'), - roleSessionName: z.string().min(1, 'Role session name is required'), - durationSeconds: z.number().int().min(900).max(43200).nullish(), - policy: z.string().max(2048).nullish(), - externalId: z.string().nullish(), - serialNumber: z.string().nullish(), - tokenCode: z.string().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -34,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = AssumeRoleSchema.parse(body) + const parsed = await parseAwsToolRequest(awsStsAssumeRoleContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Assuming role ${params.roleArn}`) @@ -64,14 +50,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error('Failed to assume role', { error: toError(error).message }) return NextResponse.json( diff --git a/apps/sim/app/api/tools/sts/get-access-key-info/route.ts b/apps/sim/app/api/tools/sts/get-access-key-info/route.ts index b2fdcd697b9..cec2b58d74c 100644 --- a/apps/sim/app/api/tools/sts/get-access-key-info/route.ts +++ b/apps/sim/app/api/tools/sts/get-access-key-info/route.ts @@ -1,26 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsStsGetAccessKeyInfoContract } from '@/lib/api/contracts/tools/aws/sts-get-access-key-info' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSTSClient, getAccessKeyInfo } from '../utils' const logger = createLogger('STSGetAccessKeyInfoAPI') -const GetAccessKeyInfoSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - targetAccessKeyId: z.string().min(1, 'Target access key ID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -28,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = GetAccessKeyInfoSchema.parse(body) + const parsed = await parseAwsToolRequest(awsStsGetAccessKeyInfoContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`Getting access key info for ${params.targetAccessKeyId}`) @@ -49,14 +41,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error('Failed to get access key info', { error: toError(error).message }) return NextResponse.json( diff --git a/apps/sim/app/api/tools/sts/get-caller-identity/route.ts b/apps/sim/app/api/tools/sts/get-caller-identity/route.ts index bec49f9ebc0..f0a8c489fa5 100644 --- a/apps/sim/app/api/tools/sts/get-caller-identity/route.ts +++ b/apps/sim/app/api/tools/sts/get-caller-identity/route.ts @@ -1,25 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsStsGetCallerIdentityContract } from '@/lib/api/contracts/tools/aws/sts-get-caller-identity' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSTSClient, getCallerIdentity } from '../utils' const logger = createLogger('STSGetCallerIdentityAPI') -const GetCallerIdentitySchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -27,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = GetCallerIdentitySchema.parse(body) + const parsed = await parseAwsToolRequest(awsStsGetCallerIdentityContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info('Getting caller identity') @@ -48,14 +41,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error('Failed to get caller identity', { error: toError(error).message }) return NextResponse.json( diff --git a/apps/sim/app/api/tools/sts/get-session-token/route.ts b/apps/sim/app/api/tools/sts/get-session-token/route.ts index 4b7a39bcd15..4c30b3ac8b2 100644 --- a/apps/sim/app/api/tools/sts/get-session-token/route.ts +++ b/apps/sim/app/api/tools/sts/get-session-token/route.ts @@ -1,28 +1,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsStsGetSessionTokenContract } from '@/lib/api/contracts/tools/aws/sts-get-session-token' +import { parseAwsToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateAwsRegion } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSTSClient, getSessionToken } from '../utils' const logger = createLogger('STSGetSessionTokenAPI') -const GetSessionTokenSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - durationSeconds: z.number().int().min(900).max(129600).nullish(), - serialNumber: z.string().nullish(), - tokenCode: z.string().nullish(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -30,8 +16,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = GetSessionTokenSchema.parse(body) + const parsed = await parseAwsToolRequest(awsStsGetSessionTokenContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info('Getting session token') @@ -56,14 +46,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - if (error instanceof z.ZodError) { - logger.warn('Invalid request data', { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error('Failed to get session token', { error: toError(error).message }) return NextResponse.json( diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index ae3f73fc361..05d1aaf9a5d 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -2,6 +2,12 @@ import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' +import { sttToolBodySchema } from '@/lib/api/contracts/media-tools' +import { + getValidationErrorMessage, + validateSchema, + validationErrorResponse, +} from '@/lib/api/server' import { extractAudioFromVideo, isVideoFile } from '@/lib/audio/extractor' import { checkInternalAuth } from '@/lib/auth/hybrid' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' @@ -15,7 +21,6 @@ import { downloadFileFromStorage, resolveInternalFileUrl, } from '@/lib/uploads/utils/file-utils.server' -import type { UserFile } from '@/executor/types' import type { TranscriptSegment } from '@/tools/stt/types' const logger = createLogger('SttProxyAPI') @@ -23,30 +28,6 @@ const logger = createLogger('SttProxyAPI') export const dynamic = 'force-dynamic' export const maxDuration = 300 // 5 minutes for large files -interface SttRequestBody { - provider: 'whisper' | 'deepgram' | 'elevenlabs' | 'assemblyai' | 'gemini' - apiKey: string - model?: string - audioFile?: UserFile | UserFile[] - audioFileReference?: UserFile | UserFile[] - audioUrl?: string - language?: string - timestamps?: 'none' | 'sentence' | 'word' - diarization?: boolean - translateToEnglish?: boolean - // Whisper-specific options - prompt?: string - temperature?: number - // AssemblyAI-specific options - sentiment?: boolean - entityDetection?: boolean - piiRedaction?: boolean - summarization?: boolean - workspaceId?: string - workflowId?: string - executionId?: string -} - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId() logger.info(`[${requestId}] STT transcription request started`) @@ -58,7 +39,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = authResult.userId - const body: SttRequestBody = await request.json() + const validation = validateSchema(sttToolBodySchema, await request.json()) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid STT request:`, validation.error.issues) + return validationErrorResponse( + validation.error, + getValidationErrorMessage(validation.error, 'Invalid request data') + ) + } + const body = validation.data const { provider, apiKey, @@ -73,13 +62,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { summarization, } = body - if (!provider || !apiKey) { - return NextResponse.json( - { error: 'Missing required fields: provider and apiKey' }, - { status: 400 } - ) - } - let audioBuffer: Buffer let audioFileName: string let audioMimeType: string diff --git a/apps/sim/app/api/tools/supabase/storage-upload/route.ts b/apps/sim/app/api/tools/supabase/storage-upload/route.ts index ab374bc1962..dd6ff3f6d75 100644 --- a/apps/sim/app/api/tools/supabase/storage-upload/route.ts +++ b/apps/sim/app/api/tools/supabase/storage-upload/route.ts @@ -1,11 +1,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { supabaseStorageUploadContract } from '@/lib/api/contracts/database-tools' +import { parseDatabaseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateSupabaseProjectId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -13,20 +13,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SupabaseStorageUploadAPI') -const SupabaseStorageUploadSchema = z.object({ - projectId: z - .string() - .min(1, 'Project ID is required') - .regex(/^[a-z0-9]+$/, 'Project ID must contain only lowercase alphanumeric characters'), - apiKey: z.string().min(1, 'API key is required'), - bucket: z.string().min(1, 'Bucket name is required'), - fileName: z.string().min(1, 'File name is required'), - path: z.string().optional().nullable(), - fileData: FileInputSchema, - contentType: z.string().optional().nullable(), - upsert: z.boolean().optional().default(false), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -53,8 +39,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = SupabaseStorageUploadSchema.parse(body) + const parsed = await parseDatabaseToolRequest(supabaseStorageUploadContract, request, { + errorFormat: 'toolDetails', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const fileData = validatedData.fileData const isStringInput = typeof fileData === 'string' @@ -243,18 +233,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error uploading to Supabase Storage:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/telegram/send-document/route.ts b/apps/sim/app/api/tools/telegram/send-document/route.ts index 738a35e9adc..c59d4d0202a 100644 --- a/apps/sim/app/api/tools/telegram/send-document/route.ts +++ b/apps/sim/app/api/tools/telegram/send-document/route.ts @@ -1,10 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { telegramSendDocumentBodySchema } from '@/lib/api/contracts' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { convertMarkdownToHTML } from '@/tools/telegram/utils' @@ -13,13 +13,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('TelegramSendDocumentAPI') -const TelegramSendDocumentSchema = z.object({ - botToken: z.string().min(1, 'Bot token is required'), - chatId: z.string().min(1, 'Chat ID is required'), - files: RawFileInputArraySchema.optional().nullable(), - caption: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -43,8 +36,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = TelegramSendDocumentSchema.parse(body) + const validation = await validateJsonBody(request, telegramSendDocumentBodySchema) + if (!validation.success) { + if (!validation.error) return validation.response + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request data'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data logger.info(`[${requestId}] Sending Telegram document`, { chatId: validatedData.chatId, diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts index 323b18568c5..b9a517b395e 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -2,17 +2,17 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { textractParseBodySchema } from '@/lib/api/contracts/media-tools' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' -import { validateAwsRegion, validateS3BucketName } from '@/lib/core/security/input-validation' +import { validateS3BucketName } from '@/lib/core/security/input-validation' import { secureFetchWithPinnedIP, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage, @@ -24,51 +24,6 @@ export const maxDuration = 300 // 5 minutes for large multi-page PDF processing const logger = createLogger('TextractParseAPI') -const QuerySchema = z.object({ - Text: z.string().min(1), - Alias: z.string().optional(), - Pages: z.array(z.string()).optional(), -}) - -const TextractParseSchema = z - .object({ - accessKeyId: z.string().min(1, 'AWS Access Key ID is required'), - secretAccessKey: z.string().min(1, 'AWS Secret Access Key is required'), - region: z.string().min(1, 'AWS region is required'), - processingMode: z.enum(['sync', 'async']).optional().default('sync'), - filePath: z.string().optional(), - file: RawFileInputSchema.optional(), - s3Uri: z.string().optional(), - featureTypes: z - .array(z.enum(['TABLES', 'FORMS', 'QUERIES', 'SIGNATURES', 'LAYOUT'])) - .optional(), - queries: z.array(QuerySchema).optional(), - }) - .superRefine((data, ctx) => { - const regionValidation = validateAwsRegion(data.region, 'AWS region') - if (!regionValidation.isValid) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: regionValidation.error, - path: ['region'], - }) - } - if (data.processingMode === 'async' && !data.s3Uri) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'S3 URI is required for multi-page processing (s3://bucket/key)', - path: ['s3Uri'], - }) - } - if (data.processingMode !== 'async' && !data.file && !data.filePath) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'File input is required for single-page processing', - path: ['filePath'], - }) - } - }) - function getSignatureKey( key: string, dateStamp: string, @@ -330,7 +285,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userId = authResult.userId const body = await request.json() - const validatedData = TextractParseSchema.parse(body) + const validation = validateSchema(textractParseBodySchema, body) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request data`, { errors: validation.error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request data'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data const processingMode = validatedData.processingMode || 'sync' const featureTypes = validatedData.featureTypes ?? [] @@ -632,18 +599,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error in Textract parse:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/thinking/route.ts b/apps/sim/app/api/tools/thinking/route.ts index b9396b93621..dea1b4ec741 100644 --- a/apps/sim/app/api/tools/thinking/route.ts +++ b/apps/sim/app/api/tools/thinking/route.ts @@ -1,8 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { thinkingToolContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { ThinkingToolParams, ThinkingToolResponse } from '@/tools/thinking/types' +import type { ThinkingToolResponse } from '@/tools/thinking/types' const logger = createLogger('ThinkingToolAPI') @@ -16,22 +18,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body: ThinkingToolParams = await request.json() + const parsed = await parseRequest(thinkingToolContract, request, {}) + if (!parsed.success) return parsed.response + const { body } = parsed.data logger.info(`[${requestId}] Processing thinking tool request`) - // Validate the required parameter - if (!body.thought || typeof body.thought !== 'string') { - logger.warn(`[${requestId}] Missing or invalid 'thought' parameter`) - return NextResponse.json( - { - success: false, - error: 'The thought parameter is required and must be a string', - }, - { status: 400 } - ) - } - // Simply acknowledge the thought by returning it in the output const response: ThinkingToolResponse = { success: true, diff --git a/apps/sim/app/api/tools/trello/boards/route.ts b/apps/sim/app/api/tools/trello/boards/route.ts index ca76382f1f3..e4ca2f42461 100644 --- a/apps/sim/app/api/tools/trello/boards/route.ts +++ b/apps/sim/app/api/tools/trello/boards/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { trelloBoardsSelectorContract } from '@/lib/api/contracts/selectors' +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' @@ -17,17 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.error('Trello API key not configured') return NextResponse.json({ error: 'Trello API key not configured' }, { status: 500 }) } - const body = (await request.json().catch(() => null)) as { - credential?: string - workflowId?: string - } | null - const credential = typeof body?.credential === 'string' ? body.credential : '' - const workflowId = typeof body?.workflowId === 'string' ? body.workflowId : undefined - - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } + const parsed = await parseRequest(trelloBoardsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId } = parsed.data.body const authz = await authorizeCredentialUse(request, { credentialId: credential, diff --git a/apps/sim/app/api/tools/tts/route.ts b/apps/sim/app/api/tools/tts/route.ts index 84007103d0f..67eb2d68fb9 100644 --- a/apps/sim/app/api/tools/tts/route.ts +++ b/apps/sim/app/api/tools/tts/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { ttsToolBodySchema } from '@/lib/api/contracts/media-tools' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' import { validateAlphanumericId } from '@/lib/core/security/input-validation' @@ -19,19 +21,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const body = await request.json() - const { - text, - voiceId, - apiKey, - modelId = 'eleven_monolingual_v1', - workspaceId, - workflowId, - executionId, - } = body - - if (!text || !voiceId || !apiKey) { - return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }) + const validationResult = validateSchema(ttsToolBodySchema, body) + if (!validationResult.success) { + return NextResponse.json( + { + error: getValidationErrorMessage(validationResult.error, 'Missing required parameters'), + }, + { status: 400 } + ) } + const { text, voiceId, apiKey, modelId, workspaceId, workflowId, executionId } = + validationResult.data const voiceIdValidation = validateAlphanumericId(voiceId, 'voiceId', 255) if (!voiceIdValidation.isValid) { @@ -40,10 +40,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } // Check if this is an execution context (from workflow tool execution) - const hasExecutionContext = workspaceId && workflowId && executionId + const executionContext = + workspaceId && workflowId && executionId ? { workspaceId, workflowId, executionId } : null logger.info('Proxying TTS request for voice:', { voiceId, - hasExecutionContext, + hasExecutionContext: Boolean(executionContext), workspaceId, workflowId, executionId, @@ -85,16 +86,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const timestamp = Date.now() // Use execution storage for workflow tool calls, copilot for chat UI - if (hasExecutionContext) { + if (executionContext) { const { uploadExecutionFile } = await import('@/lib/uploads/contexts/execution') const fileName = `tts-${timestamp}.mp3` const userFile = await uploadExecutionFile( - { - workspaceId, - workflowId, - executionId, - }, + executionContext, audioBuffer, fileName, 'audio/mpeg', diff --git a/apps/sim/app/api/tools/tts/unified/route.ts b/apps/sim/app/api/tools/tts/unified/route.ts index a6478e3894d..47ae4d025bf 100644 --- a/apps/sim/app/api/tools/tts/unified/route.ts +++ b/apps/sim/app/api/tools/tts/unified/route.ts @@ -2,6 +2,12 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { playHtOutputFormatSchema, ttsUnifiedToolBodySchema } from '@/lib/api/contracts/media-tools' +import { + getValidationErrorMessage, + validateSchema, + validationErrorResponse, +} from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -15,7 +21,6 @@ import type { GoogleTtsParams, OpenAiTtsParams, PlayHtTtsParams, - TtsProvider, TtsResponse, } from '@/tools/tts/types' import { getFileExtension, getMimeType } from '@/tools/tts/types' @@ -25,65 +30,6 @@ const logger = createLogger('TtsUnifiedProxyAPI') export const dynamic = 'force-dynamic' export const maxDuration = 60 // 1 minute -interface TtsUnifiedRequestBody { - provider: TtsProvider - text: string - apiKey: string - - // OpenAI specific - model?: 'tts-1' | 'tts-1-hd' | 'gpt-4o-mini-tts' - voice?: string - responseFormat?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm' - speed?: number - - // Deepgram specific - encoding?: 'linear16' | 'mp3' | 'opus' | 'aac' | 'flac' | 'mulaw' | 'alaw' - sampleRate?: number - bitRate?: number - container?: 'none' | 'wav' | 'ogg' - - // ElevenLabs specific - voiceId?: string - modelId?: string - stability?: number - similarityBoost?: number - style?: number | string - useSpeakerBoost?: boolean - - // Cartesia specific - language?: string - outputFormat?: object - emotion?: string[] - - // Google Cloud specific - languageCode?: string - gender?: 'MALE' | 'FEMALE' | 'NEUTRAL' - audioEncoding?: 'LINEAR16' | 'MP3' | 'OGG_OPUS' | 'MULAW' | 'ALAW' - speakingRate?: number - pitch?: number - volumeGainDb?: number - sampleRateHertz?: number - effectsProfileId?: string[] - - // Azure specific - region?: string - rate?: string - styleDegree?: number - role?: string - - // PlayHT specific - userId?: string - quality?: 'draft' | 'standard' | 'premium' - temperature?: number - voiceGuidance?: number - textGuidance?: number - - // Execution context - workspaceId?: string - workflowId?: string - executionId?: string -} - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId() logger.info(`[${requestId}] TTS unified request started`) @@ -95,19 +41,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body: TtsUnifiedRequestBody = await request.json() - const { provider, text, apiKey, workspaceId, workflowId, executionId } = body - - if (!provider || !text || !apiKey) { - return NextResponse.json( - { error: 'Missing required fields: provider, text, and apiKey' }, - { status: 400 } + const validation = validateSchema(ttsUnifiedToolBodySchema, await request.json()) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid TTS unified request:`, validation.error.issues) + return validationErrorResponse( + validation.error, + getValidationErrorMessage(validation.error, 'Invalid request data') ) } + const body = validation.data + const { provider, text, apiKey, workspaceId, workflowId, executionId } = body - const hasExecutionContext = workspaceId && workflowId && executionId + const executionContext = + workspaceId && workflowId && executionId ? { workspaceId, workflowId, executionId } : null logger.info(`[${requestId}] Processing TTS with ${provider}`, { - hasExecutionContext, + hasExecutionContext: Boolean(executionContext), textLength: text.length, }) @@ -174,7 +122,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { modelId: body.modelId, voice: body.voice, language: body.language, - outputFormat: body.outputFormat, + outputFormat: + body.outputFormat && + typeof body.outputFormat === 'object' && + !Array.isArray(body.outputFormat) + ? (body.outputFormat as CartesiaTtsParams['outputFormat']) + : undefined, speed: body.speed, emotion: body.emotion, }) @@ -190,7 +143,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { gender: body.gender, audioEncoding: body.audioEncoding, speakingRate: body.speakingRate, - pitch: body.pitch, + pitch: typeof body.pitch === 'number' ? body.pitch : undefined, volumeGainDb: body.volumeGainDb, sampleRateHertz: body.sampleRateHertz, effectsProfileId: body.effectsProfileId, @@ -204,7 +157,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { apiKey, voiceId: body.voiceId, region: body.region, - outputFormat: body.outputFormat as AzureTtsParams['outputFormat'], + outputFormat: + typeof body.outputFormat === 'string' + ? (body.outputFormat as AzureTtsParams['outputFormat']) + : undefined, rate: body.rate, pitch: body.pitch as string | undefined, style: body.style as string | undefined, @@ -221,13 +177,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } + const playHtOutputFormat = playHtOutputFormatSchema.safeParse(body.outputFormat) const result = await synthesizeWithPlayHT({ text, apiKey, userId: body.userId, voice: body.voice, quality: body.quality, - outputFormat: typeof body.outputFormat === 'string' ? body.outputFormat : undefined, + outputFormat: playHtOutputFormat.success ? playHtOutputFormat.data : undefined, speed: body.speed, temperature: body.temperature, voiceGuidance: body.voiceGuidance, @@ -250,11 +207,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const fileExtension = getFileExtension(format) const fileName = `tts-${provider}-${timestamp}.${fileExtension}` - if (hasExecutionContext) { + if (executionContext) { const { uploadExecutionFile } = await import('@/lib/uploads/contexts/execution') const userFile = await uploadExecutionFile( - { workspaceId, workflowId, executionId }, + executionContext, audioBuffer, fileName, mimeType, diff --git a/apps/sim/app/api/tools/twilio/get-recording/route.ts b/apps/sim/app/api/tools/twilio/get-recording/route.ts index 4efd33a3b57..a6795beb7b8 100644 --- a/apps/sim/app/api/tools/twilio/get-recording/route.ts +++ b/apps/sim/app/api/tools/twilio/get-recording/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { twilioGetRecordingBodySchema } from '@/lib/api/contracts' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -44,12 +45,6 @@ interface TwilioTranscriptionsResponse { transcriptions?: TwilioTranscription[] } -const TwilioGetRecordingSchema = z.object({ - accountSid: z.string().min(1, 'Account SID is required'), - authToken: z.string().min(1, 'Auth token is required'), - recordingSid: z.string().min(1, 'Recording SID is required'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -67,8 +62,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = TwilioGetRecordingSchema.parse(body) + const validation = await validateJsonBody(request, twilioGetRecordingBodySchema) + if (!validation.success) { + if (!validation.error) return validation.response + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(validation.error, 'Invalid request data'), + details: validation.error.issues, + }, + { status: 400 } + ) + } + const validatedData = validation.data const { accountSid, authToken, recordingSid } = validatedData diff --git a/apps/sim/app/api/tools/video/route.ts b/apps/sim/app/api/tools/video/route.ts index 2a91b29473a..49cd5c91388 100644 --- a/apps/sim/app/api/tools/video/route.ts +++ b/apps/sim/app/api/tools/video/route.ts @@ -2,12 +2,17 @@ import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' +import { videoProviders, videoToolBodySchema } from '@/lib/api/contracts/media-tools' +import { + getValidationErrorMessage, + validateSchema, + validationErrorResponse, +} from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import type { UserFile } from '@/executor/types' -import type { VideoRequestBody } from '@/tools/video/types' const logger = createLogger('VideoProxyAPI') @@ -24,18 +29,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body: VideoRequestBody = await request.json() - const { provider, apiKey, model, prompt, duration, aspectRatio, resolution } = body - - if (!provider || !apiKey || !prompt) { - return NextResponse.json( - { error: 'Missing required fields: provider, apiKey, and prompt' }, - { status: 400 } + const validation = validateSchema(videoToolBodySchema, await request.json()) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid video request:`, validation.error.issues) + return validationErrorResponse( + validation.error, + getValidationErrorMessage(validation.error, 'Invalid request data') ) } + const body = validation.data + const { provider, apiKey, model, prompt, duration, aspectRatio, resolution } = body - const validProviders = ['runway', 'veo', 'luma', 'minimax', 'falai'] - if (!validProviders.includes(provider)) { + const validProviders = videoProviders + if (!validProviders.includes(provider as (typeof videoProviders)[number])) { return NextResponse.json( { error: `Invalid provider. Must be one of: ${validProviders.join(', ')}` }, { status: 400 } @@ -188,11 +194,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: errorMessage }, { status: 500 }) } - const hasExecutionContext = body.workspaceId && body.workflowId && body.executionId + const executionContext = + body.workspaceId && body.workflowId && body.executionId + ? { + workspaceId: body.workspaceId, + workflowId: body.workflowId, + executionId: body.executionId, + } + : null logger.info(`[${requestId}] Storing video file, size: ${videoBuffer.length} bytes`) - if (hasExecutionContext) { + if (executionContext) { const { uploadExecutionFile } = await import('@/lib/uploads/contexts/execution') const timestamp = Date.now() const fileName = `video-${provider}-${timestamp}.mp4` @@ -200,11 +213,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let videoFile try { videoFile = await uploadExecutionFile( - { - workspaceId: body.workspaceId!, - workflowId: body.workflowId!, - executionId: body.executionId!, - }, + executionContext, videoBuffer, fileName, 'video/mp4', diff --git a/apps/sim/app/api/tools/vision/analyze/route.ts b/apps/sim/app/api/tools/vision/analyze/route.ts index 7890669d540..0b29ca929f0 100644 --- a/apps/sim/app/api/tools/vision/analyze/route.ts +++ b/apps/sim/app/api/tools/vision/analyze/route.ts @@ -1,7 +1,7 @@ import { GoogleGenAI } from '@google/genai' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { visionAnalyzeBodySchema } from '@/lib/api/contracts/media-tools' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -9,7 +9,6 @@ import { } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage, @@ -21,14 +20,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('VisionAnalyzeAPI') -const VisionAnalyzeSchema = z.object({ - apiKey: z.string().min(1, 'API key is required'), - imageUrl: z.string().optional().nullable(), - imageFile: RawFileInputSchema.optional().nullable(), - model: z.string().optional().default('gpt-5.2'), - prompt: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -52,7 +43,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userId = authResult.userId const body = await request.json() - const validatedData = VisionAnalyzeSchema.parse(body) + const validatedData = visionAnalyzeBodySchema.parse(body) if (!validatedData.imageUrl && !validatedData.imageFile) { return NextResponse.json( diff --git a/apps/sim/app/api/tools/wealthbox/item/route.ts b/apps/sim/app/api/tools/wealthbox/item/route.ts index d25bf495bc0..fd9de60baba 100644 --- a/apps/sim/app/api/tools/wealthbox/item/route.ts +++ b/apps/sim/app/api/tools/wealthbox/item/route.ts @@ -3,8 +3,10 @@ import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { wealthboxItemContract } from '@/lib/api/contracts/selectors/wealthbox' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation' +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, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' @@ -24,22 +26,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const itemId = searchParams.get('itemId') - const type = searchParams.get('type') || 'note' - - if (!credentialId || !itemId) { - logger.warn(`[${requestId}] Missing required parameters`, { credentialId, itemId }) - return NextResponse.json({ error: 'Credential ID and Item ID are required' }, { status: 400 }) - } - - const ALLOWED_TYPES = ['note', 'contact', 'task'] as const - const typeValidation = validateEnum(type, ALLOWED_TYPES, 'type') - if (!typeValidation.isValid) { - logger.warn(`[${requestId}] Invalid item type: ${type}`) - return NextResponse.json({ error: typeValidation.error }, { status: 400 }) - } + const parsed = await parseRequest(wealthboxItemContract, request, {}) + if (!parsed.success) return parsed.response + const { credentialId, itemId, type } = parsed.data.query const itemIdValidation = validatePathSegment(itemId, { paramName: 'itemId', @@ -143,16 +132,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } - const data = await response.json() + const data = (await response.json()) as Record + const firstName = typeof data.first_name === 'string' ? data.first_name : '' + const lastName = typeof data.last_name === 'string' ? data.last_name : '' const item = { id: data.id?.toString() || itemId, name: - data.content || data.name || `${data.first_name} ${data.last_name}` || `${type} ${data.id}`, + (typeof data.content === 'string' && data.content) || + (typeof data.name === 'string' && data.name) || + `${firstName} ${lastName}`.trim() || + `${type} ${data.id}`, type, - content: data.content || '', - createdAt: data.created_at, - updatedAt: data.updated_at, + content: typeof data.content === 'string' ? data.content : '', + createdAt: typeof data.created_at === 'string' ? data.created_at : '', + updatedAt: typeof data.updated_at === 'string' ? data.updated_at : '', } logger.info(`[${requestId}] Successfully fetched ${type} ${itemId} from Wealthbox`) diff --git a/apps/sim/app/api/tools/wealthbox/items/route.ts b/apps/sim/app/api/tools/wealthbox/items/route.ts index f78e7273d84..72fcd00b6f6 100644 --- a/apps/sim/app/api/tools/wealthbox/items/route.ts +++ b/apps/sim/app/api/tools/wealthbox/items/route.ts @@ -3,8 +3,10 @@ import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { wealthboxItemsSelectorContract } from '@/lib/api/contracts/selectors/wealthbox' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation' +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, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' @@ -36,15 +38,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const type = searchParams.get('type') || 'contact' - const query = searchParams.get('query') || '' - - if (!credentialId) { - logger.warn(`[${requestId}] Missing credential ID`) - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) - } + const parsed = await parseRequest(wealthboxItemsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credentialId, type } = parsed.data.query + const query = parsed.data.query.query ?? '' const credentialIdValidation = validatePathSegment(credentialId, { paramName: 'credentialId', @@ -58,13 +55,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) } - const ALLOWED_TYPES = ['contact'] as const - const typeValidation = validateEnum(type, ALLOWED_TYPES, 'type') - if (!typeValidation.isValid) { - logger.warn(`[${requestId}] Invalid item type: ${type}`) - return NextResponse.json({ error: typeValidation.error }, { status: 400 }) - } - const resolved = await resolveOAuthAccountId(credentialId) if (!resolved) { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) @@ -142,7 +132,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } - const data = await response.json() + const data = (await response.json()) as { contacts?: Array> } & Record< + string, + unknown + > logger.info(`[${requestId}] Wealthbox API raw response`, { type, @@ -164,14 +157,19 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ items: [] }, { status: 200 }) } - items = contacts.map((item: any) => ({ - id: item.id?.toString() || '', - name: `${item.first_name || ''} ${item.last_name || ''}`.trim() || `Contact ${item.id}`, - type: 'contact', - content: item.background_information || '', - createdAt: item.created_at, - updatedAt: item.updated_at, - })) + items = contacts.map((item) => { + const firstName = typeof item.first_name === 'string' ? item.first_name : '' + const lastName = typeof item.last_name === 'string' ? item.last_name : '' + return { + id: item.id?.toString() || '', + name: `${firstName} ${lastName}`.trim() || `Contact ${item.id ?? ''}`, + type: 'contact', + content: + typeof item.background_information === 'string' ? item.background_information : '', + createdAt: typeof item.created_at === 'string' ? item.created_at : '', + updatedAt: typeof item.updated_at === 'string' ? item.updated_at : '', + } + }) } if (query.trim()) { diff --git a/apps/sim/app/api/tools/webflow/collections/route.ts b/apps/sim/app/api/tools/webflow/collections/route.ts index 0baf1f7a05f..4df1bceaeca 100644 --- a/apps/sim/app/api/tools/webflow/collections/route.ts +++ b/apps/sim/app/api/tools/webflow/collections/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { webflowCollectionsSelectorContract } from '@/lib/api/contracts/selectors' +import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -10,16 +12,18 @@ const logger = createLogger('WebflowCollectionsAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +interface WebflowCollection { + id: string + displayName?: string + slug?: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { try { const requestId = generateRequestId() - const body = await request.json() - const { credential, workflowId, siteId } = body - - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } + const parsed = await parseRequest(webflowCollectionsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, siteId } = parsed.data.body const siteIdValidation = validateAlphanumericId(siteId, 'siteId') if (!siteIdValidation.isValid) { @@ -27,7 +31,7 @@ export const POST = withRouteHandler(async (request: Request) => { return NextResponse.json({ error: siteIdValidation.error }, { status: 400 }) } - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) @@ -74,10 +78,10 @@ export const POST = withRouteHandler(async (request: Request) => { ) } - const data = await response.json() + const data = (await response.json()) as { collections?: WebflowCollection[] } const collections = data.collections || [] - const formattedCollections = collections.map((collection: any) => ({ + const formattedCollections = collections.map((collection) => ({ id: collection.id, name: collection.displayName || collection.slug || collection.id, })) diff --git a/apps/sim/app/api/tools/webflow/items/route.ts b/apps/sim/app/api/tools/webflow/items/route.ts index fa62a38b92a..1ed9884e0fb 100644 --- a/apps/sim/app/api/tools/webflow/items/route.ts +++ b/apps/sim/app/api/tools/webflow/items/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { webflowItemsSelectorContract } from '@/lib/api/contracts/selectors' +import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -10,16 +12,21 @@ const logger = createLogger('WebflowItemsAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +interface WebflowItem { + id: string + fieldData?: { + name?: string + title?: string + slug?: string + } +} + +export const POST = withRouteHandler(async (request: NextRequest) => { try { const requestId = generateRequestId() - const body = await request.json() - const { credential, workflowId, collectionId, search } = body - - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } + const parsed = await parseRequest(webflowItemsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, collectionId, search } = parsed.data.body const collectionIdValidation = validateAlphanumericId(collectionId, 'collectionId') if (!collectionIdValidation.isValid) { @@ -27,7 +34,7 @@ export const POST = withRouteHandler(async (request: Request) => { return NextResponse.json({ error: collectionIdValidation.error }, { status: 400 }) } - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) @@ -77,10 +84,10 @@ export const POST = withRouteHandler(async (request: Request) => { ) } - const data = await response.json() + const data = (await response.json()) as { items?: WebflowItem[] } const items = data.items || [] - let formattedItems = items.map((item: any) => { + let formattedItems = items.map((item) => { const fieldData = item.fieldData || {} const name = fieldData.name || fieldData.title || fieldData.slug || item.id return { diff --git a/apps/sim/app/api/tools/webflow/sites/route.ts b/apps/sim/app/api/tools/webflow/sites/route.ts index 012f45d5828..89073c6eeb5 100644 --- a/apps/sim/app/api/tools/webflow/sites/route.ts +++ b/apps/sim/app/api/tools/webflow/sites/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { webflowSitesSelectorContract } from '@/lib/api/contracts/selectors' +import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' @@ -10,16 +12,18 @@ const logger = createLogger('WebflowSitesAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +interface WebflowSite { + id: string + displayName?: string + shortName?: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { try { const requestId = generateRequestId() - const body = await request.json() - const { credential, workflowId, siteId } = body - - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } + const parsed = await parseRequest(webflowSitesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, siteId } = parsed.data.body if (siteId) { const siteIdValidation = validateAlphanumericId(siteId, 'siteId') @@ -29,7 +33,7 @@ export const POST = withRouteHandler(async (request: Request) => { } } - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) @@ -80,16 +84,16 @@ export const POST = withRouteHandler(async (request: Request) => { ) } - const data = await response.json() + const data = (await response.json()) as WebflowSite | { sites?: WebflowSite[] } - let sites: any[] + let sites: WebflowSite[] if (siteId) { - sites = [data] + sites = [data as WebflowSite] } else { - sites = data.sites || [] + sites = 'sites' in data ? data.sites || [] : [] } - const formattedSites = sites.map((site: any) => ({ + const formattedSites = sites.map((site) => ({ id: site.id, name: site.displayName || site.shortName || site.id, })) diff --git a/apps/sim/app/api/tools/wordpress/upload/route.ts b/apps/sim/app/api/tools/wordpress/upload/route.ts index a18733b1e69..b24274f7481 100644 --- a/apps/sim/app/api/tools/wordpress/upload/route.ts +++ b/apps/sim/app/api/tools/wordpress/upload/route.ts @@ -1,10 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { wordpressUploadContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { getFileExtension, getMimeTypeFromExtension, @@ -18,17 +18,6 @@ const logger = createLogger('WordPressUploadAPI') const WORDPRESS_COM_API_BASE = 'https://public-api.wordpress.com/wp/v2/sites' -const WordPressUploadSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - siteId: z.string().min(1, 'Site ID is required'), - file: RawFileInputSchema.optional().nullable(), - filename: z.string().optional().nullable(), - title: z.string().optional().nullable(), - caption: z.string().optional().nullable(), - altText: z.string().optional().nullable(), - description: z.string().optional().nullable(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -53,8 +42,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = WordPressUploadSchema.parse(body) + const parsed = await parseRequest(wordpressUploadContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Uploading file to WordPress`, { siteId: validatedData.siteId, @@ -201,18 +191,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) - return NextResponse.json( - { - success: false, - error: 'Invalid request data', - details: error.errors, - }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error uploading file to WordPress:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/workday/assign-onboarding/route.ts b/apps/sim/app/api/tools/workday/assign-onboarding/route.ts index 5b51536fe35..618a3bd5d36 100644 --- a/apps/sim/app/api/tools/workday/assign-onboarding/route.ts +++ b/apps/sim/app/api/tools/workday/assign-onboarding/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { workdayAssignOnboardingContract } from '@/lib/api/contracts/tools/workday' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,16 +11,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('WorkdayAssignOnboardingAPI') -const RequestSchema = z.object({ - tenantUrl: z.string().min(1), - tenant: z.string().min(1), - username: z.string().min(1), - password: z.string().min(1), - workerId: z.string().min(1), - onboardingPlanId: z.string().min(1), - actionEventId: z.string().min(1), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -29,8 +20,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = RequestSchema.parse(body) + const parsed = await parseRequest(workdayAssignOnboardingContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = await createWorkdaySoapClient( data.tenantUrl, diff --git a/apps/sim/app/api/tools/workday/change-job/route.ts b/apps/sim/app/api/tools/workday/change-job/route.ts index e9fd133efa1..897124374a7 100644 --- a/apps/sim/app/api/tools/workday/change-job/route.ts +++ b/apps/sim/app/api/tools/workday/change-job/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { workdayChangeJobContract } from '@/lib/api/contracts/tools/workday' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,20 +11,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('WorkdayChangeJobAPI') -const RequestSchema = z.object({ - tenantUrl: z.string().min(1), - tenant: z.string().min(1), - username: z.string().min(1), - password: z.string().min(1), - workerId: z.string().min(1), - effectiveDate: z.string().min(1), - newPositionId: z.string().optional(), - newJobProfileId: z.string().optional(), - newLocationId: z.string().optional(), - newSupervisoryOrgId: z.string().optional(), - reason: z.string().min(1, 'Reason is required for job changes'), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -33,8 +20,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = RequestSchema.parse(body) + const parsed = await parseRequest(workdayChangeJobContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body const changeJobDetailData: Record = { Reason_Reference: wdRef('Change_Job_Subcategory_ID', data.reason), diff --git a/apps/sim/app/api/tools/workday/create-prehire/route.ts b/apps/sim/app/api/tools/workday/create-prehire/route.ts index 48aa4926f9d..c7937807040 100644 --- a/apps/sim/app/api/tools/workday/create-prehire/route.ts +++ b/apps/sim/app/api/tools/workday/create-prehire/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { workdayCreatePrehireContract } from '@/lib/api/contracts/tools/workday' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,18 +11,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('WorkdayCreatePrehireAPI') -const RequestSchema = z.object({ - tenantUrl: z.string().min(1), - tenant: z.string().min(1), - username: z.string().min(1), - password: z.string().min(1), - legalName: z.string().min(1), - email: z.string().optional(), - phoneNumber: z.string().optional(), - address: z.string().optional(), - countryCode: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -31,8 +20,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = RequestSchema.parse(body) + const parsed = await parseRequest(workdayCreatePrehireContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body if (!data.email && !data.phoneNumber && !data.address) { return NextResponse.json( diff --git a/apps/sim/app/api/tools/workday/get-compensation/route.ts b/apps/sim/app/api/tools/workday/get-compensation/route.ts index 46217281488..ce6f03e41f8 100644 --- a/apps/sim/app/api/tools/workday/get-compensation/route.ts +++ b/apps/sim/app/api/tools/workday/get-compensation/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { workdayGetCompensationContract } from '@/lib/api/contracts/tools/workday' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -17,14 +18,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('WorkdayGetCompensationAPI') -const RequestSchema = z.object({ - tenantUrl: z.string().min(1), - tenant: z.string().min(1), - username: z.string().min(1), - password: z.string().min(1), - workerId: z.string().min(1), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -34,8 +27,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = RequestSchema.parse(body) + const parsed = await parseRequest(workdayGetCompensationContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = await createWorkdaySoapClient( data.tenantUrl, diff --git a/apps/sim/app/api/tools/workday/get-organizations/route.ts b/apps/sim/app/api/tools/workday/get-organizations/route.ts index 063803c2aba..e6e03fbba86 100644 --- a/apps/sim/app/api/tools/workday/get-organizations/route.ts +++ b/apps/sim/app/api/tools/workday/get-organizations/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { workdayGetOrganizationsContract } from '@/lib/api/contracts/tools/workday' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -15,16 +16,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('WorkdayGetOrganizationsAPI') -const RequestSchema = z.object({ - tenantUrl: z.string().min(1), - tenant: z.string().min(1), - username: z.string().min(1), - password: z.string().min(1), - type: z.string().optional(), - limit: z.number().optional(), - offset: z.number().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -34,8 +25,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = RequestSchema.parse(body) + const parsed = await parseRequest(workdayGetOrganizationsContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = await createWorkdaySoapClient( data.tenantUrl, diff --git a/apps/sim/app/api/tools/workday/get-worker/route.ts b/apps/sim/app/api/tools/workday/get-worker/route.ts index 6a118023824..7b44467fe07 100644 --- a/apps/sim/app/api/tools/workday/get-worker/route.ts +++ b/apps/sim/app/api/tools/workday/get-worker/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { workdayGetWorkerContract } from '@/lib/api/contracts/tools/workday' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -15,14 +16,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('WorkdayGetWorkerAPI') -const RequestSchema = z.object({ - tenantUrl: z.string().min(1), - tenant: z.string().min(1), - username: z.string().min(1), - password: z.string().min(1), - workerId: z.string().min(1), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -32,8 +25,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = RequestSchema.parse(body) + const parsed = await parseRequest(workdayGetWorkerContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = await createWorkdaySoapClient( data.tenantUrl, diff --git a/apps/sim/app/api/tools/workday/hire/route.ts b/apps/sim/app/api/tools/workday/hire/route.ts index 393998d996a..28d2f776630 100644 --- a/apps/sim/app/api/tools/workday/hire/route.ts +++ b/apps/sim/app/api/tools/workday/hire/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { workdayHireContract } from '@/lib/api/contracts/tools/workday' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,17 +11,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('WorkdayHireAPI') -const RequestSchema = z.object({ - tenantUrl: z.string().min(1), - tenant: z.string().min(1), - username: z.string().min(1), - password: z.string().min(1), - preHireId: z.string().min(1), - positionId: z.string().min(1), - hireDate: z.string().min(1), - employeeType: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -30,8 +20,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = RequestSchema.parse(body) + const parsed = await parseRequest(workdayHireContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = await createWorkdaySoapClient( data.tenantUrl, diff --git a/apps/sim/app/api/tools/workday/list-workers/route.ts b/apps/sim/app/api/tools/workday/list-workers/route.ts index 15fc6715648..9fb4406f475 100644 --- a/apps/sim/app/api/tools/workday/list-workers/route.ts +++ b/apps/sim/app/api/tools/workday/list-workers/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { workdayListWorkersContract } from '@/lib/api/contracts/tools/workday' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -15,15 +16,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('WorkdayListWorkersAPI') -const RequestSchema = z.object({ - tenantUrl: z.string().min(1), - tenant: z.string().min(1), - username: z.string().min(1), - password: z.string().min(1), - limit: z.number().optional(), - offset: z.number().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -33,8 +25,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = RequestSchema.parse(body) + const parsed = await parseRequest(workdayListWorkersContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = await createWorkdaySoapClient( data.tenantUrl, diff --git a/apps/sim/app/api/tools/workday/terminate/route.ts b/apps/sim/app/api/tools/workday/terminate/route.ts index 92ccf22ae29..a4fb4b09956 100644 --- a/apps/sim/app/api/tools/workday/terminate/route.ts +++ b/apps/sim/app/api/tools/workday/terminate/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { workdayTerminateContract } from '@/lib/api/contracts/tools/workday' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,18 +11,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('WorkdayTerminateAPI') -const RequestSchema = z.object({ - tenantUrl: z.string().min(1), - tenant: z.string().min(1), - username: z.string().min(1), - password: z.string().min(1), - workerId: z.string().min(1), - terminationDate: z.string().min(1), - reason: z.string().min(1), - notificationDate: z.string().optional(), - lastDayOfWork: z.string().optional(), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -31,8 +20,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = RequestSchema.parse(body) + const parsed = await parseRequest(workdayTerminateContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = await createWorkdaySoapClient( data.tenantUrl, diff --git a/apps/sim/app/api/tools/workday/update-worker/route.ts b/apps/sim/app/api/tools/workday/update-worker/route.ts index 33c4759859f..0486d88d0c6 100644 --- a/apps/sim/app/api/tools/workday/update-worker/route.ts +++ b/apps/sim/app/api/tools/workday/update-worker/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { workdayUpdateWorkerContract } from '@/lib/api/contracts/tools/workday' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,15 +11,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('WorkdayUpdateWorkerAPI') -const RequestSchema = z.object({ - tenantUrl: z.string().min(1), - tenant: z.string().min(1), - username: z.string().min(1), - password: z.string().min(1), - workerId: z.string().min(1), - fields: z.record(z.unknown()), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -28,8 +20,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = RequestSchema.parse(body) + const parsed = await parseRequest(workdayUpdateWorkerContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = await createWorkdaySoapClient( data.tenantUrl, diff --git a/apps/sim/app/api/tools/zoom/get-recordings/route.ts b/apps/sim/app/api/tools/zoom/get-recordings/route.ts index 2c521a77c30..6e19b24466b 100644 --- a/apps/sim/app/api/tools/zoom/get-recordings/route.ts +++ b/apps/sim/app/api/tools/zoom/get-recordings/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { zoomGetRecordingsContract } from '@/lib/api/contracts/tools/zoom' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -48,14 +49,6 @@ interface ZoomErrorResponse { code?: number } -const ZoomGetRecordingsSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - meetingId: z.string().min(1, 'Meeting ID is required'), - includeFolderItems: z.boolean().optional(), - ttl: z.number().optional(), - downloadFiles: z.boolean().optional().default(false), -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -73,10 +66,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = ZoomGetRecordingsSchema.parse(body) + const parsed = await parseRequest(zoomGetRecordingsContract, request, {}) + if (!parsed.success) return parsed.response - const { accessToken, meetingId, includeFolderItems, ttl, downloadFiles } = validatedData + const { accessToken, meetingId, includeFolderItems, ttl, downloadFiles } = parsed.data.body const baseUrl = `https://api.zoom.us/v2/meetings/${encodeURIComponent(meetingId)}/recordings` const queryParams = new URLSearchParams() diff --git a/apps/sim/app/api/tools/zoom/meetings/route.ts b/apps/sim/app/api/tools/zoom/meetings/route.ts index 3e7db3d2a22..36edf498789 100644 --- a/apps/sim/app/api/tools/zoom/meetings/route.ts +++ b/apps/sim/app/api/tools/zoom/meetings/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { zoomMeetingsSelectorContract } from '@/lib/api/contracts/selectors' +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' @@ -9,18 +11,14 @@ const logger = createLogger('ZoomMeetingsAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const body = await request.json() - const { credential, workflowId } = body + const parsed = await parseRequest(zoomMeetingsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId } = parsed.data.body - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) diff --git a/apps/sim/app/api/usage/route.ts b/apps/sim/app/api/usage/route.ts index 86c00e4658b..d265148189b 100644 --- a/apps/sim/app/api/usage/route.ts +++ b/apps/sim/app/api/usage/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateUsageLimitBodySchema, usageQuerySchema } from '@/lib/api/contracts/subscription' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getUserUsageLimitInfo, updateUserUsageLimit } from '@/lib/billing' import { @@ -12,18 +13,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('UnifiedUsageAPI') -const usageContextEnum = z.enum(['user', 'organization']) - -const usageUpdateSchema = z - .object({ - limit: z.number().min(0, 'Limit must be a positive number'), - context: usageContextEnum.optional().default('user'), - organizationId: z.string().optional(), - }) - .refine((data) => data.context !== 'organization' || data.organizationId, { - message: 'Organization ID is required when context is organization', - }) - /** * Unified Usage Endpoint * GET/PUT /api/usage?context=user|organization&userId=&organizationId= @@ -37,17 +26,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const context = searchParams.get('context') || 'user' - const userId = searchParams.get('userId') || session.user.id - const organizationId = searchParams.get('organizationId') - - if (!['user', 'organization'].includes(context)) { + const queryResult = usageQuerySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!queryResult.success) { return NextResponse.json( { error: 'Invalid context. Must be "user" or "organization"' }, { status: 400 } ) } + const { context, userId = session.user.id, organizationId } = queryResult.data if (context === 'user' && userId !== session.user.id) { return NextResponse.json( @@ -85,7 +73,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { success: true, context, userId, - organizationId, + organizationId: organizationId ?? null, data: usageLimitInfo, }) } catch (error) { @@ -107,12 +95,12 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { } const body = await request.json() - const validation = usageUpdateSchema.safeParse(body) + const validation = validateSchema(updateUsageLimitBodySchema, body) if (!validation.success) { - const firstError = validation.error.errors[0] - logger.error('Validation error:', firstError) - return NextResponse.json({ error: firstError.message }, { status: 400 }) + const message = getValidationErrorMessage(validation.error) + logger.error('Validation error:', message) + return NextResponse.json({ error: message }, { status: 400 }) } const { limit, context, organizationId } = validation.data @@ -147,7 +135,7 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { success: true, context, userId, - organizationId, + organizationId: organizationId ?? null, data: updatedInfo, }) } catch (error) { diff --git a/apps/sim/app/api/users/me/api-keys/[id]/route.ts b/apps/sim/app/api/users/me/api-keys/[id]/route.ts index 147cc9d21a3..4ae92aff51d 100644 --- a/apps/sim/app/api/users/me/api-keys/[id]/route.ts +++ b/apps/sim/app/api/users/me/api-keys/[id]/route.ts @@ -4,6 +4,8 @@ import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { apiKeyIdParamsSchema } from '@/lib/api/contracts' +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' @@ -14,7 +16,14 @@ const logger = createLogger('ApiKeyAPI') export const DELETE = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const parsedParams = apiKeyIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json( + { error: getValidationErrorMessage(parsedParams.error) }, + { status: 400 } + ) + } + const { id } = parsedParams.data try { const session = await getSession() @@ -25,10 +34,6 @@ export const DELETE = withRouteHandler( const userId = session.user.id const keyId = id - if (!keyId) { - return NextResponse.json({ error: 'API key ID is required' }, { status: 400 }) - } - // Delete the API key, ensuring it belongs to the current user const result = await db .delete(apiKey) 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 b66fc85dc20..4d9a58eb34a 100644 --- a/apps/sim/app/api/users/me/api-keys/route.ts +++ b/apps/sim/app/api/users/me/api-keys/route.ts @@ -5,6 +5,8 @@ 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 { createPersonalApiKeyBodySchema } from '@/lib/api/contracts' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' import { hashApiKey } from '@/lib/api-key/crypto' import { getSession } from '@/lib/auth' @@ -62,17 +64,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = session.user.id - const body = await request.json() - - const { name: rawName } = body - if (!rawName || typeof rawName !== 'string') { - return NextResponse.json({ error: 'Invalid request. Name is required.' }, { status: 400 }) + const parsedBody = await validateJsonBody(request, createPersonalApiKeyBodySchema) + if (!parsedBody.success) { + return NextResponse.json( + { + error: parsedBody.error + ? getValidationErrorMessage(parsedBody.error) + : 'Invalid request body', + }, + { status: 400 } + ) } - const name = rawName.trim() - if (!name) { - return NextResponse.json({ error: 'Name cannot be empty.' }, { status: 400 }) - } + const { name } = parsedBody.data const existingKey = await db .select() diff --git a/apps/sim/app/api/users/me/profile/route.ts b/apps/sim/app/api/users/me/profile/route.ts index c2a7a3452a9..75324f11e23 100644 --- a/apps/sim/app/api/users/me/profile/route.ts +++ b/apps/sim/app/api/users/me/profile/route.ts @@ -3,30 +3,14 @@ import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateUserProfileBodySchema } from '@/lib/api/contracts' +import { validateSchema } 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' const logger = createLogger('UpdateUserProfileAPI') -const UpdateProfileSchema = z - .object({ - name: z.string().min(1, 'Name is required').optional(), - image: z - .string() - .refine( - (val) => { - return val.startsWith('http://') || val.startsWith('https://') || val.startsWith('/api/') - }, - { message: 'Invalid image URL' } - ) - .optional(), - }) - .refine((data) => data.name !== undefined || data.image !== undefined, { - message: 'At least one field (name or image) must be provided', - }) - interface UpdateData { updatedAt: Date name?: string @@ -49,7 +33,14 @@ export const PATCH = withRouteHandler(async (request: NextRequest) => { const userId = session.user.id const body = await request.json() - const validatedData = UpdateProfileSchema.parse(body) + const validation = validateSchema(updateUserProfileBodySchema, body, 'Invalid profile data') + if (!validation.success) { + logger.warn(`[${requestId}] Invalid profile data`, { + errors: validation.error.issues, + }) + return validation.response + } + const validatedData = validation.data const updateData: UpdateData = { updatedAt: new Date() } if (validatedData.name !== undefined) updateData.name = validatedData.name @@ -80,16 +71,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error: any) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid profile data`, { - errors: error.errors, - }) - return NextResponse.json( - { error: 'Invalid profile data', details: error.errors }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Profile update error`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/users/me/settings/route.ts b/apps/sim/app/api/users/me/settings/route.ts index 419cf096f10..7966ab5c707 100644 --- a/apps/sim/app/api/users/me/settings/route.ts +++ b/apps/sim/app/api/users/me/settings/route.ts @@ -4,34 +4,14 @@ import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' -import { z } from 'zod' +import { updateUserSettingsBodySchema } from '@/lib/api/contracts' +import { isZodError, validationErrorResponse } 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' const logger = createLogger('UserSettingsAPI') -const SettingsSchema = z.object({ - theme: z.enum(['system', 'light', 'dark']).optional(), - autoConnect: z.boolean().optional(), - telemetryEnabled: z.boolean().optional(), - emailPreferences: z - .object({ - unsubscribeAll: z.boolean().optional(), - unsubscribeMarketing: z.boolean().optional(), - unsubscribeUpdates: z.boolean().optional(), - unsubscribeNotifications: z.boolean().optional(), - }) - .optional(), - billingUsageNotificationsEnabled: z.boolean().optional(), - showTrainingControls: z.boolean().optional(), - superUserModeEnabled: z.boolean().optional(), - errorNotificationsEnabled: z.boolean().optional(), - snapToGridSize: z.number().min(0).max(50).optional(), - showActionBar: z.boolean().optional(), - lastActiveWorkspaceId: z.string().optional(), -}) - const defaultSettings = { theme: 'system', autoConnect: true, @@ -107,7 +87,7 @@ export const PATCH = withRouteHandler(async (request: Request) => { const body = await request.json() try { - const validatedData = SettingsSchema.parse(body) + const validatedData = updateUserSettingsBodySchema.parse(body) await db .insert(settings) @@ -127,14 +107,11 @@ export const PATCH = withRouteHandler(async (request: Request) => { return NextResponse.json({ success: true }, { status: 200 }) } catch (validationError) { - if (validationError instanceof z.ZodError) { + if (isZodError(validationError)) { logger.warn(`[${requestId}] Invalid settings data`, { - errors: validationError.errors, + errors: validationError.issues, }) - return NextResponse.json( - { error: 'Invalid settings data', details: validationError.errors }, - { status: 400 } - ) + return validationErrorResponse(validationError, 'Invalid settings data') } throw validationError } diff --git a/apps/sim/app/api/users/me/settings/unsubscribe/route.ts b/apps/sim/app/api/users/me/settings/unsubscribe/route.ts index 558e8a3874b..58aedbe560c 100644 --- a/apps/sim/app/api/users/me/settings/unsubscribe/route.ts +++ b/apps/sim/app/api/users/me/settings/unsubscribe/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { unsubscribeBodySchema, unsubscribeQuerySchema } from '@/lib/api/contracts/user' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { EmailType } from '@/lib/messaging/email/mailer' @@ -14,24 +14,21 @@ import { const logger = createLogger('UnsubscribeAPI') -const unsubscribeSchema = z.object({ - email: z.string().email('Invalid email address'), - token: z.string().min(1, 'Token is required'), - type: z.enum(['all', 'marketing', 'updates', 'notifications']).optional().default('all'), -}) - export const GET = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() try { const { searchParams } = new URL(req.url) - const email = searchParams.get('email') - const token = searchParams.get('token') + const parsedQuery = unsubscribeQuerySchema.safeParse({ + email: searchParams.get('email') || undefined, + token: searchParams.get('token') || undefined, + }) - if (!email || !token) { + if (!parsedQuery.success) { logger.warn(`[${requestId}] Missing email or token in GET request`) return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 }) } + const { email, token } = parsedQuery.data const tokenVerification = verifyUnsubscribeToken(email, token) if (!tokenVerification.valid) { @@ -74,25 +71,30 @@ export const POST = withRouteHandler(async (req: NextRequest) => { let type: 'all' | 'marketing' | 'updates' | 'notifications' = 'all' if (contentType.includes('application/x-www-form-urlencoded')) { - email = searchParams.get('email') || '' - token = searchParams.get('token') || '' + const parsedQuery = unsubscribeQuerySchema.safeParse({ + email: searchParams.get('email') || undefined, + token: searchParams.get('token') || undefined, + }) - if (!email || !token) { + if (!parsedQuery.success) { logger.warn(`[${requestId}] One-click unsubscribe missing email or token in URL`) return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 }) } + email = parsedQuery.data.email + token = parsedQuery.data.token + logger.info(`[${requestId}] Processing one-click unsubscribe for: ${email}`) } else { const body = await req.json() - const result = unsubscribeSchema.safeParse(body) + const result = unsubscribeBodySchema.safeParse(body) if (!result.success) { logger.warn(`[${requestId}] Invalid unsubscribe POST data`, { - errors: result.error.format(), + errors: result.error.issues, }) return NextResponse.json( - { error: 'Invalid request data', details: result.error.format() }, + { error: 'Invalid request data', details: result.error.issues }, { status: 400 } ) } diff --git a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts index 99e4095f875..6534dc57d32 100644 --- a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts +++ b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts @@ -4,7 +4,11 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + subscriptionTransferBodySchema, + subscriptionTransferParamsSchema, +} from '@/lib/api/contracts/user' +import { getValidationErrorMessage, validateJsonBody } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { isOrgPlan } from '@/lib/billing/plan-helpers' import { @@ -15,10 +19,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('SubscriptionTransferAPI') -const transferSubscriptionSchema = z.object({ - organizationId: z.string().min(1), -}) - type TransferOutcome = | { kind: 'error'; status: number; error: string } | { kind: 'noop'; message: string } @@ -27,7 +27,14 @@ type TransferOutcome = export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { try { - const subscriptionId = (await params).id + const parsedParams = subscriptionTransferParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json( + { error: getValidationErrorMessage(parsedParams.error) }, + { status: 400 } + ) + } + const subscriptionId = parsedParams.data.id const session = await getSession() if (!session?.user?.id) { @@ -35,27 +42,9 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - let body - try { - body = await request.json() - } catch (_parseError) { - return NextResponse.json( - { - error: 'Invalid JSON in request body', - }, - { status: 400 } - ) - } - - const validationResult = transferSubscriptionSchema.safeParse(body) + const validationResult = await validateJsonBody(request, subscriptionTransferBodySchema) if (!validationResult.success) { - return NextResponse.json( - { - error: 'Invalid request parameters', - details: validationResult.error.format(), - }, - { status: 400 } - ) + return validationResult.response } const { organizationId } = validationResult.data 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 015605bf049..a5214731e55 100644 --- a/apps/sim/app/api/users/me/usage-limits/route.ts +++ b/apps/sim/app/api/users/me/usage-limits/route.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { usageLimitsRequestSchema } from '@/lib/api/contracts/usage-limits' import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid' import { checkServerSideUsageLimits } from '@/lib/billing' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' @@ -12,6 +13,8 @@ import { createErrorResponse } from '@/app/api/workflows/utils' const logger = createLogger('UsageLimitsAPI') export const GET = withRouteHandler(async (request: NextRequest) => { + usageLimitsRequestSchema.parse({}) + try { const auth = await checkHybridAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { @@ -76,8 +79,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { percentUsed: storageLimit > 0 ? (storageUsage / storageLimit) * 100 : 0, }, }) - } catch (error: any) { + } catch (error) { logger.error('Error checking usage limits:', error) - return createErrorResponse(error.message || 'Failed to check usage limits', 500) + return createErrorResponse( + error instanceof Error ? error.message : 'Failed to check usage limits', + 500 + ) } }) 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 e526f266863..b5de52d9eb0 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { usageLogsQuerySchema } from '@/lib/api/contracts/user' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { getUserUsageLogs, type UsageLogSource } from '@/lib/billing/core/usage-log' import { dollarsToCredits } from '@/lib/billing/credits/conversion' @@ -9,14 +9,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('UsageLogsAPI') -const QuerySchema = z.object({ - source: z.enum(['workflow', 'wand', 'copilot']).optional(), - workspaceId: z.string().optional(), - period: z.enum(['1d', '7d', '30d', 'all']).optional().default('30d'), - limit: z.coerce.number().min(1).max(100).optional().default(50), - cursor: z.string().optional(), -}) - /** * GET /api/users/me/usage-logs * Get usage logs for the authenticated user @@ -40,7 +32,7 @@ export const GET = withRouteHandler(async (req: NextRequest) => { cursor: searchParams.get('cursor') || undefined, } - const validation = QuerySchema.safeParse(queryParams) + const validation = usageLogsQuerySchema.safeParse(queryParams) if (!validation.success) { return NextResponse.json( diff --git a/apps/sim/app/api/v1/admin/access-control/route.ts b/apps/sim/app/api/v1/admin/access-control/route.ts index 3ac24168fae..dc1b92f3b93 100644 --- a/apps/sim/app/api/v1/admin/access-control/route.ts +++ b/apps/sim/app/api/v1/admin/access-control/route.ts @@ -27,34 +27,29 @@ import { db } from '@sim/db' import { permissionGroup, permissionGroupMember, user, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq, inArray, sql } from 'drizzle-orm' +import { + type AdminV1PermissionGroup, + adminV1AccessControlQuerySchema, + adminV1DeleteAccessControlContract, +} from '@/lib/api/contracts' +import { parseRequest, searchParamsToObject, validateSchema } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { - badRequestResponse, + adminValidationErrorResponse, internalErrorResponse, singleResponse, } from '@/app/api/v1/admin/responses' const logger = createLogger('AdminAccessControlAPI') -export interface AdminPermissionGroup { - id: string - workspaceId: string - workspaceName: string | null - organizationId: string | null - name: string - description: string | null - memberCount: number - createdAt: string - createdByUserId: string - createdByEmail: string | null -} - export const GET = withRouteHandler( withAdminAuth(async (request) => { - const url = new URL(request.url) - const workspaceId = url.searchParams.get('workspaceId') - const organizationId = url.searchParams.get('organizationId') + const queryValidation = validateSchema( + adminV1AccessControlQuerySchema, + searchParamsToObject(request.nextUrl.searchParams) + ) + const { workspaceId, organizationId } = queryValidation.success ? queryValidation.data : {} try { const baseQuery = db @@ -100,7 +95,7 @@ export const GET = withRouteHandler( createdAt: group.createdAt.toISOString(), createdByUserId: group.createdByUserId, createdByEmail: group.createdByEmail, - } as AdminPermissionGroup + } as AdminV1PermissionGroup }) ) @@ -132,14 +127,18 @@ export const GET = withRouteHandler( export const DELETE = withRouteHandler( withAdminAuth(async (request) => { - const url = new URL(request.url) - const workspaceId = url.searchParams.get('workspaceId') - const organizationId = url.searchParams.get('organizationId') - const reason = url.searchParams.get('reason') || 'Enterprise plan churn cleanup' + const parsed = await parseRequest( + adminV1DeleteAccessControlContract, + request, + {}, + { + validationErrorResponse: adminValidationErrorResponse, + } + ) + if (!parsed.success) return parsed.response - if (!workspaceId && !organizationId) { - return badRequestResponse('workspaceId or organizationId is required') - } + const { workspaceId, organizationId, reason: rawReason } = parsed.data.query + const reason = rawReason || 'Enterprise plan churn cleanup' try { const selectBase = db diff --git a/apps/sim/app/api/v1/admin/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/admin/audit-logs/[id]/route.ts index a84f400fb06..b06a965891d 100644 --- a/apps/sim/app/api/v1/admin/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/audit-logs/[id]/route.ts @@ -10,9 +10,12 @@ import { db } from '@sim/db' import { auditLog } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' +import { v1AdminGetAuditLogContract } from '@/lib/api/contracts/audit-logs' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminValidationErrorResponse, internalErrorResponse, notFoundResponse, singleResponse, @@ -27,7 +30,12 @@ interface RouteParams { export const GET = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id } = await context.params + const parsed = await parseRequest(v1AdminGetAuditLogContract, request, context, { + validationErrorResponse: adminValidationErrorResponse, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params try { const [log] = await db.select().from(auditLog).where(eq(auditLog.id, id)).limit(1) 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 cba6ba03a85..c00daba2c9c 100644 --- a/apps/sim/app/api/v1/admin/audit-logs/route.ts +++ b/apps/sim/app/api/v1/admin/audit-logs/route.ts @@ -22,10 +22,12 @@ import { db } from '@sim/db' import { auditLog } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, desc } from 'drizzle-orm' +import { v1AdminAuditLogsQuerySchema } from '@/lib/api/contracts/audit-logs' +import { searchParamsToObject, validateSchema } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { - badRequestResponse, + adminValidationErrorResponse, internalErrorResponse, listResponse, } from '@/app/api/v1/admin/responses' @@ -44,26 +46,26 @@ export const GET = withRouteHandler( const url = new URL(request.url) const { limit, offset } = parsePaginationParams(url) - const startDate = url.searchParams.get('startDate') || undefined - const endDate = url.searchParams.get('endDate') || undefined + const validation = validateSchema( + v1AdminAuditLogsQuerySchema, + searchParamsToObject(url.searchParams) + ) - if (startDate && Number.isNaN(Date.parse(startDate))) { - return badRequestResponse('Invalid startDate format. Use ISO 8601.') - } - if (endDate && Number.isNaN(Date.parse(endDate))) { - return badRequestResponse('Invalid endDate format. Use ISO 8601.') + if (!validation.success) { + return adminValidationErrorResponse(validation.error) } try { + const query = validation.data const conditions = buildFilterConditions({ - action: url.searchParams.get('action') || undefined, - resourceType: url.searchParams.get('resourceType') || undefined, - resourceId: url.searchParams.get('resourceId') || undefined, - workspaceId: url.searchParams.get('workspaceId') || undefined, - actorId: url.searchParams.get('actorId') || undefined, - actorEmail: url.searchParams.get('actorEmail') || undefined, - startDate, - endDate, + action: query.action, + resourceType: query.resourceType, + resourceId: query.resourceId, + workspaceId: query.workspaceId, + actorId: query.actorId, + actorEmail: query.actorEmail, + startDate: query.startDate, + endDate: query.endDate, }) const whereClause = conditions.length > 0 ? and(...conditions) : undefined diff --git a/apps/sim/app/api/v1/admin/credits/route.ts b/apps/sim/app/api/v1/admin/credits/route.ts index 1fc0d5f658c..13920e1e14b 100644 --- a/apps/sim/app/api/v1/admin/credits/route.ts +++ b/apps/sim/app/api/v1/admin/credits/route.ts @@ -28,6 +28,8 @@ import { organization, subscription, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' +import { adminV1IssueCreditsContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { addCredits } from '@/lib/billing/credits/balance' import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase' @@ -40,6 +42,8 @@ import { import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, + adminValidationErrorResponse, badRequestResponse, internalErrorResponse, notFoundResponse, @@ -50,25 +54,19 @@ const logger = createLogger('AdminCreditsAPI') export const POST = withRouteHandler( withAdminAuth(async (request) => { - try { - const body = await request.json() - const { userId, email, amount, reason } = body - - if (!userId && !email) { - return badRequestResponse('Either userId or email is required') - } - - if (userId && typeof userId !== 'string') { - return badRequestResponse('userId must be a string') - } - - if (email && typeof email !== 'string') { - return badRequestResponse('email must be a string') + const parsed = await parseRequest( + adminV1IssueCreditsContract, + request, + {}, + { + validationErrorResponse: adminValidationErrorResponse, + invalidJsonResponse: adminInvalidJsonResponse, } + ) + if (!parsed.success) return parsed.response - if (typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) { - return badRequestResponse('amount must be a positive number') - } + try { + const { userId, email, amount, reason } = parsed.data.body let resolvedUserId: string let userEmail: string | null = null @@ -86,6 +84,10 @@ export const POST = withRouteHandler( resolvedUserId = userData.id userEmail = userData.email } else { + if (!email) { + return badRequestResponse('Either userId or email is required') + } + const normalizedEmail = email.toLowerCase().trim() const [userData] = await db .select({ id: user.id, email: user.email }) diff --git a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts index 8bee76f243c..777e26ef6ba 100644 --- a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts @@ -16,6 +16,8 @@ import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { adminV1ExportFolderContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { exportFolderToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -97,9 +99,11 @@ function collectSubfolders( export const GET = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: folderId } = await context.params - const url = new URL(request.url) - const format = url.searchParams.get('format') || 'zip' + const parsed = await parseRequest(adminV1ExportFolderContract, request, context) + if (!parsed.success) return parsed.response + + const { id: folderId } = parsed.data.params + const { format } = parsed.data.query try { const [folderData] = await db 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 c03b4ffa2cd..916b4f99ca3 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 @@ -19,11 +19,17 @@ import { db } from '@sim/db' import { member, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' +import { + adminV1GetOrganizationBillingContract, + adminV1UpdateOrganizationBillingContract, +} from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { getOrganizationBillingData } from '@/lib/billing/core/organization' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminValidationErrorResponse, badRequestResponse, internalErrorResponse, notFoundResponse, @@ -38,8 +44,13 @@ interface RouteParams { } export const GET = withRouteHandler( - withAdminAuthParams(async (_, context) => { - const { id: organizationId } = await context.params + withAdminAuthParams(async (request, context) => { + const parsed = await parseRequest(adminV1GetOrganizationBillingContract, request, context, { + validationErrorResponse: adminValidationErrorResponse, + }) + if (!parsed.success) return parsed.response + + const { id: organizationId } = parsed.data.params try { if (!isBillingEnabled) { @@ -127,10 +138,20 @@ export const GET = withRouteHandler( export const PATCH = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: organizationId } = await context.params + const routeParams = await context.params + const { id: organizationId } = routeParams try { - const body = await request.json() + const parsed = await parseRequest( + adminV1UpdateOrganizationBillingContract, + request, + { params: routeParams }, + { + validationErrorResponse: adminValidationErrorResponse, + invalidJson: 'throw', + } + ) + if (!parsed.success) return parsed.response const [orgData] = await db .select() @@ -142,15 +163,15 @@ export const PATCH = withRouteHandler( return notFoundResponse('Organization') } - if (body.orgUsageLimit !== undefined) { + const { orgUsageLimit } = parsed.data.body + + if (orgUsageLimit !== undefined) { let newLimit: string | null = null - if (body.orgUsageLimit === null) { + if (orgUsageLimit === null) { newLimit = null - } else if (typeof body.orgUsageLimit === 'number' && body.orgUsageLimit >= 0) { - newLimit = body.orgUsageLimit.toFixed(2) } else { - return badRequestResponse('orgUsageLimit must be a non-negative number or null') + newLimit = orgUsageLimit.toFixed(2) } await db 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 515c9617d45..b9ef1423507 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 @@ -29,11 +29,18 @@ import { db } from '@sim/db' import { member, organization, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' +import { + adminV1GetOrganizationMemberContract, + adminV1RemoveOrganizationMemberContract, + adminV1UpdateOrganizationMemberContract, +} from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { removeUserFromOrganization } from '@/lib/billing/organizations/membership' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminValidationErrorResponse, badRequestResponse, internalErrorResponse, notFoundResponse, @@ -49,8 +56,13 @@ interface RouteParams { } export const GET = withRouteHandler( - withAdminAuthParams(async (_, context) => { - const { id: organizationId, memberId } = await context.params + withAdminAuthParams(async (request, context) => { + const parsed = await parseRequest(adminV1GetOrganizationMemberContract, request, context, { + validationErrorResponse: adminValidationErrorResponse, + }) + if (!parsed.success) return parsed.response + + const { id: organizationId, memberId } = parsed.data.params try { const [orgData] = await db @@ -113,14 +125,22 @@ export const GET = withRouteHandler( export const PATCH = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: organizationId, memberId } = await context.params + const routeParams = await context.params + const { id: organizationId, memberId } = routeParams try { - const body = await request.json() + const parsed = await parseRequest( + adminV1UpdateOrganizationMemberContract, + request, + { params: routeParams }, + { + validationErrorResponse: adminValidationErrorResponse, + invalidJson: 'throw', + } + ) + if (!parsed.success) return parsed.response - if (!body.role || !['admin', 'member'].includes(body.role)) { - return badRequestResponse('role must be "admin" or "member"') - } + const { role } = parsed.data.body const [orgData] = await db .select({ id: organization.id }) @@ -152,7 +172,7 @@ export const PATCH = withRouteHandler( const [updated] = await db .update(member) - .set({ role: body.role }) + .set({ role }) .where(eq(member.id, memberId)) .returning() @@ -172,7 +192,7 @@ export const PATCH = withRouteHandler( userEmail: userData?.email ?? '', } - logger.info(`Admin API: Updated member ${memberId} role to ${body.role}`, { + logger.info(`Admin API: Updated member ${memberId} role to ${role}`, { organizationId, previousRole: existingMember.role, }) @@ -187,10 +207,13 @@ export const PATCH = withRouteHandler( export const DELETE = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: organizationId, memberId } = await context.params - const url = new URL(request.url) - const skipBillingLogic = - !isBillingEnabled || url.searchParams.get('skipBillingLogic') === 'true' + const parsed = await parseRequest(adminV1RemoveOrganizationMemberContract, request, context, { + validationErrorResponse: adminValidationErrorResponse, + }) + if (!parsed.success) return parsed.response + + const { id: organizationId, memberId } = parsed.data.params + const skipBillingLogic = !isBillingEnabled || parsed.data.query.skipBillingLogic try { const [orgData] = await db diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts index 8f531cd5eb7..a8e58290878 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts @@ -32,11 +32,18 @@ import { db } from '@sim/db' import { member, organization, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' +import { + adminV1AddOrganizationMemberContract, + adminV1ListOrganizationMembersContract, +} from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { addUserToOrganization } from '@/lib/billing/organizations/membership' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, + adminValidationErrorResponse, badRequestResponse, internalErrorResponse, listResponse, @@ -47,7 +54,6 @@ import { type AdminMember, type AdminMemberDetail, createPaginationMeta, - parsePaginationParams, } from '@/app/api/v1/admin/types' const logger = createLogger('AdminOrganizationMembersAPI') @@ -58,9 +64,13 @@ interface RouteParams { export const GET = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: organizationId } = await context.params - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) + const parsed = await parseRequest(adminV1ListOrganizationMembersContract, request, context, { + validationErrorResponse: adminValidationErrorResponse, + }) + if (!parsed.success) return parsed.response + + const { id: organizationId } = parsed.data.params + const { limit, offset } = parsed.data.query try { const [orgData] = await db @@ -127,18 +137,16 @@ export const GET = withRouteHandler( export const POST = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: organizationId } = await context.params + const parsed = await parseRequest(adminV1AddOrganizationMemberContract, request, context, { + validationErrorResponse: adminValidationErrorResponse, + invalidJsonResponse: adminInvalidJsonResponse, + }) + if (!parsed.success) return parsed.response - try { - const body = await request.json() + const { id: organizationId } = parsed.data.params - if (!body.userId || typeof body.userId !== 'string') { - return badRequestResponse('userId is required') - } - - if (!body.role || !['admin', 'member'].includes(body.role)) { - return badRequestResponse('role must be "admin" or "member"') - } + try { + const { userId, role } = parsed.data.body const [orgData] = await db .select({ id: organization.id, name: organization.name }) @@ -153,7 +161,7 @@ export const POST = withRouteHandler( const [userData] = await db .select({ id: user.id, name: user.name, email: user.email }) .from(user) - .where(eq(user.id, body.userId)) + .where(eq(user.id, userId)) .limit(1) if (!userData) { @@ -168,7 +176,7 @@ export const POST = withRouteHandler( organizationId: member.organizationId, }) .from(member) - .where(eq(member.userId, body.userId)) + .where(eq(member.userId, userId)) .limit(1) if (existingMember) { @@ -179,22 +187,22 @@ export const POST = withRouteHandler( ) } - if (existingMember.role !== body.role) { - await db.update(member).set({ role: body.role }).where(eq(member.id, existingMember.id)) + if (existingMember.role !== role) { + await db.update(member).set({ role }).where(eq(member.id, existingMember.id)) logger.info( - `Admin API: Updated user ${body.userId} role in organization ${organizationId}`, + `Admin API: Updated user ${userId} role in organization ${organizationId}`, { previousRole: existingMember.role, - newRole: body.role, + newRole: role, } ) return singleResponse({ id: existingMember.id, - userId: body.userId, + userId, organizationId, - role: body.role, + role, createdAt: existingMember.createdAt.toISOString(), userName: userData.name, userEmail: userData.email, @@ -208,7 +216,7 @@ export const POST = withRouteHandler( return singleResponse({ id: existingMember.id, - userId: body.userId, + userId, organizationId, role: existingMember.role, createdAt: existingMember.createdAt.toISOString(), @@ -228,9 +236,9 @@ export const POST = withRouteHandler( } const result = await addUserToOrganization({ - userId: body.userId, + userId, organizationId, - role: body.role, + role, skipBillingLogic: !isBillingEnabled, }) @@ -240,16 +248,16 @@ export const POST = withRouteHandler( const data: AdminMember = { id: result.memberId!, - userId: body.userId, + userId, organizationId, - role: body.role, + role, createdAt: new Date().toISOString(), userName: userData.name, userEmail: userData.email, } - logger.info(`Admin API: Added user ${body.userId} to organization ${organizationId}`, { - role: body.role, + logger.info(`Admin API: Added user ${userId} to organization ${organizationId}`, { + role, memberId: result.memberId, billingActions: result.billingActions, }) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts index 01b854cd470..68e23d8d6a6 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -20,6 +20,11 @@ import { db } from '@sim/db' import { member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq, inArray } from 'drizzle-orm' +import { + adminV1GetOrganizationContract, + adminV1UpdateOrganizationContract, +} from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { ensureOrganizationSlugAvailable, OrganizationSlugInvalidError, @@ -30,6 +35,8 @@ import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/util import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, + adminValidationErrorResponse, badRequestResponse, internalErrorResponse, notFoundResponse, @@ -49,7 +56,12 @@ interface RouteParams { export const GET = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: organizationId } = await context.params + const parsed = await parseRequest(adminV1GetOrganizationContract, request, context, { + validationErrorResponse: adminValidationErrorResponse, + }) + if (!parsed.success) return parsed.response + + const { id: organizationId } = parsed.data.params try { const [orgData] = await db @@ -94,11 +106,15 @@ export const GET = withRouteHandler( export const PATCH = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: organizationId } = await context.params + const parsed = await parseRequest(adminV1UpdateOrganizationContract, request, context, { + validationErrorResponse: adminValidationErrorResponse, + invalidJsonResponse: adminInvalidJsonResponse, + }) + if (!parsed.success) return parsed.response - try { - const body = await request.json() + const { id: organizationId } = parsed.data.params + try { const [existing] = await db .select() .from(organization) @@ -113,18 +129,14 @@ export const PATCH = withRouteHandler( updatedAt: new Date(), } - if (body.name !== undefined) { - if (typeof body.name !== 'string' || body.name.trim().length === 0) { - return badRequestResponse('name must be a non-empty string') - } - updateData.name = body.name.trim() + const validatedBody = parsed.data.body + + if (validatedBody.name !== undefined) { + updateData.name = validatedBody.name } - if (body.slug !== undefined) { - if (typeof body.slug !== 'string' || body.slug.trim().length === 0) { - return badRequestResponse('slug must be a non-empty string') - } - const nextSlug = body.slug.trim() + if (validatedBody.slug !== undefined) { + const nextSlug = validatedBody.slug validateOrganizationSlugOrThrow(nextSlug) await ensureOrganizationSlugAvailable({ slug: nextSlug, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts index 01f84b218ac..bac9baec5b2 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts @@ -7,10 +7,13 @@ */ import { createLogger } from '@sim/logger' +import { adminV1GetOrganizationSeatsContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { getOrganizationSeatAnalytics } from '@/lib/billing/validation/seat-management' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminValidationErrorResponse, internalErrorResponse, notFoundResponse, singleResponse, @@ -24,8 +27,13 @@ interface RouteParams { } export const GET = withRouteHandler( - withAdminAuthParams(async (_, context) => { - const { id: organizationId } = await context.params + withAdminAuthParams(async (request, context) => { + const parsed = await parseRequest(adminV1GetOrganizationSeatsContract, request, context, { + validationErrorResponse: adminValidationErrorResponse, + }) + if (!parsed.success) return parsed.response + + const { id: organizationId } = parsed.data.params try { const analytics = await getOrganizationSeatAnalytics(organizationId) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/transfer-ownership/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/transfer-ownership/route.ts index ab7dac3c813..35fac21edfa 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/transfer-ownership/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/transfer-ownership/route.ts @@ -2,10 +2,13 @@ import { db } from '@sim/db' import { member, organization, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' +import { adminV1TransferOwnershipContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { transferOrganizationOwnership } from '@/lib/billing/organizations/membership' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminValidationErrorResponse, badRequestResponse, internalErrorResponse, notFoundResponse, @@ -20,23 +23,22 @@ interface RouteParams { export const POST = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: organizationId } = await context.params + const routeParams = await context.params + const { id: organizationId } = routeParams try { - const body = await request.json().catch(() => null) - const newOwnerUserId: unknown = body?.newOwnerUserId - const currentOwnerUserIdOverride: unknown = body?.currentOwnerUserId - - if (typeof newOwnerUserId !== 'string' || newOwnerUserId.length === 0) { - return badRequestResponse('newOwnerUserId is required') - } + const parsed = await parseRequest( + adminV1TransferOwnershipContract, + request, + { params: routeParams }, + { + validationErrorResponse: adminValidationErrorResponse, + invalidJsonResponse: () => badRequestResponse('Invalid request body'), + } + ) + if (!parsed.success) return parsed.response - if ( - currentOwnerUserIdOverride !== undefined && - (typeof currentOwnerUserIdOverride !== 'string' || currentOwnerUserIdOverride.length === 0) - ) { - return badRequestResponse('currentOwnerUserId must be a non-empty string when provided') - } + const { newOwnerUserId, currentOwnerUserId: currentOwnerUserIdOverride } = parsed.data.body const [orgRow] = await db .select({ id: organization.id }) @@ -49,7 +51,7 @@ export const POST = withRouteHandler( } let currentOwnerUserId: string - if (typeof currentOwnerUserIdOverride === 'string') { + if (currentOwnerUserIdOverride) { currentOwnerUserId = currentOwnerUserIdOverride } else { const [ownerMembership] = await db diff --git a/apps/sim/app/api/v1/admin/organizations/route.ts b/apps/sim/app/api/v1/admin/organizations/route.ts index 64881d6330e..3f4b3ae18bf 100644 --- a/apps/sim/app/api/v1/admin/organizations/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/route.ts @@ -25,6 +25,11 @@ import { db } from '@sim/db' import { member, organization, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' +import { + adminV1CreateOrganizationContract, + adminV1ListOrganizationsContract, +} from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { createOrganizationWithOwner, OrganizationSlugInvalidError, @@ -33,6 +38,8 @@ import { import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, + adminValidationErrorResponse, badRequestResponse, internalErrorResponse, listResponse, @@ -42,7 +49,6 @@ import { import { type AdminOrganization, createPaginationMeta, - parsePaginationParams, toAdminOrganization, } from '@/app/api/v1/admin/types' @@ -50,8 +56,17 @@ const logger = createLogger('AdminOrganizationsAPI') export const GET = withRouteHandler( withAdminAuth(async (request) => { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) + const parsed = await parseRequest( + adminV1ListOrganizationsContract, + request, + {}, + { + validationErrorResponse: adminValidationErrorResponse, + } + ) + if (!parsed.success) return parsed.response + + const { limit, offset } = parsed.data.query try { const [countResult, organizations] = await Promise.all([ @@ -75,21 +90,24 @@ export const GET = withRouteHandler( export const POST = withRouteHandler( withAdminAuth(async (request) => { - try { - const body = await request.json() - - if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) { - return badRequestResponse('name is required') + const parsed = await parseRequest( + adminV1CreateOrganizationContract, + request, + {}, + { + validationErrorResponse: adminValidationErrorResponse, + invalidJsonResponse: adminInvalidJsonResponse, } + ) + if (!parsed.success) return parsed.response - if (!body.ownerId || typeof body.ownerId !== 'string') { - return badRequestResponse('ownerId is required') - } + try { + const { name, ownerId, slug: requestedSlug } = parsed.data.body const [ownerData] = await db .select({ id: user.id, name: user.name }) .from(user) - .where(eq(user.id, body.ownerId)) + .where(eq(user.id, ownerId)) .limit(1) if (!ownerData) { @@ -99,7 +117,7 @@ export const POST = withRouteHandler( const [existingMembership] = await db .select({ organizationId: member.organizationId }) .from(member) - .where(eq(member.userId, body.ownerId)) + .where(eq(member.userId, ownerId)) .limit(1) if (existingMembership) { @@ -108,16 +126,15 @@ export const POST = withRouteHandler( ) } - const name = body.name.trim() const slug = - body.slug?.trim() || + requestedSlug?.trim() || name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') const { organizationId, memberId } = await createOrganizationWithOwner({ - ownerUserId: body.ownerId, + ownerUserId: ownerId, name, slug, }) @@ -131,7 +148,7 @@ export const POST = withRouteHandler( logger.info(`Admin API: Created organization ${organizationId}`, { name, slug, - ownerId: body.ownerId, + ownerId, memberId, }) 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 09ad8908c6b..1e64e956fb4 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 @@ -4,6 +4,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { adminV1RequeueOutboxEventContract } from '@/lib/api/contracts' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' @@ -11,6 +13,9 @@ const logger = createLogger('AdminOutboxRequeueAPI') export const dynamic = 'force-dynamic' +const invalidOutboxEventResponse = (message: string) => + NextResponse.json({ success: false, error: message }, { status: 400 }) + /** * POST /api/v1/admin/outbox/[id]/requeue * @@ -21,8 +26,14 @@ export const dynamic = 'force-dynamic' * operator errors. */ export const POST = withRouteHandler( - withAdminAuthParams<{ id: string }>(async (_request, { params }) => { - const { id } = await params + withAdminAuthParams<{ id: string }>(async (request, context) => { + const parsed = await parseRequest(adminV1RequeueOutboxEventContract, request, context, { + validationErrorResponse: (error) => + invalidOutboxEventResponse(getValidationErrorMessage(error, 'Invalid event ID')), + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params try { const result = await db diff --git a/apps/sim/app/api/v1/admin/outbox/route.ts b/apps/sim/app/api/v1/admin/outbox/route.ts index 01d87d8b7ad..d441c408b45 100644 --- a/apps/sim/app/api/v1/admin/outbox/route.ts +++ b/apps/sim/app/api/v1/admin/outbox/route.ts @@ -4,6 +4,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, desc, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { adminV1ListOutboxContract } from '@/lib/api/contracts' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' @@ -11,6 +13,9 @@ const logger = createLogger('AdminOutboxAPI') export const dynamic = 'force-dynamic' +const invalidOutboxQueryResponse = (message: string) => + NextResponse.json({ success: false, error: message }, { status: 400 }) + /** * GET /api/v1/admin/outbox?status=dead_letter&eventType=...&limit=100 * @@ -29,27 +34,18 @@ export const dynamic = 'force-dynamic' export const GET = withRouteHandler( withAdminAuth(async (request: NextRequest) => { try { - const { searchParams } = new URL(request.url) - const validStatuses = ['pending', 'processing', 'completed', 'dead_letter'] as const - const status = (searchParams.get('status') ?? 'dead_letter') as (typeof validStatuses)[number] - if (!validStatuses.includes(status)) { - return NextResponse.json( - { - success: false, - error: `Invalid status. Must be one of: ${validStatuses.join(', ')}`, - }, - { status: 400 } - ) - } - - const eventType = searchParams.get('eventType') + const parsed = await parseRequest( + adminV1ListOutboxContract, + request, + {}, + { + validationErrorResponse: (error) => + invalidOutboxQueryResponse(getValidationErrorMessage(error, 'Invalid outbox query')), + } + ) + if (!parsed.success) return parsed.response - const rawLimit = searchParams.get('limit') - const parsedLimit = rawLimit === null ? 100 : Number.parseInt(rawLimit, 10) - const limit = - Number.isFinite(parsedLimit) && parsedLimit > 0 - ? Math.min(500, Math.max(1, parsedLimit)) - : 100 + const { status, eventType, limit } = parsed.data.query const whereConditions = [eq(outboxEvent.status, status)] if (eventType) { 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 7d1ebfc9452..96ce39e7303 100644 --- a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts +++ b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts @@ -27,12 +27,21 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import type Stripe from 'stripe' +import { + type AdminV1PromoCode, + type AdminV1ReferralCampaignAppliesTo, + type AdminV1ReferralCampaignDuration, + adminV1CreateReferralCampaignContract, + adminV1ListReferralCampaignsContract, +} from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { isPro, isTeam } from '@/lib/billing/plan-helpers' import { getPlans } from '@/lib/billing/plans' 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 { + adminValidationErrorResponse, badRequestResponse, internalErrorResponse, singleResponse, @@ -40,36 +49,6 @@ import { const logger = createLogger('AdminPromoCodes') -const VALID_DURATIONS = ['once', 'repeating', 'forever'] as const -type Duration = (typeof VALID_DURATIONS)[number] - -/** Broad categories match all tiers; specific plan names match exactly. */ -const VALID_APPLIES_TO = [ - 'pro', - 'team', - 'pro_6000', - 'pro_25000', - 'team_6000', - 'team_25000', -] as const -type AppliesTo = (typeof VALID_APPLIES_TO)[number] - -interface PromoCodeResponse { - id: string - code: string - couponId: string - name: string - percentOff: number - duration: string - durationInMonths: number | null - appliesToProductIds: string[] | null - maxRedemptions: number | null - expiresAt: string | null - active: boolean - timesRedeemed: number - createdAt: string -} - function formatPromoCode(promo: { id: string code: string @@ -86,7 +65,7 @@ function formatPromoCode(promo: { active: boolean times_redeemed: number created: number -}): PromoCodeResponse { +}): AdminV1PromoCode { return { id: promo.id, code: promo.code, @@ -109,7 +88,10 @@ function formatPromoCode(promo: { * Broad categories ('pro', 'team') match all tiers via isPro/isTeam. * Specific plan names ('pro_6000', 'team_25000') match exactly. */ -async function resolveProductIds(stripe: Stripe, targets: AppliesTo[]): Promise { +async function resolveProductIds( + stripe: Stripe, + targets: AdminV1ReferralCampaignAppliesTo[] +): Promise { const plans = getPlans() const priceIds: string[] = [] @@ -156,20 +138,20 @@ export const GET = withRouteHandler( withAdminAuth(async (request) => { try { const stripe = requireStripeClient() - const url = new URL(request.url) - - const limitParam = url.searchParams.get('limit') - let limit = limitParam ? Number.parseInt(limitParam, 10) : 50 - if (Number.isNaN(limit) || limit < 1) limit = 50 - if (limit > 100) limit = 100 - - const startingAfter = url.searchParams.get('starting_after') || undefined - const activeFilter = url.searchParams.get('active') + const parsed = await parseRequest( + adminV1ListReferralCampaignsContract, + request, + {}, + { + validationErrorResponse: adminValidationErrorResponse, + } + ) + if (!parsed.success) return parsed.response + const query = parsed.data.query - const listParams: Record = { limit } - if (startingAfter) listParams.starting_after = startingAfter - if (activeFilter === 'true') listParams.active = true - else if (activeFilter === 'false') listParams.active = false + const listParams: Record = { limit: query.limit } + if (query.starting_after) listParams.starting_after = query.starting_after + if (query.active !== undefined) listParams.active = query.active const promoCodes = await stripe.promotionCodes.list(listParams) @@ -193,95 +175,25 @@ export const POST = withRouteHandler( withAdminAuth(async (request) => { try { const stripe = requireStripeClient() - const body = await request.json() - - const { - name, - percentOff, - code, - duration, - durationInMonths, - maxRedemptions, - expiresAt, - appliesTo, - } = body - - if (!name || typeof name !== 'string' || name.trim().length === 0) { - return badRequestResponse('name is required and must be a non-empty string') - } - - if ( - typeof percentOff !== 'number' || - !Number.isFinite(percentOff) || - percentOff < 1 || - percentOff > 100 - ) { - return badRequestResponse('percentOff must be a number between 1 and 100') - } - - const effectiveDuration: Duration = duration ?? 'once' - if (!VALID_DURATIONS.includes(effectiveDuration)) { - return badRequestResponse(`duration must be one of: ${VALID_DURATIONS.join(', ')}`) - } - - if (effectiveDuration === 'repeating') { - if ( - typeof durationInMonths !== 'number' || - !Number.isInteger(durationInMonths) || - durationInMonths < 1 - ) { - return badRequestResponse( - 'durationInMonths is required and must be a positive integer when duration is "repeating"' - ) - } - } - - if (code !== undefined && code !== null) { - if (typeof code !== 'string') { - return badRequestResponse('code must be a string or null') - } - if (code.trim().length < 6) { - return badRequestResponse('code must be at least 6 characters') - } - } - - if (maxRedemptions !== undefined && maxRedemptions !== null) { - if ( - typeof maxRedemptions !== 'number' || - !Number.isInteger(maxRedemptions) || - maxRedemptions < 1 - ) { - return badRequestResponse('maxRedemptions must be a positive integer') + const parsed = await parseRequest( + adminV1CreateReferralCampaignContract, + request, + {}, + { + validationErrorResponse: adminValidationErrorResponse, + invalidJson: 'throw', } - } - - if (expiresAt !== undefined && expiresAt !== null) { - const parsed = new Date(expiresAt) - if (Number.isNaN(parsed.getTime())) { - return badRequestResponse('expiresAt must be a valid ISO 8601 date string') - } - if (parsed.getTime() <= Date.now()) { - return badRequestResponse('expiresAt must be in the future') - } - } + ) + if (!parsed.success) return parsed.response - if (appliesTo !== undefined && appliesTo !== null) { - if (!Array.isArray(appliesTo) || appliesTo.length === 0) { - return badRequestResponse('appliesTo must be a non-empty array') - } - const invalid = appliesTo.filter( - (v: unknown) => typeof v !== 'string' || !VALID_APPLIES_TO.includes(v as AppliesTo) - ) - if (invalid.length > 0) { - return badRequestResponse( - `appliesTo contains invalid values: ${invalid.join(', ')}. Valid values: ${VALID_APPLIES_TO.join(', ')}` - ) - } - } + const { name, percentOff, code, duration, durationInMonths, maxRedemptions, expiresAt } = + parsed.data.body + const appliesTo = parsed.data.body.appliesTo ?? undefined + const effectiveDuration: AdminV1ReferralCampaignDuration = duration let appliesToProducts: string[] | undefined if (appliesTo?.length) { - appliesToProducts = await resolveProductIds(stripe, appliesTo as AppliesTo[]) + appliesToProducts = await resolveProductIds(stripe, appliesTo) if (appliesToProducts.length === 0) { return badRequestResponse( 'Could not resolve any Stripe products for the specified plan categories. Ensure price IDs are configured.' diff --git a/apps/sim/app/api/v1/admin/responses.ts b/apps/sim/app/api/v1/admin/responses.ts index dfbfa854888..53d3e59f752 100644 --- a/apps/sim/app/api/v1/admin/responses.ts +++ b/apps/sim/app/api/v1/admin/responses.ts @@ -5,6 +5,8 @@ */ import { NextResponse } from 'next/server' +import type { z } from 'zod' +import { getValidationErrorMessage } from '@/lib/api/server' import type { AdminErrorResponse, AdminListResponse, @@ -69,6 +71,14 @@ export function badRequestResponse(message: string, details?: unknown): NextResp return errorResponse('BAD_REQUEST', message, 400, details) } +export function adminValidationErrorResponse(error: z.ZodError): NextResponse { + return badRequestResponse(getValidationErrorMessage(error, 'Invalid request body')) +} + +export function adminInvalidJsonResponse(): NextResponse { + return badRequestResponse('Request body must be valid JSON') +} + export function internalErrorResponse(message = 'Internal server error'): NextResponse { return errorResponse('INTERNAL_ERROR', message, 500) } diff --git a/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts b/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts index f6549403c58..8075e5d1b75 100644 --- a/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts @@ -27,12 +27,18 @@ import { db } from '@sim/db' import { subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' +import { + adminV1CancelSubscriptionContract, + adminV1GetSubscriptionContract, +} from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { requireStripeClient } from '@/lib/billing/stripe-client' import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminValidationErrorResponse, badRequestResponse, internalErrorResponse, notFoundResponse, @@ -47,8 +53,13 @@ interface RouteParams { } export const GET = withRouteHandler( - withAdminAuthParams(async (_, context) => { - const { id: subscriptionId } = await context.params + withAdminAuthParams(async (request, context) => { + const parsed = await parseRequest(adminV1GetSubscriptionContract, request, context, { + validationErrorResponse: adminValidationErrorResponse, + }) + if (!parsed.success) return parsed.response + + const { id: subscriptionId } = parsed.data.params try { const [subData] = await db @@ -73,10 +84,14 @@ export const GET = withRouteHandler( export const DELETE = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: subscriptionId } = await context.params - const url = new URL(request.url) - const atPeriodEnd = url.searchParams.get('atPeriodEnd') === 'true' - const reason = url.searchParams.get('reason') || 'Admin cancellation (no reason provided)' + const parsed = await parseRequest(adminV1CancelSubscriptionContract, request, context, { + validationErrorResponse: adminValidationErrorResponse, + }) + if (!parsed.success) return parsed.response + + const { id: subscriptionId } = parsed.data.params + const { atPeriodEnd, reason: rawReason } = parsed.data.query + const reason = rawReason || 'Admin cancellation (no reason provided)' try { const [existing] = await db diff --git a/apps/sim/app/api/v1/admin/subscriptions/route.ts b/apps/sim/app/api/v1/admin/subscriptions/route.ts index 8fa4628114c..e56ce6cb674 100644 --- a/apps/sim/app/api/v1/admin/subscriptions/route.ts +++ b/apps/sim/app/api/v1/admin/subscriptions/route.ts @@ -16,13 +16,18 @@ import { db } from '@sim/db' import { subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq, type SQL } from 'drizzle-orm' +import { adminV1ListSubscriptionsContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' -import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses' +import { + adminValidationErrorResponse, + internalErrorResponse, + listResponse, +} from '@/app/api/v1/admin/responses' import { type AdminSubscription, createPaginationMeta, - parsePaginationParams, toAdminSubscription, } from '@/app/api/v1/admin/types' @@ -30,10 +35,17 @@ const logger = createLogger('AdminSubscriptionsAPI') export const GET = withRouteHandler( withAdminAuth(async (request) => { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) - const planFilter = url.searchParams.get('plan') - const statusFilter = url.searchParams.get('status') + const parsed = await parseRequest( + adminV1ListSubscriptionsContract, + request, + {}, + { + validationErrorResponse: adminValidationErrorResponse, + } + ) + if (!parsed.success) return parsed.response + + const { limit, offset, plan: planFilter, status: statusFilter } = parsed.data.query try { const conditions: SQL[] = [] diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index dc4e748b671..0a542820179 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -53,17 +53,16 @@ export const DEFAULT_LIMIT = 50 export const MAX_LIMIT = 250 export function parsePaginationParams(url: URL): PaginationParams { - const limitParam = url.searchParams.get('limit') - const offsetParam = url.searchParams.get('offset') - - let limit = limitParam ? Number.parseInt(limitParam, 10) : DEFAULT_LIMIT - let offset = offsetParam ? Number.parseInt(offsetParam, 10) : 0 - - if (Number.isNaN(limit) || limit < 1) limit = DEFAULT_LIMIT - if (limit > MAX_LIMIT) limit = MAX_LIMIT - if (Number.isNaN(offset) || offset < 0) offset = 0 + return { + limit: parsePaginationNumber(url.searchParams.get('limit'), DEFAULT_LIMIT, MAX_LIMIT), + offset: parsePaginationNumber(url.searchParams.get('offset'), 0), + } +} - return { limit, offset } +function parsePaginationNumber(value: string | null, fallback: number, max?: number): number { + const parsed = value ? Number.parseInt(value, 10) : fallback + if (!Number.isInteger(parsed) || parsed < 1) return fallback + return max === undefined ? parsed : Math.min(parsed, max) } export function createPaginationMeta(total: number, limit: number, offset: number): PaginationMeta { diff --git a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts index 98306f6d954..cfff9e8eb7b 100644 --- a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts @@ -23,11 +23,18 @@ import { member, organization, subscription, user, userStats } from '@sim/db/sch import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { eq, or } from 'drizzle-orm' +import { + adminV1GetUserBillingContract, + adminV1UpdateUserBillingContract, +} from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, + adminValidationErrorResponse, badRequestResponse, internalErrorResponse, notFoundResponse, @@ -45,8 +52,13 @@ interface RouteParams { } export const GET = withRouteHandler( - withAdminAuthParams(async (_, context) => { - const { id: userId } = await context.params + withAdminAuthParams(async (request, context) => { + const parsed = await parseRequest(adminV1GetUserBillingContract, request, context, { + validationErrorResponse: adminValidationErrorResponse, + }) + if (!parsed.success) return parsed.response + + const { id: userId } = parsed.data.params try { const [userData] = await db @@ -136,11 +148,22 @@ export const GET = withRouteHandler( export const PATCH = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: userId } = await context.params + const parsed = await parseRequest(adminV1UpdateUserBillingContract, request, context, { + validationErrorResponse: adminValidationErrorResponse, + invalidJsonResponse: adminInvalidJsonResponse, + }) + if (!parsed.success) return parsed.response + + const { id: userId } = parsed.data.params try { - const body = await request.json() - const reason = body.reason || 'Admin update (no reason provided)' + const { + currentUsageLimit, + billingBlocked, + currentPeriodCost, + reason: providedReason, + } = parsed.data.body + const reason = providedReason || 'Admin update (no reason provided)' const [userData] = await db .select({ id: user.id }) @@ -171,60 +194,50 @@ export const PATCH = withRouteHandler( const updated: string[] = [] const warnings: string[] = [] - if (body.currentUsageLimit !== undefined) { + if (currentUsageLimit !== undefined) { if (isOrgScopedMember && orgMembership) { warnings.push( 'User is on an org-scoped subscription. Individual limits are ignored in favor of organization limits.' ) } - if (body.currentUsageLimit === null) { + if (currentUsageLimit === null) { updateData.currentUsageLimit = null - } else if (typeof body.currentUsageLimit === 'number' && body.currentUsageLimit >= 0) { + } else { const currentCost = Number.parseFloat(existingStats?.currentPeriodCost || '0') - if (body.currentUsageLimit < currentCost) { + if (currentUsageLimit < currentCost) { warnings.push( - `New limit ($${body.currentUsageLimit.toFixed(2)}) is below current usage ($${currentCost.toFixed(2)}). User may be immediately blocked.` + `New limit ($${currentUsageLimit.toFixed(2)}) is below current usage ($${currentCost.toFixed(2)}). User may be immediately blocked.` ) } - updateData.currentUsageLimit = body.currentUsageLimit.toFixed(2) - } else { - return badRequestResponse('currentUsageLimit must be a non-negative number or null') + updateData.currentUsageLimit = currentUsageLimit.toFixed(2) } updateData.usageLimitUpdatedAt = new Date() updated.push('currentUsageLimit') } - if (body.billingBlocked !== undefined) { - if (typeof body.billingBlocked !== 'boolean') { - return badRequestResponse('billingBlocked must be a boolean') - } - - if (body.billingBlocked === false && existingStats?.billingBlocked === true) { + if (billingBlocked !== undefined) { + if (billingBlocked === false && existingStats?.billingBlocked === true) { warnings.push( 'Unblocking user. Ensure payment issues are resolved to prevent re-blocking on next invoice.' ) } - updateData.billingBlocked = body.billingBlocked + updateData.billingBlocked = billingBlocked // Clear the reason when unblocking - if (body.billingBlocked === false) { + if (billingBlocked === false) { updateData.billingBlockedReason = null } updated.push('billingBlocked') } - if (body.currentPeriodCost !== undefined) { - if (typeof body.currentPeriodCost !== 'number' || body.currentPeriodCost < 0) { - return badRequestResponse('currentPeriodCost must be a non-negative number') - } - + if (currentPeriodCost !== undefined) { const previousCost = existingStats?.currentPeriodCost || '0' warnings.push( - `Manually adjusting currentPeriodCost from $${previousCost} to $${body.currentPeriodCost.toFixed(2)}. This may affect billing accuracy.` + `Manually adjusting currentPeriodCost from $${previousCost} to $${currentPeriodCost.toFixed(2)}. This may affect billing accuracy.` ) - updateData.currentPeriodCost = body.currentPeriodCost.toFixed(2) + updateData.currentPeriodCost = currentPeriodCost.toFixed(2) updated.push('currentPeriodCost') } diff --git a/apps/sim/app/api/v1/admin/users/[id]/route.ts b/apps/sim/app/api/v1/admin/users/[id]/route.ts index 61a8ba6e641..8652e3b9e77 100644 --- a/apps/sim/app/api/v1/admin/users/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/users/[id]/route.ts @@ -10,9 +10,12 @@ import { db } from '@sim/db' import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' +import { adminV1GetUserContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminValidationErrorResponse, internalErrorResponse, notFoundResponse, singleResponse, @@ -27,7 +30,12 @@ interface RouteParams { export const GET = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: userId } = await context.params + const parsed = await parseRequest(adminV1GetUserContract, request, context, { + validationErrorResponse: adminValidationErrorResponse, + }) + if (!parsed.success) return parsed.response + + const { id: userId } = parsed.data.params try { const [userData] = await db.select().from(user).where(eq(user.id, userId)).limit(1) diff --git a/apps/sim/app/api/v1/admin/users/route.ts b/apps/sim/app/api/v1/admin/users/route.ts index 3413952adcf..4f493469cf4 100644 --- a/apps/sim/app/api/v1/admin/users/route.ts +++ b/apps/sim/app/api/v1/admin/users/route.ts @@ -14,22 +14,32 @@ import { db } from '@sim/db' import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count } from 'drizzle-orm' +import { adminV1ListUsersContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' -import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses' import { - type AdminUser, - createPaginationMeta, - parsePaginationParams, - toAdminUser, -} from '@/app/api/v1/admin/types' + adminValidationErrorResponse, + internalErrorResponse, + listResponse, +} from '@/app/api/v1/admin/responses' +import { type AdminUser, createPaginationMeta, toAdminUser } from '@/app/api/v1/admin/types' const logger = createLogger('AdminUsersAPI') export const GET = withRouteHandler( withAdminAuth(async (request) => { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) + const parsed = await parseRequest( + adminV1ListUsersContract, + request, + {}, + { + validationErrorResponse: adminValidationErrorResponse, + } + ) + if (!parsed.success) return parsed.response + + const { limit, offset } = parsed.data.query try { const [countResult, users] = await Promise.all([ diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts index 5b10a1c3817..97db08f686a 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { getActiveWorkflowRecord } from '@sim/workflow-authz' +import { adminV1DeployWorkflowContract, adminV1UndeployWorkflowContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' @@ -29,7 +31,10 @@ interface RouteParams { */ export const POST = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: workflowId } = await context.params + const parsed = await parseRequest(adminV1DeployWorkflowContract, request, context) + if (!parsed.success) return parsed.response + + const { id: workflowId } = parsed.data.params const requestId = generateRequestId() try { @@ -72,8 +77,11 @@ export const POST = withRouteHandler( ) export const DELETE = withRouteHandler( - withAdminAuthParams(async (_request, context) => { - const { id: workflowId } = await context.params + withAdminAuthParams(async (request, context) => { + const parsed = await parseRequest(adminV1UndeployWorkflowContract, request, context) + if (!parsed.success) return parsed.response + + const { id: workflowId } = parsed.data.params const requestId = generateRequestId() try { diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts index 1be906792ba..3117210244b 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts @@ -10,6 +10,8 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' +import { adminV1ExportWorkflowContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' @@ -32,7 +34,10 @@ interface RouteParams { export const GET = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: workflowId } = await context.params + const parsed = await parseRequest(adminV1ExportWorkflowContract, request, context) + if (!parsed.success) return parsed.response + + const { id: workflowId } = parsed.data.params try { const [workflowData] = await db diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/route.ts index 77e5e246da9..c31b1accb93 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/route.ts @@ -18,6 +18,8 @@ import { createLogger } from '@sim/logger' import { getActiveWorkflowRecord } from '@sim/workflow-authz' import { count, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { adminV1DeleteWorkflowContract, adminV1GetWorkflowContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performDeleteWorkflow } from '@/lib/workflows/orchestration' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' @@ -36,7 +38,10 @@ interface RouteParams { export const GET = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: workflowId } = await context.params + const parsed = await parseRequest(adminV1GetWorkflowContract, request, context) + if (!parsed.success) return parsed.response + + const { id: workflowId } = parsed.data.params try { const workflowData = await getActiveWorkflowRecord(workflowId) @@ -73,8 +78,11 @@ export const GET = withRouteHandler( ) export const DELETE = withRouteHandler( - withAdminAuthParams(async (_request, context) => { - const { id: workflowId } = await context.params + withAdminAuthParams(async (request, context) => { + const parsed = await parseRequest(adminV1DeleteWorkflowContract, request, context) + if (!parsed.success) return parsed.response + + const { id: workflowId } = parsed.data.params try { const workflowData = await getActiveWorkflowRecord(workflowId) diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts index 9e27097db99..d297b091a1d 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { getActiveWorkflowRecord } from '@sim/workflow-authz' +import { adminV1ActivateWorkflowVersionContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performActivateVersion } from '@/lib/workflows/orchestration' @@ -21,7 +23,10 @@ interface RouteParams { export const POST = withRouteHandler( withAdminAuthParams(async (request, context) => { const requestId = generateRequestId() - const { id: workflowId, versionId } = await context.params + const parsed = await parseRequest(adminV1ActivateWorkflowVersionContract, request, context) + if (!parsed.success) return parsed.response + + const { id: workflowId, versionId: versionNum } = parsed.data.params try { const workflowRecord = await getActiveWorkflowRecord(workflowId) @@ -30,11 +35,6 @@ export const POST = withRouteHandler( return notFoundResponse('Workflow') } - const versionNum = Number(versionId) - if (!Number.isFinite(versionNum) || versionNum < 1) { - return badRequestResponse('Invalid version number') - } - const result = await performActivateVersion({ workflowId, version: versionNum, diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts index 6fdfc3fc0c6..e9134451918 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { getActiveWorkflowRecord } from '@sim/workflow-authz' +import { adminV1ListWorkflowVersionsContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { listWorkflowVersions } from '@/lib/workflows/persistence/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' @@ -18,7 +20,10 @@ interface RouteParams { export const GET = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: workflowId } = await context.params + const parsed = await parseRequest(adminV1ListWorkflowVersionsContract, request, context) + if (!parsed.success) return parsed.response + + const { id: workflowId } = parsed.data.params try { const workflowRecord = await getActiveWorkflowRecord(workflowId) diff --git a/apps/sim/app/api/v1/admin/workflows/export/route.ts b/apps/sim/app/api/v1/admin/workflows/export/route.ts index 1c850571061..d5db3cf3f07 100644 --- a/apps/sim/app/api/v1/admin/workflows/export/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/export/route.ts @@ -20,6 +20,8 @@ import { createLogger } from '@sim/logger' import { inArray } from 'drizzle-orm' import JSZip from 'jszip' import { NextResponse } from 'next/server' +import { adminV1ExportWorkflowsContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sanitizePathSegment } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -37,25 +39,13 @@ import { const logger = createLogger('AdminWorkflowsExportAPI') -interface ExportRequest { - ids: string[] -} - export const POST = withRouteHandler( withAdminAuth(async (request) => { - const url = new URL(request.url) - const format = url.searchParams.get('format') || 'zip' + const parsed = await parseRequest(adminV1ExportWorkflowsContract, request, {}) + if (!parsed.success) return parsed.response - let body: ExportRequest - try { - body = await request.json() - } catch { - return badRequestResponse('Invalid JSON body') - } - - if (!body.ids || !Array.isArray(body.ids) || body.ids.length === 0) { - return badRequestResponse('ids must be a non-empty array of workflow IDs') - } + const { format } = parsed.data.query + const body = parsed.data.body try { const workflows = await db.select().from(workflow).where(inArray(workflow.id, body.ids)) diff --git a/apps/sim/app/api/v1/admin/workflows/import/route.ts b/apps/sim/app/api/v1/admin/workflows/import/route.ts index 6f13d1b5e8c..f8d2c3a2881 100644 --- a/apps/sim/app/api/v1/admin/workflows/import/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts @@ -20,6 +20,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { adminV1ImportWorkflowContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -47,16 +49,10 @@ interface ImportSuccessResponse { export const POST = withRouteHandler( withAdminAuth(async (request) => { try { - const body = (await request.json()) as WorkflowImportRequest - - if (!body.workspaceId) { - return badRequestResponse('workspaceId is required') - } - - if (!body.workflow) { - return badRequestResponse('workflow is required') - } + const parsed = await parseRequest(adminV1ImportWorkflowContract, request, {}) + if (!parsed.success) return parsed.response + const body = parsed.data.body as WorkflowImportRequest const { workspaceId, folderId, name: overrideName } = body const [workspaceData] = await db diff --git a/apps/sim/app/api/v1/admin/workflows/route.ts b/apps/sim/app/api/v1/admin/workflows/route.ts index 9a13531d1f2..fbfd589e3e8 100644 --- a/apps/sim/app/api/v1/admin/workflows/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/route.ts @@ -14,22 +14,21 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count } from 'drizzle-orm' +import { adminV1ListWorkflowsContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses' -import { - type AdminWorkflow, - createPaginationMeta, - parsePaginationParams, - toAdminWorkflow, -} from '@/app/api/v1/admin/types' +import { type AdminWorkflow, createPaginationMeta, toAdminWorkflow } from '@/app/api/v1/admin/types' const logger = createLogger('AdminWorkflowsAPI') export const GET = withRouteHandler( withAdminAuth(async (request) => { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) + const parsed = await parseRequest(adminV1ListWorkflowsContract, request, {}) + if (!parsed.success) return parsed.response + + const { limit, offset } = parsed.data.query try { const [countResult, workflows] = await Promise.all([ diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts index 3f99b48d716..f375d290c99 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts @@ -16,6 +16,8 @@ import { workflow, workflowFolder, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { adminV1ExportWorkspaceContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { exportWorkspaceToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -40,9 +42,11 @@ interface RouteParams { export const GET = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params - const url = new URL(request.url) - const format = url.searchParams.get('format') || 'zip' + const parsed = await parseRequest(adminV1ExportWorkspaceContract, request, context) + if (!parsed.success) return parsed.response + + const { id: workspaceId } = parsed.data.params + const { format } = parsed.data.query try { const [workspaceData] = await db diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts index 9b59e855611..bcebc5e8950 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts @@ -14,15 +14,12 @@ import { db } from '@sim/db' import { workflowFolder, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' +import { adminV1ListWorkspaceFoldersContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, listResponse, notFoundResponse } from '@/app/api/v1/admin/responses' -import { - type AdminFolder, - createPaginationMeta, - parsePaginationParams, - toAdminFolder, -} from '@/app/api/v1/admin/types' +import { type AdminFolder, createPaginationMeta, toAdminFolder } from '@/app/api/v1/admin/types' const logger = createLogger('AdminWorkspaceFoldersAPI') @@ -32,9 +29,11 @@ interface RouteParams { export const GET = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) + const parsed = await parseRequest(adminV1ListWorkspaceFoldersContract, request, context) + if (!parsed.success) return parsed.response + + const { id: workspaceId } = parsed.data.params + const { limit, offset } = parsed.data.query try { const [workspaceData] = await db diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts index 6a2bae5bf2f..1197ce6ab9c 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts @@ -29,6 +29,11 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { + adminV1ImportWorkspaceContract, + adminV1WorkspaceImportBodySchema, +} from '@/lib/api/contracts' +import { parseJsonBody, parseRequest, validateSchema } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { extractWorkflowName, @@ -66,10 +71,11 @@ interface ParsedWorkflow { export const POST = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params - const url = new URL(request.url) - const createFolders = url.searchParams.get('createFolders') !== 'false' - const rootFolderName = url.searchParams.get('rootFolderName') + const parsed = await parseRequest(adminV1ImportWorkspaceContract, request, context) + if (!parsed.success) return parsed.response + + const { id: workspaceId } = parsed.data.params + const { createFolders, rootFolderName } = parsed.data.query try { const workspaceData = await getWorkspaceWithOwner(workspaceId) @@ -82,12 +88,18 @@ export const POST = withRouteHandler( let workflowsToImport: ParsedWorkflow[] = [] if (contentType.includes('application/json')) { - const body = (await request.json()) as WorkspaceImportRequest + const rawBody = await parseJsonBody(request) + + if (!rawBody.success) { + return badRequestResponse('Invalid JSON body. Expected { workflows: [...] }') + } - if (!body.workflows || !Array.isArray(body.workflows)) { + const validation = validateSchema(adminV1WorkspaceImportBodySchema, rawBody.data) + if (!validation.success) { return badRequestResponse('Invalid JSON body. Expected { workflows: [...] }') } + const body = validation.data as WorkspaceImportRequest workflowsToImport = body.workflows.map((w) => ({ content: typeof w.content === 'string' ? w.content : JSON.stringify(w.content), name: w.name || 'Imported Workflow', diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts index dd8c005395b..74b7dbf740e 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts @@ -25,12 +25,17 @@ import { db } from '@sim/db' import { permissions, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' +import { + adminV1GetWorkspaceMemberContract, + adminV1RemoveWorkspaceMemberContract, + adminV1UpdateWorkspaceMemberContract, +} from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access' import { getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { - badRequestResponse, internalErrorResponse, notFoundResponse, singleResponse, @@ -45,8 +50,11 @@ interface RouteParams { } export const GET = withRouteHandler( - withAdminAuthParams(async (_, context) => { - const { id: workspaceId, memberId } = await context.params + withAdminAuthParams(async (request, context) => { + const parsed = await parseRequest(adminV1GetWorkspaceMemberContract, request, context) + if (!parsed.success) return parsed.response + + const { id: workspaceId, memberId } = parsed.data.params try { const workspaceData = await getWorkspaceById(workspaceId) @@ -105,15 +113,13 @@ export const GET = withRouteHandler( export const PATCH = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: workspaceId, memberId } = await context.params + const parsed = await parseRequest(adminV1UpdateWorkspaceMemberContract, request, context) + if (!parsed.success) return parsed.response - try { - const body = await request.json() - - if (!body.permissions || !['admin', 'write', 'read'].includes(body.permissions)) { - return badRequestResponse('permissions must be "admin", "write", or "read"') - } + const { id: workspaceId, memberId } = parsed.data.params + const { permissions: permissionLevel } = parsed.data.body + try { const workspaceData = await getWorkspaceById(workspaceId) if (!workspaceData) { @@ -145,7 +151,7 @@ export const PATCH = withRouteHandler( await db .update(permissions) - .set({ permissionType: body.permissions, updatedAt: now }) + .set({ permissionType: permissionLevel, updatedAt: now }) .where(eq(permissions.id, memberId)) const [userData] = await db @@ -158,7 +164,7 @@ export const PATCH = withRouteHandler( id: existingMember.id, workspaceId, userId: existingMember.userId, - permissions: body.permissions, + permissions: permissionLevel, createdAt: existingMember.createdAt.toISOString(), updatedAt: now.toISOString(), userName: userData?.name ?? '', @@ -166,7 +172,7 @@ export const PATCH = withRouteHandler( userImage: userData?.image ?? null, } - logger.info(`Admin API: Updated member ${memberId} permissions to ${body.permissions}`, { + logger.info(`Admin API: Updated member ${memberId} permissions to ${permissionLevel}`, { workspaceId, previousPermissions: existingMember.permissionType, }) @@ -180,8 +186,11 @@ export const PATCH = withRouteHandler( ) export const DELETE = withRouteHandler( - withAdminAuthParams(async (_, context) => { - const { id: workspaceId, memberId } = await context.params + withAdminAuthParams(async (request, context) => { + const parsed = await parseRequest(adminV1RemoveWorkspaceMemberContract, request, context) + if (!parsed.success) return parsed.response + + const { id: workspaceId, memberId } = parsed.data.params try { const workspaceData = await getWorkspaceById(workspaceId) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts index 8e03e3c491b..3e7082fefda 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts @@ -35,23 +35,24 @@ import { permissions, user, workspaceEnvironment } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, count, eq } from 'drizzle-orm' +import { + adminV1CreateWorkspaceMemberContract, + adminV1DeleteWorkspaceMemberContract, + adminV1ListWorkspaceMembersContract, +} from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' import { applyWorkspaceAutoAddGroup } from '@/lib/permission-groups/auto-add' import { getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { - badRequestResponse, internalErrorResponse, listResponse, notFoundResponse, singleResponse, } from '@/app/api/v1/admin/responses' -import { - type AdminWorkspaceMember, - createPaginationMeta, - parsePaginationParams, -} from '@/app/api/v1/admin/types' +import { type AdminWorkspaceMember, createPaginationMeta } from '@/app/api/v1/admin/types' const logger = createLogger('AdminWorkspaceMembersAPI') @@ -61,9 +62,11 @@ interface RouteParams { export const GET = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) + const parsed = await parseRequest(adminV1ListWorkspaceMembersContract, request, context) + if (!parsed.success) return parsed.response + + const { id: workspaceId } = parsed.data.params + const { limit, offset } = parsed.data.query try { const workspaceData = await getWorkspaceById(workspaceId) @@ -127,19 +130,13 @@ export const GET = withRouteHandler( export const POST = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params - - try { - const body = await request.json() - - if (!body.userId || typeof body.userId !== 'string') { - return badRequestResponse('userId is required') - } + const parsed = await parseRequest(adminV1CreateWorkspaceMemberContract, request, context) + if (!parsed.success) return parsed.response - if (!body.permissions || !['admin', 'write', 'read'].includes(body.permissions)) { - return badRequestResponse('permissions must be "admin", "write", or "read"') - } + const { id: workspaceId } = parsed.data.params + const { userId, permissions: permissionLevel } = parsed.data.body + try { const workspaceData = await getWorkspaceById(workspaceId) if (!workspaceData) { @@ -149,7 +146,7 @@ export const POST = withRouteHandler( const [userData] = await db .select({ id: user.id, name: user.name, email: user.email, image: user.image }) .from(user) - .where(eq(user.id, body.userId)) + .where(eq(user.id, userId)) .limit(1) if (!userData) { @@ -166,7 +163,7 @@ export const POST = withRouteHandler( .from(permissions) .where( and( - eq(permissions.userId, body.userId), + eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId) ) @@ -174,26 +171,23 @@ export const POST = withRouteHandler( .limit(1) if (existingPermission) { - if (existingPermission.permissionType !== body.permissions) { + if (existingPermission.permissionType !== permissionLevel) { const now = new Date() await db .update(permissions) - .set({ permissionType: body.permissions, updatedAt: now }) + .set({ permissionType: permissionLevel, updatedAt: now }) .where(eq(permissions.id, existingPermission.id)) - logger.info( - `Admin API: Updated user ${body.userId} permissions in workspace ${workspaceId}`, - { - previousPermissions: existingPermission.permissionType, - newPermissions: body.permissions, - } - ) + logger.info(`Admin API: Updated user ${userId} permissions in workspace ${workspaceId}`, { + previousPermissions: existingPermission.permissionType, + newPermissions: permissionLevel, + }) return singleResponse({ id: existingPermission.id, workspaceId, - userId: body.userId, - permissions: body.permissions as 'admin' | 'write' | 'read', + userId, + permissions: permissionLevel, createdAt: existingPermission.createdAt.toISOString(), updatedAt: now.toISOString(), userName: userData.name, @@ -206,7 +200,7 @@ export const POST = withRouteHandler( return singleResponse({ id: existingPermission.id, workspaceId, - userId: body.userId, + userId, permissions: existingPermission.permissionType, createdAt: existingPermission.createdAt.toISOString(), updatedAt: existingPermission.updatedAt.toISOString(), @@ -222,18 +216,18 @@ export const POST = withRouteHandler( await db.insert(permissions).values({ id: permissionId, - userId: body.userId, + userId, entityType: 'workspace', entityId: workspaceId, - permissionType: body.permissions, + permissionType: permissionLevel, createdAt: now, updatedAt: now, }) - await applyWorkspaceAutoAddGroup(db, workspaceId, body.userId) + await applyWorkspaceAutoAddGroup(db, workspaceId, userId) - logger.info(`Admin API: Added user ${body.userId} to workspace ${workspaceId}`, { - permissions: body.permissions, + logger.info(`Admin API: Added user ${userId} to workspace ${workspaceId}`, { + permissions: permissionLevel, permissionId, }) @@ -247,15 +241,15 @@ export const POST = withRouteHandler( await syncWorkspaceEnvCredentials({ workspaceId, envKeys: wsEnvKeys, - actingUserId: body.userId, + actingUserId: userId, }) } return singleResponse({ id: permissionId, workspaceId, - userId: body.userId, - permissions: body.permissions as 'admin' | 'write' | 'read', + userId, + permissions: permissionLevel, createdAt: now.toISOString(), updatedAt: now.toISOString(), userName: userData.name, @@ -272,14 +266,15 @@ export const POST = withRouteHandler( export const DELETE = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params - const url = new URL(request.url) - const userId = url.searchParams.get('userId') + const parsed = await parseRequest(adminV1DeleteWorkspaceMemberContract, request, context) + if (!parsed.success) return parsed.response + + const { id: workspaceId } = parsed.data.params + const { userId } = parsed.data.query + let targetUserId: string | undefined try { - if (!userId) { - return badRequestResponse('userId query parameter is required') - } + targetUserId = userId const workspaceData = await getWorkspaceById(workspaceId) @@ -309,7 +304,11 @@ export const DELETE = withRouteHandler( return singleResponse({ removed: true, userId, workspaceId }) } catch (error) { - logger.error('Admin API: Failed to remove workspace member', { error, workspaceId, userId }) + logger.error('Admin API: Failed to remove workspace member', { + error, + workspaceId, + userId: targetUserId, + }) return internalErrorResponse('Failed to remove workspace member') } }) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts index 3635d1c9b51..9d7600bfc26 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts @@ -10,6 +10,8 @@ import { db } from '@sim/db' import { workflow, workflowFolder, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' +import { adminV1GetWorkspaceContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { @@ -27,7 +29,10 @@ interface RouteParams { export const GET = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params + const parsed = await parseRequest(adminV1GetWorkspaceContract, request, context) + if (!parsed.success) return parsed.response + + const { id: workspaceId } = parsed.data.params try { const [workspaceData] = await db diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/workflows/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/workflows/route.ts index 89fba14b1dc..d1aa40e1f57 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/workflows/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/workflows/route.ts @@ -21,16 +21,16 @@ import { workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { + adminV1DeleteWorkspaceWorkflowsContract, + adminV1ListWorkspaceWorkflowsContract, +} from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { archiveWorkflowsForWorkspace } from '@/lib/workflows/lifecycle' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, listResponse, notFoundResponse } from '@/app/api/v1/admin/responses' -import { - type AdminWorkflow, - createPaginationMeta, - parsePaginationParams, - toAdminWorkflow, -} from '@/app/api/v1/admin/types' +import { type AdminWorkflow, createPaginationMeta, toAdminWorkflow } from '@/app/api/v1/admin/types' const logger = createLogger('AdminWorkspaceWorkflowsAPI') @@ -40,9 +40,11 @@ interface RouteParams { export const GET = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) + const parsed = await parseRequest(adminV1ListWorkspaceWorkflowsContract, request, context) + if (!parsed.success) return parsed.response + + const { id: workspaceId } = parsed.data.params + const { limit, offset } = parsed.data.query try { const [workspaceData] = await db @@ -87,7 +89,10 @@ export const GET = withRouteHandler( export const DELETE = withRouteHandler( withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params + const parsed = await parseRequest(adminV1DeleteWorkspaceWorkflowsContract, request, context) + if (!parsed.success) return parsed.response + + const { id: workspaceId } = parsed.data.params try { const [workspaceData] = await db diff --git a/apps/sim/app/api/v1/admin/workspaces/route.ts b/apps/sim/app/api/v1/admin/workspaces/route.ts index 7446fceba8b..0d512244123 100644 --- a/apps/sim/app/api/v1/admin/workspaces/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/route.ts @@ -14,13 +14,14 @@ import { db } from '@sim/db' import { workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count } from 'drizzle-orm' +import { adminV1ListWorkspacesContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses' import { type AdminWorkspace, createPaginationMeta, - parsePaginationParams, toAdminWorkspace, } from '@/app/api/v1/admin/types' @@ -28,8 +29,10 @@ const logger = createLogger('AdminWorkspacesAPI') export const GET = withRouteHandler( withAdminAuth(async (request) => { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) + const parsed = await parseRequest(adminV1ListWorkspacesContract, request, {}) + if (!parsed.success) return parsed.response + + const { limit, offset } = parsed.data.query try { const [countResult, workspaces] = await Promise.all([ 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 2f55c888124..e4b0e0d6b1b 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.ts @@ -16,6 +16,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, inArray, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { v1GetAuditLogContract } from '@/lib/api/contracts/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' @@ -27,7 +29,7 @@ const logger = createLogger('V1AuditLogDetailAPI') export const revalidate = 0 export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateId().slice(0, 8) try { @@ -37,7 +39,13 @@ export const GET = withRouteHandler( } const userId = rateLimit.userId! - const { id } = await params + const parsed = await parseRequest(v1GetAuditLogContract, request, context, { + validationErrorResponse: () => + NextResponse.json({ error: 'Invalid audit log ID' }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params const authResult = await validateEnterpriseAuditAccess(userId) if (!authResult.success) { diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts index 2c31a0c18bb..ba3a8d129d8 100644 --- a/apps/sim/app/api/v1/audit-logs/route.ts +++ b/apps/sim/app/api/v1/audit-logs/route.ts @@ -22,7 +22,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { v1ListAuditLogsContract } from '@/lib/api/contracts/audit-logs' +import { getValidationErrorMessage, 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' @@ -39,27 +40,6 @@ const logger = createLogger('V1AuditLogsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -const isoDateString = z.string().refine((val) => !Number.isNaN(Date.parse(val)), { - message: 'Invalid date format. Use ISO 8601.', -}) - -const QueryParamsSchema = z.object({ - action: z.string().optional(), - resourceType: z.string().optional(), - resourceId: z.string().optional(), - workspaceId: z.string().optional(), - actorId: z.string().optional(), - startDate: isoDateString.optional(), - endDate: isoDateString.optional(), - includeDeparted: z - .enum(['true', 'false']) - .transform((val) => val === 'true') - .optional() - .default('false'), - limit: z.coerce.number().min(1).max(100).optional().default(50), - cursor: z.string().optional(), -}) - export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -78,18 +58,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { orgMemberIds } = authResult.context - const { searchParams } = new URL(request.url) - const rawParams = Object.fromEntries(searchParams.entries()) - const validationResult = QueryParamsSchema.safeParse(rawParams) - - if (!validationResult.success) { - return NextResponse.json( - { error: 'Invalid parameters', details: validationResult.error.errors }, - { status: 400 } - ) - } + const parsed = await parseRequest( + v1ListAuditLogsContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + error: getValidationErrorMessage(error, 'Invalid parameters'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response - const params = validationResult.data + const params = parsed.data.query if (params.actorId && !orgMemberIds.includes(params.actorId)) { return NextResponse.json( diff --git a/apps/sim/app/api/v1/copilot/chat/route.ts b/apps/sim/app/api/v1/copilot/chat/route.ts index 0a5cf8adb7b..1bdddc13a43 100644 --- a/apps/sim/app/api/v1/copilot/chat/route.ts +++ b/apps/sim/app/api/v1/copilot/chat/route.ts @@ -2,8 +2,8 @@ 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 { z } from 'zod' -import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants' +import { v1CopilotChatContract } from '@/lib/api/contracts/copilot' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getWorkflowById, resolveWorkflowIdForUser } from '@/lib/workflows/utils' @@ -14,17 +14,6 @@ export const maxDuration = 3600 const logger = createLogger('CopilotHeadlessAPI') const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6' -const RequestSchema = z.object({ - message: z.string().min(1, 'message is required'), - workflowId: z.string().optional(), - workflowName: z.string().optional(), - chatId: z.string().optional(), - mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'), - model: z.string().optional(), - autoExecuteTools: z.boolean().optional().default(true), - timeout: z.number().optional().default(3_600_000), -}) - /** * POST /api/v1/copilot/chat * Headless copilot endpoint for server-side orchestration. @@ -45,8 +34,27 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } try { - const body = await req.json() - const parsed = RequestSchema.parse(body) + const parsedRequest = await parseRequest( + v1CopilotChatContract, + req, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request'), + details: error.issues, + }, + { status: 400 } + ), + invalidJsonResponse: () => + NextResponse.json({ success: false, error: 'Invalid request' }, { status: 400 }), + } + ) + if (!parsedRequest.success) return parsedRequest.response + + const parsed = parsedRequest.data.body const selectedModel = parsed.model || DEFAULT_COPILOT_MODEL // Resolve workflow ID @@ -126,13 +134,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { error: result.error, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { success: false, error: 'Invalid request', details: error.errors }, - { status: 400 } - ) - } - logger.error( messageId ? `Headless copilot request failed [messageId:${messageId}]` diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index ef909a7da15..b3ca6366577 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -1,7 +1,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { v1DeleteFileContract, v1DownloadFileContract } from '@/lib/api/contracts/workspace-files' +import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -9,11 +10,10 @@ import { downloadWorkspaceFile, getWorkspaceFile, } from '@/lib/uploads/contexts/workspace' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { checkRateLimit, - checkWorkspaceScope, createRateLimitResponse, + validateWorkspaceAccess, } from '@/app/api/v1/middleware' const logger = createLogger('V1FileDetailAPI') @@ -21,16 +21,12 @@ const logger = createLogger('V1FileDetailAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -const WorkspaceIdSchema = z.object({ - workspaceId: z.string().min(1, 'workspaceId query parameter is required'), -}) - interface FileRouteParams { params: Promise<{ fileId: string }> } /** GET /api/v1/files/[fileId] — Download file content. */ -export const GET = withRouteHandler(async (request: NextRequest, { params }: FileRouteParams) => { +export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { const requestId = generateRequestId() try { @@ -40,28 +36,14 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Fil } const userId = rateLimit.userId! - const { fileId } = await params - const { searchParams } = new URL(request.url) - - const validation = WorkspaceIdSchema.safeParse({ - workspaceId: searchParams.get('workspaceId'), - }) - if (!validation.success) { - return NextResponse.json( - { error: 'Validation error', details: validation.error.errors }, - { status: 400 } - ) - } - - const { workspaceId } = validation.data + const parsed = await parseRequest(v1DownloadFileContract, request, context) + if (!parsed.success) return parsed.response - const scopeError = checkWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return scopeError + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission === null) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } + const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) + if (accessError) return accessError const fileRecord = await getWorkspaceFile(workspaceId, fileId) if (!fileRecord) { @@ -91,72 +73,56 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Fil }) /** DELETE /api/v1/files/[fileId] — Archive a file. */ -export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: FileRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'file-detail') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } - - const userId = rateLimit.userId! - const { fileId } = await params - const { searchParams } = new URL(request.url) - - const validation = WorkspaceIdSchema.safeParse({ - workspaceId: searchParams.get('workspaceId'), - }) - if (!validation.success) { - return NextResponse.json( - { error: 'Validation error', details: validation.error.errors }, - { status: 400 } - ) - } - - const { workspaceId } = validation.data - - const scopeError = checkWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return scopeError - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission === null || permission === 'read') { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - - const fileRecord = await getWorkspaceFile(workspaceId, fileId) - if (!fileRecord) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } - - await deleteWorkspaceFile(workspaceId, fileId) - - logger.info( - `[${requestId}] Archived file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}` - ) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.FILE_DELETED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: fileRecord.name, - description: `Archived file "${fileRecord.name}" via API`, - metadata: { fileSize: fileRecord.size, fileType: fileRecord.type }, - request, - }) - - return NextResponse.json({ - success: true, - data: { - message: 'File archived successfully', - }, - }) - } catch (error) { - logger.error(`[${requestId}] Error deleting file:`, error) - return NextResponse.json({ error: 'Failed to delete file' }, { status: 500 }) +export const DELETE = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) } + + const userId = rateLimit.userId! + const parsed = await parseRequest(v1DeleteFileContract, request, context) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (accessError) return accessError + + const fileRecord = await getWorkspaceFile(workspaceId, fileId) + if (!fileRecord) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + + await deleteWorkspaceFile(workspaceId, fileId) + + logger.info( + `[${requestId}] Archived file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}` + ) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FILE_DELETED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: fileRecord.name, + description: `Archived file "${fileRecord.name}" via API`, + metadata: { fileSize: fileRecord.size, fileType: fileRecord.type }, + request, + }) + + return NextResponse.json({ + success: true, + data: { + message: 'File archived successfully', + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error deleting file:`, error) + return NextResponse.json({ error: 'Failed to delete file' }, { status: 500 }) } -) +}) diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts index e16f8b6c885..321dbd73040 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -1,7 +1,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + v1ListFilesContract, + v1UploadFileFormFieldsSchema, +} from '@/lib/api/contracts/workspace-files' +import { getValidationErrorMessage, parseRequest, validateSchema } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -10,11 +14,11 @@ import { listWorkspaceFiles, uploadWorkspaceFile, } from '@/lib/uploads/contexts/workspace' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, + validateWorkspaceAccess, } from '@/app/api/v1/middleware' const logger = createLogger('V1FilesAPI') @@ -24,10 +28,6 @@ export const revalidate = 0 const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB -const ListFilesSchema = z.object({ - workspaceId: z.string().min(1, 'workspaceId query parameter is required'), -}) - /** GET /api/v1/files — List all files in a workspace. */ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -39,27 +39,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const userId = rateLimit.userId! - const { searchParams } = new URL(request.url) - - const validation = ListFilesSchema.safeParse({ - workspaceId: searchParams.get('workspaceId'), - }) - if (!validation.success) { - return NextResponse.json( - { error: 'Validation error', details: validation.error.errors }, - { status: 400 } - ) - } + const parsed = await parseRequest(v1ListFilesContract, request, {}) + if (!parsed.success) return parsed.response - const { workspaceId } = validation.data + const { workspaceId } = parsed.data.query - const scopeError = checkWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return scopeError - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission === null) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } + const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) + if (accessError) return accessError const files = await listWorkspaceFiles(workspaceId) @@ -109,11 +95,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const rawFile = formData.get('file') const file = rawFile instanceof File ? rawFile : null const rawWorkspaceId = formData.get('workspaceId') - const workspaceId = typeof rawWorkspaceId === 'string' ? rawWorkspaceId : null + const formFieldsResult = validateSchema(v1UploadFileFormFieldsSchema, { + workspaceId: typeof rawWorkspaceId === 'string' ? rawWorkspaceId : '', + }) - if (!workspaceId) { - return NextResponse.json({ error: 'workspaceId form field is required' }, { status: 400 }) + if (!formFieldsResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(formFieldsResult.error, 'Invalid form data') }, + { status: 400 } + ) } + const { workspaceId } = formFieldsResult.data const scopeError = checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError @@ -131,10 +123,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission === null || permission === 'read') { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } + const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (accessError) return accessError const buffer = Buffer.from(await file.arrayBuffer()) 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 21c6baf4e21..d8a9e2104f2 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 @@ -3,16 +3,15 @@ import { db } from '@sim/db' import { document, knowledgeConnector } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + v1DeleteKnowledgeDocumentContract, + v1GetKnowledgeDocumentContract, +} from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteDocument } from '@/lib/knowledge/documents/service' -import { - authenticateRequest, - handleError, - resolveKnowledgeBase, - serializeDate, - validateSchema, -} from '@/app/api/v1/knowledge/utils' +import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' +import { authenticateRequest } from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -21,29 +20,21 @@ interface DocumentDetailRouteParams { params: Promise<{ id: string; documentId: string }> } -const WorkspaceIdSchema = z.object({ - workspaceId: z.string().min(1, 'workspaceId query parameter is required'), -}) - /** GET /api/v1/knowledge/[id]/documents/[documentId] — Get document details. */ export const GET = withRouteHandler( - async (request: NextRequest, { params }: DocumentDetailRouteParams) => { + async (request: NextRequest, context: DocumentDetailRouteParams) => { const auth = await authenticateRequest(request, 'knowledge-detail') if (auth instanceof NextResponse) return auth const { requestId, userId, rateLimit } = auth try { - const { id: knowledgeBaseId, documentId } = await params - const { searchParams } = new URL(request.url) - - const validation = validateSchema(WorkspaceIdSchema, { - workspaceId: searchParams.get('workspaceId'), - }) - if (!validation.success) return validation.response + const parsed = await parseRequest(v1GetKnowledgeDocumentContract, request, context) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId, documentId } = parsed.data.params const result = await resolveKnowledgeBase( knowledgeBaseId, - validation.data.workspaceId, + parsed.data.query.workspaceId, userId, rateLimit ) @@ -120,23 +111,19 @@ export const GET = withRouteHandler( /** DELETE /api/v1/knowledge/[id]/documents/[documentId] — Delete a document. */ export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: DocumentDetailRouteParams) => { + async (request: NextRequest, context: DocumentDetailRouteParams) => { const auth = await authenticateRequest(request, 'knowledge-detail') if (auth instanceof NextResponse) return auth const { requestId, userId, rateLimit } = auth try { - const { id: knowledgeBaseId, documentId } = await params - const { searchParams } = new URL(request.url) - - const validation = validateSchema(WorkspaceIdSchema, { - workspaceId: searchParams.get('workspaceId'), - }) - if (!validation.success) return validation.response + const parsed = await parseRequest(v1DeleteKnowledgeDocumentContract, request, context) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId, documentId } = parsed.data.params const result = await resolveKnowledgeBase( knowledgeBaseId, - validation.data.workspaceId, + parsed.data.query.workspaceId, userId, rateLimit, 'write' @@ -164,7 +151,7 @@ export const DELETE = withRouteHandler( await deleteDocument(documentId, requestId) recordAudit({ - workspaceId: validation.data.workspaceId, + workspaceId: parsed.data.query.workspaceId, actorId: userId, action: AuditAction.DOCUMENT_DELETED, resourceType: AuditResourceType.DOCUMENT, 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 32107b050c2..7acbcc677f8 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts @@ -1,6 +1,10 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + v1ListKnowledgeDocumentsContract, + v1UploadKnowledgeDocumentContract, +} from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSingleDocument, @@ -11,13 +15,8 @@ import { import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { validateFileType } from '@/lib/uploads/utils/validation' -import { - authenticateRequest, - handleError, - resolveKnowledgeBase, - serializeDate, - validateSchema, -} from '@/app/api/v1/knowledge/utils' +import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' +import { authenticateRequest } from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -28,101 +27,71 @@ interface DocumentsRouteParams { params: Promise<{ id: string }> } -const ListDocumentsSchema = z.object({ - workspaceId: z.string().min(1, 'workspaceId query parameter is required'), - limit: z.coerce.number().int().min(1).max(100).default(50), - offset: z.coerce.number().int().min(0).default(0), - search: z.string().optional(), - enabledFilter: z.enum(['all', 'enabled', 'disabled']).default('all'), - sortBy: z - .enum([ - 'filename', - 'fileSize', - 'tokenCount', - 'chunkCount', - 'uploadedAt', - 'processingStatus', - 'enabled', - ]) - .default('uploadedAt'), - sortOrder: z.enum(['asc', 'desc']).default('desc'), -}) - /** GET /api/v1/knowledge/[id]/documents — List documents in a knowledge base. */ -export const GET = withRouteHandler( - async (request: NextRequest, { params }: DocumentsRouteParams) => { - const auth = await authenticateRequest(request, 'knowledge-detail') - if (auth instanceof NextResponse) return auth - const { requestId, userId, rateLimit } = auth - - try { - const { id: knowledgeBaseId } = await params - const { searchParams } = new URL(request.url) - - const validation = validateSchema(ListDocumentsSchema, { - workspaceId: searchParams.get('workspaceId'), - limit: searchParams.get('limit') ?? undefined, - offset: searchParams.get('offset') ?? undefined, - search: searchParams.get('search') ?? undefined, - enabledFilter: searchParams.get('enabledFilter') ?? undefined, - sortBy: searchParams.get('sortBy') ?? undefined, - sortOrder: searchParams.get('sortOrder') ?? undefined, - }) - if (!validation.success) return validation.response - - const { workspaceId, limit, offset, search, enabledFilter, sortBy, sortOrder } = - validation.data - - const result = await resolveKnowledgeBase(knowledgeBaseId, workspaceId, userId, rateLimit) - if (result instanceof NextResponse) return result - - const documentsResult = await getDocuments( - knowledgeBaseId, - { - enabledFilter: enabledFilter === 'all' ? undefined : enabledFilter, - search, - limit, - offset, - sortBy: sortBy as DocumentSortField, - sortOrder: sortOrder as SortOrder, - }, - requestId - ) - - return NextResponse.json({ - success: true, - data: { - documents: 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), - })), - pagination: documentsResult.pagination, - }, - }) - } catch (error) { - return handleError(requestId, error, 'Failed to list documents') - } +export const GET = withRouteHandler(async (request: NextRequest, context: DocumentsRouteParams) => { + const auth = await authenticateRequest(request, 'knowledge-detail') + if (auth instanceof NextResponse) return auth + const { requestId, userId, rateLimit } = auth + + try { + const parsed = await parseRequest(v1ListKnowledgeDocumentsContract, request, context) + if (!parsed.success) return parsed.response + + const { workspaceId, limit, offset, search, enabledFilter, sortBy, sortOrder } = + parsed.data.query + const { id: knowledgeBaseId } = parsed.data.params + + const result = await resolveKnowledgeBase(knowledgeBaseId, workspaceId, userId, rateLimit) + if (result instanceof NextResponse) return result + + const documentsResult = await getDocuments( + knowledgeBaseId, + { + enabledFilter: enabledFilter === 'all' ? undefined : enabledFilter, + search, + limit, + offset, + sortBy: sortBy as DocumentSortField, + sortOrder: sortOrder as SortOrder, + }, + requestId + ) + + return NextResponse.json({ + success: true, + data: { + documents: 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), + })), + pagination: documentsResult.pagination, + }, + }) + } catch (error) { + return handleError(requestId, error, 'Failed to list documents') } -) +}) /** POST /api/v1/knowledge/[id]/documents — Upload a document to a knowledge base. */ export const POST = withRouteHandler( - async (request: NextRequest, { params }: DocumentsRouteParams) => { + async (request: NextRequest, context: DocumentsRouteParams) => { const auth = await authenticateRequest(request, 'knowledge-detail') if (auth instanceof NextResponse) return auth const { requestId, userId, rateLimit } = auth try { - const { id: knowledgeBaseId } = await params + const parsed = await parseRequest(v1UploadKnowledgeDocumentContract, request, context) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId } = parsed.data.params let formData: FormData try { diff --git a/apps/sim/app/api/v1/knowledge/[id]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/route.ts index e0fe7d7c13f..a4012509d2e 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/route.ts @@ -1,16 +1,19 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + v1DeleteKnowledgeBaseContract, + v1GetKnowledgeBaseContract, + v1UpdateKnowledgeBaseContract, +} from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service' import { - authenticateRequest, formatKnowledgeBase, handleError, - parseJsonBody, resolveKnowledgeBase, - validateSchema, } from '@/app/api/v1/knowledge/utils' +import { authenticateRequest } from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -19,138 +22,97 @@ interface KnowledgeRouteParams { params: Promise<{ id: string }> } -const WorkspaceIdSchema = z.object({ - workspaceId: z.string().min(1, 'workspaceId query parameter is required'), -}) - -const UpdateKBSchema = z - .object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - name: z.string().min(1).max(255, 'Name must be 255 characters or less').optional(), - description: z.string().max(1000, 'Description must be 1000 characters or less').optional(), - chunkingConfig: z - .object({ - maxSize: z.number().min(100).max(4000), - minSize: z.number().min(1).max(2000), - overlap: z.number().min(0).max(500), - }) - .optional(), - }) - .refine( - (data) => - data.name !== undefined || - data.description !== undefined || - data.chunkingConfig !== undefined, - { message: 'At least one of name, description, or chunkingConfig must be provided' } - ) - /** GET /api/v1/knowledge/[id] — Get knowledge base details. */ -export const GET = withRouteHandler( - async (request: NextRequest, { params }: KnowledgeRouteParams) => { - const auth = await authenticateRequest(request, 'knowledge-detail') - if (auth instanceof NextResponse) return auth - const { requestId, userId, rateLimit } = auth - - try { - const { id } = await params - const { searchParams } = new URL(request.url) - - const validation = validateSchema(WorkspaceIdSchema, { - workspaceId: searchParams.get('workspaceId'), - }) - if (!validation.success) return validation.response - - const result = await resolveKnowledgeBase(id, validation.data.workspaceId, userId, rateLimit) - if (result instanceof NextResponse) return result - - return NextResponse.json({ - success: true, - data: { - knowledgeBase: formatKnowledgeBase(result.kb), - }, - }) - } catch (error) { - return handleError(requestId, error, 'Failed to get knowledge base') - } +export const GET = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => { + const auth = await authenticateRequest(request, 'knowledge-detail') + if (auth instanceof NextResponse) return auth + const { requestId, userId, rateLimit } = auth + + try { + const parsed = await parseRequest(v1GetKnowledgeBaseContract, request, context) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const result = await resolveKnowledgeBase(id, parsed.data.query.workspaceId, userId, rateLimit) + if (result instanceof NextResponse) return result + + return NextResponse.json({ + success: true, + data: { + knowledgeBase: formatKnowledgeBase(result.kb), + }, + }) + } catch (error) { + return handleError(requestId, error, 'Failed to get knowledge base') } -) +}) /** PUT /api/v1/knowledge/[id] — Update a knowledge base. */ -export const PUT = withRouteHandler( - async (request: NextRequest, { params }: KnowledgeRouteParams) => { - const auth = await authenticateRequest(request, 'knowledge-detail') - if (auth instanceof NextResponse) return auth - const { requestId, userId, rateLimit } = auth - - try { - const { id } = await params - - const body = await parseJsonBody(request) - if (!body.success) return body.response - - const validation = validateSchema(UpdateKBSchema, body.data) - if (!validation.success) return validation.response - - const { workspaceId, name, description, chunkingConfig } = validation.data - - 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({ - 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 NextResponse.json({ - success: true, - data: { - knowledgeBase: formatKnowledgeBase(updatedKb), - message: 'Knowledge base updated successfully', - }, - }) - } catch (error) { - return handleError(requestId, error, 'Failed to update knowledge base') - } +export const PUT = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => { + const auth = await authenticateRequest(request, 'knowledge-detail') + if (auth instanceof NextResponse) return auth + const { requestId, userId, rateLimit } = auth + + try { + const parsed = await parseRequest(v1UpdateKnowledgeBaseContract, request, context) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, name, description, chunkingConfig } = parsed.data.body + + 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({ + 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 NextResponse.json({ + success: true, + data: { + knowledgeBase: formatKnowledgeBase(updatedKb), + message: 'Knowledge base updated successfully', + }, + }) + } catch (error) { + return handleError(requestId, error, 'Failed to update knowledge base') } -) +}) /** DELETE /api/v1/knowledge/[id] — Delete a knowledge base. */ export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: KnowledgeRouteParams) => { + async (request: NextRequest, context: KnowledgeRouteParams) => { const auth = await authenticateRequest(request, 'knowledge-detail') if (auth instanceof NextResponse) return auth const { requestId, userId, rateLimit } = auth try { - const { id } = await params - const { searchParams } = new URL(request.url) - - const validation = validateSchema(WorkspaceIdSchema, { - workspaceId: searchParams.get('workspaceId'), - }) - if (!validation.success) return validation.response + const parsed = await parseRequest(v1DeleteKnowledgeBaseContract, request, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params const result = await resolveKnowledgeBase( id, - validation.data.workspaceId, + parsed.data.query.workspaceId, userId, rateLimit, 'write' @@ -160,7 +122,7 @@ export const DELETE = withRouteHandler( await deleteKnowledgeBase(id, requestId) recordAudit({ - workspaceId: validation.data.workspaceId, + workspaceId: parsed.data.query.workspaceId, actorId: userId, action: AuditAction.KNOWLEDGE_BASE_DELETED, resourceType: AuditResourceType.KNOWLEDGE_BASE, diff --git a/apps/sim/app/api/v1/knowledge/route.ts b/apps/sim/app/api/v1/knowledge/route.ts index a24ce394966..be4093ceb6b 100644 --- a/apps/sim/app/api/v1/knowledge/route.ts +++ b/apps/sim/app/api/v1/knowledge/route.ts @@ -1,41 +1,18 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + v1CreateKnowledgeBaseContract, + v1ListKnowledgeBasesContract, +} from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' -import { - authenticateRequest, - formatKnowledgeBase, - handleError, - parseJsonBody, - validateSchema, - validateWorkspaceAccess, -} from '@/app/api/v1/knowledge/utils' +import { formatKnowledgeBase, handleError } from '@/app/api/v1/knowledge/utils' +import { authenticateRequest, validateWorkspaceAccess } from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 -const ListKBSchema = z.object({ - workspaceId: z.string().min(1, 'workspaceId query parameter is required'), -}) - -const ChunkingConfigSchema = 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), -}) - -const CreateKBSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - name: z.string().min(1, 'Name is required').max(255, 'Name must be 255 characters or less'), - description: z.string().max(1000, 'Description must be 1000 characters or less').optional(), - chunkingConfig: ChunkingConfigSchema.optional().default({ - maxSize: 1024, - minSize: 100, - overlap: 200, - }), -}) - /** GET /api/v1/knowledge — List knowledge bases in a workspace. */ export const GET = withRouteHandler(async (request: NextRequest) => { const auth = await authenticateRequest(request, 'knowledge') @@ -43,13 +20,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { requestId, userId, rateLimit } = auth try { - const { searchParams } = new URL(request.url) - const validation = validateSchema(ListKBSchema, { - workspaceId: searchParams.get('workspaceId'), - }) - if (!validation.success) return validation.response + const parsed = await parseRequest(v1ListKnowledgeBasesContract, request, {}) + if (!parsed.success) return parsed.response - const { workspaceId } = validation.data + const { workspaceId } = parsed.data.query const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) if (accessError) return accessError @@ -75,13 +49,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { requestId, userId, rateLimit } = auth try { - const body = await parseJsonBody(request) - if (!body.success) return body.response - - const validation = validateSchema(CreateKBSchema, body.data) - if (!validation.success) return validation.response + const parsed = await parseRequest(v1CreateKnowledgeBaseContract, request, {}) + if (!parsed.success) return parsed.response - const { workspaceId, name, description, chunkingConfig } = validation.data + const { workspaceId, name, description, chunkingConfig } = parsed.data.body const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (accessError) return accessError diff --git a/apps/sim/app/api/v1/knowledge/search/route.ts b/apps/sim/app/api/v1/knowledge/search/route.ts index 4a622ff0bcf..f11e7cb8a80 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { v1KnowledgeSearchContract } from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' @@ -15,50 +16,12 @@ import { type SearchResult, } from '@/app/api/knowledge/search/utils' import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' -import { - authenticateRequest, - handleError, - parseJsonBody, - validateSchema, - validateWorkspaceAccess, -} from '@/app/api/v1/knowledge/utils' +import { handleError } from '@/app/api/v1/knowledge/utils' +import { authenticateRequest, validateWorkspaceAccess } from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 -const StructuredTagFilterSchema = z.object({ - tagName: z.string(), - fieldType: z.enum(['text', 'number', 'date', 'boolean']).optional(), - operator: z.string().default('eq'), - value: z.union([z.string(), z.number(), z.boolean()]), - valueTo: z.union([z.string(), z.number()]).optional(), -}) - -const SearchSchema = z - .object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - knowledgeBaseIds: z.union([ - z.string().min(1, 'Knowledge base ID is required'), - z - .array(z.string().min(1)) - .min(1, 'At least one knowledge base ID is required') - .max(20, 'Maximum 20 knowledge base IDs allowed'), - ]), - query: z.string().optional(), - topK: z.number().min(1).max(100).default(10), - tagFilters: z.array(StructuredTagFilterSchema).optional(), - }) - .refine( - (data) => { - const hasQuery = data.query && data.query.trim().length > 0 - const hasTagFilters = data.tagFilters && data.tagFilters.length > 0 - return hasQuery || hasTagFilters - }, - { - message: 'Either query or tagFilters must be provided', - } - ) - /** POST /api/v1/knowledge/search — Vector search across knowledge bases. */ export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await authenticateRequest(request, 'knowledge-search') @@ -66,20 +29,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { requestId, userId, rateLimit } = auth try { - const body = await parseJsonBody(request) - if (!body.success) return body.response - - const validation = validateSchema(SearchSchema, body.data) - if (!validation.success) return validation.response + const parsed = await parseRequest(v1KnowledgeSearchContract, request, {}) + if (!parsed.success) return parsed.response - const { workspaceId, topK, query, tagFilters } = validation.data + const { workspaceId, topK, query, tagFilters } = parsed.data.body const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) if (accessError) return accessError - const knowledgeBaseIds = Array.isArray(validation.data.knowledgeBaseIds) - ? validation.data.knowledgeBaseIds - : [validation.data.knowledgeBaseIds] + 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)) diff --git a/apps/sim/app/api/v1/knowledge/utils.ts b/apps/sim/app/api/v1/knowledge/utils.ts index 9908457054d..d87524610cd 100644 --- a/apps/sim/app/api/v1/knowledge/utils.ts +++ b/apps/sim/app/api/v1/knowledge/utils.ts @@ -1,69 +1,12 @@ import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { generateRequestId } from '@/lib/core/utils/request' +import { NextResponse } from 'next/server' +import { validationErrorResponseFromError } from '@/lib/api/server' import { getKnowledgeBaseById } from '@/lib/knowledge/service' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { - checkRateLimit, - checkWorkspaceScope, - createRateLimitResponse, - type RateLimitResult, -} from '@/app/api/v1/middleware' +import { type RateLimitResult, validateWorkspaceAccess } from '@/app/api/v1/middleware' const logger = createLogger('V1KnowledgeAPI') -type EndpointKey = 'knowledge' | 'knowledge-detail' | 'knowledge-search' - -/** - * Successful authentication result with request context - */ -export interface AuthorizedRequest { - requestId: string - userId: string - rateLimit: RateLimitResult -} - -/** - * Authenticates and rate-limits a v1 knowledge API request. - * Returns NextResponse on failure, AuthorizedRequest on success. - */ -export async function authenticateRequest( - request: NextRequest, - endpoint: EndpointKey -): Promise { - const requestId = generateRequestId() - const rateLimit = await checkRateLimit(request, endpoint) - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } - return { requestId, userId: rateLimit.userId!, rateLimit } -} - -/** - * Validates workspace scope and user permission level. - * Returns null on success, NextResponse on failure. - */ -export async function validateWorkspaceAccess( - rateLimit: RateLimitResult, - userId: string, - workspaceId: string, - level: 'read' | 'write' = 'read' -): Promise { - const scopeError = checkWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return scopeError - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission === null) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - if (level === 'write' && permission === 'read') { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - return null -} - /** * Fetches a KB by ID, validates it exists, belongs to the workspace, * and the user has permission. Returns the KB or a NextResponse error. @@ -88,43 +31,6 @@ export async function resolveKnowledgeBase( return { kb } } -/** - * Validates data against a Zod schema with consistent error response. - */ -export function validateSchema( - schema: S, - data: unknown -): { success: true; data: z.output } | { success: false; response: NextResponse } { - const result = schema.safeParse(data) - if (!result.success) { - return { - success: false, - response: NextResponse.json( - { error: 'Validation error', details: result.error.errors }, - { status: 400 } - ), - } - } - return { success: true, data: result.data } -} - -/** - * Safely parses a JSON request body with consistent error response. - */ -export async function parseJsonBody( - request: NextRequest -): Promise<{ success: true; data: unknown } | { success: false; response: NextResponse }> { - try { - const data = await request.json() - return { success: true, data } - } catch { - return { - success: false, - response: NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }), - } - } -} - /** * Serializes a date value for JSON responses. */ @@ -161,9 +67,8 @@ export function handleError( error: unknown, defaultMessage: string ): NextResponse { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: 'Validation error', details: error.errors }, { status: 400 }) - } + const validationResponse = validationErrorResponseFromError(error) + if (validationResponse) return validationResponse if (error instanceof Error) { if (error.message.includes('does not have permission')) { diff --git a/apps/sim/app/api/v1/logs/[id]/route.ts b/apps/sim/app/api/v1/logs/[id]/route.ts index 85f1b28388d..ecd63acfade 100644 --- a/apps/sim/app/api/v1/logs/[id]/route.ts +++ b/apps/sim/app/api/v1/logs/[id]/route.ts @@ -4,6 +4,8 @@ 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 { v1GetLogContract } from '@/lib/api/contracts/logs' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' @@ -13,7 +15,7 @@ const logger = createLogger('V1LogDetailsAPI') export const revalidate = 0 export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateId().slice(0, 8) try { @@ -23,7 +25,13 @@ export const GET = withRouteHandler( } const userId = rateLimit.userId! - const { id } = await params + const parsed = await parseRequest(v1GetLogContract, request, context, { + validationErrorResponse: () => + NextResponse.json({ error: 'Invalid log ID' }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params const rows = await db .select({ 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 54af2f3ea83..a50e0dd9c1c 100644 --- a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts +++ b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts @@ -3,6 +3,8 @@ import { permissions, workflowExecutionLogs, workflowExecutionSnapshots } from ' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { v1GetExecutionContract } from '@/lib/api/contracts/logs' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' @@ -10,7 +12,7 @@ import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware const logger = createLogger('V1ExecutionAPI') export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ executionId: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => { try { const rateLimit = await checkRateLimit(request, 'logs-detail') if (!rateLimit.allowed) { @@ -18,7 +20,13 @@ export const GET = withRouteHandler( } const userId = rateLimit.userId! - const { executionId } = await params + const parsed = await parseRequest(v1GetExecutionContract, request, context, { + validationErrorResponse: () => + NextResponse.json({ error: 'Invalid execution ID' }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + + const { executionId } = parsed.data.params logger.debug(`Fetching execution data for: ${executionId}`) diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index d51448826de..47c4e8ecf19 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -4,7 +4,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { v1ListLogsContract } from '@/lib/api/contracts/logs' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' @@ -15,28 +16,6 @@ const logger = createLogger('V1LogsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -const QueryParamsSchema = z.object({ - workspaceId: z.string(), - workflowIds: z.string().optional(), - folderIds: z.string().optional(), - triggers: z.string().optional(), - level: z.enum(['info', 'error']).optional(), - startDate: z.string().optional(), - endDate: z.string().optional(), - executionId: z.string().optional(), - minDurationMs: z.coerce.number().optional(), - maxDurationMs: z.coerce.number().optional(), - minCost: z.coerce.number().optional(), - maxCost: z.coerce.number().optional(), - model: z.string().optional(), - details: z.enum(['basic', 'full']).optional().default('basic'), - includeTraceSpans: z.coerce.boolean().optional().default(false), - includeFinalOutput: z.coerce.boolean().optional().default(false), - limit: z.coerce.number().optional().default(100), - cursor: z.string().optional(), - order: z.enum(['desc', 'asc']).optional().default('desc'), -}) - interface CursorData { startedAt: string id: string @@ -64,18 +43,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const userId = rateLimit.userId! - const { searchParams } = new URL(request.url) - const rawParams = Object.fromEntries(searchParams.entries()) - - const validationResult = QueryParamsSchema.safeParse(rawParams) - if (!validationResult.success) { - return NextResponse.json( - { error: 'Invalid parameters', details: validationResult.error.errors }, - { status: 400 } - ) - } + const parsed = await parseRequest( + v1ListLogsContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + error: getValidationErrorMessage(error, 'Invalid parameters'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response - const params = validationResult.data + const params = parsed.data.query logger.info(`[${requestId}] Fetching logs for workspace ${params.workspaceId}`, { userId, diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index ad42be802a3..dcb427aa3a1 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -3,11 +3,30 @@ import { type NextRequest, NextResponse } from 'next/server' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import type { SubscriptionPlan } from '@/lib/core/rate-limiter' import { getRateLimit, RateLimiter } from '@/lib/core/rate-limiter' +import { generateRequestId } from '@/lib/core/utils/request' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { authenticateV1Request } from '@/app/api/v1/auth' const logger = createLogger('V1Middleware') const rateLimiter = new RateLimiter() +export type V1Endpoint = + | 'logs' + | 'logs-detail' + | 'workflows' + | 'workflow-detail' + | 'audit-logs' + | 'tables' + | 'table-detail' + | 'table-rows' + | 'table-row-detail' + | 'table-columns' + | 'files' + | 'file-detail' + | 'knowledge' + | 'knowledge-detail' + | 'knowledge-search' + export interface RateLimitResult { allowed: boolean remaining: number @@ -20,24 +39,15 @@ export interface RateLimitResult { error?: string } +export interface AuthorizedRequest { + requestId: string + userId: string + rateLimit: RateLimitResult +} + export async function checkRateLimit( request: NextRequest, - endpoint: - | 'logs' - | 'logs-detail' - | 'workflows' - | 'workflow-detail' - | 'audit-logs' - | 'tables' - | 'table-detail' - | 'table-rows' - | 'table-row-detail' - | 'table-columns' - | 'files' - | 'file-detail' - | 'knowledge' - | 'knowledge-detail' - | 'knowledge-search' = 'logs' + endpoint: V1Endpoint = 'logs' ): Promise { try { const auth = await authenticateV1Request(request) @@ -94,6 +104,22 @@ export async function checkRateLimit( } } +/** + * Authenticates and rate-limits a v1 API request. + * Returns NextResponse on failure, AuthorizedRequest on success. + */ +export async function authenticateRequest( + request: NextRequest, + endpoint: V1Endpoint +): Promise { + const requestId = generateRequestId() + const rateLimit = await checkRateLimit(request, endpoint) + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + return { requestId, userId: rateLimit.userId!, rateLimit } +} + export function createRateLimitResponse(result: RateLimitResult): NextResponse { const headers = { 'X-RateLimit-Limit': result.limit.toString(), @@ -142,3 +168,26 @@ export function checkWorkspaceScope( } return null } + +/** + * Validates workspace-scoped API key bounds and the user's workspace permission. + * Returns null on success, NextResponse on failure. + */ +export async function validateWorkspaceAccess( + rateLimit: RateLimitResult, + userId: string, + workspaceId: string, + level: 'read' | 'write' = 'read' +): Promise { + const scopeError = checkWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return scopeError + + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission === null) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + if (level === 'write' && permission === 'read') { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + return null +} 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 bf20d38216a..1ac95fbc53a 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -1,7 +1,12 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + v1AddTableColumnContract, + v1DeleteTableColumnContract, + v1UpdateTableColumnContract, +} from '@/lib/api/contracts/tables' +import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -11,14 +16,7 @@ import { updateColumnConstraints, updateColumnType, } from '@/lib/table' -import { - accessError, - CreateColumnSchema, - checkAccess, - DeleteColumnSchema, - normalizeColumn, - UpdateColumnSchema, -} from '@/app/api/table/utils' +import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -35,206 +33,183 @@ interface ColumnsRouteParams { } /** POST /api/v1/tables/[tableId]/columns — Add a column to the table schema. */ -export const POST = withRouteHandler( - async (request: NextRequest, { params }: ColumnsRouteParams) => { - const requestId = generateRequestId() - const { tableId } = await params - - try { - const rateLimit = await checkRateLimit(request, 'table-columns') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } - - const userId = rateLimit.userId! +export const POST = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => { + const requestId = generateRequestId() - let body: unknown - try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } - - const validated = CreateColumnSchema.parse(body) + try { + const rateLimit = await checkRateLimit(request, 'table-columns') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return scopeError + const userId = rateLimit.userId! - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) + const parsed = await parseRequest(v1AddTableColumnContract, request, context) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const validated = parsed.data.body - const { table } = result + const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return scopeError - if (table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) - const updatedTable = await addTableColumn(tableId, validated.column, requestId) + const { table } = result - 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, - }) + if (table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) + 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 NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + }, + }) + } catch (error) { + const validationResponse = validationErrorResponseFromError(error) + if (validationResponse) return validationResponse + + if (error instanceof Error) { + if (error.message.includes('already exists') || error.message.includes('maximum column')) { + return NextResponse.json({ error: error.message }, { status: 400 }) } - - if (error instanceof Error) { - if (error.message.includes('already exists') || error.message.includes('maximum column')) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - if (error.message === 'Table not found') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } + if (error.message === 'Table not found') { + return NextResponse.json({ error: error.message }, { status: 404 }) } - - logger.error(`[${requestId}] Error adding column to table ${tableId}:`, error) - return NextResponse.json({ error: 'Failed to add column' }, { status: 500 }) } + + logger.error(`[${requestId}] Error adding column to table:`, error) + return NextResponse.json({ error: 'Failed to add column' }, { status: 500 }) } -) +}) /** PATCH /api/v1/tables/[tableId]/columns — Update a column (rename, type change, constraints). */ -export const PATCH = withRouteHandler( - async (request: NextRequest, { params }: ColumnsRouteParams) => { - const requestId = generateRequestId() - const { tableId } = await params - - try { - const rateLimit = await checkRateLimit(request, 'table-columns') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } +export const PATCH = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => { + const requestId = generateRequestId() - const userId = rateLimit.userId! + try { + const rateLimit = await checkRateLimit(request, 'table-columns') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } - let body: unknown - try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } + const userId = rateLimit.userId! - const validated = UpdateColumnSchema.parse(body) + const parsed = await parseRequest(v1UpdateTableColumnContract, request, context) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const validated = parsed.data.body - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return scopeError - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) + const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return scopeError - const { table } = result + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) - if (table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + const { table } = result - const { updates } = validated - let updatedTable = null + if (table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - if (updates.name) { - updatedTable = await renameColumn( - { tableId, oldName: validated.columnName, newName: updates.name }, - requestId - ) - } + const { updates } = validated + let updatedTable = null - if (updates.type) { - updatedTable = await updateColumnType( - { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, - requestId - ) - } + if (updates.name) { + updatedTable = await renameColumn( + { tableId, oldName: validated.columnName, newName: updates.name }, + 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 (updates.type) { + updatedTable = await updateColumnType( + { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, + requestId + ) + } - if (!updatedTable) { - return NextResponse.json({ error: 'No updates specified' }, { status: 400 }) - } + 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 + ) + } - 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, - }) + if (!updatedTable) { + return NextResponse.json({ error: 'No updates specified' }, { status: 400 }) + } - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { 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), + }, + }) + } catch (error) { + const validationResponse = validationErrorResponseFromError(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 (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') - ) { - return NextResponse.json({ error: msg }, { status: 400 }) - } + 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 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 }) } + + logger.error(`[${requestId}] Error updating column in table:`, error) + return NextResponse.json({ error: 'Failed to update column' }, { status: 500 }) } -) +}) /** DELETE /api/v1/tables/[tableId]/columns — Delete a column from the table schema. */ export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: ColumnsRouteParams) => { + async (request: NextRequest, context: ColumnsRouteParams) => { const requestId = generateRequestId() - const { tableId } = await params try { const rateLimit = await checkRateLimit(request, 'table-columns') @@ -244,14 +219,10 @@ export const DELETE = withRouteHandler( const userId = rateLimit.userId! - let body: unknown - try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } - - const validated = DeleteColumnSchema.parse(body) + const parsed = await parseRequest(v1DeleteTableColumnContract, request, context) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const validated = parsed.data.body const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError @@ -289,12 +260,8 @@ export const DELETE = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) - } + const validationResponse = validationErrorResponseFromError(error) + if (validationResponse) return validationResponse if (error instanceof Error) { if (error.message.includes('not found') || error.message === 'Table not found') { @@ -305,7 +272,7 @@ export const DELETE = withRouteHandler( } } - logger.error(`[${requestId}] Error deleting column from table ${tableId}:`, error) + 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 dad51353a5f..c9da03cbc2b 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -1,6 +1,8 @@ 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/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, type TableSchema } from '@/lib/table' @@ -21,7 +23,7 @@ interface TableRouteParams { } /** GET /api/v1/tables/[tableId] — Get table details. */ -export const GET = withRouteHandler(async (request: NextRequest, { params }: TableRouteParams) => { +export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { const requestId = generateRequestId() try { @@ -31,16 +33,23 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab } const userId = rateLimit.userId! - const { tableId } = await params - const { searchParams } = new URL(request.url) - const workspaceId = searchParams.get('workspaceId') - - if (!workspaceId) { - return NextResponse.json( - { error: 'workspaceId query parameter is required' }, - { status: 400 } - ) - } + const parsed = await parseRequest(v1GetTableContract, request, context, { + validationErrorResponse: (error) => { + const hasInvalidTableId = error.issues.some((issue) => issue.path.includes('tableId')) + return NextResponse.json( + { + error: hasInvalidTableId + ? 'Invalid table ID' + : 'workspaceId query parameter is required', + }, + { status: 400 } + ) + }, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query const scopeError = checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError @@ -86,60 +95,65 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab }) /** DELETE /api/v1/tables/[tableId] — Archive a table. */ -export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-detail') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } - - const userId = rateLimit.userId! - const { tableId } = await params - const { searchParams } = new URL(request.url) - const workspaceId = searchParams.get('workspaceId') - - if (!workspaceId) { +export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-detail') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const parsed = await parseRequest(v1DeleteTableContract, request, context, { + validationErrorResponse: (error) => { + const hasInvalidTableId = error.issues.some((issue) => issue.path.includes('tableId')) return NextResponse.json( - { error: 'workspaceId query parameter is required' }, + { + error: hasInvalidTableId + ? 'Invalid table ID' + : 'workspaceId query parameter is required', + }, { status: 400 } ) - } - - const scopeError = checkWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return scopeError - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) - - if (result.table.workspaceId !== workspaceId) { - 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, - }) - - return NextResponse.json({ - success: true, - data: { - message: 'Table archived successfully', - }, - }) - } catch (error) { - logger.error(`[${requestId}] Error deleting table:`, error) - return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 }) + }, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = checkWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return scopeError + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + + if (result.table.workspaceId !== workspaceId) { + 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, + }) + + return NextResponse.json({ + success: true, + data: { + message: 'Table archived successfully', + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error deleting table:`, error) + return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 }) } -) +}) 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 12ce351a811..643c01c6180 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 @@ -4,7 +4,12 @@ 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 { z } from 'zod' +import { + v1DeleteTableRowContract, + v1GetTableRowContract, + v1UpdateTableRowContract, +} from '@/lib/api/contracts/tables' +import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData } from '@/lib/table' @@ -21,17 +26,12 @@ const logger = createLogger('V1TableRowAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -const UpdateRowSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - data: z.record(z.unknown(), { required_error: 'Row data is required' }), -}) - interface RowRouteParams { params: Promise<{ tableId: string; rowId: string }> } /** GET /api/v1/tables/[tableId]/rows/[rowId] — Get a single row. */ -export const GET = withRouteHandler(async (request: NextRequest, { params }: RowRouteParams) => { +export const GET = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { const requestId = generateRequestId() try { @@ -41,16 +41,13 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Row } const userId = rateLimit.userId! - const { tableId, rowId } = await params - const { searchParams } = new URL(request.url) - const workspaceId = searchParams.get('workspaceId') - - if (!workspaceId) { - return NextResponse.json( - { error: 'workspaceId query parameter is required' }, - { status: 400 } - ) - } + const parsed = await parseRequest(v1GetTableRowContract, request, context, { + validationErrorResponse: () => + NextResponse.json({ error: 'workspaceId query parameter is required' }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + const { tableId, rowId } = parsed.data.params + const { workspaceId } = parsed.data.query const scopeError = checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError @@ -105,7 +102,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Row }) /** PATCH /api/v1/tables/[tableId]/rows/[rowId] — Partial update a single row. */ -export const PATCH = withRouteHandler(async (request: NextRequest, { params }: RowRouteParams) => { +export const PATCH = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { const requestId = generateRequestId() try { @@ -115,16 +112,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R } const userId = rateLimit.userId! - const { tableId, rowId } = await params - - let body: unknown - try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } - - const validated = UpdateRowSchema.parse(body) + const parsed = await parseRequest(v1UpdateTableRowContract, request, context) + if (!parsed.success) return parsed.response + const { tableId, rowId } = parsed.data.params + const validated = parsed.data.body const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError @@ -169,12 +160,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) - } + const validationResponse = validationErrorResponseFromError(error) + if (validationResponse) return validationResponse const errorMessage = toError(error).message @@ -198,7 +185,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R }) /** DELETE /api/v1/tables/[tableId]/rows/[rowId] — Delete a single row. */ -export const DELETE = withRouteHandler(async (request: NextRequest, { params }: RowRouteParams) => { +export const DELETE = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { const requestId = generateRequestId() try { @@ -208,16 +195,13 @@ export const DELETE = withRouteHandler(async (request: NextRequest, { params }: } const userId = rateLimit.userId! - const { tableId, rowId } = await params - const { searchParams } = new URL(request.url) - const workspaceId = searchParams.get('workspaceId') - - if (!workspaceId) { - return NextResponse.json( - { error: 'workspaceId query parameter is required' }, - { status: 400 } - ) - } + const parsed = await parseRequest(v1DeleteTableRowContract, request, context, { + validationErrorResponse: () => + NextResponse.json({ error: 'workspaceId query parameter is required' }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + const { tableId, rowId } = parsed.data.params + const { workspaceId } = parsed.data.query const scopeError = checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index 406b2f6c17b..925e2367120 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -4,16 +4,26 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + type V1BatchInsertTableRowsBody, + v1CreateTableRowContract, + v1DeleteTableRowsContract, + v1ListTableRowsContract, + v1UpdateRowsByFilterContract, +} from '@/lib/api/contracts/tables' +import { + parseRequest, + validationErrorResponse, + validationErrorResponseFromError, +} from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { Filter, RowData, Sort, TableSchema } from '@/lib/table' +import type { Filter, RowData, TableSchema } from '@/lib/table' import { batchInsertRows, deleteRowsByFilter, deleteRowsByIds, insertRow, - TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME, updateRowsByFilter, validateBatchRows, @@ -33,90 +43,6 @@ const logger = createLogger('V1TableRowsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -const InsertRowSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - data: z.record(z.unknown(), { required_error: 'Row data is required' }), -}) - -const BatchInsertRowsSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - rows: z - .array(z.record(z.unknown()), { required_error: 'Rows array is required' }) - .min(1, 'At least one row is required') - .max(1000, 'Cannot insert more than 1000 rows per batch'), -}) - -const QueryRowsSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - filter: z.record(z.unknown()).optional(), - sort: z.record(z.enum(['asc', 'desc'])).optional(), - limit: z - .preprocess( - (val) => (val === null || val === undefined || val === '' ? undefined : Number(val)), - z - .number({ required_error: 'Limit must be a number' }) - .int('Limit must be an integer') - .min(1, 'Limit must be at least 1') - .max(TABLE_LIMITS.MAX_QUERY_LIMIT, `Limit cannot exceed ${TABLE_LIMITS.MAX_QUERY_LIMIT}`) - .optional() - ) - .default(100), - offset: z - .preprocess( - (val) => (val === null || val === undefined || val === '' ? undefined : Number(val)), - z - .number({ required_error: 'Offset must be a number' }) - .int('Offset must be an integer') - .min(0, 'Offset must be 0 or greater') - .optional() - ) - .default(0), - includeTotal: z - .preprocess( - (val) => (val === null || val === undefined || val === '' ? undefined : val === 'true'), - z.boolean().optional() - ) - .default(true), -}) - -const nonEmptyFilter = z - .record(z.unknown(), { required_error: 'Filter criteria is required' }) - .refine((f) => Object.keys(f).length > 0, { message: 'Filter must not be empty' }) - -const optionalPositiveLimit = (max: number, label: string) => - z.preprocess( - (val) => (val === null || val === undefined || val === '' ? undefined : Number(val)), - z - .number() - .int(`${label} must be an integer`) - .min(1, `${label} must be at least 1`) - .max(max, `Cannot ${label.toLowerCase()} more than ${max} rows per operation`) - .optional() - ) - -const UpdateRowsByFilterSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - filter: nonEmptyFilter, - data: z.record(z.unknown(), { required_error: 'Update data is required' }), - limit: optionalPositiveLimit(1000, 'Limit'), -}) - -const DeleteRowsByFilterSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - filter: nonEmptyFilter, - limit: optionalPositiveLimit(1000, 'Limit'), -}) - -const DeleteRowsByIdsSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - rowIds: z - .array(z.string().min(1), { required_error: 'Row IDs are required' }) - .min(1, 'At least one row ID is required') - .max(1000, 'Cannot delete more than 1000 rows per operation'), -}) - -const DeleteRowsRequestSchema = z.union([DeleteRowsByFilterSchema, DeleteRowsByIdsSchema]) - interface TableRowsRouteParams { params: Promise<{ tableId: string }> } @@ -124,7 +50,7 @@ interface TableRowsRouteParams { async function handleBatchInsert( requestId: string, tableId: string, - validated: z.infer, + validated: V1BatchInsertTableRowsBody, userId: string ): Promise { const accessResult = await checkAccess(tableId, userId, 'write') @@ -189,124 +115,89 @@ async function handleBatchInsert( } /** GET /api/v1/tables/[tableId]/rows — Query rows with filtering, sorting, pagination. */ -export const GET = withRouteHandler( - async (request: NextRequest, { params }: TableRowsRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } - - const userId = rateLimit.userId! - const { tableId } = await params - const { searchParams } = new URL(request.url) +export const GET = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() - let filter: Record | undefined - let sort: Sort | undefined + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } - try { - const filterParam = searchParams.get('filter') - const sortParam = searchParams.get('sort') - if (filterParam) { - filter = JSON.parse(filterParam) as Record - } - if (sortParam) { - sort = JSON.parse(sortParam) as Sort + const userId = rateLimit.userId! + const parsed = await parseRequest(v1ListTableRowsContract, request, context, { + validationErrorResponse: (error) => { + const hasJsonError = error.issues.some( + (issue) => + issue.message === 'Invalid filter JSON' || issue.message === 'Invalid sort JSON' + ) + if (hasJsonError) { + return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 }) } - } catch { - return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 }) - } - - const validated = QueryRowsSchema.parse({ - workspaceId: searchParams.get('workspaceId'), - filter, - sort, - limit: searchParams.get('limit'), - offset: searchParams.get('offset'), - includeTotal: searchParams.get('includeTotal'), - }) + return validationErrorResponse(error) + }, + }) + if (!parsed.success) return parsed.response - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return scopeError + const { tableId } = parsed.data.params + const validated = parsed.data.query + const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return scopeError - const accessResult = await checkAccess(tableId, userId, 'read') - if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + const accessResult = await checkAccess(tableId, userId, 'read') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) - const { table } = accessResult + const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + if (validated.workspaceId !== table.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - const baseConditions = [ - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, validated.workspaceId), - ] + const baseConditions = [ + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, validated.workspaceId), + ] - if (validated.filter) { - const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME) - if (filterClause) { - baseConditions.push(filterClause) - } + if (validated.filter) { + const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME) + if (filterClause) { + baseConditions.push(filterClause) } + } - let query = db - .select({ - id: userTableRows.id, - data: userTableRows.data, - position: userTableRows.position, - createdAt: userTableRows.createdAt, - updatedAt: userTableRows.updatedAt, - }) - .from(userTableRows) - .where(and(...baseConditions)) - - if (validated.sort) { - const schema = table.schema as TableSchema - const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns) - if (sortClause) { - query = query.orderBy(sortClause) as typeof query - } else { - query = query.orderBy(userTableRows.position) as typeof query - } + let query = db + .select({ + id: userTableRows.id, + data: userTableRows.data, + position: userTableRows.position, + createdAt: userTableRows.createdAt, + updatedAt: userTableRows.updatedAt, + }) + .from(userTableRows) + .where(and(...baseConditions)) + + if (validated.sort) { + const schema = table.schema as TableSchema + const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns) + if (sortClause) { + query = query.orderBy(sortClause) as typeof query } else { query = query.orderBy(userTableRows.position) as typeof query } + } else { + query = query.orderBy(userTableRows.position) as typeof query + } - const rowsPromise = query.limit(validated.limit).offset(validated.offset) - - let totalCount: number | null = null - if (validated.includeTotal) { - const countQuery = db - .select({ count: sql`count(*)` }) - .from(userTableRows) - .where(and(...baseConditions)) - const [countResult, rows] = await Promise.all([countQuery, rowsPromise]) - totalCount = Number(countResult[0].count) - return NextResponse.json({ - success: true, - data: { - rows: rows.map((r) => ({ - id: r.id, - data: r.data, - position: r.position, - createdAt: - r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), - updatedAt: - r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), - })), - rowCount: rows.length, - totalCount, - limit: validated.limit, - offset: validated.offset, - }, - }) - } - - const rows = await rowsPromise + const rowsPromise = query.limit(validated.limit).offset(validated.offset) + let totalCount: number | null = null + if (validated.includeTotal) { + const countQuery = db + .select({ count: sql`count(*)` }) + .from(userTableRows) + .where(and(...baseConditions)) + const [countResult, rows] = await Promise.all([countQuery, rowsPromise]) + totalCount = Number(countResult[0].count) return NextResponse.json({ success: true, data: { @@ -325,23 +216,38 @@ export const GET = withRouteHandler( offset: validated.offset, }, }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) - } - - logger.error(`[${requestId}] Error querying rows:`, error) - return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 }) } + + const rows = await rowsPromise + + return NextResponse.json({ + success: true, + data: { + rows: rows.map((r) => ({ + id: r.id, + data: r.data, + position: r.position, + createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), + updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), + })), + rowCount: rows.length, + totalCount, + limit: validated.limit, + offset: validated.offset, + }, + }) + } catch (error) { + const validationResponse = validationErrorResponseFromError(error) + if (validationResponse) return validationResponse + + logger.error(`[${requestId}] Error querying rows:`, error) + return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 }) } -) +}) /** POST /api/v1/tables/[tableId]/rows — Insert row(s). Supports single or batch. */ export const POST = withRouteHandler( - async (request: NextRequest, { params }: TableRowsRouteParams) => { + async (request: NextRequest, context: TableRowsRouteParams) => { const requestId = generateRequestId() try { @@ -351,28 +257,18 @@ export const POST = withRouteHandler( } const userId = rateLimit.userId! - const { tableId } = await params - - let body: unknown - try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } + const parsed = await parseRequest(v1CreateTableRowContract, request, context) + if (!parsed.success) return parsed.response - if ( - typeof body === 'object' && - body !== null && - 'rows' in body && - Array.isArray((body as Record).rows) - ) { - const batchValidated = BatchInsertRowsSchema.parse(body) + const { tableId } = parsed.data.params + if ('rows' in parsed.data.body) { + const batchValidated = parsed.data.body const scopeError = checkWorkspaceScope(rateLimit, batchValidated.workspaceId) if (scopeError) return scopeError return handleBatchInsert(requestId, tableId, batchValidated, userId) } - const validated = InsertRowSchema.parse(body) + const validated = parsed.data.body const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError @@ -420,12 +316,8 @@ export const POST = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) - } + const validationResponse = validationErrorResponseFromError(error) + if (validationResponse) return validationResponse const errorMessage = toError(error).message @@ -446,108 +338,96 @@ export const POST = withRouteHandler( ) /** PUT /api/v1/tables/[tableId]/rows — Bulk update rows by filter. */ -export const PUT = withRouteHandler( - async (request: NextRequest, { params }: TableRowsRouteParams) => { - const requestId = generateRequestId() +export const PUT = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() - try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } - - const userId = rateLimit.userId! - const { tableId } = await params - - let body: unknown - try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } - - const validated = UpdateRowsByFilterSchema.parse(body) + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return scopeError + const userId = rateLimit.userId! + const parsed = await parseRequest(v1UpdateRowsByFilterContract, request, context) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const validated = parsed.data.body - const accessResult = await checkAccess(tableId, userId, 'write') - if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return scopeError - const { table } = accessResult + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) - if (validated.workspaceId !== table.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + const { table } = accessResult - const sizeValidation = validateRowSize(validated.data as RowData) - if (!sizeValidation.valid) { - return NextResponse.json( - { error: 'Validation error', details: sizeValidation.errors }, - { status: 400 } - ) - } + if (validated.workspaceId !== table.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - const result = await updateRowsByFilter( - { - tableId, - filter: validated.filter as Filter, - data: validated.data as RowData, - limit: validated.limit, - workspaceId: validated.workspaceId, - }, - table, - requestId + const sizeValidation = validateRowSize(validated.data as RowData) + if (!sizeValidation.valid) { + return NextResponse.json( + { error: 'Validation error', details: sizeValidation.errors }, + { status: 400 } ) + } - if (result.affectedCount === 0) { - return NextResponse.json({ - success: true, - data: { - message: 'No rows matched the filter criteria', - updatedCount: 0, - }, - }) - } + const result = await updateRowsByFilter( + { + tableId, + filter: validated.filter as Filter, + data: validated.data as RowData, + limit: validated.limit, + workspaceId: validated.workspaceId, + }, + table, + requestId + ) + if (result.affectedCount === 0) { return NextResponse.json({ success: true, data: { - message: 'Rows updated successfully', - updatedCount: result.affectedCount, - updatedRowIds: result.affectedRowIds, + message: 'No rows matched the filter criteria', + updatedCount: 0, }, }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) - } + } - const errorMessage = toError(error).message + return NextResponse.json({ + success: true, + data: { + message: 'Rows updated successfully', + updatedCount: result.affectedCount, + updatedRowIds: result.affectedRowIds, + }, + }) + } catch (error) { + const validationResponse = validationErrorResponseFromError(error) + if (validationResponse) return validationResponse - 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') || - errorMessage.includes('Filter is required') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const errorMessage = toError(error).message - logger.error(`[${requestId}] Error updating rows by filter:`, error) - return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) + 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') || + errorMessage.includes('Filter is required') + ) { + return NextResponse.json({ error: errorMessage }, { status: 400 }) } + + logger.error(`[${requestId}] Error updating rows by filter:`, error) + return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) } -) +}) /** DELETE /api/v1/tables/[tableId]/rows — Delete rows by filter or IDs. */ export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: TableRowsRouteParams) => { + async (request: NextRequest, context: TableRowsRouteParams) => { const requestId = generateRequestId() try { @@ -557,16 +437,10 @@ export const DELETE = withRouteHandler( } const userId = rateLimit.userId! - const { tableId } = await params - - let body: unknown - try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } - - const validated = DeleteRowsRequestSchema.parse(body) + const parsed = await parseRequest(v1DeleteTableRowsContract, request, context) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const validated = parsed.data.body const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError @@ -580,7 +454,7 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - if ('rowIds' in validated) { + if (validated.rowIds) { const result = await deleteRowsByIds( { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, requestId @@ -623,12 +497,8 @@ export const DELETE = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) - } + const validationResponse = validationErrorResponseFromError(error) + if (validationResponse) return validationResponse const errorMessage = toError(error).message 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 2859e6af019..b357e16e2ed 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,7 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { v1UpsertTableRowContract } from '@/lib/api/contracts/tables' +import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData } from '@/lib/table' @@ -18,106 +19,88 @@ const logger = createLogger('V1TableUpsertAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -const UpsertRowSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - data: z.record(z.unknown(), { required_error: 'Row data is required' }), - conflictTarget: z.string().optional(), -}) - interface UpsertRouteParams { params: Promise<{ tableId: string }> } /** POST /api/v1/tables/[tableId]/rows/upsert — Insert or update a row based on unique columns. */ -export const POST = withRouteHandler( - async (request: NextRequest, { params }: UpsertRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } - - const userId = rateLimit.userId! - const { tableId } = await params +export const POST = withRouteHandler(async (request: NextRequest, context: UpsertRouteParams) => { + const requestId = generateRequestId() - let body: unknown - try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } - - const validated = UpsertRowSchema.parse(body) + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return scopeError + const userId = rateLimit.userId! + const parsed = await parseRequest(v1UpsertTableRowContract, request, context) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const validated = parsed.data.body - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) + const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return scopeError - const { table } = result + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) - if (table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + const { table } = result - const upsertResult = await upsertRow( - { - tableId, - workspaceId: validated.workspaceId, - data: validated.data as RowData, - userId, - conflictTarget: validated.conflictTarget, - }, - table, - requestId - ) + if (table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - return NextResponse.json({ - success: true, - data: { - row: { - id: upsertResult.row.id, - data: upsertResult.row.data, - createdAt: - upsertResult.row.createdAt instanceof Date - ? upsertResult.row.createdAt.toISOString() - : upsertResult.row.createdAt, - updatedAt: - upsertResult.row.updatedAt instanceof Date - ? upsertResult.row.updatedAt.toISOString() - : upsertResult.row.updatedAt, - }, - operation: upsertResult.operation, - message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`, + const upsertResult = await upsertRow( + { + tableId, + workspaceId: validated.workspaceId, + data: validated.data as RowData, + userId, + conflictTarget: validated.conflictTarget, + }, + table, + requestId + ) + + return NextResponse.json({ + success: true, + data: { + row: { + id: upsertResult.row.id, + data: upsertResult.row.data, + createdAt: + upsertResult.row.createdAt instanceof Date + ? upsertResult.row.createdAt.toISOString() + : upsertResult.row.createdAt, + updatedAt: + upsertResult.row.updatedAt instanceof Date + ? upsertResult.row.updatedAt.toISOString() + : upsertResult.row.updatedAt, }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) - } - - 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 }) - } - - logger.error(`[${requestId}] Error upserting row:`, error) - return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 }) + operation: upsertResult.operation, + message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`, + }, + }) + } catch (error) { + const validationResponse = validationErrorResponseFromError(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 }) } + + 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 43618f93102..d793b5a260e 100644 --- a/apps/sim/app/api/v1/tables/route.ts +++ b/apps/sim/app/api/v1/tables/route.ts @@ -1,22 +1,16 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { v1CreateTableContract, v1ListTablesContract } from '@/lib/api/contracts/tables' +import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - createTable, - getWorkspaceTableLimits, - listTables, - TABLE_LIMITS, - type TableSchema, -} from '@/lib/table' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { createTable, getWorkspaceTableLimits, listTables, type TableSchema } from '@/lib/table' import { normalizeColumn } from '@/app/api/table/utils' import { checkRateLimit, - checkWorkspaceScope, createRateLimitResponse, + validateWorkspaceAccess, } from '@/app/api/v1/middleware' const logger = createLogger('V1TablesAPI') @@ -24,62 +18,6 @@ const logger = createLogger('V1TablesAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -const ListTablesSchema = z.object({ - workspaceId: z.string().min(1, 'workspaceId query parameter is required'), -}) - -const ColumnSchema = z.object({ - name: z - .string() - .min(1, 'Column name is required') - .max( - TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH, - `Column name must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less` - ) - .regex( - /^[a-z_][a-z0-9_]*$/i, - 'Column name must start with a letter or underscore and contain only alphanumeric characters and underscores' - ), - type: z.enum(['string', 'number', 'boolean', 'date', 'json'], { - errorMap: () => ({ - message: 'Column type must be one of: string, number, boolean, date, json', - }), - }), - required: z.boolean().optional().default(false), - unique: z.boolean().optional().default(false), -}) - -const CreateTableSchema = z.object({ - name: z - .string() - .min(1, 'Table name is required') - .max( - TABLE_LIMITS.MAX_TABLE_NAME_LENGTH, - `Table name must be ${TABLE_LIMITS.MAX_TABLE_NAME_LENGTH} characters or less` - ) - .regex( - /^[a-z_][a-z0-9_]*$/i, - 'Table name must start with a letter or underscore and contain only alphanumeric characters and underscores' - ), - description: z - .string() - .max( - TABLE_LIMITS.MAX_DESCRIPTION_LENGTH, - `Description must be ${TABLE_LIMITS.MAX_DESCRIPTION_LENGTH} characters or less` - ) - .optional(), - schema: z.object({ - columns: z - .array(ColumnSchema) - .min(1, 'Table must have at least one column') - .max( - TABLE_LIMITS.MAX_COLUMNS_PER_TABLE, - `Table cannot have more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} columns` - ), - }), - workspaceId: z.string().min(1, 'Workspace ID is required'), -}) - /** GET /api/v1/tables — List all tables in a workspace. */ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -91,27 +29,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const userId = rateLimit.userId! - const { searchParams } = new URL(request.url) - - const validation = ListTablesSchema.safeParse({ - workspaceId: searchParams.get('workspaceId'), - }) - if (!validation.success) { - return NextResponse.json( - { error: 'Validation error', details: validation.error.errors }, - { status: 400 } - ) - } - - const { workspaceId } = validation.data + const parsed = await parseRequest(v1ListTablesContract, request, {}) + if (!parsed.success) return parsed.response - const scopeError = checkWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return scopeError + const { workspaceId } = parsed.data.query - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission === null) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } + const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) + if (accessError) return accessError const tables = await listTables(workspaceId) @@ -139,12 +63,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) - } + const validationResponse = validationErrorResponseFromError(error) + if (validationResponse) return validationResponse logger.error(`[${requestId}] Error listing tables:`, error) return NextResponse.json({ error: 'Failed to list tables' }, { status: 500 }) @@ -163,22 +83,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userId = rateLimit.userId! - let body: unknown - try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } + const parsed = await parseRequest(v1CreateTableContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body - const params = CreateTableSchema.parse(body) - - const scopeError = checkWorkspaceScope(rateLimit, params.workspaceId) - if (scopeError) return scopeError - - const permission = await getUserEntityPermissions(userId, 'workspace', params.workspaceId) - if (permission === null || permission === 'read') { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + params.workspaceId, + 'write' + ) + if (accessError) return accessError const planLimits = await getWorkspaceTableLimits(params.workspaceId) @@ -236,12 +151,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) - } + const validationResponse = validationErrorResponseFromError(error) + if (validationResponse) return validationResponse if (error instanceof Error) { if (error.message.includes('maximum table limit')) { diff --git a/apps/sim/app/api/v1/workflows/[id]/route.ts b/apps/sim/app/api/v1/workflows/[id]/route.ts index 44994ebfa57..3cd569a0b5d 100644 --- a/apps/sim/app/api/v1/workflows/[id]/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/route.ts @@ -5,6 +5,8 @@ import { generateId } from '@sim/utils/id' import { getActiveWorkflowRecord } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { v1GetWorkflowContract } from '@/lib/api/contracts/workflows' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -16,7 +18,7 @@ const logger = createLogger('V1WorkflowDetailsAPI') export const revalidate = 0 export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateId().slice(0, 8) try { @@ -26,7 +28,13 @@ export const GET = withRouteHandler( } const userId = rateLimit.userId! - const { id } = await params + const parsed = await parseRequest(v1GetWorkflowContract, request, context, { + validationErrorResponse: () => + NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params logger.info(`[${requestId}] Fetching workflow details for ${id}`, { userId }) diff --git a/apps/sim/app/api/v1/workflows/route.ts b/apps/sim/app/api/v1/workflows/route.ts index 29be1a1b708..d630cde8c56 100644 --- a/apps/sim/app/api/v1/workflows/route.ts +++ b/apps/sim/app/api/v1/workflows/route.ts @@ -4,7 +4,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, asc, eq, gt, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { v1ListWorkflowsContract } from '@/lib/api/contracts/workflows' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' @@ -15,14 +16,6 @@ const logger = createLogger('V1WorkflowsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -const QueryParamsSchema = z.object({ - workspaceId: z.string(), - folderId: z.string().optional(), - deployedOnly: z.coerce.boolean().optional().default(false), - limit: z.coerce.number().min(1).max(100).optional().default(50), - cursor: z.string().optional(), -}) - interface CursorData { sortOrder: number createdAt: string @@ -51,18 +44,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const userId = rateLimit.userId! - const { searchParams } = new URL(request.url) - const rawParams = Object.fromEntries(searchParams.entries()) - - const validationResult = QueryParamsSchema.safeParse(rawParams) - if (!validationResult.success) { - return NextResponse.json( - { error: 'Invalid parameters', details: validationResult.error.errors }, - { status: 400 } - ) - } + const parsed = await parseRequest( + v1ListWorkflowsContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + error: getValidationErrorMessage(error, 'Invalid parameters'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response - const params = validationResult.data + const params = parsed.data.query logger.info(`[${requestId}] Fetching workflows for workspace ${params.workspaceId}`, { userId, diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts index 7b692368bd6..075d39f2275 100644 --- a/apps/sim/app/api/wand/route.ts +++ b/apps/sim/app/api/wand/route.ts @@ -3,6 +3,8 @@ import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { wandGenerateContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { getBYOKKey } from '@/lib/api-key/byok' import { getSession } from '@/lib/auth' import { recordUsage } from '@/lib/billing/core/usage-log' @@ -43,16 +45,6 @@ interface ChatMessage { content: string } -interface RequestBody { - prompt: string - systemPrompt?: string - stream?: boolean - history?: ChatMessage[] - workflowId?: string - generationType?: string - wandContext?: Record -} - function safeStringify(value: unknown): string { try { return JSON.stringify(value) @@ -168,7 +160,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } try { - const body = (await req.json()) as RequestBody + const parsed = await parseRequest(wandGenerateContract, req, {}) + if (!parsed.success) return parsed.response + const { body } = parsed.data const { prompt, diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 5c2a5cd51ad..3bb0531bd6e 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -5,8 +5,9 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { webhookIdParamsSchema, webhookPatchBodySchema } from '@/lib/api/contracts/webhooks' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { validateInteger } from '@/lib/core/security/input-validation' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -23,7 +24,9 @@ export const GET = withRouteHandler( const requestId = generateRequestId() try { - const { id } = await params + const paramsValidation = validateSchema(webhookIdParamsSchema, await params) + if (!paramsValidation.success) return paramsValidation.response + const { id } = paramsValidation.data const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { @@ -80,7 +83,9 @@ export const PATCH = withRouteHandler( const requestId = generateRequestId() try { - const { id } = await params + const paramsValidation = validateSchema(webhookIdParamsSchema, await params) + if (!paramsValidation.success) return paramsValidation.response + const { id } = paramsValidation.data const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { @@ -89,17 +94,16 @@ export const PATCH = withRouteHandler( } const userId = auth.userId - const body = await request.json() - const { isActive, failedCount } = body - - if (failedCount !== undefined) { - const validation = validateInteger(failedCount, 'failedCount', { min: 0 }) - if (!validation.isValid) { - logger.warn(`[${requestId}] ${validation.error}`) - return NextResponse.json({ error: validation.error }, { status: 400 }) - } + const bodyResult = validateSchema(webhookPatchBodySchema, await request.json()) + if (!bodyResult.success) { + const message = getValidationErrorMessage(bodyResult.error) + logger.warn(`[${requestId}] ${message}`) + return NextResponse.json({ error: message }, { status: 400 }) } + const body = bodyResult.data + const { isActive, failedCount } = body + const webhooks = await db .select({ webhook: webhook, @@ -157,7 +161,9 @@ export const DELETE = withRouteHandler( const requestId = generateRequestId() try { - const { id } = await params + const paramsValidation = validateSchema(webhookIdParamsSchema, await params) + if (!paramsValidation.success) return paramsValidation.response + const { id } = paramsValidation.data const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index a603078b00e..6d5c14cab68 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -10,9 +10,15 @@ import { import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { tasks } from '@trigger.dev/sdk' -import { and, eq, gt, ne, sql } from 'drizzle-orm' +import { and, eq, gt, isNotNull, ne, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { Webhook } from 'svix' +import { + agentMailEnvelopeSchema, + agentMailMessageSchema, + webhookSvixHeadersSchema, +} from '@/lib/api/contracts/webhooks' +import { validateSchema } from '@/lib/api/server' import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { executeInboxTask } from '@/lib/mothership/inbox/executor' @@ -26,23 +32,17 @@ const MAX_EMAILS_PER_HOUR = 20 export const POST = withRouteHandler(async (req: Request) => { try { const rawBody = await req.text() - const svixId = req.headers.get('svix-id') - const svixTimestamp = req.headers.get('svix-timestamp') - const svixSignature = req.headers.get('svix-signature') - - const payload = JSON.parse(rawBody) as AgentMailWebhookPayload - - if (payload.event_type !== 'message.received') { - return NextResponse.json({ ok: true }) - } + const headersResult = validateSchema(webhookSvixHeadersSchema, { + 'svix-id': req.headers.get('svix-id'), + 'svix-timestamp': req.headers.get('svix-timestamp'), + 'svix-signature': req.headers.get('svix-signature'), + }) - const { message } = payload - const inboxId = message?.inbox_id - if (!message || !inboxId) { - return NextResponse.json({ ok: true }) + if (!headersResult.success) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const [result] = await db + const webhookCandidates = await db .select({ id: workspace.id, inboxEnabled: workspace.inboxEnabled, @@ -52,29 +52,56 @@ export const POST = withRouteHandler(async (req: Request) => { }) .from(workspace) .leftJoin(mothershipInboxWebhook, eq(mothershipInboxWebhook.workspaceId, workspace.id)) - .where(eq(workspace.inboxProviderId, inboxId)) - .limit(1) - - if (!result || !result.webhookSecret) { - if (!result) { - logger.warn('No workspace found for inbox', { inboxId }) - } else { - logger.warn('No webhook secret found for workspace', { workspaceId: result.id }) - } + .where(isNotNull(mothershipInboxWebhook.secret)) + + let result: (typeof webhookCandidates)[number] | undefined + for (const candidate of webhookCandidates) { + if (!candidate.webhookSecret) continue + + try { + const wh = new Webhook(candidate.webhookSecret) + wh.verify(rawBody, headersResult.data) + result = candidate + break + } catch {} + } + + if (!result) { + logger.warn('Webhook signature verification failed', { + candidateCount: webhookCandidates.length, + }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - try { - const wh = new Webhook(result.webhookSecret) - wh.verify(rawBody, { - 'svix-id': svixId || '', - 'svix-timestamp': svixTimestamp || '', - 'svix-signature': svixSignature || '', + const envelopeResult = validateSchema(agentMailEnvelopeSchema, JSON.parse(rawBody)) + if (!envelopeResult.success) { + logger.warn('Invalid AgentMail webhook payload', { + workspaceId: result.id, + issues: envelopeResult.error.issues, }) - } catch (verifyErr) { - logger.warn('Webhook signature verification failed', { + return NextResponse.json({ ok: true }) + } + + if (envelopeResult.data.event_type !== 'message.received') { + return NextResponse.json({ ok: true }) + } + + const messageResult = validateSchema(agentMailMessageSchema, envelopeResult.data.message) + if (!messageResult.success) { + logger.warn('Invalid AgentMail message payload', { + workspaceId: result.id, + issues: messageResult.error.issues, + }) + return NextResponse.json({ ok: true }) + } + + const message: AgentMailWebhookPayload['message'] = messageResult.data + const inboxId = message.inbox_id + if (result.inboxProviderId !== inboxId) { + logger.warn('Verified AgentMail payload inbox mismatch', { workspaceId: result.id, - error: verifyErr instanceof Error ? verifyErr.message : 'Unknown error', + verifiedInboxId: result.inboxProviderId, + payloadInboxId: inboxId, }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts b/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts index 2d4312b54be..b3c59db5df6 100644 --- a/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts +++ b/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { verifyCronAuth } from '@/lib/auth/internal' import { cleanupExpiredIdempotencyKeys, getIdempotencyKeyStats } from '@/lib/core/idempotency' import { generateRequestId } from '@/lib/core/utils/request' @@ -11,6 +13,9 @@ export const dynamic = 'force-dynamic' export const maxDuration = 300 // Allow up to 5 minutes for cleanup export const GET = withRouteHandler(async (request: NextRequest) => { + const queryValidation = validateSchema(noInputSchema, {}) + if (!queryValidation.success) return queryValidation.response + const requestId = generateRequestId() logger.info(`Idempotency cleanup triggered (${requestId})`) diff --git a/apps/sim/app/api/webhooks/outbox/process/route.ts b/apps/sim/app/api/webhooks/outbox/process/route.ts index 6a5f2b385ee..4910b5d5c03 100644 --- a/apps/sim/app/api/webhooks/outbox/process/route.ts +++ b/apps/sim/app/api/webhooks/outbox/process/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { validateSchema } from '@/lib/api/server' import { verifyCronAuth } from '@/lib/auth/internal' import { billingOutboxHandlers } from '@/lib/billing/webhooks/outbox-handlers' import { processOutboxEvents } from '@/lib/core/outbox/service' @@ -20,6 +22,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { + const validation = validateSchema(noInputSchema, {}) + if (!validation.success) return validation.response + const authError = verifyCronAuth(request, 'Outbox processor') if (authError) { return authError diff --git a/apps/sim/app/api/webhooks/poll/[provider]/route.ts b/apps/sim/app/api/webhooks/poll/[provider]/route.ts index 06e3837e49c..c79abd204de 100644 --- a/apps/sim/app/api/webhooks/poll/[provider]/route.ts +++ b/apps/sim/app/api/webhooks/poll/[provider]/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' +import { webhookPollingParamsSchema } from '@/lib/api/contracts/webhooks' +import { validateSchema } from '@/lib/api/server' import { verifyCronAuth } from '@/lib/auth/internal' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -16,7 +18,8 @@ export const maxDuration = 180 export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ provider: string }> }) => { - const { provider } = await params + const paramsResult = validateSchema(webhookPollingParamsSchema, await params) + const provider = paramsResult.success ? paramsResult.data.provider : '' const requestId = generateShortId() try { diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index e1121b1caf0..1fd1fa24858 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -6,6 +6,8 @@ import { generateId, generateShortId } from '@sim/utils/id' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { webhooksRouteQuerySchema, webhookUpsertBodySchema } from '@/lib/api/contracts/webhooks' +import { validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' @@ -68,8 +70,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const { searchParams } = new URL(request.url) - const workflowId = searchParams.get('workflowId') - const blockId = searchParams.get('blockId') + const queryValidation = validateSchema(webhooksRouteQuerySchema, { + workflowId: searchParams.get('workflowId'), + blockId: searchParams.get('blockId'), + }) + if (!queryValidation.success) return queryValidation.response + const { workflowId, blockId } = queryValidation.data if (workflowId && blockId) { // Collaborative-aware path: allow collaborators with read access to view webhooks @@ -183,8 +189,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { workflowId, path, provider, providerConfig, blockId } = body + const bodyResult = validateSchema( + webhookUpsertBodySchema, + await request.json(), + 'Invalid request data' + ) + if (!bodyResult.success) { + logger.warn(`[${requestId}] Invalid webhook request data`, { + issues: bodyResult.error.issues, + }) + return bodyResult.response + } + + const body = bodyResult.data + const { workflowId, path, providerConfig, blockId } = body + const provider = body.provider || '' // Validate input if (!workflowId) { @@ -376,7 +395,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const syncResult = await syncWebhooksForCredentialSet({ workflowId, - blockId, + blockId: blockId || '', provider, basePath: finalPath, credentialSetId, diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 6a6b509155e..cb1bc5becad 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { webhookTriggerParamsSchema } from '@/lib/api/contracts/webhooks' +import { validateSchema } from '@/lib/api/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' @@ -26,7 +28,11 @@ export const maxDuration = 60 export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ path: string }> }) => { const requestId = generateRequestId() - const { path } = await params + const paramsResult = validateSchema(webhookTriggerParamsSchema, await params) + if (!paramsResult.success) { + return new NextResponse('Not Found', { status: 404 }) + } + const { path } = paramsResult.data // Handle provider-specific GET verifications (Microsoft Graph, WhatsApp, etc.) const challengeResponse = await handleProviderChallenges({}, request, requestId, path) @@ -61,7 +67,11 @@ async function handleWebhookPost( params: Promise<{ path: string }> ): Promise { const requestId = generateRequestId() - const { path } = await params + const paramsResult = validateSchema(webhookTriggerParamsSchema, await params) + if (!paramsResult.success) { + return new NextResponse('Not Found', { status: 404 }) + } + const { path } = paramsResult.data const earlyChallenge = await handleProviderChallenges({}, request, requestId, path) if (earlyChallenge) { diff --git a/apps/sim/app/api/workflows/[id]/autolayout/route.ts b/apps/sim/app/api/workflows/[id]/autolayout/route.ts index 9b659744d25..99a0f1a60e2 100644 --- a/apps/sim/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/sim/app/api/workflows/[id]/autolayout/route.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { workflowAutoLayoutBodySchema } from '@/lib/api/contracts/workflows' +import { parseJsonBody, validateSchema } 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' @@ -20,29 +21,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('AutoLayoutAPI') -const AutoLayoutRequestSchema = z.object({ - spacing: z - .object({ - horizontal: z.number().min(100).max(1000).optional(), - vertical: z.number().min(50).max(500).optional(), - }) - .optional() - .default({}), - alignment: z.enum(['start', 'center', 'end']).optional().default('center'), - padding: z - .object({ - x: z.number().min(50).max(500).optional(), - y: z.number().min(50).max(500).optional(), - }) - .optional() - .default({}), - gridSize: z.number().min(0).max(50).optional(), - blocks: z.record(z.any()).optional(), - edges: z.array(z.any()).optional(), - loops: z.record(z.any()).optional(), - parallels: z.record(z.any()).optional(), -}) - /** * POST /api/workflows/[id]/autolayout * Apply autolayout to an existing workflow @@ -62,8 +40,21 @@ export const POST = withRouteHandler( const userId = auth.userId - const body = await request.json() - const layoutOptions = AutoLayoutRequestSchema.parse(body) + const parsedBody = await parseJsonBody(request) + if (!parsedBody.success) return parsedBody.response + + const validation = validateSchema( + workflowAutoLayoutBodySchema, + parsedBody.data, + 'Invalid request data' + ) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid autolayout request data`, { + errors: validation.error.issues, + }) + return validation.response + } + const layoutOptions = validation.data logger.info(`[${requestId}] Processing autolayout request for workflow ${workflowId}`, { userId, @@ -164,14 +155,6 @@ export const POST = withRouteHandler( } catch (error) { const elapsed = Date.now() - startTime - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid autolayout request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Autolayout failed after ${elapsed}ms:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.ts index 5b11700b56b..ab515c01220 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.ts @@ -4,6 +4,8 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows' +import { getValidationErrorMessage, validateSchema } 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' @@ -16,7 +18,14 @@ const logger = createLogger('ChatStatusAPI') */ export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id } = await params + const paramsValidation = validateSchema(workflowIdParamsSchema, await params) + if (!paramsValidation.success) { + return createErrorResponse( + getValidationErrorMessage(paramsValidation.error, 'Invalid route parameters'), + 400 + ) + } + const { id } = paramsValidation.data const requestId = generateRequestId() try { diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index dd57844def7..f38861a1f61 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -2,6 +2,9 @@ import { db, workflow } from '@sim/db' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { updatePublicApiBodySchema } from '@/lib/api/contracts/deployments' +import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows' +import { getValidationErrorMessage, validateSchema } 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' @@ -25,7 +28,14 @@ export const runtime = 'nodejs' export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const paramsValidation = validateSchema(workflowIdParamsSchema, await params) + if (!paramsValidation.success) { + return createErrorResponse( + getValidationErrorMessage(paramsValidation.error, 'Invalid route parameters'), + 400 + ) + } + const { id } = paramsValidation.data try { const { error, workflow: workflowData } = await validateWorkflowPermissions( @@ -73,7 +83,14 @@ export const GET = withRouteHandler( export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const paramsValidation = validateSchema(workflowIdParamsSchema, await params) + if (!paramsValidation.success) { + return createErrorResponse( + getValidationErrorMessage(paramsValidation.error, 'Invalid route parameters'), + 400 + ) + } + const { id } = paramsValidation.data try { const { @@ -138,7 +155,14 @@ export const POST = withRouteHandler( export const PATCH = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const paramsValidation = validateSchema(workflowIdParamsSchema, await params) + if (!paramsValidation.success) { + return createErrorResponse( + getValidationErrorMessage(paramsValidation.error, 'Invalid route parameters'), + 400 + ) + } + const { id } = paramsValidation.data try { const { @@ -151,11 +175,11 @@ export const PATCH = withRouteHandler( } const body = await request.json() - const { isPublicApi } = body - - if (typeof isPublicApi !== 'boolean') { + const bodyValidation = updatePublicApiBodySchema.safeParse(body) + if (!bodyValidation.success) { return createErrorResponse('Invalid request body: isPublicApi must be a boolean', 400) } + const { isPublicApi } = bodyValidation.data if (isPublicApi) { try { @@ -193,7 +217,14 @@ export const PATCH = withRouteHandler( export const DELETE = withRouteHandler( async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const paramsValidation = validateSchema(workflowIdParamsSchema, await params) + if (!paramsValidation.success) { + return createErrorResponse( + getValidationErrorMessage(paramsValidation.error, 'Invalid route parameters'), + 400 + ) + } + const { id } = paramsValidation.data try { const { diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.ts b/apps/sim/app/api/workflows/[id]/deployed/route.ts index 48b33f5e816..4befe0f6e3b 100644 --- a/apps/sim/app/api/workflows/[id]/deployed/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployed/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import type { NextRequest, NextResponse } from 'next/server' +import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows' +import { getValidationErrorMessage, validateSchema } 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' @@ -20,7 +22,15 @@ function addNoCacheHeaders(response: NextResponse): NextResponse { export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const paramsValidation = validateSchema(workflowIdParamsSchema, await params) + if (!paramsValidation.success) { + const response = createErrorResponse( + getValidationErrorMessage(paramsValidation.error, 'Invalid route parameters'), + 400 + ) + return addNoCacheHeaders(response) + } + const { id } = paramsValidation.data try { const authHeader = request.headers.get('authorization') 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 b023a0b2af9..3a56eeca5e1 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,5 +1,8 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' +import { deploymentVersionRouteParamsSchema } from '@/lib/api/contracts/deployments' +import { workflowDeploymentVersionParamSchema } from '@/lib/api/contracts/workflows' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performRevertToVersion } from '@/lib/workflows/orchestration' @@ -17,7 +20,18 @@ export const POST = withRouteHandler( { params }: { params: Promise<{ id: string; version: string }> } ) => { const requestId = generateRequestId() - const { id, version } = await params + const paramsValidation = validateSchema( + deploymentVersionRouteParamsSchema, + await params, + 'Invalid route parameters' + ) + if (!paramsValidation.success) { + return createErrorResponse( + getValidationErrorMessage(paramsValidation.error, 'Invalid route parameters'), + 400 + ) + } + const { id, version } = paramsValidation.data try { const { @@ -29,14 +43,18 @@ export const POST = withRouteHandler( return createErrorResponse(error.message, error.status) } - const versionSelector = version === 'active' ? null : Number(version) - if (version !== 'active' && !Number.isFinite(versionSelector)) { + const versionValidation = validateSchema( + workflowDeploymentVersionParamSchema, + version, + 'Invalid version' + ) + if (!versionValidation.success) { return createErrorResponse('Invalid version', 400) } const result = await performRevertToVersion({ workflowId: id, - version: version === 'active' ? 'active' : (versionSelector as number), + version: versionValidation.data, userId: session!.user.id, workflow: (workflowRecord ?? {}) as Record, request, 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 59039f21737..66ef28a6d40 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -2,7 +2,11 @@ import { db, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { z } from 'zod' +import { + deploymentVersionPatchBodySchema, + deploymentVersionRouteParamsSchema, +} from '@/lib/api/contracts/deployments' +import { getValidationErrorMessage, validateSchema } 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' @@ -12,29 +16,6 @@ import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/ const logger = createLogger('WorkflowDeploymentVersionAPI') -const patchBodySchema = z - .object({ - name: z - .string() - .trim() - .min(1, 'Name cannot be empty') - .max(100, 'Name must be 100 characters or less') - .optional(), - description: z - .string() - .trim() - .max(2000, 'Description must be 2000 characters or less') - .nullable() - .optional(), - isActive: z.literal(true).optional(), // Set to true to activate this version - }) - .refine( - (data) => data.name !== undefined || data.description !== undefined || data.isActive === true, - { - message: 'At least one of name, description, or isActive must be provided', - } - ) - export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -44,7 +25,14 @@ export const GET = withRouteHandler( { params }: { params: Promise<{ id: string; version: string }> } ) => { const requestId = generateRequestId() - const { id, version } = await params + const paramsValidation = validateSchema(deploymentVersionRouteParamsSchema, await params) + if (!paramsValidation.success) { + return createErrorResponse( + getValidationErrorMessage(paramsValidation.error, 'Invalid route parameters'), + 400 + ) + } + const { id, version } = paramsValidation.data try { const { error } = await validateWorkflowPermissions(id, requestId, 'read') @@ -89,15 +77,22 @@ export const PATCH = withRouteHandler( { params }: { params: Promise<{ id: string; version: string }> } ) => { const requestId = generateRequestId() - const { id, version } = await params + const paramsValidation = validateSchema(deploymentVersionRouteParamsSchema, await params) + if (!paramsValidation.success) { + return createErrorResponse( + getValidationErrorMessage(paramsValidation.error, 'Invalid route parameters'), + 400 + ) + } + const { id, version } = paramsValidation.data try { const body = await request.json() - const validation = patchBodySchema.safeParse(body) + const validation = validateSchema(deploymentVersionPatchBodySchema, body) if (!validation.success) { return createErrorResponse( - validation.error.errors[0]?.message || 'Invalid request body', + getValidationErrorMessage(validation.error, 'Invalid request body'), 400 ) } diff --git a/apps/sim/app/api/workflows/[id]/deployments/route.ts b/apps/sim/app/api/workflows/[id]/deployments/route.ts index 1bc72ae66b1..a82673bd47e 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/route.ts @@ -2,6 +2,8 @@ import { db, user, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' import { desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { validateWorkflowPermissions } from '@/lib/workflows/utils' @@ -15,7 +17,14 @@ export const runtime = 'nodejs' export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const paramsValidation = validateSchema(workflowIdParamsSchema, await params) + if (!paramsValidation.success) { + return createErrorResponse( + getValidationErrorMessage(paramsValidation.error, 'Invalid route parameters'), + 400 + ) + } + const { id } = paramsValidation.data try { const { error } = await validateWorkflowPermissions(id, requestId, 'read') diff --git a/apps/sim/app/api/workflows/[id]/duplicate/route.ts b/apps/sim/app/api/workflows/[id]/duplicate/route.ts index a1fdc2de0d4..3f451fca697 100644 --- a/apps/sim/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workflows/[id]/duplicate/route.ts @@ -1,7 +1,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { duplicateWorkflowBodySchema } from '@/lib/api/contracts/workflows' +import { validateSchema } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' @@ -11,15 +12,6 @@ import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' const logger = createLogger('WorkflowDuplicateAPI') -const DuplicateRequestSchema = z.object({ - name: z.string().min(1, 'Name is required'), - description: z.string().optional(), - color: z.string().optional(), - workspaceId: z.string().optional(), - folderId: z.string().nullable().optional(), - newId: z.string().uuid().optional(), -}) - // POST /api/workflows/[id]/duplicate - Duplicate a workflow with all its blocks, edges, and subflows export const POST = withRouteHandler( async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { @@ -38,8 +30,14 @@ export const POST = withRouteHandler( try { const body = await req.json() - const { name, description, color, workspaceId, folderId, newId } = - DuplicateRequestSchema.parse(body) + const validation = validateSchema(duplicateWorkflowBodySchema, body, 'Invalid request data') + if (!validation.success) { + logger.warn(`[${requestId}] Invalid duplication request data`, { + errors: validation.error.issues, + }) + return validation.response + } + const { name, description, color, workspaceId, folderId, newId } = validation.data logger.info(`[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${userId}`) @@ -115,14 +113,6 @@ export const POST = withRouteHandler( } } - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid duplication request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const elapsed = Date.now() - startTime logger.error( `[${requestId}] Error duplicating workflow ${sourceWorkflowId} after ${elapsed}ms:`, diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 34b99b16744..d13863c29d6 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -6,7 +6,8 @@ import { generateId, isValidUuid } from '@sim/utils/id' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { executeWorkflowBodySchema } from '@/lib/api/contracts/workflows' +import { validateSchema } from '@/lib/api/server' import { AuthType, checkHybridAuth, hasExternalApiCredentials } from '@/lib/auth/hybrid' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' @@ -67,49 +68,6 @@ import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/ const logger = createLogger('WorkflowExecuteAPI') -const ExecuteWorkflowSchema = z.object({ - selectedOutputs: z.array(z.string()).optional().default([]), - triggerType: z.enum(CORE_TRIGGER_TYPES).optional(), - stream: z.boolean().optional(), - useDraftState: z.boolean().optional(), - input: z.any().optional(), - isClientSession: z.boolean().optional(), - includeFileBase64: z.boolean().optional().default(true), - base64MaxBytes: z.number().int().positive().optional(), - workflowStateOverride: z - .object({ - blocks: z.record(z.any()), - edges: z.array(z.any()), - loops: z.record(z.any()).optional(), - parallels: z.record(z.any()).optional(), - }) - .optional(), - triggerBlockId: z.string().optional(), - stopAfterBlockId: z.string().optional(), - runFromBlock: z - .object({ - startBlockId: z.string().min(1, 'Start block ID is required'), - sourceSnapshot: z - .object({ - blockStates: z.record(z.any()), - executedBlocks: z.array(z.string()), - blockLogs: z.array(z.any()), - decisions: z.object({ - router: z.record(z.string()), - condition: z.record(z.string()), - }), - completedLoops: z.array(z.string()), - loopExecutions: z.record(z.any()).optional(), - parallelExecutions: z.record(z.any()).optional(), - parallelBlockMapping: z.record(z.any()).optional(), - activeExecutionPath: z.array(z.string()), - }) - .optional(), - executionId: z.string().optional(), - }) - .optional(), -}) - export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -356,13 +314,13 @@ async function handleExecutePost( reqLogger.warn('Failed to parse request body, using defaults') } - const validation = ExecuteWorkflowSchema.safeParse(body) + const validation = validateSchema(executeWorkflowBodySchema, body, 'Invalid request body') if (!validation.success) { - reqLogger.warn('Invalid request body:', validation.error.errors) + reqLogger.warn('Invalid request body:', validation.error.issues) return NextResponse.json( { error: 'Invalid request body', - details: validation.error.errors.map((e) => ({ + details: validation.error.issues.map((e) => ({ path: e.path.join('.'), message: e.message, })), @@ -384,10 +342,12 @@ async function handleExecutePost( includeFileBase64, base64MaxBytes, workflowStateOverride, - triggerBlockId, + triggerBlockId: parsedTriggerBlockId, + startBlockId, stopAfterBlockId, runFromBlock: rawRunFromBlock, } = validation.data + const triggerBlockId = parsedTriggerBlockId ?? startBlockId if (isPublicApiAccess && isClientSession) { return NextResponse.json( 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 1c23e6c6a09..4a79d0aa58f 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 @@ -4,6 +4,8 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { workflowExecutionParamsSchema } from '@/lib/api/contracts/workflows' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { markExecutionCancelled } from '@/lib/execution/cancellation' @@ -22,7 +24,20 @@ export const POST = withRouteHandler( req: NextRequest, { params }: { params: Promise<{ id: string; executionId: string }> } ) => { - const { id: workflowId, executionId } = await params + const paramsValidation = validateSchema( + workflowExecutionParamsSchema, + await params, + 'Invalid route parameters' + ) + if (!paramsValidation.success) { + return NextResponse.json( + { + error: getValidationErrorMessage(paramsValidation.error, 'Invalid route parameters'), + }, + { status: 400 } + ) + } + const { id: workflowId, executionId } = paramsValidation.data try { const auth = await checkHybridAuth(req, { requireWorkflowId: false }) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts index c89246cdece..7224c49815c 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts @@ -3,6 +3,11 @@ import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' +import { + workflowExecutionParamsSchema, + workflowExecutionStreamQuerySchema, +} from '@/lib/api/contracts/workflows' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -30,7 +35,20 @@ export const GET = withRouteHandler( req: NextRequest, { params }: { params: Promise<{ id: string; executionId: string }> } ) => { - const { id: workflowId, executionId } = await params + const paramsValidation = validateSchema( + workflowExecutionParamsSchema, + await params, + 'Invalid route parameters' + ) + if (!paramsValidation.success) { + return NextResponse.json( + { + error: getValidationErrorMessage(paramsValidation.error, 'Invalid route parameters'), + }, + { status: 400 } + ) + } + const { id: workflowId, executionId } = paramsValidation.data try { const session = await getSession() @@ -59,9 +77,20 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Run does not belong to this workflow' }, { status: 403 }) } - const fromParam = req.nextUrl.searchParams.get('from') - const parsed = fromParam ? Number.parseInt(fromParam, 10) : 0 - const fromEventId = Number.isFinite(parsed) && parsed >= 0 ? parsed : 0 + const queryValidation = validateSchema( + workflowExecutionStreamQuerySchema, + { from: req.nextUrl.searchParams.get('from') }, + 'Invalid query parameters' + ) + if (!queryValidation.success) { + return NextResponse.json( + { + error: getValidationErrorMessage(queryValidation.error, 'Invalid query parameters'), + }, + { status: 400 } + ) + } + const { from: fromEventId } = queryValidation.data logger.info('Reconnection stream requested', { workflowId, diff --git a/apps/sim/app/api/workflows/[id]/form/status/route.ts b/apps/sim/app/api/workflows/[id]/form/status/route.ts index ebe71b1ba29..e8bbcd3a3be 100644 --- a/apps/sim/app/api/workflows/[id]/form/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/form/status/route.ts @@ -4,12 +4,18 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { formStatusParamsSchema } from '@/lib/api/contracts/forms' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('FormStatusAPI') +function getErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback +} + export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { try { @@ -18,7 +24,14 @@ export const GET = withRouteHandler( return createErrorResponse('Unauthorized', 401) } - const { id: workflowId } = await params + const paramsValidation = validateSchema(formStatusParamsSchema, await params) + if (!paramsValidation.success) { + return createErrorResponse( + getValidationErrorMessage(paramsValidation.error, 'Invalid route parameters'), + 400 + ) + } + const { id: workflowId } = paramsValidation.data const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId, userId: auth.userId, @@ -53,9 +66,9 @@ export const GET = withRouteHandler( isDeployed: true, form: formResult[0], }) - } catch (error: any) { + } catch (error) { logger.error('Error fetching form status:', error) - return createErrorResponse(error.message || 'Failed to fetch form status', 500) + return createErrorResponse(getErrorMessage(error, 'Failed to fetch form status'), 500) } } ) diff --git a/apps/sim/app/api/workflows/[id]/log/route.ts b/apps/sim/app/api/workflows/[id]/log/route.ts index 74d56940aa3..b49da31affa 100644 --- a/apps/sim/app/api/workflows/[id]/log/route.ts +++ b/apps/sim/app/api/workflows/[id]/log/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' -import { z } from 'zod' +import { workflowLogBodySchema } from '@/lib/api/contracts/workflows' +import { getValidationErrorMessage, parseJsonBody, validateSchema } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { LoggingSession } from '@/lib/logs/execution/logging-session' @@ -12,24 +13,6 @@ import type { ExecutionResult } from '@/executor/types' const logger = createLogger('WorkflowLogAPI') -const postBodySchema = z.object({ - logs: z.array(z.any()).optional(), - executionId: z.string().min(1, 'Execution ID is required').optional(), - result: z - .object({ - success: z.boolean(), - error: z.string().optional(), - output: z.any(), - metadata: z - .object({ - source: z.string().optional(), - duration: z.number().optional(), - }) - .optional(), - }) - .optional(), -}) - export const dynamic = 'force-dynamic' export const POST = withRouteHandler( @@ -46,13 +29,18 @@ export const POST = withRouteHandler( return createErrorResponse(accessValidation.error.message, accessValidation.error.status) } - const body = await request.json() - const validation = postBodySchema.safeParse(body) + const parsedBody = await parseJsonBody(request) + if (!parsedBody.success) return parsedBody.response + const validation = validateSchema( + workflowLogBodySchema, + parsedBody.data, + 'Invalid request body' + ) if (!validation.success) { - logger.warn(`[${requestId}] Invalid request body: ${validation.error.message}`) + logger.warn(`[${requestId}] Invalid request body`) return createErrorResponse( - validation.error.errors[0]?.message || 'Invalid request body', + getValidationErrorMessage(validation.error, 'Invalid request body'), 400 ) } diff --git a/apps/sim/app/api/workflows/[id]/paused/[executionId]/route.ts b/apps/sim/app/api/workflows/[id]/paused/[executionId]/route.ts index a049fa1101e..755f3483b9f 100644 --- a/apps/sim/app/api/workflows/[id]/paused/[executionId]/route.ts +++ b/apps/sim/app/api/workflows/[id]/paused/[executionId]/route.ts @@ -1,4 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' +import { workflowExecutionParamsSchema } from '@/lib/api/contracts/workflows' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' @@ -15,7 +17,20 @@ export const GET = withRouteHandler( params: Promise<{ id: string; executionId: string }> } ) => { - const { id: workflowId, executionId } = await params + const paramsValidation = validateSchema( + workflowExecutionParamsSchema, + await params, + 'Invalid route parameters' + ) + if (!paramsValidation.success) { + return NextResponse.json( + { + error: getValidationErrorMessage(paramsValidation.error, 'Invalid route parameters'), + }, + { status: 400 } + ) + } + const { id: workflowId, executionId } = paramsValidation.data const access = await validateWorkflowAccess(request, workflowId, false) if (access.error) { diff --git a/apps/sim/app/api/workflows/[id]/paused/route.ts b/apps/sim/app/api/workflows/[id]/paused/route.ts index 740fda7686b..2b224129478 100644 --- a/apps/sim/app/api/workflows/[id]/paused/route.ts +++ b/apps/sim/app/api/workflows/[id]/paused/route.ts @@ -1,13 +1,10 @@ import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { pausedWorkflowExecutionsQuerySchema } from '@/lib/api/contracts/workflows' +import { getValidationErrorMessage, validateSchema } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' -const queryParamsSchema = z.object({ - status: z.string().optional(), -}) - export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -27,13 +24,17 @@ export const GET = withRouteHandler( return NextResponse.json({ error: access.error.message }, { status: access.error.status }) } - const validation = queryParamsSchema.safeParse({ - status: request.nextUrl.searchParams.get('status'), - }) + const validation = validateSchema( + pausedWorkflowExecutionsQuerySchema, + { status: request.nextUrl.searchParams.get('status') }, + 'Invalid query parameters' + ) if (!validation.success) { return NextResponse.json( - { error: validation.error.errors[0]?.message || 'Invalid query parameters' }, + { + error: getValidationErrorMessage(validation.error, 'Invalid query parameters'), + }, { status: 400 } ) } diff --git a/apps/sim/app/api/workflows/[id]/restore/route.ts b/apps/sim/app/api/workflows/[id]/restore/route.ts index e0205917494..a0688a732c0 100644 --- a/apps/sim/app/api/workflows/[id]/restore/route.ts +++ b/apps/sim/app/api/workflows/[id]/restore/route.ts @@ -1,6 +1,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows' +import { getValidationErrorMessage, validateSchema } 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' @@ -14,7 +16,16 @@ const logger = createLogger('RestoreWorkflowAPI') export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id: workflowId } = await params + const paramsValidation = validateSchema(workflowIdParamsSchema, await params) + if (!paramsValidation.success) { + return NextResponse.json( + { + error: getValidationErrorMessage(paramsValidation.error, 'Invalid route parameters'), + }, + { status: 400 } + ) + } + const { id: workflowId } = paramsValidation.data try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 7f862131f81..cbcf4bd16f0 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -4,7 +4,8 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateWorkflowBodySchema } from '@/lib/api/contracts/workflows' +import { validateSchema } from '@/lib/api/server' import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -15,14 +16,6 @@ import { getWorkflowById } from '@/lib/workflows/utils' const logger = createLogger('WorkflowByIdAPI') -const UpdateWorkflowSchema = z.object({ - name: z.string().min(1, 'Name is required').optional(), - description: z.string().optional(), - color: z.string().optional(), - folderId: z.string().nullable().optional(), - sortOrder: z.number().int().min(0).optional(), -}) - /** * GET /api/workflows/[id] * Fetch a single workflow by ID @@ -265,7 +258,14 @@ export const PUT = withRouteHandler( const userId = auth.userId const body = await request.json() - const updates = UpdateWorkflowSchema.parse(body) + const validation = validateSchema(updateWorkflowBodySchema, body, 'Invalid request data') + if (!validation.success) { + logger.warn(`[${requestId}] Invalid workflow update data for ${workflowId}`, { + errors: validation.error.issues, + }) + return validation.response + } + const updates = validation.data // Fetch the workflow to check ownership/access const authorization = await authorizeWorkflowByWorkspacePermission({ @@ -354,16 +354,6 @@ export const PUT = withRouteHandler( return NextResponse.json({ workflow: updatedWorkflow }, { status: 200 }) } catch (error: any) { const elapsed = Date.now() - startTime - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid workflow update data for ${workflowId}`, { - errors: error.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error updating workflow ${workflowId} after ${elapsed}ms`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index d6c164da53a..d302fdb216d 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -5,7 +5,8 @@ import { toError } from '@sim/utils/errors' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { workflowStateSchema } from '@/lib/api/contracts/workflows' +import { validateSchema } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' @@ -23,98 +24,6 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w const logger = createLogger('WorkflowStateAPI') -const PositionSchema = z.object({ - x: z.number(), - y: z.number(), -}) - -const BlockDataSchema = z.object({ - parentId: z.string().optional(), - extent: z.literal('parent').optional(), - width: z.number().optional(), - height: z.number().optional(), - collection: z.unknown().optional(), - count: z.number().optional(), - loopType: z.enum(['for', 'forEach', 'while', 'doWhile']).optional(), - whileCondition: z.string().optional(), - doWhileCondition: z.string().optional(), - parallelType: z.enum(['collection', 'count']).optional(), - type: z.string().optional(), - canonicalModes: z.record(z.enum(['basic', 'advanced'])).optional(), -}) - -const SubBlockStateSchema = z.object({ - id: z.string(), - type: z.string(), - value: z.any(), -}) - -const BlockOutputSchema = z.any() - -const BlockStateSchema = z.object({ - id: z.string(), - type: z.string(), - name: z.string(), - position: PositionSchema, - subBlocks: z.record(SubBlockStateSchema), - outputs: z.record(BlockOutputSchema), - enabled: z.boolean(), - horizontalHandles: z.boolean().optional(), - height: z.number().optional(), - advancedMode: z.boolean().optional(), - triggerMode: z.boolean().optional(), - data: BlockDataSchema.optional(), -}) - -const EdgeSchema = z.object({ - id: z.string(), - source: z.string(), - target: z.string(), - sourceHandle: z.string().optional(), - targetHandle: z.string().optional(), - type: z.string().optional(), - animated: z.boolean().optional(), - style: z.record(z.any()).optional(), - data: z.record(z.any()).optional(), - label: z.string().optional(), - labelStyle: z.record(z.any()).optional(), - labelShowBg: z.boolean().optional(), - labelBgStyle: z.record(z.any()).optional(), - labelBgPadding: z.array(z.number()).optional(), - labelBgBorderRadius: z.number().optional(), - markerStart: z.string().optional(), - markerEnd: z.string().optional(), -}) - -const LoopSchema = z.object({ - id: z.string(), - nodes: z.array(z.string()), - iterations: z.number(), - loopType: z.enum(['for', 'forEach', 'while', 'doWhile']), - forEachItems: z.union([z.array(z.any()), z.record(z.any()), z.string()]).optional(), - whileCondition: z.string().optional(), - doWhileCondition: z.string().optional(), -}) - -const ParallelSchema = z.object({ - id: z.string(), - nodes: z.array(z.string()), - distribution: z.union([z.array(z.any()), z.record(z.any()), z.string()]).optional(), - count: z.number().optional(), - parallelType: z.enum(['count', 'collection']).optional(), -}) - -const WorkflowStateSchema = z.object({ - blocks: z.record(BlockStateSchema), - edges: z.array(EdgeSchema), - loops: z.record(LoopSchema).optional(), - parallels: z.record(ParallelSchema).optional(), - lastSaved: z.number().optional(), - isDeployed: z.boolean().optional(), - deployedAt: z.coerce.date().optional(), - variables: z.any().optional(), // Workflow variables -}) - /** * GET /api/workflows/[id]/state * Fetch the current workflow state from normalized tables. @@ -149,6 +58,7 @@ export const GET = withRouteHandler( edges: normalized.edges, loops: normalized.loops || {}, parallels: normalized.parallels || {}, + variables: authorization.workflow?.variables || {}, }) } catch (error) { logger.error('Failed to fetch workflow state', { @@ -179,7 +89,9 @@ export const PUT = withRouteHandler( const userId = auth.userId const body = await request.json() - const state = WorkflowStateSchema.parse(body) + const validation = validateSchema(workflowStateSchema, body, 'Invalid request body') + if (!validation.success) return validation.response + const state = validation.data const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId, @@ -205,6 +117,26 @@ export const PUT = withRouteHandler( ) } + if (state.variables) { + const mismatchedVariable = Object.values(state.variables).find( + (variable) => variable.workflowId !== workflowId + ) + if (mismatchedVariable) { + return NextResponse.json( + { + error: 'Invalid workflow state', + details: [ + { + path: ['variables', mismatchedVariable.id, 'workflowId'], + message: 'Variable workflowId must match the workflow route parameter', + }, + ], + }, + { status: 400 } + ) + } + } + // Sanitize custom tools in agent blocks before saving const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks( state.blocks as Record @@ -349,13 +281,6 @@ export const PUT = withRouteHandler( error ) - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request body', details: error.errors }, - { status: 400 } - ) - } - 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 c3b578d5a38..4906051c3c1 100644 --- a/apps/sim/app/api/workflows/[id]/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/status/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' +import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows' +import { getValidationErrorMessage, validateSchema } 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' @@ -14,10 +16,16 @@ const logger = createLogger('WorkflowStatusAPI') export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() + const paramsValidation = validateSchema(workflowIdParamsSchema, await params) + if (!paramsValidation.success) { + return createErrorResponse( + getValidationErrorMessage(paramsValidation.error, 'Invalid route parameters'), + 400 + ) + } + const { id } = paramsValidation.data try { - const { id } = await params - const validation = await validateWorkflowAccess(request, id, false) if (validation.error) { logger.warn(`[${requestId}] Workflow access validation failed: ${validation.error.message}`) @@ -35,7 +43,7 @@ export const GET = withRouteHandler( needsRedeployment, }) } catch (error) { - logger.error(`[${requestId}] Error getting status for workflow: ${(await params).id}`, error) + logger.error(`[${requestId}] Error getting status for workflow: ${id}`, error) return createErrorResponse('Failed to get status', 500) } } diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index 62d90a7e8a5..40c76e5abc1 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -5,7 +5,8 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { workflowVariablesBodySchema } from '@/lib/api/contracts/workflows' +import { parseJsonBody, validateSchema } 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' @@ -13,24 +14,6 @@ import type { Variable } from '@/stores/variables/types' const logger = createLogger('WorkflowVariablesAPI') -const VariableSchema = z.object({ - id: z.string(), - workflowId: z.string(), - name: z.string(), - type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'plain']), - value: z.union([ - z.string(), - z.number(), - z.boolean(), - z.record(z.unknown()), - z.array(z.unknown()), - ]), -}) - -const VariablesSchema = z.object({ - variables: z.record(z.string(), VariableSchema), -}) - export const POST = withRouteHandler( async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() @@ -67,52 +50,69 @@ export const POST = withRouteHandler( ) } - const body = await req.json() - - try { - const { variables } = VariablesSchema.parse(body) - - // Variables are already in Record format - use directly - // The frontend is the source of truth for what variables should exist - await db - .update(workflow) - .set({ - variables, - updatedAt: new Date(), - }) - .where(eq(workflow.id, workflowId)) - - recordAudit({ - workspaceId: workflowData.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.WORKFLOW_VARIABLES_UPDATED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: workflowData.name ?? undefined, - description: `Updated workflow variables`, - metadata: { - variableCount: Object.keys(variables).length, - variableNames: Object.values(variables).map((v) => v.name), - workflowName: workflowData.name ?? undefined, - }, - request: req, + const parsedBody = await parseJsonBody(req) + if (!parsedBody.success) return parsedBody.response + + const validation = validateSchema( + workflowVariablesBodySchema, + parsedBody.data, + 'Invalid request data' + ) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid workflow variables data`, { + errors: validation.error.issues, }) + return validation.response + } - return NextResponse.json({ success: true }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid workflow variables data`, { - errors: validationError.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) - } - throw validationError + const { variables } = validation.data + const mismatchedVariable = Object.values(variables).find( + (variable) => variable.workflowId !== workflowId + ) + if (mismatchedVariable) { + return NextResponse.json( + { + error: 'Invalid request data', + details: [ + { + path: ['variables', mismatchedVariable.id, 'workflowId'], + message: 'Variable workflowId must match the workflow route parameter', + }, + ], + }, + { status: 400 } + ) } + + // Variables are already in Record format - use directly + // The frontend is the source of truth for what variables should exist + await db + .update(workflow) + .set({ + variables, + updatedAt: new Date(), + }) + .where(eq(workflow.id, workflowId)) + + recordAudit({ + workspaceId: workflowData.workspaceId ?? null, + actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + action: AuditAction.WORKFLOW_VARIABLES_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: workflowData.name ?? undefined, + description: `Updated workflow variables`, + metadata: { + variableCount: Object.keys(variables).length, + variableNames: Object.values(variables).map((v) => v.name), + workflowName: workflowData.name ?? undefined, + }, + request: req, + }) + + return NextResponse.json({ success: true }) } catch (error) { logger.error(`[${requestId}] Error updating workflow variables`, error) return NextResponse.json({ error: 'Failed to update workflow variables' }, { status: 500 }) diff --git a/apps/sim/app/api/workflows/reorder/route.ts b/apps/sim/app/api/workflows/reorder/route.ts index dbd2980db3b..261e1415415 100644 --- a/apps/sim/app/api/workflows/reorder/route.ts +++ b/apps/sim/app/api/workflows/reorder/route.ts @@ -3,7 +3,8 @@ import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { reorderWorkflowsBodySchema } from '@/lib/api/contracts/workflows' +import { validateSchema } 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' @@ -11,17 +12,6 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkflowReorderAPI') -const ReorderSchema = z.object({ - workspaceId: z.string(), - updates: z.array( - z.object({ - id: z.string(), - sortOrder: z.number().int().min(0), - folderId: z.string().nullable().optional(), - }) - ), -}) - export const PUT = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) @@ -33,7 +23,12 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { try { const body = await req.json() - const { workspaceId, updates } = ReorderSchema.parse(body) + const validation = validateSchema(reorderWorkflowsBodySchema, body, 'Invalid request data') + if (!validation.success) { + logger.warn(`[${requestId}] Invalid reorder data`, { errors: validation.error.issues }) + return validation.response + } + const { workspaceId, updates } = validation.data const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!permission || permission === 'read') { @@ -78,14 +73,6 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ success: true, updated: validUpdates.length }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid reorder data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error reordering workflows`, error) return NextResponse.json({ error: 'Failed to reorder workflows' }, { status: 500 }) } diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index d8b902388ea..8c0247fff2a 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -5,41 +5,33 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, isNull, min, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { createWorkflowBodySchema, workflowListQuerySchema } from '@/lib/api/contracts/workflows' +import { validateSchema } 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 { getNextWorkflowColor } from '@/lib/workflows/colors' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' -import { deduplicateWorkflowName, listWorkflows, type WorkflowScope } from '@/lib/workflows/utils' +import { deduplicateWorkflowName, listWorkflows } from '@/lib/workflows/utils' import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' const logger = createLogger('WorkflowAPI') -const CreateWorkflowSchema = z.object({ - id: z.string().uuid().optional(), - name: z.string().min(1, 'Name is required'), - description: z.string().optional().default(''), - color: z - .string() - .optional() - .transform((c) => c || getNextWorkflowColor()), - workspaceId: z.string().optional(), - folderId: z.string().nullable().optional(), - sortOrder: z.number().int().optional(), - deduplicate: z.boolean().optional(), -}) - // GET /api/workflows - Get workflows for user (optionally filtered by workspaceId) export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const startTime = Date.now() const url = new URL(request.url) - const workspaceId = url.searchParams.get('workspaceId') - const scope = (url.searchParams.get('scope') ?? 'active') as WorkflowScope + const query = workflowListQuerySchema.safeParse(Object.fromEntries(url.searchParams.entries())) + if (!query.success) { + return NextResponse.json( + { error: 'Invalid query parameters', details: query.error.issues }, + { status: 400 } + ) + } + const { workspaceId, scope } = query.data try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -75,10 +67,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } } - if (!['active', 'archived', 'all'].includes(scope)) { - return NextResponse.json({ error: 'Invalid scope' }, { status: 400 }) - } - let workflows const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)] @@ -130,6 +118,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { try { const body = await req.json() + const validation = validateSchema(createWorkflowBodySchema, body, 'Invalid request data') + if (!validation.success) { + logger.warn(`[${requestId}] Invalid workflow creation data`, { + errors: validation.error.issues, + }) + return validation.response + } const { id: clientId, name: requestedName, @@ -139,7 +134,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { folderId, sortOrder: providedSortOrder, deduplicate, - } = CreateWorkflowSchema.parse(body) + } = validation.data if (!workspaceId) { logger.warn(`[${requestId}] Workflow creation blocked: missing workspaceId`) @@ -322,16 +317,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { subBlockValues, }) } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid workflow creation data`, { - errors: error.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - logger.error(`[${requestId}] Error creating workflow`, error) return NextResponse.json({ error: 'Failed to create workflow' }, { status: 500 }) } diff --git a/apps/sim/app/api/workspaces/[id]/_preview/create-preview-route.ts b/apps/sim/app/api/workspaces/[id]/_preview/create-preview-route.ts index 0e9c6a43e6c..76aa304366f 100644 --- a/apps/sim/app/api/workspaces/[id]/_preview/create-preview-route.ts +++ b/apps/sim/app/api/workspaces/[id]/_preview/create-preview-route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import type { z } from 'zod' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' @@ -23,6 +25,10 @@ export interface DocumentPreviewRouteConfig { contentType: string /** Short label used for the logger name + 500 log message. */ label: 'PDF' | 'PPTX' | 'DOCX' + /** Route params schema owned by the concrete route.ts boundary. */ + routeParamsSchema: z.ZodType<{ id: string }> + /** JSON body schema owned by the concrete route.ts boundary. */ + previewBodySchema: z.ZodType<{ code: string }> } /** @@ -38,7 +44,14 @@ export function createDocumentPreviewRoute(config: DocumentPreviewRouteConfig) { const logger = createLogger(`${config.label}PreviewAPI`) return async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id: workspaceId } = await params + const paramsResult = config.routeParamsSchema.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() @@ -57,11 +70,14 @@ export function createDocumentPreviewRoute(config: DocumentPreviewRouteConfig) { } catch { return NextResponse.json({ error: 'Invalid or missing JSON body' }, { status: 400 }) } - const { code } = body as { code?: string } - - if (typeof code !== 'string' || code.trim().length === 0) { - return NextResponse.json({ error: 'code is required' }, { status: 400 }) + const bodyResult = config.previewBodySchema.safeParse(body) + if (!bodyResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(bodyResult.error, 'code is required') }, + { status: 400 } + ) } + const { code } = bodyResult.data if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { return NextResponse.json({ error: 'code exceeds maximum size' }, { status: 413 }) diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts index 4677eb2e544..c1fe33f501d 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts @@ -4,7 +4,7 @@ import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, not } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateWorkspaceApiKeyBodySchema } from '@/lib/api/contracts/api-keys' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -13,10 +13,6 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceApiKeyAPI') -const UpdateKeySchema = z.object({ - name: z.string().min(1, 'Name is required'), -}) - export const PUT = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string; keyId: string }> }) => { const requestId = generateRequestId() @@ -37,7 +33,7 @@ export const PUT = withRouteHandler( } const body = await request.json() - const { name } = UpdateKeySchema.parse(body) + const { name } = updateWorkspaceApiKeyBodySchema.parse(body) const existingKey = await db .select() diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts index f0539f5b3a9..50b674a8ccc 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts @@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { createApiKeyBodySchema, deleteWorkspaceApiKeysBodySchema } from '@/lib/api/contracts' import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' import { hashApiKey } from '@/lib/api-key/crypto' import { getSession } from '@/lib/auth' @@ -17,15 +17,6 @@ import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/per const logger = createLogger('WorkspaceApiKeysAPI') -const CreateKeySchema = z.object({ - name: z.string().trim().min(1, 'Name is required'), - source: z.enum(['settings', 'deploy_modal']).optional(), -}) - -const DeleteKeysSchema = z.object({ - keys: z.array(z.string()).min(1), -}) - export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() @@ -108,7 +99,7 @@ export const POST = withRouteHandler( } const body = await request.json() - const { name, source } = CreateKeySchema.parse(body) + const { name, source } = createApiKeyBodySchema.parse(body) const existingKey = await db .select() @@ -228,7 +219,7 @@ export const DELETE = withRouteHandler( } const body = await request.json() - const { keys } = DeleteKeysSchema.parse(body) + const { keys } = deleteWorkspaceApiKeysBodySchema.parse(body) const deletedCount = await db .delete(apiKey) diff --git a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts index aa9728b7df0..c8cf0a8845a 100644 --- a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts @@ -5,7 +5,8 @@ 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 { z } from 'zod' +import { deleteByokKeyBodySchema, upsertByokKeyBodySchema } from '@/lib/api/contracts/byok-keys' +import { getValidationErrorMessage, isZodError } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' @@ -15,32 +16,6 @@ import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/per const logger = createLogger('WorkspaceBYOKKeysAPI') -const VALID_PROVIDERS = [ - 'openai', - 'anthropic', - 'google', - 'mistral', - 'fireworks', - 'firecrawl', - 'exa', - 'serper', - 'linkup', - 'perplexity', - 'jina', - 'google_cloud', - 'parallel_ai', - 'brandfetch', -] as const - -const UpsertKeySchema = z.object({ - providerId: z.enum(VALID_PROVIDERS), - apiKey: z.string().min(1, 'API key is required'), -}) - -const DeleteKeySchema = z.object({ - providerId: z.enum(VALID_PROVIDERS), -}) - function maskApiKey(key: string): string { if (key.length <= 8) { return '•'.repeat(8) @@ -153,7 +128,7 @@ export const POST = withRouteHandler( } const body = await request.json() - const { providerId, apiKey } = UpsertKeySchema.parse(body) + const { providerId, apiKey } = upsertByokKeyBodySchema.parse(body) const { encrypted } = await encryptSecret(apiKey) @@ -256,8 +231,8 @@ export const POST = withRouteHandler( }) } catch (error: unknown) { logger.error(`[${requestId}] BYOK key POST error`, error) - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + if (isZodError(error)) { + return NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }) } return NextResponse.json( { error: error instanceof Error ? error.message : 'Failed to save BYOK key' }, @@ -290,7 +265,7 @@ export const DELETE = withRouteHandler( } const body = await request.json() - const { providerId } = DeleteKeySchema.parse(body) + const { providerId } = deleteByokKeyBodySchema.parse(body) const result = await db .delete(workspaceBYOKKeys) @@ -326,8 +301,8 @@ export const DELETE = withRouteHandler( return NextResponse.json({ success: true }) } catch (error: unknown) { logger.error(`[${requestId}] BYOK key DELETE error`, error) - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + if (isZodError(error)) { + return NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }) } return NextResponse.json( { error: error instanceof Error ? error.message : 'Failed to delete BYOK key' }, diff --git a/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts b/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts index c907ae337be..0e759a02f9b 100644 --- a/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts @@ -1,3 +1,4 @@ +import { workspaceParamsSchema, workspacePreviewBodySchema } from '@/lib/api/contracts/workspaces' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDocumentPreviewRoute } from '@/app/api/workspaces/[id]/_preview/create-preview-route' @@ -13,5 +14,7 @@ export const POST = withRouteHandler( taskId: 'docx-generate', contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', label: 'DOCX', + routeParamsSchema: workspaceParamsSchema, + previewBodySchema: workspacePreviewBodySchema, }) ) diff --git a/apps/sim/app/api/workspaces/[id]/duplicate/route.ts b/apps/sim/app/api/workspaces/[id]/duplicate/route.ts index 3d0f939073e..3271fb54554 100644 --- a/apps/sim/app/api/workspaces/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workspaces/[id]/duplicate/route.ts @@ -1,7 +1,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { duplicateWorkspaceBodySchema } from '@/lib/api/contracts/workspaces' +import { validateSchema } 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' @@ -9,10 +10,6 @@ import { duplicateWorkspace } from '@/lib/workspaces/duplicate' const logger = createLogger('WorkspaceDuplicateAPI') -const DuplicateRequestSchema = z.object({ - name: z.string().min(1, 'Name is required'), -}) - // POST /api/workspaces/[id]/duplicate - Duplicate a workspace with all its workflows export const POST = withRouteHandler( async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { @@ -30,7 +27,14 @@ export const POST = withRouteHandler( try { const body = await req.json() - const { name } = DuplicateRequestSchema.parse(body) + const validation = validateSchema(duplicateWorkspaceBodySchema, body, 'Invalid request data') + if (!validation.success) { + logger.warn(`[${requestId}] Invalid duplication request data`, { + errors: validation.error.issues, + }) + return validation.response + } + const { name } = validation.data logger.info( `[${requestId}] Duplicating workspace ${sourceWorkspaceId} for user ${session.user.id}` @@ -81,14 +85,6 @@ export const POST = withRouteHandler( } } - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid duplication request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - const elapsed = Date.now() - startTime logger.error( `[${requestId}] Error duplicating workspace ${sourceWorkspaceId} after ${elapsed}ms:`, diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index 4cabaec2583..9643b779113 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -5,7 +5,10 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + removeWorkspaceEnvironmentBodySchema, + savePersonalEnvironmentBodySchema, +} from '@/lib/api/contracts/environment' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' @@ -19,14 +22,6 @@ import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/per const logger = createLogger('WorkspaceEnvironmentAPI') -const UpsertSchema = z.object({ - variables: z.record(z.string()), -}) - -const DeleteSchema = z.object({ - keys: z.array(z.string()).min(1), -}) - export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() @@ -97,7 +92,7 @@ export const PUT = withRouteHandler( } const body = await request.json() - const { variables } = UpsertSchema.parse(body) + const { variables } = savePersonalEnvironmentBodySchema.parse(body) // Read existing encrypted ws vars const existingRows = await db @@ -183,7 +178,7 @@ export const DELETE = withRouteHandler( } const body = await request.json() - const { keys } = DeleteSchema.parse(body) + const { keys } = removeWorkspaceEnvironmentBodySchema.parse(body) const wsRows = await db .select() 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 155f426607b..a0924939747 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 @@ -2,6 +2,11 @@ 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 { + updateWorkspaceFileContentBodySchema, + workspaceFileParamsSchema, +} from '@/lib/api/contracts/workspace-files' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace' @@ -17,7 +22,14 @@ const logger = createLogger('WorkspaceFileContentAPI') */ export const PUT = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { - const { id: workspaceId, fileId } = await params + 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() @@ -35,12 +47,16 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - const body = await request.json() - const { content, encoding } = body as { content: string; encoding?: 'base64' | 'utf-8' } - - if (typeof content !== 'string') { - return NextResponse.json({ error: 'Content must be a string' }, { status: 400 }) + const bodyResult = updateWorkspaceFileContentBodySchema.safeParse( + await request.json().catch(() => ({})) + ) + if (!bodyResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(bodyResult.error, 'Content must be a string') }, + { status: 400 } + ) } + const { content, encoding } = bodyResult.data const buffer = encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8') 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 597d0290cfe..d14969e131d 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,5 +1,7 @@ import { createLogger } from '@sim/logger' 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' @@ -18,7 +20,14 @@ const logger = createLogger('WorkspaceFileDownloadAPI') export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { const requestId = generateRequestId() - const { id: workspaceId, fileId } = await params + 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() 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 525db3167c4..fe6ef946bc1 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,6 +1,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' 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' @@ -12,7 +14,14 @@ const logger = createLogger('RestoreWorkspaceFileAPI') export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { const requestId = generateRequestId() - const { id: workspaceId, fileId } = await params + 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() 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 1e9f63a61d0..83575ba10ec 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts @@ -1,6 +1,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { + renameWorkspaceFileBodySchema, + 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' @@ -22,7 +27,14 @@ const logger = createLogger('WorkspaceFileAPI') export const PATCH = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { const requestId = generateRequestId() - const { id: workspaceId, fileId } = await params + 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() @@ -42,12 +54,16 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - const body = await request.json() - const { name } = body - - if (!name || typeof name !== 'string' || !name.trim()) { - return NextResponse.json({ error: 'Name is required' }, { status: 400 }) + const bodyResult = renameWorkspaceFileBodySchema.safeParse( + await request.json().catch(() => ({})) + ) + if (!bodyResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(bodyResult.error, 'Name is required') }, + { status: 400 } + ) } + const { name } = bodyResult.data const updatedFile = await renameWorkspaceFile(workspaceId, fileId, name) @@ -90,7 +106,14 @@ export const PATCH = withRouteHandler( export const DELETE = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { const requestId = generateRequestId() - const { id: workspaceId, fileId } = await params + 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() diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index a006dd2e963..090e2aa1ee2 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -1,6 +1,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { + listWorkspaceFilesQuerySchema, + workspaceFilesParamsSchema, +} 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' @@ -9,7 +14,6 @@ import { FileConflictError, listWorkspaceFiles, uploadWorkspaceFile, - type WorkspaceFileScope, } from '@/lib/uploads/contexts/workspace' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' @@ -25,7 +29,14 @@ const logger = createLogger('WorkspaceFilesAPI') export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id: workspaceId } = await params + 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() @@ -42,11 +53,16 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - const scope = (new URL(request.url).searchParams.get('scope') ?? - 'active') as WorkspaceFileScope - if (!['active', 'archived', 'all'].includes(scope)) { - return NextResponse.json({ error: 'Invalid scope' }, { status: 400 }) + 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 }) @@ -76,7 +92,14 @@ export const GET = withRouteHandler( export const POST = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id: workspaceId } = await params + 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() diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.ts index 34cdc7e8037..702749e0f9b 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.ts @@ -2,7 +2,8 @@ import { db, mothershipInboxTask, workspace } from '@sim/db' import { createLogger } from '@sim/logger' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateInboxConfigBodySchema } from '@/lib/api/contracts/inbox' +import { validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasInboxAccess } from '@/lib/billing/core/subscription' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,11 +12,6 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InboxConfigAPI') -const patchSchema = z.object({ - enabled: z.boolean().optional(), - username: z.string().min(1).max(64).optional(), -}) - export const GET = withRouteHandler( async (_req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const { id: workspaceId } = await params @@ -101,7 +97,13 @@ export const PATCH = withRouteHandler( } try { - const body = patchSchema.parse(await req.json()) + const validation = validateSchema( + updateInboxConfigBodySchema, + await req.json(), + 'Invalid request' + ) + if (!validation.success) return validation.response + const body = validation.data if (body.enabled === true) { const [current] = await db @@ -128,13 +130,6 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: 'No valid update provided' }, { status: 400 }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request', details: error.errors }, - { status: 400 } - ) - } - logger.error('Inbox config update failed', { workspaceId, error: error instanceof Error ? error.message : 'Unknown error', diff --git a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts index 41c87210e5f..2fc9a23ed09 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts @@ -3,7 +3,8 @@ 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 { z } from 'zod' +import { createInboxSenderBodySchema, deleteInboxSenderBodySchema } from '@/lib/api/contracts/inbox' +import { validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasInboxAccess } from '@/lib/billing/core/subscription' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,15 +12,6 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InboxSendersAPI') -const addSenderSchema = z.object({ - email: z.string().email('Invalid email address'), - label: z.string().max(100).optional(), -}) - -const deleteSenderSchema = z.object({ - senderId: z.string().min(1), -}) - export const GET = withRouteHandler( async (_req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const { id: workspaceId } = await params @@ -96,7 +88,13 @@ export const POST = withRouteHandler( } try { - const { email, label } = addSenderSchema.parse(await req.json()) + const validation = validateSchema( + createInboxSenderBodySchema, + await req.json(), + 'Invalid request' + ) + if (!validation.success) return validation.response + const { email, label } = validation.data const normalizedEmail = email.toLowerCase() const [existing] = await db @@ -127,12 +125,6 @@ export const POST = withRouteHandler( return NextResponse.json({ sender }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request', details: error.errors }, - { status: 400 } - ) - } logger.error('Failed to add sender', { workspaceId, error }) return NextResponse.json({ error: 'Failed to add sender' }, { status: 500 }) } @@ -159,7 +151,13 @@ export const DELETE = withRouteHandler( } try { - const { senderId } = deleteSenderSchema.parse(await req.json()) + const validation = validateSchema( + deleteInboxSenderBodySchema, + await req.json(), + 'Invalid request' + ) + if (!validation.success) return validation.response + const { senderId } = validation.data await db .delete(mothershipInboxAllowedSender) @@ -172,12 +170,6 @@ export const DELETE = withRouteHandler( return NextResponse.json({ ok: true }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request', details: error.errors }, - { status: 400 } - ) - } logger.error('Failed to delete sender', { workspaceId, error }) return NextResponse.json({ error: 'Failed to delete sender' }, { status: 500 }) } diff --git a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts index 77b536e2215..63e4b398a28 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts @@ -1,17 +1,23 @@ import { db, mothershipInboxTask } from '@sim/db' -import { createLogger } from '@sim/logger' import { and, desc, eq, lt } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { inboxTasksQuerySchema, inboxWorkspaceParamsSchema } from '@/lib/api/contracts/inbox' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasInboxAccess } from '@/lib/billing/core/subscription' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -const logger = createLogger('InboxTasksAPI') - export const GET = withRouteHandler( async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id: workspaceId } = await params + const paramsResult = inboxWorkspaceParamsSchema.safeParse(await params) + if (!paramsResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, + { status: 400 } + ) + } + const { id: workspaceId } = paramsResult.data const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -28,18 +34,23 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Not found' }, { status: 404 }) } - const url = new URL(req.url) - const status = url.searchParams.get('status') || 'all' - const limit = Math.min(Number(url.searchParams.get('limit') || '20'), 50) - const cursor = url.searchParams.get('cursor') // ISO date string for cursor-based pagination + const queryResult = inboxTasksQuerySchema.safeParse( + Object.fromEntries(req.nextUrl.searchParams.entries()) + ) + if (!queryResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(queryResult.error, 'Invalid query parameters') }, + { status: 400 } + ) + } + + const { cursor } = queryResult.data + const status = queryResult.data.status ?? 'all' + const limit = queryResult.data.limit ?? 20 const conditions = [eq(mothershipInboxTask.workspaceId, workspaceId)] - const validStatuses = ['received', 'processing', 'completed', 'failed', 'rejected'] as const if (status !== 'all') { - if (!validStatuses.includes(status as (typeof validStatuses)[number])) { - return NextResponse.json({ error: 'Invalid status filter' }, { status: 400 }) - } conditions.push(eq(mothershipInboxTask.status, status)) } diff --git a/apps/sim/app/api/workspaces/[id]/members/route.ts b/apps/sim/app/api/workspaces/[id]/members/route.ts index 8a46593570c..aadf468d583 100644 --- a/apps/sim/app/api/workspaces/[id]/members/route.ts +++ b/apps/sim/app/api/workspaces/[id]/members/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { workspaceParamsSchema } from '@/lib/api/contracts' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -19,7 +21,14 @@ const logger = createLogger('WorkspaceMembersAPI') export const GET = withRouteHandler( async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { try { - const { id: workspaceId } = await params + const paramsResult = workspaceParamsSchema.safeParse(await params) + if (!paramsResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, + { status: 400 } + ) + } + const { id: workspaceId } = paramsResult.data const session = await getSession() if (!session?.user?.id) { diff --git a/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts b/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts index 506688d27c9..9562343261c 100644 --- a/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts @@ -3,32 +3,20 @@ import { pausedExecutions, permissions, workflow, workflowExecutionLogs } from ' import { createLogger } from '@sim/logger' import { and, eq, gte, inArray, isNotNull, isNull, lte, or, type SQL, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { workspaceMetricsExecutionsQuerySchema } from '@/lib/api/contracts/workspaces' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('MetricsExecutionsAPI') -const QueryParamsSchema = z.object({ - startTime: z.string().optional(), - endTime: z.string().optional(), - segments: z.coerce.number().min(1).max(200).default(72), - workflowIds: z.string().optional(), - folderIds: z.string().optional(), - triggers: z.string().optional(), - level: z.string().optional(), // Supports comma-separated values: 'error,running' - allTime: z - .enum(['true', 'false']) - .optional() - .transform((v) => v === 'true'), -}) - export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { try { const { id: workspaceId } = await params const { searchParams } = new URL(request.url) - const qp = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) + const qp = workspaceMetricsExecutionsQuerySchema.parse( + Object.fromEntries(searchParams.entries()) + ) const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts index 201a34bf704..e4c67f3f135 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts @@ -4,7 +4,7 @@ import { workflow, workspaceNotificationSubscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { buildServerUpdateNotificationSchema } from '@/lib/api/contracts/notifications' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -14,87 +14,11 @@ import { MAX_EMAIL_RECIPIENTS, MAX_WORKFLOW_IDS } from '../constants' const logger = createLogger('WorkspaceNotificationAPI') -const levelFilterSchema = z.array(z.enum(['info', 'error'])) -const triggerFilterSchema = z.array(z.string().min(1)) - -const alertRuleSchema = z.enum([ - 'consecutive_failures', - 'failure_rate', - 'latency_threshold', - 'latency_spike', - 'cost_threshold', - 'no_activity', - 'error_count', -]) - -const alertConfigSchema = z - .object({ - rule: alertRuleSchema, - consecutiveFailures: z.number().int().min(1).max(100).optional(), - failureRatePercent: z.number().int().min(1).max(100).optional(), - windowHours: z.number().int().min(1).max(168).optional(), - durationThresholdMs: z.number().int().min(1000).max(3600000).optional(), - latencySpikePercent: z.number().int().min(10).max(1000).optional(), - costThresholdDollars: z.number().min(0.01).max(1000).optional(), - inactivityHours: z.number().int().min(1).max(168).optional(), - errorCountThreshold: z.number().int().min(1).max(1000).optional(), - }) - .refine( - (data) => { - switch (data.rule) { - case 'consecutive_failures': - return data.consecutiveFailures !== undefined - case 'failure_rate': - return data.failureRatePercent !== undefined && data.windowHours !== undefined - case 'latency_threshold': - return data.durationThresholdMs !== undefined - case 'latency_spike': - return data.latencySpikePercent !== undefined && data.windowHours !== undefined - case 'cost_threshold': - return data.costThresholdDollars !== undefined - case 'no_activity': - return data.inactivityHours !== undefined - case 'error_count': - return data.errorCountThreshold !== undefined && data.windowHours !== undefined - default: - return false - } - }, - { message: 'Missing required fields for alert rule' } - ) - .nullable() - -const webhookConfigSchema = z.object({ - url: z.string().url(), - secret: z.string().optional(), -}) - -const slackConfigSchema = z.object({ - channelId: z.string(), - channelName: z.string(), - accountId: z.string(), +const updateNotificationSchema = buildServerUpdateNotificationSchema({ + maxEmailRecipients: MAX_EMAIL_RECIPIENTS, + maxWorkflowIds: MAX_WORKFLOW_IDS, }) -const updateNotificationSchema = z - .object({ - workflowIds: z.array(z.string()).max(MAX_WORKFLOW_IDS).optional(), - allWorkflows: z.boolean().optional(), - levelFilter: levelFilterSchema.optional(), - triggerFilter: triggerFilterSchema.optional(), - includeFinalOutput: z.boolean().optional(), - includeTraceSpans: z.boolean().optional(), - includeRateLimits: z.boolean().optional(), - includeUsageData: z.boolean().optional(), - alertConfig: alertConfigSchema.optional(), - webhookConfig: webhookConfigSchema.optional(), - emailRecipients: z.array(z.string().email()).max(MAX_EMAIL_RECIPIENTS).optional(), - slackConfig: slackConfigSchema.optional(), - active: z.boolean().optional(), - }) - .refine((data) => !(data.allWorkflows && data.workflowIds && data.workflowIds.length > 0), { - message: 'Cannot specify both allWorkflows and workflowIds', - }) - type RouteParams = { params: Promise<{ id: string; notificationId: string }> } async function checkWorkspaceWriteAccess( @@ -192,7 +116,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, { params }: Rou if (!validationResult.success) { return NextResponse.json( - { error: 'Invalid request', details: validationResult.error.errors }, + { error: 'Invalid request', details: validationResult.error.issues }, { status: 400 } ) } diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts index d3afc81e232..97e6942808e 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts @@ -11,6 +11,8 @@ import { type EmailUsageData, renderWorkflowNotificationEmail, } from '@/components/emails' +import { notificationParamsSchema } from '@/lib/api/contracts/notifications' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { decryptSecret } from '@/lib/core/security/encryption' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' @@ -286,7 +288,14 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: workspaceId, notificationId } = await params + const paramsResult = notificationParamsSchema.safeParse(await params) + if (!paramsResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, + { status: 400 } + ) + } + const { id: workspaceId, notificationId } = paramsResult.data const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) if (permission !== 'write' && permission !== 'admin') { diff --git a/apps/sim/app/api/workspaces/[id]/notifications/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/route.ts index 25bd80bba8c..856cc062f59 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/route.ts @@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { buildServerCreateNotificationSchema } from '@/lib/api/contracts/notifications' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -15,99 +15,11 @@ import { MAX_EMAIL_RECIPIENTS, MAX_NOTIFICATIONS_PER_TYPE, MAX_WORKFLOW_IDS } fr const logger = createLogger('WorkspaceNotificationsAPI') -const notificationTypeSchema = z.enum(['webhook', 'email', 'slack']) -const levelFilterSchema = z.array(z.enum(['info', 'error'])) -const triggerFilterSchema = z.array(z.string().min(1)) - -const alertRuleSchema = z.enum([ - 'consecutive_failures', - 'failure_rate', - 'latency_threshold', - 'latency_spike', - 'cost_threshold', - 'no_activity', - 'error_count', -]) - -const alertConfigSchema = z - .object({ - rule: alertRuleSchema, - consecutiveFailures: z.number().int().min(1).max(100).optional(), - failureRatePercent: z.number().int().min(1).max(100).optional(), - windowHours: z.number().int().min(1).max(168).optional(), - durationThresholdMs: z.number().int().min(1000).max(3600000).optional(), - latencySpikePercent: z.number().int().min(10).max(1000).optional(), - costThresholdDollars: z.number().min(0.01).max(1000).optional(), - inactivityHours: z.number().int().min(1).max(168).optional(), - errorCountThreshold: z.number().int().min(1).max(1000).optional(), - }) - .refine( - (data) => { - switch (data.rule) { - case 'consecutive_failures': - return data.consecutiveFailures !== undefined - case 'failure_rate': - return data.failureRatePercent !== undefined && data.windowHours !== undefined - case 'latency_threshold': - return data.durationThresholdMs !== undefined - case 'latency_spike': - return data.latencySpikePercent !== undefined && data.windowHours !== undefined - case 'cost_threshold': - return data.costThresholdDollars !== undefined - case 'no_activity': - return data.inactivityHours !== undefined - case 'error_count': - return data.errorCountThreshold !== undefined && data.windowHours !== undefined - default: - return false - } - }, - { message: 'Missing required fields for alert rule' } - ) - .nullable() - -const webhookConfigSchema = z.object({ - url: z.string().url(), - secret: z.string().optional(), +const createNotificationSchema = buildServerCreateNotificationSchema({ + maxEmailRecipients: MAX_EMAIL_RECIPIENTS, + maxWorkflowIds: MAX_WORKFLOW_IDS, }) -const slackConfigSchema = z.object({ - channelId: z.string(), - channelName: z.string(), - accountId: z.string(), -}) - -const createNotificationSchema = z - .object({ - notificationType: notificationTypeSchema, - workflowIds: z.array(z.string()).max(MAX_WORKFLOW_IDS).default([]), - allWorkflows: z.boolean().default(false), - levelFilter: levelFilterSchema.default(['info', 'error']), - triggerFilter: triggerFilterSchema.default([]), - includeFinalOutput: z.boolean().default(false), - includeTraceSpans: z.boolean().default(false), - includeRateLimits: z.boolean().default(false), - includeUsageData: z.boolean().default(false), - alertConfig: alertConfigSchema.optional(), - webhookConfig: webhookConfigSchema.optional(), - emailRecipients: z.array(z.string().email()).max(MAX_EMAIL_RECIPIENTS).optional(), - slackConfig: slackConfigSchema.optional(), - }) - .refine( - (data) => { - if (data.notificationType === 'webhook') return !!data.webhookConfig?.url - if (data.notificationType === 'email') - return !!data.emailRecipients && data.emailRecipients.length > 0 - if (data.notificationType === 'slack') - return !!data.slackConfig?.channelId && !!data.slackConfig?.accountId - return false - }, - { message: 'Missing required fields for notification type' } - ) - .refine((data) => !(data.allWorkflows && data.workflowIds.length > 0), { - message: 'Cannot specify both allWorkflows and workflowIds', - }) - async function checkWorkspaceWriteAccess( userId: string, workspaceId: string @@ -184,7 +96,7 @@ export const POST = withRouteHandler( if (!validationResult.success) { return NextResponse.json( - { error: 'Invalid request', details: validationResult.error.errors }, + { error: 'Invalid request', details: validationResult.error.issues }, { status: 400 } ) } diff --git a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts index 71450a60c8b..6d1c1231119 100644 --- a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts @@ -1,3 +1,4 @@ +import { workspaceParamsSchema, workspacePreviewBodySchema } from '@/lib/api/contracts/workspaces' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDocumentPreviewRoute } from '@/app/api/workspaces/[id]/_preview/create-preview-route' @@ -13,5 +14,7 @@ export const POST = withRouteHandler( taskId: 'pdf-generate', contentType: 'application/pdf', label: 'PDF', + routeParamsSchema: workspaceParamsSchema, + previewBodySchema: workspacePreviewBodySchema, }) ) diff --git a/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/members/bulk/route.ts b/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/members/bulk/route.ts index a0031101bf4..c557e2d0660 100644 --- a/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/members/bulk/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/members/bulk/route.ts @@ -6,7 +6,8 @@ import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/erro import { generateId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { bulkAddPermissionGroupMembersBodySchema } from '@/lib/api/contracts/permission-groups' +import { getValidationErrorMessage, isZodError } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { isWorkspaceOnEnterprisePlan } from '@/lib/billing' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -29,11 +30,6 @@ async function loadGroupInWorkspace(groupId: string, workspaceId: string) { return group ?? null } -const bulkAddSchema = z.object({ - userIds: z.array(z.string()).optional(), - addAllWorkspaceMembers: z.boolean().optional(), -}) - export const POST = withRouteHandler( async (req: NextRequest, { params }: { params: Promise<{ id: string; groupId: string }> }) => { const session = await getSession() @@ -63,7 +59,8 @@ export const POST = withRouteHandler( } const body = await req.json() - const { userIds, addAllWorkspaceMembers } = bulkAddSchema.parse(body) + const { userIds, addAllWorkspaceMembers } = + bulkAddPermissionGroupMembersBodySchema.parse(body) let targetUserIds: string[] = [] @@ -183,8 +180,8 @@ export const POST = withRouteHandler( return NextResponse.json({ added: addedUserIds.length, moved: movedCount }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + if (isZodError(error)) { + return NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }) } if (getPostgresErrorCode(error) === '23505') { const constraint = getPostgresConstraintName(error) diff --git a/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/members/route.ts b/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/members/route.ts index d7a0cdc59b6..4135adfb0d2 100644 --- a/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/members/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/members/route.ts @@ -6,7 +6,8 @@ import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/erro import { generateId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { addPermissionGroupMemberBodySchema } from '@/lib/api/contracts/permission-groups' +import { getValidationErrorMessage, isZodError } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { isWorkspaceOnEnterprisePlan } from '@/lib/billing' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -76,10 +77,6 @@ export const GET = withRouteHandler( } ) -const addMemberSchema = z.object({ - userId: z.string().min(1), -}) - export const POST = withRouteHandler( async (req: NextRequest, { params }: { params: Promise<{ id: string; groupId: string }> }) => { const session = await getSession() @@ -109,7 +106,7 @@ export const POST = withRouteHandler( } const body = await req.json() - const { userId } = addMemberSchema.parse(body) + const { userId } = addPermissionGroupMemberBodySchema.parse(body) const [workspaceMember] = await db .select({ email: user.email }) @@ -202,8 +199,8 @@ export const POST = withRouteHandler( return NextResponse.json({ member: newMember }, { status: 201 }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + if (isZodError(error)) { + return NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }) } if (error instanceof Error && error.message === 'ALREADY_IN_GROUP') { return NextResponse.json( diff --git a/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/route.ts b/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/route.ts index bc4f4643b5b..e68fbaddf9e 100644 --- a/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permission-groups/[groupId]/route.ts @@ -4,26 +4,19 @@ import { permissionGroup, permissionGroupMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updatePermissionGroupBodySchema } from '@/lib/api/contracts/permission-groups' +import { getValidationErrorMessage, isZodError } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { isWorkspaceOnEnterprisePlan } from '@/lib/billing' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { type PermissionGroupConfig, parsePermissionGroupConfig, - permissionGroupConfigSchema, } from '@/lib/permission-groups/types' import { checkWorkspaceAccess, hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspacePermissionGroup') -const updateSchema = z.object({ - name: z.string().trim().min(1).max(100).optional(), - description: z.string().max(500).nullable().optional(), - config: permissionGroupConfigSchema.optional(), - autoAddNewMembers: z.boolean().optional(), -}) - async function loadGroupInWorkspace(groupId: string, workspaceId: string) { const [group] = await db .select({ @@ -113,7 +106,7 @@ export const PUT = withRouteHandler( } const body = await req.json() - const updates = updateSchema.parse(body) + const updates = updatePermissionGroupBodySchema.parse(body) if (updates.name) { const existingGroup = await db @@ -201,8 +194,8 @@ export const PUT = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + if (isZodError(error)) { + return NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }) } logger.error('Error updating permission group', error) return NextResponse.json({ error: 'Failed to update permission group' }, { status: 500 }) diff --git a/apps/sim/app/api/workspaces/[id]/permission-groups/route.ts b/apps/sim/app/api/workspaces/[id]/permission-groups/route.ts index 1680b7c1128..3486fa6a53b 100644 --- a/apps/sim/app/api/workspaces/[id]/permission-groups/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permission-groups/route.ts @@ -5,7 +5,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, count, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { createPermissionGroupBodySchema } from '@/lib/api/contracts/permission-groups' +import { getValidationErrorMessage, isZodError } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { isWorkspaceOnEnterprisePlan } from '@/lib/billing' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -13,19 +14,11 @@ import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, parsePermissionGroupConfig, - permissionGroupConfigSchema, } from '@/lib/permission-groups/types' import { checkWorkspaceAccess, hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspacePermissionGroups') -const createSchema = z.object({ - name: z.string().trim().min(1).max(100), - description: z.string().max(500).optional(), - config: permissionGroupConfigSchema.optional(), - autoAddNewMembers: z.boolean().optional(), -}) - export const GET = withRouteHandler( async (_req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const session = await getSession() @@ -112,7 +105,8 @@ export const POST = withRouteHandler( } const body = await req.json() - const { name, description, config, autoAddNewMembers } = createSchema.parse(body) + const { name, description, config, autoAddNewMembers } = + createPermissionGroupBodySchema.parse(body) const existingGroup = await db .select({ id: permissionGroup.id }) @@ -182,8 +176,8 @@ export const POST = withRouteHandler( return NextResponse.json({ permissionGroup: newGroup }, { status: 201 }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + if (isZodError(error)) { + return NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }) } logger.error('Error creating permission group', error) return NextResponse.json({ error: 'Failed to create permission group' }, { status: 500 }) diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index 236bcb7187b..332e0e87858 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -5,7 +5,7 @@ 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 { z } from 'zod' +import { updateWorkspacePermissionsBodySchema } from '@/lib/api/contracts/workspaces' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' @@ -21,15 +21,6 @@ import { const logger = createLogger('WorkspacesPermissionsAPI') -const updatePermissionsSchema = z.object({ - updates: z.array( - z.object({ - userId: z.string(), - permissions: z.enum(['admin', 'write', 'read']), - }) - ), -}) - /** * GET /api/workspaces/[id]/permissions * @@ -116,7 +107,7 @@ export const PATCH = withRouteHandler( ) } - const body = updatePermissionsSchema.parse(await request.json()) + const body = updateWorkspacePermissionsBodySchema.parse(await request.json()) const workspaceRow = await db .select({ billedAccountUserId: workspace.billedAccountUserId }) diff --git a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts index 6c5bc642348..9d4631c67fa 100644 --- a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts @@ -1,3 +1,4 @@ +import { workspaceParamsSchema, workspacePreviewBodySchema } from '@/lib/api/contracts/workspaces' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDocumentPreviewRoute } from '@/app/api/workspaces/[id]/_preview/create-preview-route' @@ -13,5 +14,7 @@ export const POST = withRouteHandler( taskId: 'pptx-generate', contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', label: 'PPTX', + routeParamsSchema: workspaceParamsSchema, + previewBodySchema: workspacePreviewBodySchema, }) ) diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index a17a78a2a55..4e17253b5f0 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -3,7 +3,8 @@ import { workflow } 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 { z } from 'zod' +import { deleteWorkspaceBodySchema, updateWorkspaceBodySchema } from '@/lib/api/contracts' +import { validateJsonBody, validateSchema } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { captureServerEvent } from '@/lib/posthog/server' import { archiveWorkspace } from '@/lib/workspaces/lifecycle' @@ -15,27 +16,6 @@ import { permissions, templates, workspace } from '@sim/db/schema' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -const patchWorkspaceSchema = z.object({ - name: z.string().trim().min(1).optional(), - color: z - .string() - .regex(/^#[0-9a-fA-F]{6}$/) - .optional(), - logoUrl: z - .string() - .refine((val) => val.startsWith('/') || val.startsWith('https://'), { - message: 'Logo URL must be an absolute path or HTTPS URL', - }) - .nullable() - .optional(), - billedAccountUserId: z.string().optional(), - allowPersonalApiKeys: z.boolean().optional(), -}) - -const deleteWorkspaceSchema = z.object({ - deleteTemplates: z.boolean().default(false), -}) - export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const { id } = await params @@ -65,7 +45,11 @@ export const GET = withRouteHandler( .where(eq(workflow.workspaceId, workspaceId)) if (workspaceWorkflows.length === 0) { - return NextResponse.json({ hasPublishedTemplates: false, publishedTemplates: [] }) + return NextResponse.json({ + hasPublishedTemplates: false, + publishedTemplates: [], + count: 0, + }) } const workflowIds = workspaceWorkflows.map((w) => w.id) @@ -128,8 +112,11 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } + const bodyValidation = await validateJsonBody(request, updateWorkspaceBodySchema) + if (!bodyValidation.success) return bodyValidation.response + try { - const body = patchWorkspaceSchema.parse(await request.json()) + const body = bodyValidation.data const { name, color, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body if ( @@ -298,8 +285,10 @@ export const DELETE = withRouteHandler( } const workspaceId = id - const body = deleteWorkspaceSchema.parse(await request.json().catch(() => ({}))) - const { deleteTemplates } = body // User's choice: false = keep templates (recommended), true = delete templates + const rawBody = await request.json().catch(() => ({})) + const bodyValidation = validateSchema(deleteWorkspaceBodySchema, rawBody) + if (!bodyValidation.success) return bodyValidation.response + const { deleteTemplates } = bodyValidation.data // User's choice: false = keep templates (recommended), true = delete templates // Check if user has admin permissions to delete workspace const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) diff --git a/apps/sim/app/api/workspaces/invitations/batch/route.ts b/apps/sim/app/api/workspaces/invitations/batch/route.ts index 1f48746dd5a..cb70597cde0 100644 --- a/apps/sim/app/api/workspaces/invitations/batch/route.ts +++ b/apps/sim/app/api/workspaces/invitations/batch/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { batchWorkspaceInvitationBodySchema } from '@/lib/api/contracts/invitations' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { normalizeEmail } from '@/lib/invitations/core' @@ -21,20 +22,6 @@ interface BatchInvitationFailure { error: string } -const batchInvitationSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - invitations: z - .array( - z.object({ - email: z.string().trim().min(1, 'Invitation email is required'), - permission: z.string().optional(), - }) - ) - .min(1, 'At least one invitation is required'), -}) - -type BatchInvitationRequest = z.infer - function batchErrorResponse(error: unknown) { if (error instanceof WorkspaceInvitationError) { return NextResponse.json( @@ -62,14 +49,18 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } try { - const parsedBody = batchInvitationSchema.safeParse(await req.json().catch(() => null)) + const parsedBody = batchWorkspaceInvitationBodySchema.safeParse( + await req.json().catch(() => null) + ) if (!parsedBody.success) { return NextResponse.json( - { error: parsedBody.error.errors[0]?.message ?? 'Invalid invitation batch payload' }, + { + error: getValidationErrorMessage(parsedBody.error, 'Invalid invitation batch payload'), + }, { status: 400 } ) } - const body: BatchInvitationRequest = parsedBody.data + const body = parsedBody.data const context = await prepareWorkspaceInvitationContext({ workspaceId: body.workspaceId, diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index b178c4c2905..5dff856f9fe 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -5,7 +5,7 @@ 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 { z } from 'zod' +import { removeWorkspaceMemberBodySchema } from '@/lib/api/contracts/invitations' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' @@ -13,9 +13,6 @@ import { captureServerEvent } from '@/lib/posthog/server' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceMemberAPI') -const deleteMemberSchema = z.object({ - workspaceId: z.string().uuid(), -}) // DELETE /api/workspaces/members/[id] - Remove a member from a workspace export const DELETE = withRouteHandler( @@ -29,7 +26,7 @@ export const DELETE = withRouteHandler( try { // Get the workspace ID from the request body or URL - const body = deleteMemberSchema.parse(await req.json()) + const body = removeWorkspaceMemberBodySchema.parse(await req.json()) const { workspaceId } = body const workspaceRow = await db diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index e1e874170a9..53b2e9e6e87 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, desc, eq, isNull, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' -import { z } from 'zod' +import { createWorkspaceBodySchema, listWorkspacesQuerySchema } from '@/lib/api/contracts' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -22,19 +22,9 @@ import { UPGRADE_TO_INVITE_REASON, WORKSPACE_MODE, } from '@/lib/workspaces/policy' -import type { WorkspaceScope } from '@/lib/workspaces/utils' const logger = createLogger('Workspaces') -const createWorkspaceSchema = z.object({ - name: z.string().trim().min(1, 'Name is required'), - color: z - .string() - .regex(/^#[0-9a-fA-F]{6}$/) - .optional(), - skipDefaultWorkflow: z.boolean().optional().default(false), -}) - // Get all workspaces for the current user export const GET = withRouteHandler(async (request: Request) => { const session = await getSession() @@ -50,10 +40,16 @@ export const GET = withRouteHandler(async (request: Request) => { activeOrganizationId, }) - const scope = (new URL(request.url).searchParams.get('scope') ?? 'active') as WorkspaceScope - if (!['active', 'archived', 'all'].includes(scope)) { - return NextResponse.json({ error: 'Invalid scope' }, { status: 400 }) + const scopeResult = listWorkspacesQuerySchema.safeParse( + Object.fromEntries(new URL(request.url).searchParams.entries()) + ) + if (!scopeResult.success) { + return NextResponse.json( + { error: 'Invalid query parameters', details: scopeResult.error.issues }, + { status: 400 } + ) } + const { scope } = scopeResult.data const settingsQuery = db .select({ lastActiveWorkspaceId: settings.lastActiveWorkspaceId }) @@ -180,7 +176,7 @@ export const POST = withRouteHandler(async (req: Request) => { } try { - const { name, color, skipDefaultWorkflow } = createWorkspaceSchema.parse(await req.json()) + const { name, color, skipDefaultWorkflow } = createWorkspaceBodySchema.parse(await req.json()) const activeOrganizationId = (session.session as { activeOrganizationId?: string } | null)?.activeOrganizationId ?? null const creationPolicy = await getWorkspaceCreationPolicy({ diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/chat/[identifier]/chat.tsx index f43faf5eb51..279c7d29932 100644 --- a/apps/sim/app/chat/[identifier]/chat.tsx +++ b/apps/sim/app/chat/[identifier]/chat.tsx @@ -26,7 +26,7 @@ const logger = createLogger('ChatClient') interface AudioStreamingOptions { voiceId: string - chatId?: string + chatId: string onError: (error: Error) => void } @@ -69,7 +69,7 @@ function fileToBase64(file: File): Promise { function createAudioStreamHandler( streamTextToAudio: (text: string, options: AudioStreamingOptions) => Promise, voiceId: string, - chatId?: string + chatId: string ) { return async (text: string) => { try { @@ -299,13 +299,14 @@ export default function ChatClient({ identifier }: { identifier: string }) { } const shouldPlayAudio = isVoiceInput || isVoiceFirstMode - const audioHandler = shouldPlayAudio - ? createAudioStreamHandler( - streamTextToAudio, - DEFAULT_VOICE_SETTINGS.voiceId, - chatConfig?.id - ) - : undefined + const audioHandler = + shouldPlayAudio && chatConfig?.id + ? createAudioStreamHandler( + streamTextToAudio, + DEFAULT_VOICE_SETTINGS.voiceId, + chatConfig.id + ) + : undefined logger.info('Starting to handle streamed response:', { shouldPlayAudio }) diff --git a/apps/sim/app/chat/hooks/use-audio-streaming.ts b/apps/sim/app/chat/hooks/use-audio-streaming.ts index b7bda6208e9..98b7d23388f 100644 --- a/apps/sim/app/chat/hooks/use-audio-streaming.ts +++ b/apps/sim/app/chat/hooks/use-audio-streaming.ts @@ -14,7 +14,7 @@ declare global { interface AudioStreamingOptions { voiceId: string modelId?: string - chatId?: string + chatId: string onAudioStart?: () => void onAudioEnd?: () => void onError?: (error: Error) => void @@ -107,7 +107,8 @@ export function useAudioStreaming(sharedAudioContextRef?: RefObject '') + throw new Error(errorText || `TTS request failed: ${response.statusText}`) } const arrayBuffer = await response.arrayBuffer() diff --git a/apps/sim/app/templates/page.tsx b/apps/sim/app/templates/page.tsx index d2a8c12abf4..22ca921f093 100644 --- a/apps/sim/app/templates/page.tsx +++ b/apps/sim/app/templates/page.tsx @@ -69,11 +69,5 @@ export default function TemplatesPage() { // .orderBy(desc(templates.views), desc(templates.createdAt)) // .then((rows) => rows.map((row) => ({ ...row, isStarred: false }))) // - // return ( - // - // ) + // return } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index 2fbd230a5f5..dba25d79eb9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -154,7 +154,7 @@ interface ResourceTabItemProps { onDragEnd: () => void onTabClick: (e: React.MouseEvent, idx: number) => void setHoveredTabId: Dispatch> - onRemove: (e: React.MouseEvent, resource: MothershipResource) => void + onRemove: (e: React.SyntheticEvent, resource: MothershipResource) => void } const ResourceTabItem = memo(function ResourceTabItem({ @@ -216,7 +216,7 @@ const ResourceTabItem = memo(function ResourceTabItem({ tabIndex={-1} onClick={(e) => onRemove(e, resource)} onKeyDown={(e) => { - if (e.key === 'Enter') onRemove(e as unknown as React.MouseEvent, resource) + if (e.key === 'Enter') onRemove(e, resource) }} className='-translate-y-1/2 absolute top-1/2 right-[4px] flex items-center justify-center rounded-sm p-[1px] hover-hover:bg-[var(--surface-5)]' aria-label={`Close ${displayName}`} @@ -400,7 +400,7 @@ export function ResourceTabs({ ) const handleRemove = useCallback( - (e: React.MouseEvent, resource: MothershipResource) => { + (e: React.SyntheticEvent, resource: MothershipResource) => { e.stopPropagation() if (!chatId) return const isMulti = selectedIds.has(resource.id) && selectedIds.size > 1 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 e6884cc332d..7ebe3b1cc84 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 @@ -108,7 +108,8 @@ const FormSchema = z } ) -type FormValues = z.infer +type FormInputValues = z.input +type FormValues = z.output interface SubmitStatus { type: 'success' | 'error' @@ -163,7 +164,7 @@ export const CreateBaseModal = memo(function CreateBaseModal({ watch, setValue, formState: { errors }, - } = useForm({ + } = useForm({ resolver: zodResolver(FormSchema), defaultValues: { name: '', diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx index 61ae2bcf235..25c37a4ae46 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx @@ -3,6 +3,7 @@ import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' import { Plus, X } from 'lucide-react' +import type { z } from 'zod' import { Badge, Button, @@ -23,6 +24,11 @@ import { type TagItem, } from '@/components/emcn' import { SlackIcon } from '@/components/icons' +import type { + alertRuleSchema, + notificationLevelSchema, + notificationTypeSchema, +} from '@/lib/api/contracts/notifications' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getTriggerOptions } from '@/lib/logs/get-trigger-options' import { quickValidateEmail } from '@/lib/messaging/email/validation' @@ -47,17 +53,10 @@ const logger = createLogger('NotificationSettings') const TRIGGER_OPTIONS = getTriggerOptions() const ALL_TRIGGER_VALUES = TRIGGER_OPTIONS.map((t) => t.value) -type NotificationType = 'webhook' | 'email' | 'slack' -type LogLevel = 'info' | 'error' -type AlertRule = - | 'none' - | 'consecutive_failures' - | 'failure_rate' - | 'latency_threshold' - | 'latency_spike' - | 'cost_threshold' - | 'no_activity' - | 'error_count' +type NotificationType = z.output +type LogLevel = z.output +/** Contract alert rule plus a UI-only `'none'` sentinel meaning "no alert config". */ +type AlertRule = z.output | 'none' const ALERT_RULES: { value: AlertRule; label: string; description: string }[] = [ { value: 'none', label: 'None', description: 'Notify on every matching execution' }, diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx index 53494a181dd..33fce96c685 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx @@ -670,7 +670,7 @@ export const LogsToolbar = memo(function LogsToolbar({ Time Range Time Range
( schedule?.lifecycle === 'until_complete' ? 'until_complete' : 'persistent' ) - const [maxRuns, setMaxRuns] = useState(schedule?.maxRuns ? String(schedule.maxRuns) : '') + const [maxRuns, setMaxRuns] = useState(schedule?.maxRuns != null ? String(schedule.maxRuns) : '') const [submitError, setSubmitError] = useState(null) const computedCron = useMemo( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index 8b07b87de27..9c566724729 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -117,7 +117,7 @@ export function ApiKeys() { } } - const formatLastUsed = (dateString?: string) => { + const formatLastUsed = (dateString?: string | null) => { if (!dateString) return 'Never' return formatDate(new Date(dateString)) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-task-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-task-list.tsx index 795f49e9104..051b8550228 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-task-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-task-list.tsx @@ -27,6 +27,8 @@ const STATUS_OPTIONS = [ { value: 'rejected', label: 'Rejected' }, ] as const +type StatusFilter = (typeof STATUS_OPTIONS)[number]['value'] + const STATUS_BADGES: Record< string, { label: string; variant: 'gray' | 'amber' | 'green' | 'red' | 'gray-secondary' } @@ -43,7 +45,7 @@ export function InboxTaskList() { const router = useRouter() const workspaceId = params.workspaceId as string - const [statusFilter, setStatusFilter] = useState('all') + const [statusFilter, setStatusFilter] = useState('all') const [searchTerm, setSearchTerm] = useState('') const { data: config } = useInboxConfig(workspaceId) @@ -98,7 +100,14 @@ export function InboxTaskList() { - + { + if (STATUS_OPTIONS.some((option) => option.value === value)) { + setStatusFilter(value as StatusFilter) + } + }} + > {STATUS_OPTIONS.map((opt) => ( {opt.label} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index 99a9c9d04ff..3de2477d79b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -24,6 +24,8 @@ import { } from '@/lib/mcp/tool-validation' import type { McpTransport } from '@/lib/mcp/types' import { + type McpServer, + type McpTool, useAllowedMcpDomains, useCreateMcpServer, useDeleteMcpServer, @@ -41,31 +43,6 @@ import { McpServerFormModal, McpServerSkeleton } from './components' const logger = createLogger('McpSettings') -interface McpToolSchema { - type: 'object' - properties?: Record - required?: string[] -} - -interface McpTool { - name: string - description?: string - serverId: string - inputSchema?: McpToolSchema -} - -interface McpServer { - id: string - name?: string - transport?: string - url?: string - headers?: Record - enabled?: boolean - connectionStatus?: 'connected' | 'disconnected' | 'error' - lastError?: string | null - lastConnected?: string -} - function formatTransportLabel(transport: string): string { return transport .split('-') diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-import.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-import.tsx index eb701141393..ed137eba307 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-import.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-import.tsx @@ -5,6 +5,8 @@ import { useCallback, useRef, useState } from 'react' import { Loader2 } from 'lucide-react' import { Button, Input, Label, Textarea } from '@/components/emcn' import { Upload } from '@/components/emcn/icons' +import { requestJson } from '@/lib/api/client/request' +import { importSkillContract } from '@/lib/api/contracts' import { cn } from '@/lib/core/utils/cn' import { extractSkillFromZip, parseSkillMarkdown } from './utils' @@ -130,18 +132,7 @@ export function SkillImport({ onImport }: SkillImportProps) { setGithubError('') try { - const res = await fetch('/api/skills/import', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: trimmed }), - }) - - const data = await res.json() - - if (!res.ok) { - throw new Error(data.error || `Import failed (HTTP ${res.status})`) - } - + const data = await requestJson(importSkillContract, { body: { url: trimmed } }) const parsed = parseSkillMarkdown(data.content) setGithubState('idle') onImport(parsed) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx index a6f8ed631a9..ea0280a7302 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx @@ -610,7 +610,7 @@ export function Subscription() { } limit={ subscription.isOrgScoped - ? organizationBillingData?.data?.totalUsageLimit + ? (organizationBillingData?.data?.totalUsageLimit ?? usage.limit) : !subscription.isFree && (permissions.canEditUsageLimit || permissions.showTeamMemberView) ? usage.current diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 7f28b5bc246..e48414b66ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -1666,7 +1666,7 @@ export function Table({ ) }, [generateColumnName]) - const handleChangeType = useCallback((columnName: string, newType: string) => { + const handleChangeType = useCallback((columnName: string, newType: ColumnDefinition['type']) => { const column = columnsRef.current.find((c) => c.name === columnName) const previousType = column?.type updateColumnMutation.mutate( @@ -3057,7 +3057,11 @@ const TableBodySkeleton = React.memo(function TableBodySkeleton({ ) }) -const COLUMN_TYPE_OPTIONS: { type: string; label: string; icon: React.ElementType }[] = [ +const COLUMN_TYPE_OPTIONS: { + type: ColumnDefinition['type'] + label: string + icon: React.ElementType +}[] = [ { type: 'string', label: 'Text', icon: TypeText }, { type: 'number', label: 'Number', icon: TypeNumber }, { type: 'boolean', label: 'Boolean', icon: TypeBoolean }, @@ -3102,7 +3106,7 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onRenameCancel: () => void onRenameColumn: (columnName: string) => void onColumnSelect: (colIndex: number, shiftKey: boolean) => void - onChangeType: (columnName: string, newType: string) => void + onChangeType: (columnName: string, newType: ColumnDefinition['type']) => void onInsertLeft: (columnName: string) => void onInsertRight: (columnName: string) => void onToggleUnique: (columnName: string) => void diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index 9f7a4f44026..a4dca9a66dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -33,6 +33,7 @@ import { useDeleteChat, useUpdateChat, } from '@/hooks/queries/chats' +import type { ChatDetail } from '@/hooks/queries/deployments' import { useIdentifierValidation } from './hooks' import { getPasswordHelperText, @@ -63,21 +64,7 @@ interface ChatDeployProps { onVersionActivated?: () => void } -export interface ExistingChat { - id: string - identifier: string - title: string - description: string - authType: 'public' | 'password' | 'email' | 'sso' - allowedEmails: string[] - outputConfigs: Array<{ blockId: string; path: string }> - customizations?: { - welcomeMessage?: string - imageUrl?: string - } - hasPassword: boolean - isActive: boolean -} +export type ExistingChat = ChatDetail interface FormErrors { identifier?: string @@ -261,7 +248,6 @@ export function ChatDeploy({ const result = await createChatMutation.mutateAsync({ workflowId, formData, - apiKey: deploymentInfo?.apiKey, imageUrl, }) chatUrl = result.chatUrl diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/use-identifier-validation.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/use-identifier-validation.ts index a7d78d47a73..90fd7077c02 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/use-identifier-validation.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/use-identifier-validation.ts @@ -1,4 +1,6 @@ import { useEffect, useRef, useState } from 'react' +import { requestJson } from '@/lib/api/client/request' +import { validateChatIdentifierContract } from '@/lib/api/contracts/chats' const IDENTIFIER_PATTERN = /^[a-z0-9-]+$/ const DEBOUNCE_MS = 500 @@ -57,15 +59,11 @@ export function useIdentifierValidation( setIsChecking(true) timeoutRef.current = setTimeout(async () => { try { - const response = await fetch( - `/api/chat/validate?identifier=${encodeURIComponent(identifier)}` - ) - const data = await response.json() + const data = await requestJson(validateChatIdentifierContract, { + query: { identifier }, + }) - if (!response.ok) { - setError('Error checking identifier availability') - setIsValid(false) - } else if (!data.available) { + if (!data.available) { setError(data.error || 'This identifier is already in use') setIsValid(false) } else { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/form/hooks/use-identifier-validation.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/form/hooks/use-identifier-validation.ts index 964ca12f05d..3f5b9a7e5c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/form/hooks/use-identifier-validation.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/form/hooks/use-identifier-validation.ts @@ -1,4 +1,6 @@ import { useEffect, useRef, useState } from 'react' +import { requestJson } from '@/lib/api/client/request' +import { validateFormIdentifierContract } from '@/lib/api/contracts/forms' const IDENTIFIER_PATTERN = /^[a-z0-9-]+$/ const DEBOUNCE_MS = 500 @@ -57,15 +59,11 @@ export function useIdentifierValidation( setIsChecking(true) timeoutRef.current = setTimeout(async () => { try { - const response = await fetch( - `/api/form/validate?identifier=${encodeURIComponent(identifier)}` - ) - const data = await response.json() + const data = await requestJson(validateFormIdentifierContract, { + query: { identifier }, + }) - if (!response.ok) { - setError('Error checking identifier availability') - setIsValid(false) - } else if (!data.available) { + if (!data.available) { setError(data.error || 'This identifier is already in use') setIsValid(false) } else { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx index ff08b13ca7c..b53ce7dac5b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx @@ -62,7 +62,7 @@ function generateParameterSchema( ...field, description: descriptions[field.name]?.trim() || undefined, })) - return generateToolInputSchema(fieldsWithDescriptions) as unknown as Record + return { ...generateToolInputSchema(fieldsWithDescriptions) } } /** 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 d7229b160ae..38df5590737 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 @@ -291,7 +291,7 @@ export function DeployModal({ try { // Deploy mutation handles query invalidation in its onSuccess callback - const result = await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false }) + const result = await deployMutation.mutateAsync({ workflowId }) if (result.warnings && result.warnings.length > 0) { setDeployWarnings(result.warnings) } @@ -354,7 +354,7 @@ export function DeployModal({ } try { - const result = await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false }) + const result = await deployMutation.mutateAsync({ workflowId }) if (result.warnings && result.warnings.length > 0) { setDeployWarnings(result.warnings) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts index 47ed17a7125..3b894847c4b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts @@ -45,7 +45,7 @@ export function useDeployment({ workflowId, isDeployed }: UseDeploymentProps) { } try { - await mutateAsync({ workflowId, deployChatEnabled: false }) + await mutateAsync({ workflowId }) return { success: true, shouldOpenModal: true } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts index babf0f62874..65170680b07 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts @@ -1,6 +1,8 @@ import { useCallback, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' +import { requestRaw } from '@/lib/api/client' +import { wandGenerateStreamContract } from '@/lib/api/contracts' import { readSSEStream } from '@/lib/core/utils/sse' import type { GenerationType } from '@/blocks/types' import { subscriptionKeys } from '@/hooks/queries/subscription' @@ -177,29 +179,27 @@ export function useWand({ const currentPrompt = prompt - const response = await fetch('/api/wand', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-cache, no-transform', + const response = await requestRaw( + wandGenerateStreamContract, + { + body: { + prompt: userMessage, + systemPrompt: systemPrompt, + stream: true, + history: wandConfig?.maintainHistory ? conversationHistory : [], + generationType: wandConfig?.generationType, + workflowId: workflowId ?? undefined, + wandContext: contextParams?.tableId ? { tableId: contextParams.tableId } : undefined, + }, + signal: abortControllerRef.current.signal, }, - body: JSON.stringify({ - prompt: userMessage, - systemPrompt: systemPrompt, - stream: true, - history: wandConfig?.maintainHistory ? conversationHistory : [], - generationType: wandConfig?.generationType, - workflowId, - wandContext: contextParams?.tableId ? { tableId: contextParams.tableId } : undefined, - }), - signal: abortControllerRef.current.signal, - cache: 'no-store', - }) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(errorText || `HTTP error! status: ${response.status}`) - } + { + headers: { + 'Cache-Control': 'no-cache, no-transform', + }, + cache: 'no-store', + } + ) if (!response.body) { throw new Error('Response body is null') diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx index f811b631d51..3a5cc71c433 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx @@ -44,7 +44,7 @@ const formSchema = z.object({ subject: z.string().min(1, 'Subject is required'), message: z.string().min(1, 'Message is required'), type: z.enum(['bug', 'feedback', 'feature_request', 'other'], { - required_error: 'Please select a request type', + error: 'Please select a request type', }), }) diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index ed3e1fe9d83..bfd515695a3 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -11,7 +11,10 @@ import { IdempotencyService, webhookIdempotency } from '@/lib/core/idempotency' 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 { WebhookAttachmentProcessor } from '@/lib/webhooks/attachment-processor' +import { + type WebhookAttachment, + WebhookAttachmentProcessor, +} from '@/lib/webhooks/attachment-processor' import { resolveWebhookRecordProviderConfig } from '@/lib/webhooks/env-resolver' import { getProviderHandler } from '@/lib/webhooks/providers' import { @@ -31,6 +34,99 @@ import { getTrigger, isTriggerValid } from '@/triggers' const logger = createLogger('TriggerWebhookExecution') +type WebhookAttachmentInput = Omit & { data: unknown } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isSerializedBuffer(value: unknown): value is { type: 'Buffer'; data: number[] } { + return isRecord(value) && value.type === 'Buffer' && Array.isArray(value.data) +} + +function hasSupportedAttachmentData(value: unknown): boolean { + return ( + Buffer.isBuffer(value) || + typeof value === 'string' || + value instanceof ArrayBuffer || + ArrayBuffer.isView(value) || + Array.isArray(value) || + isSerializedBuffer(value) + ) +} + +function toAttachmentBuffer(data: unknown, name: string): Buffer { + if (Buffer.isBuffer(data)) { + return data + } + + if (isSerializedBuffer(data)) { + return Buffer.from(data.data) + } + + if (data instanceof ArrayBuffer) { + return Buffer.from(data) + } + + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength) + } + + if (Array.isArray(data)) { + return Buffer.from(data) + } + + if (typeof data === 'string') { + const trimmed = data.trim() + if (trimmed.startsWith('data:')) { + const [, base64Data] = trimmed.split(',') + return Buffer.from(base64Data ?? '', 'base64') + } + return Buffer.from(trimmed, 'base64') + } + + throw new Error(`Attachment '${name}' has unsupported data format`) +} + +function isWebhookAttachmentInput(value: unknown): value is WebhookAttachmentInput { + if (!isRecord(value)) { + return false + } + + return ( + typeof value.name === 'string' && + typeof value.size === 'number' && + hasSupportedAttachmentData(value.data) && + (value.contentType === undefined || typeof value.contentType === 'string') && + (value.mimeType === undefined || typeof value.mimeType === 'string') + ) +} + +function normalizeWebhookAttachment(value: unknown): WebhookAttachment | null { + if (!isWebhookAttachmentInput(value)) { + return null + } + + return { + name: value.name, + data: toAttachmentBuffer(value.data, value.name), + contentType: value.contentType, + mimeType: value.mimeType, + size: value.size, + } +} + +function normalizeWebhookAttachments(value: unknown): WebhookAttachment[] { + if (!Array.isArray(value)) { + return [] + } + + return value.flatMap((attachment) => { + const normalized = normalizeWebhookAttachment(attachment) + return normalized ? [normalized] : [] + }) +} + export function buildWebhookCorrelation( payload: WebhookExecutionPayload ): AsyncExecutionCorrelation { @@ -74,27 +170,32 @@ async function processTriggerFileOutputs( for (const [key, value] of Object.entries(input)) { const currentPath = path ? `${path}.${key}` : key const outputDef = triggerOutputs[key] as Record | undefined - const val = value as Record - if (outputDef?.type === 'file[]' && Array.isArray(val)) { + if (outputDef?.type === 'file[]' && Array.isArray(value)) { try { processed[key] = await WebhookAttachmentProcessor.processAttachments( - val as unknown as Parameters[0], + normalizeWebhookAttachments(value), context ) } catch (error) { processed[key] = [] } - } else if (outputDef?.type === 'file' && val) { + } else if (outputDef?.type === 'file' && value) { + const attachment = normalizeWebhookAttachment(value) + if (!attachment) { + processed[key] = value + continue + } + try { const [processedFile] = await WebhookAttachmentProcessor.processAttachments( - [val] as unknown as Parameters[0], + [attachment], context ) processed[key] = processedFile } catch (error) { logger.error(`[${context.requestId}] Error processing ${currentPath}:`, error) - processed[key] = val + processed[key] = value } } else if ( outputDef && @@ -103,20 +204,20 @@ async function processTriggerFileOutputs( outputDef.properties ) { processed[key] = await processTriggerFileOutputs( - val, + value, outputDef.properties as Record, context, currentPath ) } else if (outputDef && typeof outputDef === 'object' && !outputDef.type) { processed[key] = await processTriggerFileOutputs( - val, + value, outputDef as Record, context, currentPath ) } else { - processed[key] = val + processed[key] = value } } diff --git a/apps/sim/background/workspace-notification-delivery.ts b/apps/sim/background/workspace-notification-delivery.ts index 454e68bea90..fdaae3257d7 100644 --- a/apps/sim/background/workspace-notification-delivery.ts +++ b/apps/sim/background/workspace-notification-delivery.ts @@ -494,6 +494,77 @@ export type NotificationDeliveryResult = | { status: 'success' | 'skipped' | 'failed' } | { status: 'retry'; retryDelayMs: number } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function formatLogDate(value: Date | string | null | undefined, fallback = ''): string { + if (value instanceof Date) { + return value.toISOString() + } + return typeof value === 'string' ? value : fallback +} + +function normalizeLogCost(value: unknown): WorkflowExecutionLog['cost'] { + if (!isRecord(value)) { + return undefined + } + + const tokens = isRecord(value.tokens) + ? { + input: typeof value.tokens.input === 'number' ? value.tokens.input : undefined, + output: typeof value.tokens.output === 'number' ? value.tokens.output : undefined, + total: typeof value.tokens.total === 'number' ? value.tokens.total : undefined, + } + : undefined + + return { + input: typeof value.input === 'number' ? value.input : undefined, + output: typeof value.output === 'number' ? value.output : undefined, + total: typeof value.total === 'number' ? value.total : undefined, + tokens, + } +} + +function normalizeLogFiles(value: unknown): WorkflowExecutionLog['files'] { + if (!Array.isArray(value)) { + return undefined + } + + return value.filter( + (file): file is NonNullable[number] => + isRecord(file) && + typeof file.id === 'string' && + typeof file.name === 'string' && + typeof file.size === 'number' && + typeof file.type === 'string' && + typeof file.url === 'string' && + typeof file.key === 'string' + ) +} + +function normalizeWorkflowExecutionLog( + row: typeof workflowExecutionLogs.$inferSelect +): WorkflowExecutionLog { + const startedAt = formatLogDate(row.startedAt) + + return { + id: row.id, + workflowId: row.workflowId, + executionId: row.executionId, + stateSnapshotId: row.stateSnapshotId, + level: row.level === 'error' ? 'error' : 'info', + trigger: row.trigger, + startedAt, + endedAt: formatLogDate(row.endedAt, startedAt), + totalDurationMs: row.totalDurationMs ?? 0, + files: normalizeLogFiles(row.files), + executionData: isRecord(row.executionData) ? row.executionData : {}, + cost: normalizeLogCost(row.cost), + createdAt: formatLogDate(row.createdAt, startedAt), + } +} + async function buildRetryLog(params: NotificationDeliveryParams): Promise { const conditions = [eq(workflowExecutionLogs.executionId, params.log.executionId)] if (params.log.workflowId) { @@ -507,7 +578,7 @@ async function buildRetryLog(params: NotificationDeliveryParams): Promise = { mode: 'advanced', condition: { field: 'operation', - value: LIST_OPERATIONS as unknown as string[], + value: [...LIST_OPERATIONS], }, }, // Order ID diff --git a/apps/sim/components/emcn/components/combobox/combobox.tsx b/apps/sim/components/emcn/components/combobox/combobox.tsx index e33285bce9b..7a8d1a03af7 100644 --- a/apps/sim/components/emcn/components/combobox/combobox.tsx +++ b/apps/sim/components/emcn/components/combobox/combobox.tsx @@ -664,7 +664,7 @@ const Combobox = memo( e.key === 'Enter' || e.key === 'Escape' ) { - handleKeyDown(e as unknown as KeyboardEvent) + handleKeyDown(e) } }} /> diff --git a/apps/sim/connectors/intercom/intercom.ts b/apps/sim/connectors/intercom/intercom.ts index 112eb72b35d..9d703f9505d 100644 --- a/apps/sim/connectors/intercom/intercom.ts +++ b/apps/sim/connectors/intercom/intercom.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { z } from 'zod' import { IntercomIcon } from '@/components/icons' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' @@ -12,56 +13,92 @@ const DEFAULT_MAX_ITEMS = 500 const ARTICLES_PER_PAGE = 50 const CONVERSATIONS_PER_PAGE = 50 -/** Intercom article as returned by GET /articles */ -interface IntercomArticle { - type: string - id: string - title: string - description: string | null - body: string | null - author_id: number - state: 'published' | 'draft' - created_at: number - updated_at: number - url?: string - parent_id?: number | null - parent_type?: string | null -} - -/** Intercom conversation as returned by GET /conversations */ -interface IntercomConversation { - type: string - id: string - created_at: number - updated_at: number - title: string | null - state: string - open: boolean - source: { - type: string - id: string - subject: string - body: string - author: { type: string; id: string; name?: string } - delivered_as: string - } - tags: { type: string; tags: { id: string; name: string }[] } - conversation_parts?: { - type: string - conversation_parts: IntercomConversationPart[] - total_count: number - } -} +const IntercomAuthorSchema = z + .object({ + type: z.string(), + id: z.string(), + name: z.string().optional(), + }) + .passthrough() + +const IntercomArticleSchema = z + .object({ + type: z.string().optional(), + id: z.string(), + title: z.string().optional(), + description: z.string().nullable().optional(), + body: z.string().nullable().optional(), + author_id: z.union([z.number(), z.string()]).optional(), + state: z.string(), + created_at: z.number(), + updated_at: z.number(), + url: z.string().optional(), + parent_id: z.number().nullable().optional(), + parent_type: z.string().nullable().optional(), + }) + .passthrough() + +type IntercomArticle = z.infer + +const IntercomConversationPartSchema = z + .object({ + type: z.string().optional(), + id: z.string(), + part_type: z.string().optional(), + body: z.string().nullable().optional(), + created_at: z.number(), + author: IntercomAuthorSchema.optional(), + }) + .passthrough() + +const IntercomConversationSchema = z + .object({ + type: z.string().optional(), + id: z.string(), + created_at: z.number(), + updated_at: z.number(), + title: z.string().nullable().optional(), + state: z.string(), + open: z.boolean().optional(), + source: z + .object({ + type: z.string(), + id: z.string(), + subject: z.string().optional(), + body: z.string().nullable().optional(), + author: IntercomAuthorSchema, + delivered_as: z.string().optional(), + }) + .passthrough() + .optional(), + tags: z + .object({ + type: z.string().optional(), + tags: z + .array( + z + .object({ + id: z.string(), + name: z.string(), + }) + .passthrough() + ) + .default([]), + }) + .passthrough() + .optional(), + conversation_parts: z + .object({ + type: z.string(), + conversation_parts: z.array(IntercomConversationPartSchema), + total_count: z.number(), + }) + .passthrough() + .optional(), + }) + .passthrough() -/** A single part within a conversation */ -interface IntercomConversationPart { - type: string - id: string - part_type: string - body: string | null - created_at: number - author: { type: string; id: string; name?: string } -} +type IntercomConversation = z.infer /** * Makes a GET request to the Intercom API with Bearer token auth. @@ -117,7 +154,7 @@ async function fetchArticles( per_page: String(ARTICLES_PER_PAGE), }) - const articles = (data.data as IntercomArticle[]) || [] + const articles = z.array(IntercomArticleSchema).parse(data.data ?? []) if (articles.length === 0) break for (const article of articles) { @@ -154,7 +191,7 @@ async function fetchConversations( } const data = await intercomApiGet('/conversations', accessToken, params) - const conversations = (data.conversations as IntercomConversation[]) || [] + const conversations = z.array(IntercomConversationSchema).parse(data.conversations ?? []) if (conversations.length === 0) break for (const conversation of conversations) { @@ -180,7 +217,7 @@ async function fetchConversationDetail( conversationId: string ): Promise { const data = await intercomApiGet(`/conversations/${conversationId}`, accessToken) - return data as unknown as IntercomConversation + return IntercomConversationSchema.parse(data) } /** @@ -193,10 +230,10 @@ function formatConversation(conversation: IntercomConversation): string { lines.push(`Subject: ${conversation.title}`) } - const sourceBody = conversation.source?.body + const source = conversation.source + const sourceBody = source?.body if (sourceBody) { - const authorName = - conversation.source.author?.name || conversation.source.author?.type || 'unknown' + const authorName = source.author?.name || source.author?.type || 'unknown' const timestamp = new Date(conversation.created_at * 1000).toISOString() lines.push(`[${timestamp}] ${authorName}: ${htmlToPlainText(sourceBody)}`) } @@ -373,7 +410,7 @@ export const intercomConnector: ConnectorConfig = { if (externalId.startsWith('article-')) { const articleId = externalId.replace('article-', '') const data = await intercomApiGet(`/articles/${articleId}`, accessToken) - const article = data as unknown as IntercomArticle + const article = IntercomArticleSchema.parse(data) const content = formatArticle(article) if (!content.trim()) return null diff --git a/apps/sim/connectors/servicenow/servicenow.ts b/apps/sim/connectors/servicenow/servicenow.ts index 86f6a40617b..fa9905bc1b8 100644 --- a/apps/sim/connectors/servicenow/servicenow.ts +++ b/apps/sim/connectors/servicenow/servicenow.ts @@ -123,6 +123,16 @@ async function serviceNowApiGet( } } +function isServiceNowRecord(record: unknown): record is ServiceNowRecord & Record { + return ( + typeof record === 'object' && + record !== null && + !Array.isArray(record) && + typeof (record as Record).sys_id === 'string' && + ((record as Record).sys_id as string).length > 0 + ) +} + /** * Extracts a display value from a field that may be a string or a reference object. * When sysparm_display_value=true, fields are plain strings. @@ -198,7 +208,7 @@ function kbArticleToDocument(article: KBArticle, instanceUrl: string): ExternalD const title = rawValue(article.short_description) || rawValue(article.number) || article.sys_id const articleText = rawValue(article.text) || rawValue(article.wiki) || '' const content = htmlToPlainText(articleText) - const sysId = rawValue(article.sys_id as unknown as string) || article.sys_id + const sysId = rawValue(article.sys_id) || article.sys_id const updatedOn = rawValue(article.sys_updated_on) || '' const contentHash = `servicenow:${sysId}:${updatedOn}` const sourceUrl = `${instanceUrl}/kb_view.do?sys_kb_id=${sysId}` @@ -263,7 +273,7 @@ function incidentToDocument(incident: Incident, instanceUrl: string): ExternalDo } const content = parts.join('\n') - const sysId = rawValue(incident.sys_id as unknown as string) || incident.sys_id + const sysId = rawValue(incident.sys_id) || incident.sys_id const updatedOn = rawValue(incident.sys_updated_on) || '' const contentHash = `servicenow:${sysId}:${updatedOn}` const sourceUrl = `${instanceUrl}/incident.do?sys_id=${sysId}` @@ -483,9 +493,14 @@ export const servicenowConnector: ConnectorConfig = { const documents: ExternalDocument[] = [] for (const record of result) { + if (!isServiceNowRecord(record)) { + logger.warn('Skipping ServiceNow record without sys_id', { table: tableName }) + continue + } + const doc = isKB - ? kbArticleToDocument(record as unknown as KBArticle, instanceUrl) - : incidentToDocument(record as unknown as Incident, instanceUrl) + ? kbArticleToDocument(record, instanceUrl) + : incidentToDocument(record, instanceUrl) if (doc.content.trim()) { documents.push(doc) @@ -538,9 +553,13 @@ export const servicenowConnector: ConnectorConfig = { } const record = result[0] + if (!record || !isServiceNowRecord(record)) { + return null + } + const doc = isKB - ? kbArticleToDocument(record as unknown as KBArticle, instanceUrl) - : incidentToDocument(record as unknown as Incident, instanceUrl) + ? kbArticleToDocument(record, instanceUrl) + : incidentToDocument(record, instanceUrl) return doc.content.trim() ? doc : null } catch (error) { diff --git a/apps/sim/ee/access-control/hooks/permission-groups.ts b/apps/sim/ee/access-control/hooks/permission-groups.ts index 0bc9fcfc439..4a45617c3f3 100644 --- a/apps/sim/ee/access-control/hooks/permission-groups.ts +++ b/apps/sim/ee/access-control/hooks/permission-groups.ts @@ -1,38 +1,26 @@ 'use client' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { z } from 'zod' +import { requestJson } from '@/lib/api/client/request' +import { + bulkAddPermissionGroupMembersContract, + createPermissionGroupContract, + deletePermissionGroupContract, + getUserPermissionConfigContract, + listPermissionGroupMembersContract, + listPermissionGroupsContract, + type permissionGroupMemberSchema, + type permissionGroupSchema, + removePermissionGroupMemberContract, + updatePermissionGroupContract, + type userPermissionConfigSchema, +} from '@/lib/api/contracts' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' -import { fetchJson } from '@/hooks/selectors/helpers' -export interface PermissionGroup { - id: string - name: string - description: string | null - config: PermissionGroupConfig - createdBy: string - createdAt: string - updatedAt: string - creatorName: string | null - creatorEmail: string | null - memberCount: number - autoAddNewMembers: boolean -} - -export interface PermissionGroupMember { - id: string - userId: string - assignedAt: string - userName: string | null - userEmail: string | null - userImage: string | null -} - -export interface UserPermissionConfig { - permissionGroupId: string | null - groupName: string | null - config: PermissionGroupConfig | null - entitled: boolean -} +export type PermissionGroup = z.output +export type PermissionGroupMember = z.output +export type UserPermissionConfig = z.output export const permissionGroupKeys = { all: ['permissionGroups'] as const, @@ -47,18 +35,15 @@ export const permissionGroupKeys = { [...permissionGroupKeys.all, 'userConfig', workspaceId ?? ''] as const, } -interface PermissionGroupsResponse { - permissionGroups?: PermissionGroup[] -} - export function usePermissionGroups(workspaceId?: string, enabled = true) { return useQuery({ queryKey: permissionGroupKeys.list(workspaceId), queryFn: async ({ signal }) => { - const data = await fetchJson( - `/api/workspaces/${workspaceId}/permission-groups`, - { signal } - ) + if (!workspaceId) return [] + const data = await requestJson(listPermissionGroupsContract, { + params: { id: workspaceId }, + signal, + }) return data.permissionGroups ?? [] }, enabled: Boolean(workspaceId) && enabled, @@ -66,18 +51,15 @@ export function usePermissionGroups(workspaceId?: string, enabled = true) { }) } -interface MembersResponse { - members?: PermissionGroupMember[] -} - export function usePermissionGroupMembers(workspaceId?: string, permissionGroupId?: string) { return useQuery({ queryKey: permissionGroupKeys.members(workspaceId, permissionGroupId), queryFn: async ({ signal }) => { - const data = await fetchJson( - `/api/workspaces/${workspaceId}/permission-groups/${permissionGroupId}/members`, - { signal } - ) + if (!workspaceId || !permissionGroupId) return [] + const data = await requestJson(listPermissionGroupMembersContract, { + params: { id: workspaceId, groupId: permissionGroupId }, + signal, + }) return data.members ?? [] }, enabled: Boolean(workspaceId) && Boolean(permissionGroupId), @@ -89,8 +71,8 @@ export function useUserPermissionConfig(workspaceId?: string) { return useQuery({ queryKey: permissionGroupKeys.userConfig(workspaceId), queryFn: async ({ signal }) => { - const data = await fetchJson('/api/permission-groups/user', { - searchParams: { workspaceId: workspaceId ?? '' }, + const data = await requestJson(getUserPermissionConfigContract, { + query: { workspaceId: workspaceId ?? '' }, signal, }) return data @@ -113,16 +95,10 @@ export function useCreatePermissionGroup() { return useMutation({ mutationFn: async ({ workspaceId, ...data }: CreatePermissionGroupData) => { - const response = await fetch(`/api/workspaces/${workspaceId}/permission-groups`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), + return requestJson(createPermissionGroupContract, { + params: { id: workspaceId }, + body: data, }) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to create permission group') - } - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ @@ -146,16 +122,10 @@ export function useUpdatePermissionGroup() { return useMutation({ mutationFn: async ({ id, workspaceId, ...data }: UpdatePermissionGroupData) => { - const response = await fetch(`/api/workspaces/${workspaceId}/permission-groups/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), + return requestJson(updatePermissionGroupContract, { + params: { id: workspaceId, groupId: id }, + body: data, }) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to update permission group') - } - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ @@ -181,15 +151,9 @@ export function useDeletePermissionGroup() { return useMutation({ mutationFn: async ({ permissionGroupId, workspaceId }: DeletePermissionGroupParams) => { - const response = await fetch( - `/api/workspaces/${workspaceId}/permission-groups/${permissionGroupId}`, - { method: 'DELETE' } - ) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to delete permission group') - } - return response.json() + return requestJson(deletePermissionGroupContract, { + params: { id: workspaceId, groupId: permissionGroupId }, + }) }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ @@ -211,15 +175,10 @@ export function useRemovePermissionGroupMember() { permissionGroupId: string memberId: string }) => { - const response = await fetch( - `/api/workspaces/${data.workspaceId}/permission-groups/${data.permissionGroupId}/members?memberId=${data.memberId}`, - { method: 'DELETE' } - ) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to remove member') - } - return response.json() + return requestJson(removePermissionGroupMemberContract, { + params: { id: data.workspaceId, groupId: data.permissionGroupId }, + query: { memberId: data.memberId }, + }) }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ @@ -245,19 +204,10 @@ export function useBulkAddPermissionGroupMembers() { return useMutation({ mutationFn: async ({ workspaceId, permissionGroupId, ...data }: BulkAddMembersData) => { - const response = await fetch( - `/api/workspaces/${workspaceId}/permission-groups/${permissionGroupId}/members/bulk`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - } - ) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to add members') - } - return response.json() as Promise<{ added: number; moved: number }> + return requestJson(bulkAddPermissionGroupMembersContract, { + params: { id: workspaceId, groupId: permissionGroupId }, + body: data, + }) }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 4145252fc08..b0384a0b7c4 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -37,7 +37,7 @@ import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { executeProviderRequest } from '@/providers' import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' -import { filterSchemaForLLM } from '@/tools/params' +import { filterSchemaForLLM, type ToolSchema } from '@/tools/params' import { getTool } from '@/tools/utils' import { getToolAsync } from '@/tools/utils.server' @@ -514,15 +514,11 @@ export class AgentBlockHandler implements BlockHandler { serverId: string toolName: string description: string - schema: Record + schema: ToolSchema userProvidedParams: Record usageControl?: 'auto' | 'force' | 'none' }) { - const { filterSchemaForLLM } = await import('@/tools/params') - const filteredSchema = filterSchemaForLLM( - config.schema as unknown as Parameters[0], - config.userProvidedParams as Record - ) + const filteredSchema = filterSchemaForLLM(config.schema, config.userProvidedParams) const toolId = createMcpToolId(config.serverId, config.toolName) return { diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 26d3b3df330..c99b907ea5f 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -579,7 +579,7 @@ export class WorkflowBlockHandler implements BlockHandler { }) } - return { + const output: BlockOutput = { success: true, childWorkflowName, childWorkflowId, @@ -587,6 +587,7 @@ export class WorkflowBlockHandler implements BlockHandler { result, childTraceSpans: childTraceSpans || [], _childWorkflowInstanceId: instanceId, - } as unknown as BlockOutput + } + return output } } diff --git a/apps/sim/hooks/kb/use-knowledge.ts b/apps/sim/hooks/kb/use-knowledge.ts index afef640adda..dd7f45eff5b 100644 --- a/apps/sim/hooks/kb/use-knowledge.ts +++ b/apps/sim/hooks/kb/use-knowledge.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' +import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import type { ChunkData, DocumentData, KnowledgeBaseData } from '@/lib/knowledge/types' import { type DocumentTagFilter, @@ -65,8 +66,8 @@ export function useKnowledgeBaseDocuments( search?: string limit?: number offset?: number - sortBy?: string - sortOrder?: string + sortBy?: DocumentSortField + sortOrder?: SortOrder enabled?: boolean refetchInterval?: | number diff --git a/apps/sim/hooks/queries/a2a/agents.ts b/apps/sim/hooks/queries/a2a/agents.ts index b317c2e6b38..84d19d7a3cc 100644 --- a/apps/sim/hooks/queries/a2a/agents.ts +++ b/apps/sim/hooks/queries/a2a/agents.ts @@ -4,32 +4,23 @@ * Hooks for managing A2A agents in the UI. */ -import type { AgentCapabilities, AgentSkill } from '@a2a-js/sdk' +import type { AgentSkill } from '@a2a-js/sdk' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import type { AgentAuthentication } from '@/lib/a2a/types' - -/** - * A2A Agent as returned from the API - */ -export interface A2AAgent { - id: string - workspaceId: string - workflowId: string - name: string - description?: string - version: string - capabilities: AgentCapabilities - skills: AgentSkill[] - authentication: AgentAuthentication - isPublished: boolean - publishedAt?: string - createdAt: string - updatedAt: string - workflowName?: string - workflowDescription?: string - isDeployed?: boolean - taskCount?: number -} +import { requestJson } from '@/lib/api/client/request' +import { + type A2AAgent, + type A2AAgentCard, + type CreateA2AAgentBody, + createA2AAgentContract, + deleteA2AAgentContract, + getA2AAgentCardContract, + listA2AAgentsContract, + publishA2AAgentContract, + type UpdateA2AAgentBody, + updateA2AAgentContract, +} from '@/lib/api/contracts/a2a-agents' + +export type { A2AAgent, A2AAgentCard } /** * Query keys for A2A agents @@ -49,11 +40,10 @@ export const a2aAgentKeys = { * Fetch A2A agents for a workspace */ async function fetchA2AAgents(workspaceId: string, signal?: AbortSignal): Promise { - const response = await fetch(`/api/a2a/agents?workspaceId=${workspaceId}`, { signal }) - if (!response.ok) { - throw new Error('Failed to fetch A2A agents') - } - const data = await response.json() + const data = await requestJson(listA2AAgentsContract, { + query: { workspaceId }, + signal, + }) return data.agents } @@ -69,35 +59,14 @@ export function useA2AAgents(workspaceId: string) { }) } -/** - * Agent Card as returned from the agent detail endpoint - */ -export interface A2AAgentCard { - name: string - description?: string - url: string - version: string - documentationUrl?: string - provider?: { - organization: string - url?: string - } - capabilities: AgentCapabilities - skills: AgentSkill[] - authentication?: AgentAuthentication - defaultInputModes?: string[] - defaultOutputModes?: string[] -} - /** * Fetch a single A2A agent card (discovery document) */ async function fetchA2AAgentCard(agentId: string, signal?: AbortSignal): Promise { - const response = await fetch(`/api/a2a/agents/${agentId}`, { signal }) - if (!response.ok) { - throw new Error('Failed to fetch A2A agent') - } - return response.json() + return requestJson(getA2AAgentCardContract, { + params: { agentId }, + signal, + }) } /** @@ -112,33 +81,15 @@ export function useA2AAgentCard(agentId: string) { }) } -/** - * Create A2A agent params - */ -export interface CreateA2AAgentParams { - workspaceId: string - workflowId: string - name?: string - description?: string - capabilities?: AgentCapabilities - authentication?: AgentAuthentication - skillTags?: string[] -} +export type CreateA2AAgentParams = CreateA2AAgentBody /** * Create a new A2A agent */ async function createA2AAgent(params: CreateA2AAgentParams): Promise { - const response = await fetch('/api/a2a/agents', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(params), + const data = await requestJson(createA2AAgentContract, { + body: params, }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to create A2A agent') - } - const data = await response.json() return data.agent } @@ -161,19 +112,8 @@ export function useCreateA2AAgent() { }) } -/** - * Update A2A agent params - */ -export interface UpdateA2AAgentParams { +export type UpdateA2AAgentParams = UpdateA2AAgentBody & { agentId: string - name?: string - description?: string - version?: string - capabilities?: AgentCapabilities - skills?: AgentSkill[] - authentication?: AgentAuthentication - isPublished?: boolean - skillTags?: string[] } /** @@ -181,16 +121,10 @@ export interface UpdateA2AAgentParams { */ async function updateA2AAgent(params: UpdateA2AAgentParams): Promise { const { agentId, ...body } = params - const response = await fetch(`/api/a2a/agents/${agentId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + const data = await requestJson(updateA2AAgentContract, { + params: { agentId }, + body, }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to update A2A agent') - } - const data = await response.json() return data.agent } @@ -220,13 +154,9 @@ export function useUpdateA2AAgent() { * Delete an A2A agent */ async function deleteA2AAgent(params: { agentId: string; workspaceId: string }): Promise { - const response = await fetch(`/api/a2a/agents/${params.agentId}`, { - method: 'DELETE', + await requestJson(deleteA2AAgentContract, { + params: { agentId: params.agentId }, }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to delete A2A agent') - } } /** @@ -267,16 +197,10 @@ async function publishA2AAgent(params: PublishA2AAgentParams): Promise<{ isPublished?: boolean skills?: AgentSkill[] }> { - const response = await fetch(`/api/a2a/agents/${params.agentId}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: params.action }), + return requestJson(publishA2AAgentContract, { + params: { agentId: params.agentId }, + body: { action: params.action }, }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to update A2A agent') - } - return response.json() } /** @@ -309,12 +233,11 @@ async function fetchA2AAgentByWorkflow( workflowId: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/a2a/agents?workspaceId=${workspaceId}`, { signal }) - if (!response.ok) { - throw new Error('Failed to fetch A2A agents') - } - const data = await response.json() - const agents = data.agents as A2AAgent[] + const data = await requestJson(listA2AAgentsContract, { + query: { workspaceId }, + signal, + }) + const agents = data.agents return agents.find((agent) => agent.workflowId === workflowId) || null } diff --git a/apps/sim/hooks/queries/a2a/tasks.ts b/apps/sim/hooks/queries/a2a/tasks.ts index b32aace330a..42bfbf5c474 100644 --- a/apps/sim/hooks/queries/a2a/tasks.ts +++ b/apps/sim/hooks/queries/a2a/tasks.ts @@ -54,9 +54,9 @@ export interface SendA2ATaskParams { } /** - * Send task response + * Result of sending a message to an A2A agent. */ -export interface SendA2ATaskResponse { +export interface SendA2ATaskOutcome { content: string taskId: string contextId?: string @@ -68,7 +68,7 @@ export interface SendA2ATaskResponse { /** * Send a message to an A2A agent (v0.3) */ -async function sendA2ATask(params: SendA2ATaskParams): Promise { +async function sendA2ATask(params: SendA2ATaskParams): Promise { const userMessage: Message = { kind: 'message', messageId: generateId(), @@ -78,6 +78,7 @@ async function sendA2ATask(params: SendA2ATaskParams): Promise { + // boundary-raw-fetch: external-origin A2A agent endpoint, JSON-RPC payload not modeled by a same-origin contract const response = await fetch(params.agentUrl, { method: 'POST', signal, @@ -220,6 +222,7 @@ export interface CancelA2ATaskParams { * Cancel a task */ async function cancelA2ATask(params: CancelA2ATaskParams): Promise { + // boundary-raw-fetch: external-origin A2A agent endpoint, JSON-RPC payload not modeled by a same-origin contract const response = await fetch(params.agentUrl, { method: 'POST', headers: { diff --git a/apps/sim/hooks/queries/academy.ts b/apps/sim/hooks/queries/academy.ts index 41843340a55..8194910191c 100644 --- a/apps/sim/hooks/queries/academy.ts +++ b/apps/sim/hooks/queries/academy.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { AcademyCertificate } from '@/lib/academy/types' -import { fetchJson } from '@/hooks/selectors/helpers' +import { requestJson } from '@/lib/api/client/request' +import { getAcademyCertificateContract, issueAcademyCertificateContract } from '@/lib/api/contracts' export const academyKeys = { all: ['academy'] as const, @@ -12,10 +13,10 @@ async function fetchCourseCertificate( courseId: string, signal: AbortSignal ): Promise { - const data = await fetchJson<{ certificate: AcademyCertificate | null }>( - `/api/academy/certificates?courseId=${encodeURIComponent(courseId)}`, - { signal } - ) + const data = await requestJson(getAcademyCertificateContract, { + query: { courseId }, + signal, + }) return data.certificate } @@ -32,11 +33,7 @@ export function useIssueCertificate() { const queryClient = useQueryClient() return useMutation({ mutationFn: (variables: { courseId: string; completedLessonIds: string[] }) => - fetchJson<{ certificate: AcademyCertificate }>('/api/academy/certificates', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(variables), - }).then((d) => d.certificate), + requestJson(issueAcademyCertificateContract, { body: variables }).then((d) => d.certificate), onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: academyKeys.certificate(variables.courseId) }) }, diff --git a/apps/sim/hooks/queries/admin-users.ts b/apps/sim/hooks/queries/admin-users.ts index 2d593797b93..378a7243f08 100644 --- a/apps/sim/hooks/queries/admin-users.ts +++ b/apps/sim/hooks/queries/admin-users.ts @@ -21,7 +21,7 @@ interface AdminUser { banReason: string | null } -interface AdminUsersResponse { +interface AdminUserListData { users: AdminUser[] total: number } @@ -48,7 +48,7 @@ async function fetchAdminUsers( offset: number, limit: number, searchQuery: string -): Promise { +): Promise { if (isValidUuid(searchQuery.trim())) { const { data, error } = await client.admin.getUser({ query: { id: searchQuery.trim() } }) if (error) throw new Error(error.message ?? 'Failed to fetch user') diff --git a/apps/sim/hooks/queries/allowed-providers.ts b/apps/sim/hooks/queries/allowed-providers.ts index ed251567b4b..218cc9ce7bd 100644 --- a/apps/sim/hooks/queries/allowed-providers.ts +++ b/apps/sim/hooks/queries/allowed-providers.ts @@ -1,6 +1,9 @@ 'use client' import { useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import type { ContractJsonResponse } from '@/lib/api/contracts' +import { getAllowedProvidersContract } from '@/lib/api/contracts' /** * Query key factory for allowed providers queries @@ -10,16 +13,16 @@ export const allowedProvidersKeys = { blacklisted: () => [...allowedProvidersKeys.all, 'blacklisted'] as const, } -interface BlacklistedProvidersResponse { - blacklistedProviders: string[] -} +type BlacklistedProvidersResponse = ContractJsonResponse async function fetchBlacklistedProviders( signal: AbortSignal ): Promise { - const res = await fetch('/api/settings/allowed-providers', { signal }) - if (!res.ok) return { blacklistedProviders: [] } - return res.json() + try { + return await requestJson(getAllowedProvidersContract, { signal }) + } catch { + return { blacklistedProviders: [] } + } } /** diff --git a/apps/sim/hooks/queries/api-keys.ts b/apps/sim/hooks/queries/api-keys.ts index 59804a2cba2..da9f5cd71ec 100644 --- a/apps/sim/hooks/queries/api-keys.ts +++ b/apps/sim/hooks/queries/api-keys.ts @@ -1,6 +1,20 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import type { ContractBodyInput } from '@/lib/api/contracts' +import { + type ApiKey, + createPersonalApiKeyContract, + createWorkspaceApiKeyContract, + deletePersonalApiKeyContract, + deleteWorkspaceApiKeyContract, + listPersonalApiKeysContract, + listWorkspaceApiKeysContract, + updateWorkspaceContract, +} from '@/lib/api/contracts' import { workspaceKeys } from '@/hooks/queries/workspace' +export type { ApiKey } + /** * Query key factories for API keys-related queries */ @@ -13,24 +27,7 @@ export const apiKeysKeys = { combined: (workspaceId: string) => [...apiKeysKeys.combineds(), workspaceId] as const, } -/** - * API Key type definition - */ -export interface ApiKey { - id: string - name: string - key: string - displayKey?: string - lastUsed?: string - createdAt: string - expiresAt?: string - createdBy?: string -} - -/** - * Combined API keys response - */ -interface ApiKeysResponse { +type CombinedApiKeysData = { workspaceKeys: ApiKey[] personalKeys: ApiKey[] conflicts: string[] @@ -39,23 +36,16 @@ interface ApiKeysResponse { /** * Fetch both workspace and personal API keys */ -async function fetchApiKeys(workspaceId: string, signal?: AbortSignal): Promise { - const [workspaceResponse, personalResponse] = await Promise.all([ - fetch(`/api/workspaces/${workspaceId}/api-keys`, { signal }), - fetch('/api/users/me/api-keys', { signal }), +async function fetchApiKeys( + workspaceId: string, + signal?: AbortSignal +): Promise { + const [workspaceData, personalData] = await Promise.all([ + requestJson(listWorkspaceApiKeysContract, { params: { id: workspaceId }, signal }), + requestJson(listPersonalApiKeysContract, { signal }), ]) - - if (!workspaceResponse.ok) { - throw new Error(`Failed to fetch workspace API keys: ${workspaceResponse.status}`) - } - if (!personalResponse.ok) { - throw new Error(`Failed to fetch personal API keys: ${personalResponse.status}`) - } - - const workspaceData = await workspaceResponse.json() - const personalData = await personalResponse.json() - const workspaceKeys: ApiKey[] = workspaceData.keys || [] - const personalKeys: ApiKey[] = personalData.keys || [] + const workspaceKeys: ApiKey[] = workspaceData.keys + const personalKeys: ApiKey[] = personalData.keys const workspaceKeyNames = new Set(workspaceKeys.map((k) => k.name)) const conflicts = personalKeys @@ -85,12 +75,10 @@ export function useApiKeys(workspaceId: string) { /** * Create API key mutation params */ -interface CreateApiKeyParams { +type CreateApiKeyParams = { workspaceId: string - name: string keyType: 'personal' | 'workspace' - source?: 'settings' | 'deploy_modal' -} +} & ContractBodyInput /** * Hook to create a new API key @@ -100,26 +88,14 @@ export function useCreateApiKey() { return useMutation({ mutationFn: async ({ workspaceId, name, keyType, source }: CreateApiKeyParams) => { - const url = - keyType === 'workspace' - ? `/api/workspaces/${workspaceId}/api-keys` - : '/api/users/me/api-keys' - - const body: Record = { name: name.trim() } - if (keyType === 'workspace' && source) body.source = source - - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Failed to create API key' })) - throw new Error(error.error || 'Failed to create API key') + if (keyType === 'workspace') { + return requestJson(createWorkspaceApiKeyContract, { + params: { id: workspaceId }, + body: { name, source }, + }) } - return response.json() + return requestJson(createPersonalApiKeyContract, { body: { name } }) }, onSuccess: (_data, variables) => { queryClient.invalidateQueries({ @@ -132,7 +108,7 @@ export function useCreateApiKey() { /** * Delete API key mutation params */ -interface DeleteApiKeyParams { +type DeleteApiKeyParams = { workspaceId: string keyId: string keyType: 'personal' | 'workspace' @@ -146,21 +122,13 @@ export function useDeleteApiKey() { return useMutation({ mutationFn: async ({ workspaceId, keyId, keyType }: DeleteApiKeyParams) => { - const url = - keyType === 'workspace' - ? `/api/workspaces/${workspaceId}/api-keys/${keyId}` - : `/api/users/me/api-keys/${keyId}` - - const response = await fetch(url, { - method: 'DELETE', - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Failed to delete API key' })) - throw new Error(error.error || 'Failed to delete API key') + if (keyType === 'workspace') { + return requestJson(deleteWorkspaceApiKeyContract, { + params: { id: workspaceId, keyId }, + }) } - return response.json() + return requestJson(deletePersonalApiKeyContract, { params: { id: keyId } }) }, onSuccess: (_data, variables) => { queryClient.invalidateQueries({ @@ -173,10 +141,10 @@ export function useDeleteApiKey() { /** * Update workspace API key settings mutation params */ -interface UpdateWorkspaceApiKeySettingsParams { - workspaceId: string - allowPersonalApiKeys: boolean -} +type UpdateWorkspaceApiKeySettingsParams = { workspaceId: string } & Pick< + ContractBodyInput, + 'allowPersonalApiKeys' +> /** * Hook to update workspace API key settings @@ -189,18 +157,10 @@ export function useUpdateWorkspaceApiKeySettings() { workspaceId, allowPersonalApiKeys, }: UpdateWorkspaceApiKeySettingsParams) => { - const response = await fetch(`/api/workspaces/${workspaceId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ allowPersonalApiKeys }), + return requestJson(updateWorkspaceContract, { + params: { id: workspaceId }, + body: { allowPersonalApiKeys }, }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Failed to update settings' })) - throw new Error(error.error || 'Failed to update workspace settings') - } - - return response.json() }, onSuccess: (_data, variables) => { queryClient.invalidateQueries({ diff --git a/apps/sim/hooks/queries/byok-keys.ts b/apps/sim/hooks/queries/byok-keys.ts index 3dc2755b9dc..54bdac46e32 100644 --- a/apps/sim/hooks/queries/byok-keys.ts +++ b/apps/sim/hooks/queries/byok-keys.ts @@ -1,22 +1,18 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { API_ENDPOINTS } from '@/stores/constants' -import type { BYOKProviderId } from '@/tools/types' +import { requestJson } from '@/lib/api/client/request' +import type { ContractBodyInput } from '@/lib/api/contracts' +import { + type BYOKKey, + type BYOKKeysResponse, + deleteByokKeyContract, + listByokKeysContract, + upsertByokKeyContract, +} from '@/lib/api/contracts' const logger = createLogger('BYOKKeysQueries') -export interface BYOKKey { - id: string - providerId: BYOKProviderId - maskedKey: string - createdBy: string | null - createdAt: string - updatedAt: string -} - -export interface BYOKKeysResponse { - keys: BYOKKey[] -} +export type { BYOKKey, BYOKKeysResponse } export const byokKeysKeys = { all: ['byok-keys'] as const, @@ -24,11 +20,10 @@ export const byokKeysKeys = { } async function fetchBYOKKeys(workspaceId: string, signal?: AbortSignal): Promise { - const response = await fetch(API_ENDPOINTS.WORKSPACE_BYOK_KEYS(workspaceId), { signal }) - if (!response.ok) { - throw new Error(`Failed to load BYOK keys: ${response.statusText}`) - } - const data = await response.json() + const data = await requestJson(listByokKeysContract, { + params: { id: workspaceId }, + signal, + }) return { keys: data.keys ?? [], } @@ -44,30 +39,21 @@ export function useBYOKKeys(workspaceId: string) { }) } -interface UpsertBYOKKeyParams { +type UpsertBYOKKeyParams = { workspaceId: string - providerId: BYOKProviderId - apiKey: string -} +} & ContractBodyInput export function useUpsertBYOKKey() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ workspaceId, providerId, apiKey }: UpsertBYOKKeyParams) => { - const response = await fetch(API_ENDPOINTS.WORKSPACE_BYOK_KEYS(workspaceId), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ providerId, apiKey }), + const data = await requestJson(upsertByokKeyContract, { + params: { id: workspaceId }, + body: { providerId, apiKey }, }) - - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || `Failed to save BYOK key: ${response.statusText}`) - } - logger.info(`Saved BYOK key for ${providerId} in workspace ${workspaceId}`) - return await response.json() + return data }, onSuccess: (_data, variables) => { queryClient.invalidateQueries({ @@ -77,29 +63,21 @@ export function useUpsertBYOKKey() { }) } -interface DeleteBYOKKeyParams { +type DeleteBYOKKeyParams = { workspaceId: string - providerId: BYOKProviderId -} +} & ContractBodyInput export function useDeleteBYOKKey() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ workspaceId, providerId }: DeleteBYOKKeyParams) => { - const response = await fetch(API_ENDPOINTS.WORKSPACE_BYOK_KEYS(workspaceId), { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ providerId }), + const data = await requestJson(deleteByokKeyContract, { + params: { id: workspaceId }, + body: { providerId }, }) - - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || `Failed to delete BYOK key: ${response.statusText}`) - } - logger.info(`Deleted BYOK key for ${providerId} from workspace ${workspaceId}`) - return await response.json() + return data }, onSuccess: (_data, variables) => { queryClient.invalidateQueries({ diff --git a/apps/sim/hooks/queries/chats.ts b/apps/sim/hooks/queries/chats.ts index 8b4e65e1de0..96eb12bb4f1 100644 --- a/apps/sim/hooks/queries/chats.ts +++ b/apps/sim/hooks/queries/chats.ts @@ -1,5 +1,23 @@ import { createLogger } from '@sim/logger' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { ApiClientError } from '@/lib/api/client/errors' +import { requestJson } from '@/lib/api/client/request' +import { + authenticateDeployedChatContract, + type ChatAuthType, + type CreateChatBody, + type CreateChatResponse, + createChatContract, + type DeployedChatAuthBody, + type DeployedChatConfig, + deleteChatContract, + getDeployedChatConfigContract, + requestChatEmailOtpContract, + type UpdateChatBody, + type UpdateChatResponse, + updateChatContract, + verifyChatEmailOtpContract, +} from '@/lib/api/contracts/chats' import type { OutputConfig } from '@/stores/chat/types' import { deploymentKeys } from './deployments' @@ -19,25 +37,10 @@ export const chatKeys = { /** * Auth types for chat access control */ -export type AuthType = 'public' | 'password' | 'email' | 'sso' +export type AuthType = ChatAuthType -/** - * Deployed chat configuration returned from the public chat endpoint - */ -export interface DeployedChatConfig { - id: string - title: string - description: string - customizations: { - primaryColor?: string - logoUrl?: string - imageUrl?: string - welcomeMessage?: string - headerText?: string - } - authType?: AuthType - outputConfigs?: Array<{ blockId: string; path?: string }> -} +/** Deployed chat configuration returned from the public chat endpoint. */ +export type { DeployedChatConfig } /** * Result of loading a deployed chat's configuration. @@ -59,27 +62,23 @@ async function fetchDeployedChatConfig( identifier: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/chat/${identifier}`, { - credentials: 'same-origin', - headers: { 'X-Requested-With': 'XMLHttpRequest' }, - signal, - }) - - if (response.status === 401) { - const errorData = await response.json().catch(() => ({})) - const authType = AUTH_ERROR_MAP[errorData?.error] - if (authType) { - return { kind: 'auth', authType } + try { + const config = await requestJson(getDeployedChatConfigContract, { + params: { identifier }, + signal, + }) + return { kind: 'config', config } + } catch (error) { + if (error instanceof ApiClientError && error.status === 401) { + const authType = AUTH_ERROR_MAP[error.message] + if (authType) { + return { kind: 'auth', authType } + } + throw new Error('Unauthorized', { cause: error }) } - throw new Error('Unauthorized') - } - if (!response.ok) { - throw new Error(`Failed to load chat configuration: ${response.status}`) + throw error } - - const config = (await response.json()) as DeployedChatConfig - return { kind: 'config', config } } /** @@ -99,24 +98,12 @@ export function useDeployedChatConfig(identifier: string) { async function postChatAuth( identifier: string, - body: Record + body: DeployedChatAuthBody ): Promise { - const response = await fetch(`/api/chat/${identifier}`, { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }, - body: JSON.stringify(body), + return requestJson(authenticateDeployedChatContract, { + params: { identifier }, + body, }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData?.error || 'Authentication failed') - } - - return (await response.json()) as DeployedChatConfig } /** @@ -144,18 +131,10 @@ export function useChatPasswordAuth(identifier: string) { export function useChatEmailOtpRequest(identifier: string) { return useMutation({ mutationFn: async ({ email }: { email: string }) => { - const response = await fetch(`/api/chat/${identifier}/otp`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }, - body: JSON.stringify({ email }), + await requestJson(requestChatEmailOtpContract, { + params: { identifier }, + body: { email }, }) - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData?.error || 'Failed to send verification code') - } }, }) } @@ -168,19 +147,10 @@ export function useChatEmailOtpVerify(identifier: string) { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ email, otp }: { email: string; otp: string }) => { - const response = await fetch(`/api/chat/${identifier}/otp`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }, - body: JSON.stringify({ email, otp }), + return requestJson(verifyChatEmailOtpContract, { + params: { identifier }, + body: { email, otp }, }) - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData?.error || 'Invalid verification code') - } - return (await response.json()) as DeployedChatConfig }, onSuccess: (config) => { queryClient.setQueryData(chatKeys.config(identifier), { @@ -211,7 +181,6 @@ export interface ChatFormData { interface CreateChatVariables { workflowId: string formData: ChatFormData - apiKey?: string imageUrl?: string | null } @@ -234,11 +203,18 @@ interface DeleteChatVariables { } /** - * Response from chat create/update mutations + * Data returned by chat create/update mutations */ -interface ChatMutationResult { - chatUrl: string - chatId?: string +type ChatMutationData = + | Pick + | Pick + +function throwUserFriendlyIdentifierError(error: unknown): never { + if (error instanceof ApiClientError && error.message === 'Identifier already in use') { + throw new Error('This identifier is already in use', { cause: error }) + } + + throw error } /** @@ -266,10 +242,8 @@ function parseOutputConfigs(selectedOutputBlocks: string[]): OutputConfig[] { function buildChatPayload( workflowId: string, formData: ChatFormData, - apiKey?: string, - imageUrl?: string | null, - isUpdate?: boolean -) { + imageUrl?: string | null +): CreateChatBody { const outputConfigs = parseOutputConfigs(formData.selectedOutputBlocks) return { @@ -287,8 +261,6 @@ function buildChatPayload( allowedEmails: formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [], outputConfigs, - apiKey, - deployApiEnabled: !isUpdate, } } @@ -303,32 +275,17 @@ export function useCreateChat() { mutationFn: async ({ workflowId, formData, - apiKey, imageUrl, - }: CreateChatVariables): Promise => { - const payload = buildChatPayload(workflowId, formData, apiKey, imageUrl, false) - - const response = await fetch('/api/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) - - const result = await response.json() - - if (!response.ok) { - if (result.error === 'Identifier already in use') { - throw new Error('This identifier is already in use') - } - throw new Error(result.error || 'Failed to deploy chat') + }: CreateChatVariables): Promise => { + const payload = buildChatPayload(workflowId, formData, imageUrl) + + try { + const result = await requestJson(createChatContract, { body: payload }) + logger.info('Chat deployed successfully:', result.chatUrl) + return { chatUrl: result.chatUrl, chatId: result.chatId } + } catch (error) { + throwUserFriendlyIdentifierError(error) } - - if (!result.chatUrl) { - throw new Error('Response missing chatUrl') - } - - logger.info('Chat deployed successfully:', result.chatUrl) - return { chatUrl: result.chatUrl, chatId: result.chatId } }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ @@ -360,30 +317,19 @@ export function useUpdateChat() { workflowId, formData, imageUrl, - }: UpdateChatVariables): Promise => { - const payload = buildChatPayload(workflowId, formData, undefined, imageUrl, true) - - const response = await fetch(`/api/chat/manage/${chatId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) - - const result = await response.json() - - if (!response.ok) { - if (result.error === 'Identifier already in use') { - throw new Error('This identifier is already in use') - } - throw new Error(result.error || 'Failed to update chat') - } - - if (!result.chatUrl) { - throw new Error('Response missing chatUrl') + }: UpdateChatVariables): Promise => { + const payload = buildChatPayload(workflowId, formData, imageUrl) + + try { + const result = await requestJson(updateChatContract, { + params: { id: chatId }, + body: payload satisfies UpdateChatBody, + }) + logger.info('Chat updated successfully:', result.chatUrl) + return { chatUrl: result.chatUrl, chatId } + } catch (error) { + throwUserFriendlyIdentifierError(error) } - - logger.info('Chat updated successfully:', result.chatUrl) - return { chatUrl: result.chatUrl, chatId } }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ @@ -408,15 +354,7 @@ export function useDeleteChat() { return useMutation({ mutationFn: async ({ chatId }: DeleteChatVariables): Promise => { - const response = await fetch(`/api/chat/manage/${chatId}`, { - method: 'DELETE', - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to delete chat') - } - + await requestJson(deleteChatContract, { params: { id: chatId } }) logger.info('Chat deleted successfully') }, onSettled: (_data, _error, variables) => { diff --git a/apps/sim/hooks/queries/copilot-chats.ts b/apps/sim/hooks/queries/copilot-chats.ts index e37ccd71f8d..fe6f7462d2b 100644 --- a/apps/sim/hooks/queries/copilot-chats.ts +++ b/apps/sim/hooks/queries/copilot-chats.ts @@ -1,12 +1,9 @@ import { skipToken, useQuery } from '@tanstack/react-query' +import { ApiClientError } from '@/lib/api/client/errors' +import { requestJson } from '@/lib/api/client/request' +import { type CopilotChatListItem, listCopilotChatsContract } from '@/lib/api/contracts/copilot' -export interface CopilotChatListItem { - id: string - title: string | null - workflowId?: string - updatedAt: string - activeStreamId: string | null -} +export type { CopilotChatListItem } export const copilotChatsKeys = { all: ['copilot-chats'] as const, @@ -18,11 +15,13 @@ async function fetchCopilotChats( workflowId: string, signal?: AbortSignal ): Promise { - const res = await fetch('/api/copilot/chats', { signal }) - if (!res.ok) return [] - const data = await res.json() - const all = Array.isArray(data?.chats) ? (data.chats as CopilotChatListItem[]) : [] - return all.filter((c) => c.workflowId === workflowId) + try { + const data = await requestJson(listCopilotChatsContract, { signal }) + return data.chats.filter((c) => c.workflowId === workflowId) + } catch (error) { + if (error instanceof ApiClientError) return [] + throw error + } } /** diff --git a/apps/sim/hooks/queries/copilot-feedback.ts b/apps/sim/hooks/queries/copilot-feedback.ts index 6901f93a2de..26ba342e0c8 100644 --- a/apps/sim/hooks/queries/copilot-feedback.ts +++ b/apps/sim/hooks/queries/copilot-feedback.ts @@ -1,36 +1,20 @@ import { createLogger } from '@sim/logger' import { useMutation } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type SubmitCopilotFeedbackBody, + type SubmitCopilotFeedbackResult, + submitCopilotFeedbackContract, +} from '@/lib/api/contracts' const logger = createLogger('CopilotFeedbackMutation') -interface SubmitFeedbackVariables { - chatId: string - userQuery: string - agentResponse: string - isPositiveFeedback: boolean - feedback?: string -} - -interface SubmitFeedbackResponse { - success: boolean - feedbackId: string -} - export function useSubmitCopilotFeedback() { return useMutation({ - mutationFn: async (variables: SubmitFeedbackVariables): Promise => { - const response = await fetch('/api/copilot/feedback', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(variables), - }) - - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'Failed to submit feedback') - } - - return response.json() + mutationFn: async ( + variables: SubmitCopilotFeedbackBody + ): Promise => { + return requestJson(submitCopilotFeedbackContract, { body: variables }) }, onError: (error) => { logger.error('Failed to submit copilot feedback:', error) diff --git a/apps/sim/hooks/queries/copilot-keys.ts b/apps/sim/hooks/queries/copilot-keys.ts index 75058d0566a..d20037df831 100644 --- a/apps/sim/hooks/queries/copilot-keys.ts +++ b/apps/sim/hooks/queries/copilot-keys.ts @@ -1,5 +1,13 @@ import { createLogger } from '@sim/logger' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type CopilotApiKey, + deleteCopilotApiKeyContract, + type GenerateCopilotApiKeyResult, + generateCopilotApiKeyContract, + listCopilotApiKeysContract, +} from '@/lib/api/contracts' import { isHosted } from '@/lib/core/config/feature-flags' const logger = createLogger('CopilotKeysQuery') @@ -13,39 +21,16 @@ export const copilotKeysKeys = { } /** - * Copilot API key type + * Copilot API key type (re-exported from the API contract). */ -export interface CopilotKey { - id: string - displayKey: string // "•••••{last6}" - name: string | null - createdAt: string | null - lastUsed: string | null -} - -/** - * Generate key response type - */ -export interface GenerateKeyResponse { - success: boolean - key: { - id: string - apiKey: string // Full key (only shown once) - } -} +export type CopilotKey = CopilotApiKey /** * Fetch Copilot API keys */ async function fetchCopilotKeys(signal?: AbortSignal): Promise { - const response = await fetch('/api/copilot/api-keys', { signal }) - - if (!response.ok) { - throw new Error('Failed to fetch Copilot API keys') - } - - const data = await response.json() - return data.keys || [] + const data = await requestJson(listCopilotApiKeysContract, { signal }) + return data.keys } /** @@ -74,21 +59,8 @@ export function useGenerateCopilotKey() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ name }: GenerateKeyParams): Promise => { - const response = await fetch('/api/copilot/api-keys/generate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ name }), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to generate Copilot API key') - } - - return response.json() + mutationFn: async ({ name }: GenerateKeyParams): Promise => { + return requestJson(generateCopilotApiKeyContract, { body: { name } }) }, onSuccess: () => { queryClient.invalidateQueries({ @@ -113,16 +85,7 @@ export function useDeleteCopilotKey() { return useMutation({ mutationFn: async ({ keyId }: DeleteKeyParams) => { - const response = await fetch(`/api/copilot/api-keys?id=${keyId}`, { - method: 'DELETE', - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to delete Copilot API key') - } - - return response.json() + return requestJson(deleteCopilotApiKeyContract, { query: { id: keyId } }) }, onMutate: async ({ keyId }) => { await queryClient.cancelQueries({ queryKey: copilotKeysKeys.keys() }) diff --git a/apps/sim/hooks/queries/creator-profile.ts b/apps/sim/hooks/queries/creator-profile.ts index 24770698d54..c49c678eeec 100644 --- a/apps/sim/hooks/queries/creator-profile.ts +++ b/apps/sim/hooks/queries/creator-profile.ts @@ -1,6 +1,15 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import type { CreatorProfileDetails } from '@/app/_types/creator-profile' +import { requestJson } from '@/lib/api/client/request' +import { + type CreatorOrganization, + type CreatorProfileContract, + type CreatorProfileDetails, + createCreatorProfileContract, + listCreatorOrganizationsContract, + listCreatorProfilesContract, + updateCreatorProfileContract, +} from '@/lib/api/contracts/creator-profile' const logger = createLogger('CreatorProfileQuery') @@ -17,39 +26,20 @@ export const creatorProfileKeys = { /** * Organization type */ -export interface Organization { - id: string - name: string - role: string -} +export type Organization = CreatorOrganization /** * Creator profile type */ -export interface CreatorProfile { - id: string - referenceType: 'user' | 'organization' - referenceId: string - name: string - profileImageUrl: string - details?: CreatorProfileDetails - createdAt: string - updatedAt: string -} +export type CreatorProfile = CreatorProfileContract /** * Fetch organizations where user is owner or admin * Note: Filtering is done server-side in the API route */ async function fetchOrganizations(signal?: AbortSignal): Promise { - const response = await fetch('/api/organizations', { signal }) - - if (!response.ok) { - throw new Error('Failed to fetch organizations') - } - - const data = await response.json() - return data.organizations || [] + const data = await requestJson(listCreatorOrganizationsContract, { signal }) + return data.organizations } /** @@ -67,14 +57,8 @@ export function useOrganizations() { * Fetch all creator profiles for the current user */ async function fetchCreatorProfiles(signal?: AbortSignal): Promise { - const response = await fetch('/api/creators', { signal }) - - if (!response.ok) { - throw new Error('Failed to fetch creator profiles') - } - - const data = await response.json() - return data.profiles || [] + const data = await requestJson(listCreatorProfilesContract, { query: {}, signal }) + return data.profiles } /** @@ -95,20 +79,12 @@ async function fetchCreatorProfile( userId: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/creators?userId=${userId}`, { signal }) - - // Treat 404 as "no profile" - if (response.status === 404) { - return null - } - - if (!response.ok) { - throw new Error('Failed to fetch creator profile') - } - - const data = await response.json() + const data = await requestJson(listCreatorProfilesContract, { + query: { userId }, + signal, + }) - if (data.profiles && data.profiles.length > 0) { + if (data.profiles.length > 0) { return data.profiles[0] } @@ -161,22 +137,15 @@ export function useSaveCreatorProfile() { details: details && Object.keys(details).length > 0 ? details : undefined, } - const url = existingProfileId ? `/api/creators/${existingProfileId}` : '/api/creators' - const method = existingProfileId ? 'PUT' : 'POST' - - const response = await fetch(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - const errorMessage = errorData.error || 'Failed to save creator profile' - throw new Error(errorMessage) + if (existingProfileId) { + const result = await requestJson(updateCreatorProfileContract, { + params: { id: existingProfileId }, + body: payload, + }) + return result.data } - const result = await response.json() + const result = await requestJson(createCreatorProfileContract, { body: payload }) return result.data }, onSuccess: (_data, variables) => { diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts index e1b104fbb75..89e4f04af56 100644 --- a/apps/sim/hooks/queries/credential-sets.ts +++ b/apps/sim/hooks/queries/credential-sets.ts @@ -1,58 +1,42 @@ 'use client' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { fetchJson } from '@/hooks/selectors/helpers' - -export interface CredentialSet { - id: string - name: string - description: string | null - providerId: string | null - createdBy: string - createdAt: string - updatedAt: string - creatorName: string | null - creatorEmail: string | null - memberCount: number -} - -export interface CredentialSetMembership { - membershipId: string - status: string - joinedAt: string | null - credentialSetId: string - credentialSetName: string - credentialSetDescription: string | null - providerId: string | null - organizationId: string - organizationName: string -} - -export interface CredentialSetInvitation { - invitationId: string - token: string - status: string - expiresAt: string - createdAt: string - credentialSetId: string - credentialSetName: string - providerId: string | null - organizationId: string - organizationName: string - invitedByName: string | null - invitedByEmail: string | null -} - -interface CredentialSetsResponse { - credentialSets?: CredentialSet[] -} - -interface MembershipsResponse { - memberships?: CredentialSetMembership[] -} - -interface InvitationsResponse { - invitations?: CredentialSetInvitation[] +import { requestJson } from '@/lib/api/client/request' +import type { + ContractBodyInput, + ContractParamsInput, + ContractQueryInput, +} from '@/lib/api/contracts' +import { + acceptCredentialSetInvitationContract, + type CreateCredentialSetData, + type CredentialSet, + type CredentialSetInvitation, + type CredentialSetInvitationDetail, + type CredentialSetMember, + type CredentialSetMembership, + cancelCredentialSetInvitationContract, + createCredentialSetContract, + createCredentialSetInvitationContract, + deleteCredentialSetContract, + getCredentialSetContract, + leaveCredentialSetContract, + listCredentialSetInvitationDetailsContract, + listCredentialSetInvitationsContract, + listCredentialSetMembersContract, + listCredentialSetMembershipsContract, + listCredentialSetsContract, + removeCredentialSetMemberContract, + resendCredentialSetInvitationContract, +} from '@/lib/api/contracts' + +export type { + CreateCredentialSetData, + CredentialSet, + CredentialSetInvitation, + CredentialSetInvitationDetail, + CredentialSetMember, + CredentialSetMembership, } export const credentialSetKeys = { @@ -75,8 +59,8 @@ export async function fetchCredentialSets( signal?: AbortSignal ): Promise { if (!organizationId) return [] - const data = await fetchJson('/api/credential-sets', { - searchParams: { organizationId }, + const data = await requestJson(listCredentialSetsContract, { + query: { organizationId }, signal, }) return data.credentialSets ?? [] @@ -92,16 +76,13 @@ export function useCredentialSets(organizationId?: string, enabled = true) { }) } -interface CredentialSetDetailResponse { - credentialSet?: CredentialSet -} - export async function fetchCredentialSetById( id: string, signal?: AbortSignal ): Promise { if (!id) return null - const data = await fetchJson(`/api/credential-sets/${id}`, { + const data = await requestJson(getCredentialSetContract, { + params: { id }, signal, }) return data.credentialSet ?? null @@ -121,9 +102,7 @@ export function useCredentialSetMemberships() { return useQuery({ queryKey: credentialSetKeys.memberships(), queryFn: async ({ signal }) => { - const data = await fetchJson('/api/credential-sets/memberships', { - signal, - }) + const data = await requestJson(listCredentialSetMembershipsContract, { signal }) return data.memberships ?? [] }, staleTime: 60 * 1000, @@ -134,9 +113,7 @@ export function useCredentialSetInvitations() { return useQuery({ queryKey: credentialSetKeys.invitations(), queryFn: async ({ signal }) => { - const data = await fetchJson('/api/credential-sets/invitations', { - signal, - }) + const data = await requestJson(listCredentialSetInvitationsContract, { signal }) return data.invitations ?? [] }, staleTime: 30 * 1000, @@ -148,14 +125,9 @@ export function useAcceptCredentialSetInvitation() { return useMutation({ mutationFn: async (token: string) => { - const response = await fetch(`/api/credential-sets/invite/${token}`, { - method: 'POST', + return requestJson(acceptCredentialSetInvitationContract, { + params: { token }, }) - if (!response.ok) { - const data = await response.json() - throw new Error(data.error || 'Failed to accept invitation') - } - return response.json() }, onSettled: () => { queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() }) @@ -164,28 +136,12 @@ export function useAcceptCredentialSetInvitation() { }) } -export interface CreateCredentialSetData { - organizationId: string - name: string - description?: string - providerId?: string -} - export function useCreateCredentialSet() { const queryClient = useQueryClient() return useMutation({ mutationFn: async (data: CreateCredentialSetData) => { - const response = await fetch('/api/credential-sets', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to create credential set') - } - return response.json() + return requestJson(createCredentialSetContract, { body: data }) }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: credentialSetKeys.list(variables.organizationId) }) @@ -197,17 +153,15 @@ export function useCreateCredentialSetInvitation() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async (data: { credentialSetId: string; email?: string }) => { - const response = await fetch(`/api/credential-sets/${data.credentialSetId}/invite`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: data.email }), + mutationFn: async ( + data: { credentialSetId: string } & ContractBodyInput< + typeof createCredentialSetInvitationContract + > + ) => { + return requestJson(createCredentialSetInvitationContract, { + params: { id: data.credentialSetId }, + body: { email: data.email }, }) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to create invitation') - } - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ @@ -218,30 +172,15 @@ export function useCreateCredentialSetInvitation() { }) } -export interface CredentialSetMember { - id: string - userId: string - status: string - joinedAt: string | null - createdAt: string - userName: string | null - userEmail: string | null - userImage: string | null - credentials: { providerId: string; accountId: string }[] -} - -interface MembersResponse { - members?: CredentialSetMember[] -} - export function useCredentialSetMembers(credentialSetId?: string) { return useQuery({ queryKey: credentialSetKeys.detailMembers(credentialSetId), queryFn: async ({ signal }) => { - const data = await fetchJson( - `/api/credential-sets/${credentialSetId}/members`, - { signal } - ) + if (!credentialSetId) return [] + const data = await requestJson(listCredentialSetMembersContract, { + params: { id: credentialSetId }, + signal, + }) return data.members ?? [] }, enabled: Boolean(credentialSetId), @@ -253,16 +192,15 @@ export function useRemoveCredentialSetMember() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async (data: { credentialSetId: string; memberId: string }) => { - const response = await fetch( - `/api/credential-sets/${data.credentialSetId}/members?memberId=${data.memberId}`, - { method: 'DELETE' } - ) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to remove member') - } - return response.json() + mutationFn: async ( + data: { credentialSetId: string } & ContractQueryInput< + typeof removeCredentialSetMemberContract + > + ) => { + return requestJson(removeCredentialSetMemberContract, { + params: { id: data.credentialSetId }, + query: { memberId: data.memberId }, + }) }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ @@ -278,15 +216,9 @@ export function useLeaveCredentialSet() { return useMutation({ mutationFn: async (credentialSetId: string) => { - const response = await fetch( - `/api/credential-sets/memberships?credentialSetId=${credentialSetId}`, - { method: 'DELETE' } - ) - if (!response.ok) { - const data = await response.json() - throw new Error(data.error || 'Failed to leave credential set') - } - return response.json() + return requestJson(leaveCredentialSetContract, { + query: { credentialSetId }, + }) }, onSettled: () => { queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() }) @@ -304,14 +236,9 @@ export function useDeleteCredentialSet() { return useMutation({ mutationFn: async ({ credentialSetId }: DeleteCredentialSetParams) => { - const response = await fetch(`/api/credential-sets/${credentialSetId}`, { - method: 'DELETE', + return requestJson(deleteCredentialSetContract, { + params: { id: credentialSetId }, }) - if (!response.ok) { - const data = await response.json() - throw new Error(data.error || 'Failed to delete credential set') - } - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ @@ -325,29 +252,15 @@ export function useDeleteCredentialSet() { }) } -export interface CredentialSetInvitationDetail { - id: string - credentialSetId: string - email: string | null - token: string - status: string - expiresAt: string - createdAt: string - invitedBy: string -} - -interface InvitationsDetailResponse { - invitations?: CredentialSetInvitationDetail[] -} - export function useCredentialSetInvitationsDetail(credentialSetId?: string) { return useQuery({ queryKey: credentialSetKeys.detailInvitations(credentialSetId), queryFn: async ({ signal }) => { - const data = await fetchJson( - `/api/credential-sets/${credentialSetId}/invite`, - { signal } - ) + if (!credentialSetId) return [] + const data = await requestJson(listCredentialSetInvitationDetailsContract, { + params: { id: credentialSetId }, + signal, + }) return (data.invitations ?? []).filter((inv) => inv.status === 'pending') }, enabled: Boolean(credentialSetId), @@ -359,16 +272,15 @@ export function useCancelCredentialSetInvitation() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async (data: { credentialSetId: string; invitationId: string }) => { - const response = await fetch( - `/api/credential-sets/${data.credentialSetId}/invite?invitationId=${data.invitationId}`, - { method: 'DELETE' } - ) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to cancel invitation') - } - return response.json() + mutationFn: async ( + data: { credentialSetId: string } & ContractQueryInput< + typeof cancelCredentialSetInvitationContract + > + ) => { + return requestJson(cancelCredentialSetInvitationContract, { + params: { id: data.credentialSetId }, + query: { invitationId: data.invitationId }, + }) }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ @@ -382,16 +294,15 @@ export function useResendCredentialSetInvitation() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async (data: { credentialSetId: string; invitationId: string; email: string }) => { - const response = await fetch( - `/api/credential-sets/${data.credentialSetId}/invite/${data.invitationId}`, - { method: 'POST' } - ) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to resend invitation') - } - return response.json() + mutationFn: async ( + data: { credentialSetId: string; email: string } & Pick< + ContractParamsInput, + 'invitationId' + > + ) => { + return requestJson(resendCredentialSetInvitationContract, { + params: { id: data.credentialSetId, invitationId: data.invitationId }, + }) }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index 708554aa965..cade11bb7a7 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -2,8 +2,25 @@ import type { QueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import type { ContractBodyInput, ContractQueryInput } from '@/lib/api/contracts' +import { + createCredentialDraftContract, + createWorkspaceCredentialContract, + deleteWorkspaceCredentialContract, + getWorkspaceCredentialContract, + listWorkspaceCredentialMembersContract, + listWorkspaceCredentialsContract, + removeWorkspaceCredentialMemberContract, + updateWorkspaceCredentialContract, + upsertWorkspaceCredentialMemberContract, + type WorkspaceCredential, + type WorkspaceCredentialMember, + type WorkspaceCredentialMemberStatus, + type WorkspaceCredentialRole, + type WorkspaceCredentialType, +} from '@/lib/api/contracts' import { environmentKeys } from '@/hooks/queries/environment' -import { fetchJson } from '@/hooks/selectors/helpers' /** * Key prefix for OAuth credential queries. @@ -11,51 +28,12 @@ import { fetchJson } from '@/hooks/selectors/helpers' */ const OAUTH_CREDENTIALS_KEY = ['oauthCredentials'] as const -export type WorkspaceCredentialType = 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' -export type WorkspaceCredentialRole = 'admin' | 'member' -export type WorkspaceCredentialMemberStatus = 'active' | 'pending' | 'revoked' - -export interface WorkspaceCredential { - id: string - workspaceId: string - type: WorkspaceCredentialType - displayName: string - description: string | null - providerId: string | null - accountId: string | null - envKey: string | null - envOwnerUserId: string | null - createdBy: string - createdAt: string - updatedAt: string - role?: WorkspaceCredentialRole - status?: WorkspaceCredentialMemberStatus -} - -export interface WorkspaceCredentialMember { - id: string - userId: string - role: WorkspaceCredentialRole - status: WorkspaceCredentialMemberStatus - joinedAt: string | null - invitedBy: string | null - createdAt: string - updatedAt: string - userName: string | null - userEmail: string | null - userImage: string | null -} - -interface CredentialListResponse { - credentials?: WorkspaceCredential[] -} - -interface CredentialResponse { - credential?: WorkspaceCredential | null -} - -interface MembersResponse { - members?: WorkspaceCredentialMember[] +export type { + WorkspaceCredential, + WorkspaceCredentialMember, + WorkspaceCredentialMemberStatus, + WorkspaceCredentialRole, + WorkspaceCredentialType, } export const workspaceCredentialKeys = { @@ -83,8 +61,8 @@ export async function fetchWorkspaceCredentialList( workspaceId: string, signal?: AbortSignal ): Promise { - const data = await fetchJson('/api/credentials', { - searchParams: { workspaceId }, + const data = await requestJson(listWorkspaceCredentialsContract, { + query: { workspaceId }, signal, }) return data.credentials ?? [] @@ -114,8 +92,8 @@ export function useWorkspaceCredentials(params: { queryKey: workspaceCredentialKeys.list(workspaceId, type, providerId), queryFn: async ({ signal }) => { if (!workspaceId) return [] - const data = await fetchJson('/api/credentials', { - searchParams: { + const data = await requestJson(listWorkspaceCredentialsContract, { + query: { workspaceId, type, providerId, @@ -134,7 +112,8 @@ export function useWorkspaceCredential(credentialId?: string, enabled = true) { queryKey: workspaceCredentialKeys.detail(credentialId), queryFn: async ({ signal }) => { if (!credentialId) return null - const data = await fetchJson(`/api/credentials/${credentialId}`, { + const data = await requestJson(getWorkspaceCredentialContract, { + params: { id: credentialId }, signal, }) return data.credential ?? null @@ -146,22 +125,8 @@ export function useWorkspaceCredential(credentialId?: string, enabled = true) { export function useCreateCredentialDraft() { return useMutation({ - mutationFn: async (payload: { - workspaceId: string - providerId: string - displayName: string - description?: string - credentialId?: string - }) => { - const response = await fetch('/api/credentials/draft', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) - if (!response.ok) { - const data = await response.json() - throw new Error(data.error || 'Failed to create credential draft') - } + mutationFn: async (payload: ContractBodyInput) => { + await requestJson(createCredentialDraftContract, { body: payload }) }, }) } @@ -170,29 +135,8 @@ export function useCreateWorkspaceCredential() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async (payload: { - workspaceId: string - type: WorkspaceCredentialType - displayName?: string - description?: string - providerId?: string - accountId?: string - envKey?: string - envOwnerUserId?: string - serviceAccountJson?: string - }) => { - const response = await fetch('/api/credentials', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) - - if (!response.ok) { - const data = await response.json() - throw new Error(data.error || 'Failed to create credential') - } - - return response.json() + mutationFn: async (payload: ContractBodyInput) => { + return requestJson(createWorkspaceCredentialContract, { body: payload }) }, onSettled: () => { queryClient.invalidateQueries({ @@ -209,28 +153,19 @@ export function useUpdateWorkspaceCredential() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async (payload: { - credentialId: string - displayName?: string - description?: string | null - accountId?: string - serviceAccountJson?: string - }) => { - const response = await fetch(`/api/credentials/${payload.credentialId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + mutationFn: async ( + payload: { + credentialId: string + } & ContractBodyInput + ) => { + return requestJson(updateWorkspaceCredentialContract, { + params: { id: payload.credentialId }, + body: { displayName: payload.displayName, description: payload.description, - accountId: payload.accountId, serviceAccountJson: payload.serviceAccountJson, - }), + }, }) - if (!response.ok) { - const data = await response.json() - throw new Error(data.error || 'Failed to update credential') - } - return response.json() }, onMutate: async (variables) => { await queryClient.cancelQueries({ @@ -290,14 +225,7 @@ export function useDeleteWorkspaceCredential() { return useMutation({ mutationFn: async (credentialId: string) => { - const response = await fetch(`/api/credentials/${credentialId}`, { - method: 'DELETE', - }) - if (!response.ok) { - const data = await response.json() - throw new Error(data.error || 'Failed to delete credential') - } - return response.json() + return requestJson(deleteWorkspaceCredentialContract, { params: { id: credentialId } }) }, onSettled: (_data, _error, credentialId) => { queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.detail(credentialId) }) @@ -313,7 +241,8 @@ export function useWorkspaceCredentialMembers(credentialId?: string) { queryKey: workspaceCredentialKeys.members(credentialId), queryFn: async ({ signal }) => { if (!credentialId) return [] - const data = await fetchJson(`/api/credentials/${credentialId}/members`, { + const data = await requestJson(listWorkspaceCredentialMembersContract, { + params: { id: credentialId }, signal, }) return data.members ?? [] @@ -327,24 +256,18 @@ export function useUpsertWorkspaceCredentialMember() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async (payload: { - credentialId: string - userId: string - role: WorkspaceCredentialRole - }) => { - const response = await fetch(`/api/credentials/${payload.credentialId}/members`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + mutationFn: async ( + payload: { + credentialId: string + } & ContractBodyInput + ) => { + return requestJson(upsertWorkspaceCredentialMemberContract, { + params: { id: payload.credentialId }, + body: { userId: payload.userId, role: payload.role, - }), + }, }) - if (!response.ok) { - const data = await response.json() - throw new Error(data.error || 'Failed to update credential member') - } - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ @@ -361,16 +284,15 @@ export function useRemoveWorkspaceCredentialMember() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async (payload: { credentialId: string; userId: string }) => { - const response = await fetch( - `/api/credentials/${payload.credentialId}/members?userId=${encodeURIComponent(payload.userId)}`, - { method: 'DELETE' } - ) - if (!response.ok) { - const data = await response.json() - throw new Error(data.error || 'Failed to remove credential member') - } - return response.json() + mutationFn: async ( + payload: { + credentialId: string + } & ContractQueryInput + ) => { + return requestJson(removeWorkspaceCredentialMemberContract, { + params: { id: payload.credentialId }, + query: { userId: payload.userId }, + }) }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ diff --git a/apps/sim/hooks/queries/custom-tools.ts b/apps/sim/hooks/queries/custom-tools.ts index 78af01fd0ee..e82340599d4 100644 --- a/apps/sim/hooks/queries/custom-tools.ts +++ b/apps/sim/hooks/queries/custom-tools.ts @@ -1,16 +1,24 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + deleteCustomToolContract, + listCustomToolsContract, + upsertCustomToolsContract, +} from '@/lib/api/contracts/custom-tools' import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys' const logger = createLogger('CustomToolsQueries') -const API_ENDPOINT = '/api/tools/custom' export interface CustomToolSchema { - type: string + [key: string]: unknown + type: 'function' function: { + [key: string]: unknown name: string description?: string parameters: { + [key: string]: unknown type: string properties: Record required?: string[] @@ -42,6 +50,10 @@ type ApiCustomTool = Partial & { code?: string } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomToolDefinition { const fallbackName = tool.schema.function?.name || tool.id const parameters = tool.schema.function?.parameters ?? { @@ -84,23 +96,15 @@ async function fetchCustomTools( workspaceId: string, signal?: AbortSignal ): Promise { - const response = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`, { signal }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || `Failed to fetch custom tools: ${response.statusText}`) - } - - const { data } = await response.json() - - if (!Array.isArray(data)) { - throw new Error('Invalid response format') - } + const { data } = await requestJson(listCustomToolsContract, { + query: { workspaceId }, + signal, + }) const normalizedTools: CustomToolDefinition[] = [] data.forEach((tool, index) => { - if (!tool || typeof tool !== 'object') { + if (!isRecord(tool)) { logger.warn(`Skipping invalid tool at index ${index}: not an object`) return } @@ -112,24 +116,43 @@ async function fetchCustomTools( logger.warn(`Skipping invalid tool at index ${index}: missing or invalid title`) return } - if (!tool.schema || typeof tool.schema !== 'object') { + if (!isRecord(tool.schema)) { logger.warn(`Skipping invalid tool at index ${index}: missing or invalid schema`) return } - if (!tool.schema.function || typeof tool.schema.function !== 'object') { + if (!isRecord(tool.schema.function)) { logger.warn(`Skipping invalid tool at index ${index}: missing function schema`) return } + const functionSchema = tool.schema.function + const parameters = isRecord(functionSchema.parameters) ? functionSchema.parameters : {} + const properties = isRecord(parameters.properties) ? parameters.properties : {} + const required = Array.isArray(parameters.required) + ? parameters.required.filter((value): value is string => typeof value === 'string') + : undefined + const apiTool: ApiCustomTool = { id: tool.id, title: tool.title, - schema: tool.schema, + schema: { + type: 'function', + function: { + name: typeof functionSchema.name === 'string' ? functionSchema.name : tool.id, + description: + typeof functionSchema.description === 'string' ? functionSchema.description : undefined, + parameters: { + type: typeof parameters.type === 'string' ? parameters.type : 'object', + properties, + required, + }, + }, + }, code: typeof tool.code === 'string' ? tool.code : '', - workspaceId: tool.workspaceId ?? null, - userId: tool.userId ?? null, - createdAt: tool.createdAt ?? undefined, - updatedAt: tool.updatedAt ?? undefined, + workspaceId: typeof tool.workspaceId === 'string' ? tool.workspaceId : null, + userId: typeof tool.userId === 'string' ? tool.userId : null, + createdAt: typeof tool.createdAt === 'string' ? tool.createdAt : undefined, + updatedAt: typeof tool.updatedAt === 'string' ? tool.updatedAt : undefined, } try { @@ -174,10 +197,8 @@ export function useCreateCustomTool() { mutationFn: async ({ workspaceId, tool }: CreateCustomToolParams) => { logger.info(`Creating custom tool: ${tool.title} in workspace ${workspaceId}`) - const response = await fetch(API_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const data = await requestJson(upsertCustomToolsContract, { + body: { tools: [ { title: tool.title, @@ -186,21 +207,15 @@ export function useCreateCustomTool() { }, ], workspaceId, - }), + }, }) - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to create tool') - } - if (!data.data || !Array.isArray(data.data)) { throw new Error('Invalid API response: missing tools data') } logger.info(`Created custom tool: ${tool.title}`) - return data.data + return data.data as CustomToolDefinition[] }, onSuccess: (_data, variables) => { // Invalidate tools list for the workspace @@ -238,10 +253,8 @@ export function useUpdateCustomTool() { throw new Error('Tool not found') } - const response = await fetch(API_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const data = await requestJson(upsertCustomToolsContract, { + body: { tools: [ { id: toolId, @@ -251,21 +264,15 @@ export function useUpdateCustomTool() { }, ], workspaceId, - }), + }, }) - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to update tool') - } - if (!data.data || !Array.isArray(data.data)) { throw new Error('Invalid API response: missing tools data') } logger.info(`Updated custom tool: ${toolId}`) - return data.data + return data.data as CustomToolDefinition[] }, onMutate: async ({ workspaceId, toolId, updates }) => { await queryClient.cancelQueries({ queryKey: customToolsKeys.list(workspaceId) }) @@ -318,20 +325,10 @@ export function useDeleteCustomTool() { mutationFn: async ({ workspaceId, toolId }: DeleteCustomToolParams) => { logger.info(`Deleting custom tool: ${toolId}`) - const url = workspaceId - ? `${API_ENDPOINT}?id=${toolId}&workspaceId=${workspaceId}` - : `${API_ENDPOINT}?id=${toolId}` - - const response = await fetch(url, { - method: 'DELETE', + const data = await requestJson(deleteCustomToolContract, { + query: { id: toolId, workspaceId: workspaceId ?? undefined }, }) - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to delete tool') - } - logger.info(`Deleted custom tool: ${toolId}`) return data }, diff --git a/apps/sim/hooks/queries/deployments.test.ts b/apps/sim/hooks/queries/deployments.test.ts index 97628bf202b..20701175468 100644 --- a/apps/sim/hooks/queries/deployments.test.ts +++ b/apps/sim/hooks/queries/deployments.test.ts @@ -29,12 +29,14 @@ describe('deployment query helpers', () => { }) it('fetches deployment version state through the shared helper', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - deployedState: { blocks: {}, edges: [], loops: {}, parallels: {}, lastSaved: 1 }, - }), - }) as typeof fetch + global.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + deployedState: { blocks: {}, edges: [], loops: {}, parallels: {}, lastSaved: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ) as typeof fetch await expect(fetchDeploymentVersionState('wf-1', 3)).resolves.toEqual({ blocks: {}, @@ -45,6 +47,9 @@ describe('deployment query helpers', () => { }) expect(global.fetch).toHaveBeenCalledWith('/api/workflows/wf-1/deployments/3', { + method: 'GET', + headers: {}, + body: undefined, signal: undefined, }) }) diff --git a/apps/sim/hooks/queries/deployments.ts b/apps/sim/hooks/queries/deployments.ts index d80ce3a2106..e7b838a3e5e 100644 --- a/apps/sim/hooks/queries/deployments.ts +++ b/apps/sim/hooks/queries/deployments.ts @@ -2,13 +2,35 @@ import { useCallback } from 'react' import { createLogger } from '@sim/logger' import type { QueryClient } from '@tanstack/react-query' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' +import { requestJson, requestRaw } from '@/lib/api/client/request' +import { + type ActivateDeploymentVersionResponse, + activateDeploymentVersionContract, + type ChatDeploymentStatus, + type ChatDetail, + type DeploymentInfoResponse, + type DeploymentVersionsResponse, + type DeployWorkflowResponse, + deployWorkflowContract, + getChatDeploymentStatusContract, + getChatDetailContract, + getDeployedWorkflowStateContract, + getDeploymentInfoContract, + listDeploymentVersionsContract, + type UpdateDeploymentVersionMetadataResponse, + undeployWorkflowContract, + updateDeploymentVersionMetadataContract, + updatePublicApiContract, +} from '@/lib/api/contracts/deployments' +import { wandGenerateStreamContract } from '@/lib/api/contracts/hotspots' import { fetchDeploymentVersionState } from '@/hooks/queries/utils/fetch-deployment-version-state' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('DeploymentQueries') +export type { ChatDeploymentStatus, ChatDetail, DeploymentVersionsResponse } + /** * Query key factory for deployment-related queries */ @@ -47,11 +69,7 @@ export function invalidateDeploymentQueries(queryClient: QueryClient, workflowId ]) } -/** - * Response type from /api/workflows/[id]/deploy GET endpoint - */ -export interface WorkflowDeploymentInfo { - isDeployed: boolean +export type WorkflowDeploymentInfo = DeploymentInfoResponse & { deployedAt: string | null apiKey: string | null needsRedeployment: boolean @@ -65,13 +83,10 @@ async function fetchDeploymentInfo( workflowId: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { signal }) - - if (!response.ok) { - throw new Error('Failed to fetch deployment information') - } - - const data = await response.json() + const data = await requestJson(getDeploymentInfoContract, { + params: { id: workflowId }, + signal, + }) return { isDeployed: data.isDeployed ?? false, deployedAt: data.deployedAt ?? null, @@ -106,14 +121,10 @@ async function fetchDeployedWorkflowState( workflowId: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/workflows/${workflowId}/deployed`, { signal }) - - if (!response.ok) { - if (response.status === 404) return null - throw new Error('Failed to fetch deployed workflow state') - } - - const data = await response.json() + const data = await requestJson(getDeployedWorkflowStateContract, { + params: { id: workflowId }, + signal, + }) return data.deployedState || null } @@ -134,13 +145,6 @@ export function useDeployedWorkflowState( }) } -/** - * Response type from /api/workflows/[id]/deployments GET endpoint - */ -export interface DeploymentVersionsResponse { - versions: WorkflowDeploymentVersionResponse[] -} - /** * Fetches all deployment versions for a workflow */ @@ -148,13 +152,10 @@ async function fetchDeploymentVersions( workflowId: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/workflows/${workflowId}/deployments`, { signal }) - - if (!response.ok) { - throw new Error('Failed to fetch deployment versions') - } - - const data = await response.json() + const data = await requestJson(listDeploymentVersionsContract, { + params: { id: workflowId }, + signal, + }) return { versions: Array.isArray(data.versions) ? data.versions : [], } @@ -174,17 +175,6 @@ export function useDeploymentVersions(workflowId: string | null, options?: { ena }) } -/** - * Response type from /api/workflows/[id]/chat/status GET endpoint - */ -export interface ChatDeploymentStatus { - isDeployed: boolean - deployment: { - id: string - identifier: string - } | null -} - /** * Fetches chat deployment status for a workflow */ @@ -192,13 +182,10 @@ async function fetchChatDeploymentStatus( workflowId: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/workflows/${workflowId}/chat/status`, { signal }) - - if (!response.ok) { - throw new Error('Failed to fetch chat deployment status') - } - - const data = await response.json() + const data = await requestJson(getChatDeploymentStatusContract, { + params: { id: workflowId }, + signal, + }) return { isDeployed: data.isDeployed ?? false, deployment: data.deployment ?? null, @@ -222,38 +209,14 @@ export function useChatDeploymentStatus( }) } -/** - * Response type from /api/chat/manage/[id] GET endpoint - */ -export interface ChatDetail { - id: string - identifier: string - title: string - description: string - authType: 'public' | 'password' | 'email' | 'sso' - allowedEmails: string[] - outputConfigs: Array<{ blockId: string; path: string }> - customizations?: { - welcomeMessage?: string - imageUrl?: string - primaryColor?: string - } - isActive: boolean - chatUrl: string - hasPassword: boolean -} - /** * Fetches chat detail by chat ID */ async function fetchChatDetail(chatId: string, signal?: AbortSignal): Promise { - const response = await fetch(`/api/chat/manage/${chatId}`, { signal }) - - if (!response.ok) { - throw new Error('Failed to fetch chat detail') - } - - return response.json() + return requestJson(getChatDetailContract, { + params: { id: chatId }, + signal, + }) } /** @@ -307,17 +270,11 @@ export function useChatDeploymentInfo(workflowId: string | null, options?: { ena */ interface DeployWorkflowVariables { workflowId: string - deployChatEnabled?: boolean } -/** - * Response from deploy workflow mutation - */ -interface DeployWorkflowResult { - isDeployed: boolean +type DeployWorkflowResult = Omit & { deployedAt?: string apiKey?: string - warnings?: string[] } /** @@ -328,30 +285,14 @@ export function useDeployWorkflow() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ - workflowId, - deployChatEnabled = false, - }: DeployWorkflowVariables): Promise => { - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - deployChatEnabled, - }), + mutationFn: async ({ workflowId }: DeployWorkflowVariables): Promise => { + const data = await requestJson(deployWorkflowContract, { + params: { id: workflowId }, }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to deploy workflow') - } - - const data = await response.json() return { isDeployed: data.isDeployed ?? false, - deployedAt: data.deployedAt, - apiKey: data.apiKey, + deployedAt: data.deployedAt ?? undefined, + apiKey: data.apiKey ?? undefined, warnings: data.warnings, } }, @@ -387,14 +328,9 @@ export function useUndeployWorkflow() { return useMutation({ mutationFn: async ({ workflowId }: UndeployWorkflowVariables): Promise => { - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { - method: 'DELETE', + await requestJson(undeployWorkflowContract, { + params: { id: workflowId }, }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to undeploy workflow') - } }, onSettled: (_data, error, variables) => { if (error) { @@ -422,13 +358,7 @@ interface UpdateDeploymentVersionVariables { description?: string | null } -/** - * Response from update deployment version mutation - */ -interface UpdateDeploymentVersionResult { - name: string | null - description: string | null -} +type UpdateDeploymentVersionResult = UpdateDeploymentVersionMetadataResponse /** * Mutation hook for updating a deployment version's name or description. @@ -444,20 +374,10 @@ export function useUpdateDeploymentVersion() { name, description, }: UpdateDeploymentVersionVariables): Promise => { - const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ name, description }), + return requestJson(updateDeploymentVersionMetadataContract, { + params: { id: workflowId, version }, + body: { name, description }, }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to update deployment version') - } - - return response.json() }, onSettled: (_data, error, variables) => { if (!error) { @@ -539,25 +459,23 @@ export function useGenerateVersionDescription() { workflowId ) - const wandResponse = await fetch('/api/wand', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-cache, no-transform', + const wandResponse = await requestRaw( + wandGenerateStreamContract, + { + body: { + prompt: `Generate a deployment version description based on these changes:\n\n${diffText}`, + systemPrompt: VERSION_DESCRIPTION_SYSTEM_PROMPT, + stream: true, + workflowId, + }, }, - body: JSON.stringify({ - prompt: `Generate a deployment version description based on these changes:\n\n${diffText}`, - systemPrompt: VERSION_DESCRIPTION_SYSTEM_PROMPT, - stream: true, - workflowId, - }), - cache: 'no-store', - }) - - if (!wandResponse.ok) { - const errorText = await wandResponse.text() - throw new Error(errorText || 'Failed to generate description') - } + { + headers: { + 'Cache-Control': 'no-cache, no-transform', + }, + cache: 'no-store', + } + ) if (!wandResponse.body) { throw new Error('Response body is null') @@ -591,14 +509,7 @@ interface ActivateVersionVariables { version: number } -/** - * Response from activate version mutation - */ -interface ActivateVersionResult { - deployedAt?: string - apiKey?: string - warnings?: string[] -} +type ActivateVersionResult = ActivateDeploymentVersionResponse /** * Mutation hook for activating (promoting) a specific deployment version. @@ -612,20 +523,10 @@ export function useActivateDeploymentVersion() { workflowId, version, }: ActivateVersionVariables): Promise => { - const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ isActive: true }), + return requestJson(activateDeploymentVersionContract, { + params: { id: workflowId, version }, + body: { isActive: true }, }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to activate version') - } - - return response.json() }, onMutate: async ({ workflowId, version }) => { await queryClient.cancelQueries({ queryKey: deploymentKeys.versions(workflowId) }) @@ -684,18 +585,10 @@ export function useUpdatePublicApi() { return useMutation({ mutationFn: async ({ workflowId, isPublicApi }: UpdatePublicApiVariables) => { - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ isPublicApi }), + return requestJson(updatePublicApiContract, { + params: { id: workflowId }, + body: { isPublicApi }, }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to update public API setting') - } - - return response.json() }, onSettled: (_data, error, variables) => { if (!error) { diff --git a/apps/sim/hooks/queries/environment.ts b/apps/sim/hooks/queries/environment.ts index 575b09b0661..ab347d195e3 100644 --- a/apps/sim/hooks/queries/environment.ts +++ b/apps/sim/hooks/queries/environment.ts @@ -1,8 +1,14 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type ContractBodyInput, + removeWorkspaceEnvironmentContract, + savePersonalEnvironmentContract, + upsertWorkspaceEnvironmentContract, +} from '@/lib/api/contracts' import type { WorkspaceEnvironmentData } from '@/lib/environment/api' import { fetchPersonalEnvironment, fetchWorkspaceEnvironment } from '@/lib/environment/api' -import { API_ENDPOINTS } from '@/stores/constants' const logger = createLogger('EnvironmentQueries') @@ -46,24 +52,14 @@ export function useWorkspaceEnvironment( /** * Save personal environment variables mutation */ -interface SavePersonalEnvironmentParams { - variables: Record -} +type SavePersonalEnvironmentParams = ContractBodyInput export function useSavePersonalEnvironment() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ variables }: SavePersonalEnvironmentParams) => { - const response = await fetch(API_ENDPOINTS.ENVIRONMENT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ variables }), - }) - - if (!response.ok) { - throw new Error(`Failed to save environment variables: ${response.statusText}`) - } + await requestJson(savePersonalEnvironmentContract, { body: { variables } }) logger.info('Saved personal environment variables') }, @@ -76,28 +72,21 @@ export function useSavePersonalEnvironment() { /** * Upsert workspace environment variables mutation */ -interface UpsertWorkspaceEnvironmentParams { - workspaceId: string - variables: Record -} +type UpsertWorkspaceEnvironmentParams = { workspaceId: string } & ContractBodyInput< + typeof upsertWorkspaceEnvironmentContract +> export function useUpsertWorkspaceEnvironment() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ workspaceId, variables }: UpsertWorkspaceEnvironmentParams) => { - const response = await fetch(API_ENDPOINTS.WORKSPACE_ENVIRONMENT(workspaceId), { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ variables }), + const data = await requestJson(upsertWorkspaceEnvironmentContract, { + params: { id: workspaceId }, + body: { variables }, }) - - if (!response.ok) { - throw new Error(`Failed to update workspace environment: ${response.statusText}`) - } - logger.info(`Upserted workspace environment variables for workspace: ${workspaceId}`) - return await response.json() + return data }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ @@ -110,28 +99,21 @@ export function useUpsertWorkspaceEnvironment() { /** * Remove workspace environment variables mutation */ -interface RemoveWorkspaceEnvironmentParams { - workspaceId: string - keys: string[] -} +type RemoveWorkspaceEnvironmentParams = { workspaceId: string } & ContractBodyInput< + typeof removeWorkspaceEnvironmentContract +> export function useRemoveWorkspaceEnvironment() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ workspaceId, keys }: RemoveWorkspaceEnvironmentParams) => { - const response = await fetch(API_ENDPOINTS.WORKSPACE_ENVIRONMENT(workspaceId), { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ keys }), + const data = await requestJson(removeWorkspaceEnvironmentContract, { + params: { id: workspaceId }, + body: { keys }, }) - - if (!response.ok) { - throw new Error(`Failed to remove workspace environment keys: ${response.statusText}`) - } - logger.info(`Removed ${keys.length} workspace environment keys for workspace: ${workspaceId}`) - return await response.json() + return data }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index 68a61093193..adfc6c51df2 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -1,6 +1,17 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + createFolderContract, + deleteFolderContract, + duplicateFolderContract, + type FolderApi, + listFoldersContract, + reorderFoldersContract, + restoreFolderContract, + updateFolderContract, +} from '@/lib/api/contracts' import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { type FolderQueryScope, folderKeys } from '@/hooks/queries/utils/folder-keys' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' @@ -14,14 +25,14 @@ import type { WorkflowFolder } from '@/stores/folders/types' const logger = createLogger('FolderQueries') -function mapFolder(folder: any): WorkflowFolder { +function mapFolder(folder: FolderApi): WorkflowFolder { return { id: folder.id, name: folder.name, userId: folder.userId, workspaceId: folder.workspaceId, - parentId: folder.parentId ?? null, - color: folder.color, + parentId: folder.parentId, + color: folder.color ?? '#6B7280', isExpanded: folder.isExpanded, sortOrder: folder.sortOrder, createdAt: new Date(folder.createdAt), @@ -35,13 +46,10 @@ async function fetchFolders( scope: FolderQueryScope = 'active', signal?: AbortSignal ): Promise { - const response = await fetch(`/api/folders?workspaceId=${workspaceId}&scope=${scope}`, { signal }) - - if (!response.ok) { - throw new Error('Failed to fetch folders') - } - - const { folders }: { folders: any[] } = await response.json() + const { folders } = await requestJson(listFoldersContract, { + query: { workspaceId, scope }, + signal, + }) return folders.map(mapFolder) } @@ -175,18 +183,9 @@ export function useCreateFolder() { return useMutation({ mutationFn: async ({ workspaceId, sortOrder, ...payload }: CreateFolderVariables) => { - const response = await fetch('/api/folders', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...payload, workspaceId, sortOrder }), + const { folder } = await requestJson(createFolderContract, { + body: { ...payload, workspaceId, sortOrder }, }) - - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new Error(error.error || 'Failed to create folder') - } - - const { folder } = await response.json() return mapFolder(folder) }, ...handlers, @@ -198,18 +197,10 @@ export function useUpdateFolder() { return useMutation({ mutationFn: async ({ workspaceId, id, updates }: UpdateFolderVariables) => { - const response = await fetch(`/api/folders/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates), + const { folder } = await requestJson(updateFolderContract, { + params: { id }, + body: updates, }) - - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new Error(error.error || 'Failed to update folder') - } - - const { folder } = await response.json() return mapFolder(folder) }, onSettled: (_data, _error, variables) => { @@ -223,14 +214,7 @@ export function useDeleteFolderMutation() { return useMutation({ mutationFn: async ({ workspaceId: _workspaceId, id }: DeleteFolderVariables) => { - const response = await fetch(`/api/folders/${id}`, { method: 'DELETE' }) - - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new Error(error.error || 'Failed to delete folder') - } - - return response.json() + return requestJson(deleteFolderContract, { params: { id } }) }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: folderKeys.lists() }) @@ -249,18 +233,10 @@ export function useRestoreFolder() { return useMutation({ mutationFn: async ({ workspaceId, folderId }: RestoreFolderVariables) => { - const response = await fetch(`/api/folders/${folderId}/restore`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId }), + return requestJson(restoreFolderContract, { + params: { id: folderId }, + body: { workspaceId }, }) - - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new Error(error.error || 'Failed to restore folder') - } - - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: folderKeys.lists() }) @@ -313,25 +289,17 @@ export function useDuplicateFolderMutation() { color, newId, }: DuplicateFolderVariables): Promise => { - const response = await fetch(`/api/folders/${id}/duplicate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const { folder } = await requestJson(duplicateFolderContract, { + params: { id }, + body: { workspaceId, name, parentId: parentId ?? null, color, newId, - }), + }, }) - - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new Error(error.error || 'Failed to duplicate folder') - } - - const data = await response.json() - return mapFolder(data.folder || data) + return mapFolder(folder) }, ...handlers, onSettled: (_data, _error, variables) => { @@ -355,16 +323,7 @@ export function useReorderFolders() { return useMutation({ mutationFn: async (variables: ReorderFoldersVariables): Promise => { - const response = await fetch('/api/folders/reorder', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(variables), - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new Error(error.error || 'Failed to reorder folders') - } + await requestJson(reorderFoldersContract, { body: variables }) }, onMutate: async (variables) => { await queryClient.cancelQueries({ queryKey: folderKeys.list(variables.workspaceId) }) diff --git a/apps/sim/hooks/queries/forms.ts b/apps/sim/hooks/queries/forms.ts index 2889388e2db..e43e8fe7ecc 100644 --- a/apps/sim/hooks/queries/forms.ts +++ b/apps/sim/hooks/queries/forms.ts @@ -1,5 +1,22 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { ApiClientError } from '@/lib/api/client/errors' +import { requestJson } from '@/lib/api/client/request' +import { + type CreateFormInput, + type CreateFormResponse, + createFormContract, + deleteFormContract, + type ExistingForm, + type FormAuthType, + type FormCustomizations, + type FormFieldConfig, + type FormStatusResponse, + getFormDetailContract, + getFormStatusContract, + type UpdateFormInput, + updateFormContract, +} from '@/lib/api/contracts/forms' import { deploymentKeys } from './deployments' const logger = createLogger('FormMutations') @@ -16,55 +33,34 @@ export const formKeys = { /** * Auth types for form access control */ -export type FormAuthType = 'public' | 'password' | 'email' +export type { FormAuthType } /** * Field configuration for form fields */ -export interface FieldConfig { - name: string - type: string - label: string - description?: string - required?: boolean -} +export type FieldConfig = FormFieldConfig /** * Customizations for form appearance */ -export interface FormCustomizations { - primaryColor?: string - welcomeMessage?: string - thankYouTitle?: string - thankYouMessage?: string - logoUrl?: string - fieldConfigs?: FieldConfig[] -} +export type { FormCustomizations } /** * Existing form data returned from API */ -export interface ExistingForm { - id: string - identifier: string - title: string - description?: string - customizations: FormCustomizations - authType: FormAuthType - hasPassword?: boolean - allowedEmails?: string[] - showBranding: boolean - isActive: boolean -} +export type { ExistingForm } /** * Form status response from workflow form status API */ -interface FormStatusResponse { - isDeployed: boolean - form?: { - id: string +export type { FormStatusResponse } + +function throwUserFriendlyIdentifierError(error: unknown): never { + if (error instanceof ApiClientError && error.message === 'Identifier already in use') { + throw new Error('This identifier is already in use', { cause: error }) } + + throw error } /** @@ -74,27 +70,21 @@ async function fetchFormStatus( workflowId: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/workflows/${workflowId}/form/status`, { signal }) - - if (!response.ok) { - throw new Error('Failed to fetch form status') - } - - return response.json() + return requestJson(getFormStatusContract, { + params: { id: workflowId }, + signal, + }) } /** * Fetches form detail by ID */ async function fetchFormDetail(formId: string, signal?: AbortSignal): Promise { - const response = await fetch(`/api/form/manage/${formId}`, { signal }) - - if (!response.ok) { - throw new Error('Failed to fetch form details') - } - - const data = await response.json() - return data.form as ExistingForm + const data = await requestJson(getFormDetailContract, { + params: { id: formId }, + signal, + }) + return data.form } /** @@ -130,17 +120,7 @@ export function useFormByWorkflow(workflowId: string | null) { /** * Variables for create form mutation */ -interface CreateFormVariables { - workflowId: string - identifier: string - title: string - description?: string - customizations?: FormCustomizations - authType?: FormAuthType - password?: string - allowedEmails?: string[] - showBranding?: boolean -} +type CreateFormVariables = CreateFormInput /** * Variables for update form mutation @@ -148,17 +128,7 @@ interface CreateFormVariables { interface UpdateFormVariables { formId: string workflowId: string - data: { - identifier?: string - title?: string - description?: string - customizations?: FormCustomizations - authType?: FormAuthType - password?: string - allowedEmails?: string[] - showBranding?: boolean - isActive?: boolean - } + data: UpdateFormInput } /** @@ -172,10 +142,7 @@ interface DeleteFormVariables { /** * Response from form create mutation */ -interface CreateFormResult { - id: string - formUrl: string -} +type CreateFormResult = CreateFormResponse /** * Mutation hook for creating a new form deployment. @@ -186,26 +153,12 @@ export function useCreateForm() { return useMutation({ mutationFn: async (params: CreateFormVariables): Promise => { - const response = await fetch('/api/form', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(params), - }) - - const data = await response.json() - - if (!response.ok) { - // Handle specific error cases - if (data.error === 'Identifier already in use') { - throw new Error('This identifier is already in use') - } - throw new Error(data.error || 'Failed to create form') - } - - logger.info('Form created successfully:', { id: data.id }) - return { - id: data.id, - formUrl: data.formUrl, + try { + const data = await requestJson(createFormContract, { body: params }) + logger.info('Form created successfully:', { id: data.id }) + return data + } catch (error) { + throwUserFriendlyIdentifierError(error) } }, onSuccess: (_, variables) => { @@ -234,22 +187,15 @@ export function useUpdateForm() { return useMutation({ mutationFn: async ({ formId, data }: UpdateFormVariables): Promise => { - const response = await fetch(`/api/form/manage/${formId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - - const result = await response.json() - - if (!response.ok) { - if (result.error === 'Identifier already in use') { - throw new Error('This identifier is already in use') - } - throw new Error(result.error || 'Failed to update form') + try { + await requestJson(updateFormContract, { + params: { id: formId }, + body: data, + }) + logger.info('Form updated successfully:', { id: formId }) + } catch (error) { + throwUserFriendlyIdentifierError(error) } - - logger.info('Form updated successfully:', { id: formId }) }, onSuccess: (_, variables) => { queryClient.invalidateQueries({ @@ -274,16 +220,7 @@ export function useDeleteForm() { return useMutation({ mutationFn: async ({ formId }: DeleteFormVariables): Promise => { - const response = await fetch(`/api/form/manage/${formId}`, { - method: 'DELETE', - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to delete form') - } - + await requestJson(deleteFormContract, { params: { id: formId } }) logger.info('Form deleted successfully:', { id: formId }) }, onSuccess: (_, variables) => { diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts index 0fb05164dd1..746fb407367 100644 --- a/apps/sim/hooks/queries/general-settings.ts +++ b/apps/sim/hooks/queries/general-settings.ts @@ -1,6 +1,12 @@ import { createLogger } from '@sim/logger' import type { QueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + getUserSettingsContract, + type UserSettingsApi, + updateUserSettingsContract, +} from '@/lib/api/contracts' import { syncThemeToNextThemes } from '@/lib/core/utils/theme' const logger = createLogger('GeneralSettingsQuery') @@ -32,17 +38,17 @@ export interface GeneralSettings { * Map raw API response data to GeneralSettings with defaults. * Shared by both client fetch and server prefetch to prevent shape drift. */ -export function mapGeneralSettingsResponse(data: Record): GeneralSettings { +export function mapGeneralSettingsResponse(data: UserSettingsApi): GeneralSettings { return { - autoConnect: (data.autoConnect as boolean) ?? true, - showTrainingControls: (data.showTrainingControls as boolean) ?? false, - superUserModeEnabled: (data.superUserModeEnabled as boolean) ?? false, - theme: (data.theme as GeneralSettings['theme']) || 'system', - telemetryEnabled: (data.telemetryEnabled as boolean) ?? true, - billingUsageNotificationsEnabled: (data.billingUsageNotificationsEnabled as boolean) ?? true, - errorNotificationsEnabled: (data.errorNotificationsEnabled as boolean) ?? true, - snapToGridSize: (data.snapToGridSize as number) ?? 0, - showActionBar: (data.showActionBar as boolean) ?? true, + autoConnect: data.autoConnect, + showTrainingControls: data.showTrainingControls, + superUserModeEnabled: data.superUserModeEnabled, + theme: data.theme, + telemetryEnabled: data.telemetryEnabled, + billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled, + errorNotificationsEnabled: data.errorNotificationsEnabled, + snapToGridSize: data.snapToGridSize, + showActionBar: data.showActionBar, } } @@ -50,13 +56,7 @@ export function mapGeneralSettingsResponse(data: Record): Gener * Fetch general settings from API */ async function fetchGeneralSettings(signal?: AbortSignal): Promise { - const response = await fetch('/api/users/me/settings', { signal }) - - if (!response.ok) { - throw new Error('Failed to fetch general settings') - } - - const { data } = await response.json() + const { data } = await requestJson(getUserSettingsContract, { signal }) return mapGeneralSettingsResponse(data) } @@ -130,27 +130,19 @@ export function useErrorNotificationsEnabled(): boolean { /** * Update general settings mutation */ -interface UpdateSettingParams { - key: keyof GeneralSettings - value: GeneralSettings[keyof GeneralSettings] -} +type UpdateSettingParams = { + [K in keyof GeneralSettings]: { + key: K + value: GeneralSettings[K] + } +}[keyof GeneralSettings] export function useUpdateGeneralSetting() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ key, value }: UpdateSettingParams) => { - const response = await fetch('/api/users/me/settings', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ [key]: value }), - }) - - if (!response.ok) { - throw new Error(`Failed to update setting: ${key}`) - } - - return response.json() + return requestJson(updateUserSettingsContract, { body: { [key]: value } }) }, onMutate: async ({ key, value }) => { await queryClient.cancelQueries({ queryKey: generalSettingsKeys.settings() }) diff --git a/apps/sim/hooks/queries/github-stars.ts b/apps/sim/hooks/queries/github-stars.ts index 0702a4b3314..1e0f9c9fe0f 100644 --- a/apps/sim/hooks/queries/github-stars.ts +++ b/apps/sim/hooks/queries/github-stars.ts @@ -1,4 +1,6 @@ import { useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { getStarsContract } from '@/lib/api/contracts' /** * Query key factory for GitHub stars queries @@ -15,15 +17,8 @@ export const githubStarsKeys = { export const GITHUB_STARS_FALLBACK = '27.8k' async function fetchGitHubStars(signal?: AbortSignal): Promise { - const response = await fetch('/api/stars', { - signal, - headers: { 'Cache-Control': 'max-age=3600' }, - }) - if (!response.ok) { - throw new Error('Failed to fetch GitHub stars') - } - const data = await response.json() - const value = data?.stars + const data = await requestJson(getStarsContract, { signal }) + const value = data.stars return typeof value === 'string' && value.length > 0 ? value : GITHUB_STARS_FALLBACK } diff --git a/apps/sim/hooks/queries/inbox.ts b/apps/sim/hooks/queries/inbox.ts index b11b3438e55..9173d4514b6 100644 --- a/apps/sim/hooks/queries/inbox.ts +++ b/apps/sim/hooks/queries/inbox.ts @@ -1,6 +1,25 @@ import { generateId } from '@sim/utils/id' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import type { InboxTaskStatus } from '@/lib/mothership/inbox/types' +import { requestJson } from '@/lib/api/client/request' +import { + addInboxSenderContract, + getInboxConfigContract, + type InboxConfig, + type InboxMember, + type InboxSender, + type InboxSendersResponseBody, + type InboxTask, + type InboxTaskStatus, + type InboxTasksResponseBody, + listInboxSendersContract, + listInboxTasksContract, + removeInboxSenderContract, + updateInboxConfigContract, +} from '@/lib/api/contracts' + +export type { InboxConfig, InboxMember, InboxSender, InboxSendersResponseBody } +export type InboxTaskItem = InboxTask +export type InboxTasksResponse = InboxTasksResponseBody export const inboxKeys = { all: ['inbox'] as const, @@ -13,93 +32,39 @@ export const inboxKeys = { [...inboxKeys.tasks(), workspaceId, status ?? 'all'] as const, } -export interface InboxConfigResponse { - enabled: boolean - address: string | null - taskStats: { - total: number - completed: number - processing: number - failed: number - } -} - -export interface InboxSender { - id: string - email: string - label: string | null - createdAt: string -} - -export interface InboxMember { - email: string - name: string - isAutoAllowed: boolean -} - -export interface InboxSendersResponse { - senders: InboxSender[] - workspaceMembers: InboxMember[] -} - -export interface InboxTaskItem { - id: string - fromEmail: string - fromName: string | null - subject: string - bodyPreview: string | null - status: InboxTaskStatus - hasAttachments: boolean - resultSummary: string | null - errorMessage: string | null - rejectionReason: string | null - chatId: string | null - createdAt: string - completedAt: string | null -} - -export interface InboxTasksResponse { - tasks: InboxTaskItem[] - pagination: { - limit: number - hasMore: boolean - nextCursor: string | null - } -} +type InboxTaskStatusFilter = InboxTaskStatus -async function fetchInboxConfig( - workspaceId: string, - signal?: AbortSignal -): Promise { - const response = await fetch(`/api/workspaces/${workspaceId}/inbox`, { signal }) - if (!response.ok) throw new Error('Failed to fetch inbox config') - return response.json() +async function fetchInboxConfig(workspaceId: string, signal?: AbortSignal): Promise { + return requestJson(getInboxConfigContract, { + params: { id: workspaceId }, + signal, + }) } async function fetchInboxSenders( workspaceId: string, signal?: AbortSignal -): Promise { - const response = await fetch(`/api/workspaces/${workspaceId}/inbox/senders`, { signal }) - if (!response.ok) throw new Error('Failed to fetch inbox senders') - return response.json() +): Promise { + return requestJson(listInboxSendersContract, { + params: { id: workspaceId }, + signal, + }) } async function fetchInboxTasks( workspaceId: string, - opts: { status?: string; cursor?: string; limit?: number }, + opts: { status?: InboxTaskStatusFilter; cursor?: string; limit?: number }, signal?: AbortSignal -): Promise { - const params = new URLSearchParams() - if (opts.status && opts.status !== 'all') params.set('status', opts.status) - if (opts.cursor) params.set('cursor', opts.cursor) - if (opts.limit) params.set('limit', String(opts.limit)) - const qs = params.toString() - const response = await fetch(`/api/workspaces/${workspaceId}/inbox/tasks${qs ? `?${qs}` : ''}`, { +): Promise { + return requestJson(listInboxTasksContract, { + params: { id: workspaceId }, + query: { + status: opts.status && opts.status !== 'all' ? opts.status : undefined, + cursor: opts.cursor, + limit: opts.limit, + }, signal, }) - if (!response.ok) throw new Error('Failed to fetch inbox tasks') - return response.json() } export function useInboxConfig(workspaceId: string) { @@ -122,7 +87,7 @@ export function useInboxSenders(workspaceId: string) { export function useInboxTasks( workspaceId: string, - opts: { status?: string; cursor?: string; limit?: number } = {} + opts: { status?: InboxTaskStatusFilter; cursor?: string; limit?: number } = {} ) { return useQuery({ queryKey: inboxKeys.taskList(workspaceId, opts.status), @@ -146,16 +111,10 @@ export function useToggleInbox() { enabled: boolean username?: string }) => { - const response = await fetch(`/api/workspaces/${workspaceId}/inbox`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled, username }), + return requestJson(updateInboxConfigContract, { + params: { id: workspaceId }, + body: { enabled, username }, }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'Failed to toggle inbox') - } - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: inboxKeys.config(variables.workspaceId) }) @@ -168,16 +127,10 @@ export function useUpdateInboxAddress() { return useMutation({ mutationFn: async ({ workspaceId, username }: { workspaceId: string; username: string }) => { - const response = await fetch(`/api/workspaces/${workspaceId}/inbox`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username }), + return requestJson(updateInboxConfigContract, { + params: { id: workspaceId }, + body: { username }, }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'Failed to update inbox address') - } - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: inboxKeys.config(variables.workspaceId) }) @@ -198,24 +151,18 @@ export function useAddInboxSender() { email: string label?: string }) => { - const response = await fetch(`/api/workspaces/${workspaceId}/inbox/senders`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, label }), + return requestJson(addInboxSenderContract, { + params: { id: workspaceId }, + body: { email, label }, }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'Failed to add sender') - } - return response.json() }, onMutate: async ({ workspaceId, email, label }) => { await queryClient.cancelQueries({ queryKey: inboxKeys.senderList(workspaceId) }) - const previous = queryClient.getQueryData( + const previous = queryClient.getQueryData( inboxKeys.senderList(workspaceId) ) if (previous) { - queryClient.setQueryData(inboxKeys.senderList(workspaceId), { + queryClient.setQueryData(inboxKeys.senderList(workspaceId), { ...previous, senders: [ ...previous.senders, @@ -246,24 +193,18 @@ export function useRemoveInboxSender() { return useMutation({ mutationFn: async ({ workspaceId, senderId }: { workspaceId: string; senderId: string }) => { - const response = await fetch(`/api/workspaces/${workspaceId}/inbox/senders`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ senderId }), + return requestJson(removeInboxSenderContract, { + params: { id: workspaceId }, + body: { senderId }, }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'Failed to remove sender') - } - return response.json() }, onMutate: async ({ workspaceId, senderId }) => { await queryClient.cancelQueries({ queryKey: inboxKeys.senderList(workspaceId) }) - const previous = queryClient.getQueryData( + const previous = queryClient.getQueryData( inboxKeys.senderList(workspaceId) ) if (previous) { - queryClient.setQueryData(inboxKeys.senderList(workspaceId), { + queryClient.setQueryData(inboxKeys.senderList(workspaceId), { ...previous, senders: previous.senders.filter((s) => s.id !== senderId), }) diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index ed530775a3c..02030d0aede 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -1,7 +1,19 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import type { ContractBodyInput } from '@/lib/api/contracts' +import { + type BatchInvitationResult as BatchInvitationResultContract, + batchWorkspaceInvitationsContract, + cancelInvitationContract, + listWorkspaceInvitationsContract, + type PendingInvitationRow, + removeWorkspaceMemberContract, + resendInvitationContract, +} from '@/lib/api/contracts/invitations' +import { updateWorkspacePermissionsContract } from '@/lib/api/contracts/workspaces' import { workspaceCredentialKeys } from '@/hooks/queries/credentials' import { organizationKeys } from '@/hooks/queries/organization' -import { workspaceKeys } from './workspace' +import { workspaceKeys } from '@/hooks/queries/workspace' export const invitationKeys = { all: ['invitations'] as const, @@ -9,15 +21,7 @@ export const invitationKeys = { list: (workspaceId: string) => [...invitationKeys.lists(), workspaceId] as const, } -export interface PendingInvitationRow { - id: string - workspaceId: string - email: string - permission: 'admin' | 'write' | 'read' - membershipIntent?: 'internal' | 'external' - status: string - createdAt: string -} +export type { PendingInvitationRow } export interface WorkspaceInvitation { email: string @@ -31,13 +35,7 @@ async function fetchPendingInvitations( workspaceId: string, signal?: AbortSignal ): Promise { - const response = await fetch('/api/workspaces/invitations', { signal }) - - if (!response.ok) { - throw new Error('Failed to fetch pending invitations') - } - - const data = await response.json() + const data = await requestJson(listWorkspaceInvitationsContract, { signal }) return ( data.invitations @@ -68,16 +66,11 @@ export function usePendingInvitations(workspaceId: string | undefined) { }) } -interface BatchSendInvitationsParams { - workspaceId: string +type BatchSendInvitationsParams = ContractBodyInput & { organizationId?: string | null - invitations: Array<{ email: string; permission: 'admin' | 'write' | 'read' }> } -interface BatchInvitationResult { - successful: string[] - failed: Array<{ email: string; error: string }> -} +type BatchInvitationResult = Pick /** * Sends workspace invitations through the server-side batch endpoint. @@ -91,21 +84,13 @@ export function useBatchSendWorkspaceInvitations() { workspaceId, invitations, }: BatchSendInvitationsParams): Promise => { - const response = await fetch('/api/workspaces/invitations/batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const result = await requestJson(batchWorkspaceInvitationsContract, { + body: { workspaceId, invitations, - }), + }, }) - const result = await response.json() - - if (!response.ok) { - throw new Error(result.error || 'Failed to send invitations') - } - return { successful: result.successful ?? [], failed: result.failed ?? [], @@ -142,17 +127,9 @@ export function useCancelWorkspaceInvitation() { return useMutation({ mutationFn: async ({ invitationId }: CancelInvitationParams) => { - const response = await fetch(`/api/invitations/${invitationId}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, + return requestJson(cancelInvitationContract, { + params: { id: invitationId }, }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to cancel invitation') - } - - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ @@ -184,17 +161,9 @@ export function useResendWorkspaceInvitation() { return useMutation({ mutationFn: async ({ invitationId }: ResendInvitationParams) => { - const response = await fetch(`/api/invitations/${invitationId}/resend`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + return requestJson(resendInvitationContract, { + params: { id: invitationId }, }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to resend invitation') - } - - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ @@ -204,9 +173,8 @@ export function useResendWorkspaceInvitation() { }) } -interface RemoveMemberParams { +type RemoveMemberParams = ContractBodyInput & { userId: string - workspaceId: string organizationId?: string | null } @@ -219,18 +187,10 @@ export function useRemoveWorkspaceMember() { return useMutation({ mutationFn: async ({ userId, workspaceId }: RemoveMemberParams) => { - const response = await fetch(`/api/workspaces/members/${userId}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId }), + return requestJson(removeWorkspaceMemberContract, { + params: { id: userId }, + body: { workspaceId }, }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to remove member') - } - - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ @@ -251,9 +211,8 @@ export function useRemoveWorkspaceMember() { }) } -interface LeaveWorkspaceParams { +type LeaveWorkspaceParams = ContractBodyInput & { userId: string - workspaceId: string } /** @@ -265,18 +224,10 @@ export function useLeaveWorkspace() { return useMutation({ mutationFn: async ({ userId, workspaceId }: LeaveWorkspaceParams) => { - const response = await fetch(`/api/workspaces/members/${userId}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId }), + return requestJson(removeWorkspaceMemberContract, { + params: { id: userId }, + body: { workspaceId }, }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to leave workspace') - } - - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ @@ -289,29 +240,20 @@ export function useLeaveWorkspace() { }) } -interface UpdatePermissionsParams { +type UpdatePermissionsParams = { workspaceId: string - updates: Array<{ userId: string; permissions: 'admin' | 'write' | 'read' }> organizationId?: string -} +} & ContractBodyInput export function useUpdateWorkspacePermissions() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ workspaceId, updates }: UpdatePermissionsParams) => { - const response = await fetch(`/api/workspaces/${workspaceId}/permissions`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ updates }), + return requestJson(updateWorkspacePermissionsContract, { + params: { id: workspaceId }, + body: { updates }, }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to update permissions') - } - - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index ca85a3f6ff7..dae5bcb729d 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -1,44 +1,26 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type ConnectorData, + type ConnectorDetailData, + type ConnectorDocumentData, + type ConnectorDocumentsData, + createKnowledgeConnectorContract, + deleteKnowledgeConnectorContract, + getKnowledgeConnectorContract, + listKnowledgeConnectorDocumentsContract, + listKnowledgeConnectorsContract, + patchKnowledgeConnectorDocumentsContract, + type SyncLogData, + triggerKnowledgeConnectorSyncContract, + updateKnowledgeConnectorContract, +} from '@/lib/api/contracts/knowledge' import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' const logger = createLogger('KnowledgeConnectorQueries') -export interface ConnectorData { - id: string - knowledgeBaseId: string - connectorType: string - credentialId: string | null - sourceConfig: Record - syncMode: string - syncIntervalMinutes: number - status: 'active' | 'paused' | 'syncing' | 'error' | 'disabled' - lastSyncAt: string | null - lastSyncError: string | null - lastSyncDocCount: number | null - nextSyncAt: string | null - consecutiveFailures: number - createdAt: string - updatedAt: string -} - -export interface SyncLogData { - id: string - connectorId: string - status: string - startedAt: string - completedAt: string | null - docsAdded: number - docsUpdated: number - docsDeleted: number - docsUnchanged: number - docsFailed: number - errorMessage: string | null -} - -export interface ConnectorDetailData extends ConnectorData { - syncLogs: SyncLogData[] -} +export type { ConnectorData, ConnectorDetailData, SyncLogData } export const connectorKeys = { all: (knowledgeBaseId: string) => @@ -53,18 +35,12 @@ async function fetchConnectors( knowledgeBaseId: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/knowledge/${knowledgeBaseId}/connectors`, { signal }) - - if (!response.ok) { - throw new Error(`Failed to fetch connectors: ${response.status}`) - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to fetch connectors') - } + const result = await requestJson(listKnowledgeConnectorsContract, { + params: { id: knowledgeBaseId }, + signal, + }) - return Array.isArray(result.data) ? result.data : [] + return result.data } async function fetchConnectorDetail( @@ -72,19 +48,11 @@ async function fetchConnectorDetail( connectorId: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/knowledge/${knowledgeBaseId}/connectors/${connectorId}`, { + const result = await requestJson(getKnowledgeConnectorContract, { + params: { id: knowledgeBaseId, connectorId }, signal, }) - if (!response.ok) { - throw new Error(`Failed to fetch connector: ${response.status}`) - } - - const result = await response.json() - if (!result?.success || !result?.data) { - throw new Error(result?.error || 'Failed to fetch connector') - } - return result.data } @@ -142,22 +110,11 @@ async function createConnector({ knowledgeBaseId, ...body }: CreateConnectorParams): Promise { - const response = await fetch(`/api/knowledge/${knowledgeBaseId}/connectors`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + const result = await requestJson(createKnowledgeConnectorContract, { + params: { id: knowledgeBaseId }, + body, }) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to create connector') - } - - const result = await response.json() - if (!result?.success || !result?.data) { - throw new Error(result?.error || 'Failed to create connector') - } - return result.data } @@ -189,22 +146,11 @@ async function updateConnector({ connectorId, updates, }: UpdateConnectorParams): Promise { - const response = await fetch(`/api/knowledge/${knowledgeBaseId}/connectors/${connectorId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates), + const result = await requestJson(updateKnowledgeConnectorContract, { + params: { id: knowledgeBaseId, connectorId }, + body: updates, }) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to update connector') - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to update connector') - } - return result.data } @@ -232,20 +178,10 @@ async function deleteConnector({ connectorId, deleteDocuments, }: DeleteConnectorParams): Promise { - const base = `/api/knowledge/${knowledgeBaseId}/connectors/${connectorId}` - const response = await fetch(deleteDocuments ? `${base}?deleteDocuments=true` : base, { - method: 'DELETE', + await requestJson(deleteKnowledgeConnectorContract, { + params: { id: knowledgeBaseId, connectorId }, + query: { deleteDocuments }, }) - - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to delete connector') - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to delete connector') - } } export function useDeleteConnector() { @@ -267,14 +203,9 @@ export interface TriggerSyncParams { } async function triggerSync({ knowledgeBaseId, connectorId }: TriggerSyncParams): Promise { - const response = await fetch(`/api/knowledge/${knowledgeBaseId}/connectors/${connectorId}/sync`, { - method: 'POST', + await requestJson(triggerKnowledgeConnectorSyncContract, { + params: { id: knowledgeBaseId, connectorId }, }) - - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to trigger sync') - } } export function useTriggerSync() { @@ -290,22 +221,7 @@ export function useTriggerSync() { }) } -export interface ConnectorDocumentData { - id: string - filename: string - externalId: string | null - sourceUrl: string | null - enabled: boolean - deletedAt: string | null - userExcluded: boolean - uploadedAt: string - processingStatus: string -} - -export interface ConnectorDocumentsResponse { - documents: ConnectorDocumentData[] - counts: { active: number; excluded: number } -} +export type { ConnectorDocumentData } export const connectorDocumentKeys = { list: (knowledgeBaseId?: string, connectorId?: string) => @@ -317,21 +233,12 @@ async function fetchConnectorDocuments( connectorId: string, includeExcluded: boolean, signal?: AbortSignal -): Promise { - const params = includeExcluded ? '?includeExcluded=true' : '' - const response = await fetch( - `/api/knowledge/${knowledgeBaseId}/connectors/${connectorId}/documents${params}`, - { signal } - ) - - if (!response.ok) { - throw new Error(`Failed to fetch connector documents: ${response.status}`) - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to fetch connector documents') - } +): Promise { + const result = await requestJson(listKnowledgeConnectorDocumentsContract, { + params: { id: knowledgeBaseId, connectorId }, + query: { includeExcluded }, + signal, + }) return result.data } @@ -370,22 +277,12 @@ async function excludeConnectorDocuments({ connectorId, documentIds, }: ConnectorDocumentMutationParams): Promise<{ excludedCount: number }> { - const response = await fetch( - `/api/knowledge/${knowledgeBaseId}/connectors/${connectorId}/documents`, - { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ operation: 'exclude', documentIds }), - } - ) - - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to exclude documents') - } + const result = await requestJson(patchKnowledgeConnectorDocumentsContract, { + params: { id: knowledgeBaseId, connectorId }, + body: { operation: 'exclude', documentIds }, + }) - const result = await response.json() - return result.data + return { excludedCount: result.data.excludedCount ?? 0 } } export function useExcludeConnectorDocument() { @@ -409,22 +306,12 @@ async function restoreConnectorDocuments({ connectorId, documentIds, }: ConnectorDocumentMutationParams): Promise<{ restoredCount: number }> { - const response = await fetch( - `/api/knowledge/${knowledgeBaseId}/connectors/${connectorId}/documents`, - { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ operation: 'restore', documentIds }), - } - ) - - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to restore documents') - } + const result = await requestJson(patchKnowledgeConnectorDocumentsContract, { + params: { id: knowledgeBaseId, connectorId }, + body: { operation: 'restore', documentIds }, + }) - const result = await response.json() - return result.data + return { restoredCount: result.data.restoredCount ?? 0 } } export function useRestoreConnectorDocument() { diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index a93e8b2204a..f118c481e02 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -1,18 +1,62 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from '@/components/emcn' +import { ApiClientError } from '@/lib/api/client/errors' +import { requestJson } from '@/lib/api/client/request' +import { + type BulkChunkOperationData, + type BulkDocumentOperationData, + bulkKnowledgeChunksContract, + bulkKnowledgeDocumentsContract, + type ChunkData, + type ChunksPagination, + createKnowledgeBaseContract, + createKnowledgeChunkContract, + createTagDefinitionContract, + type DocumentData, + type DocumentTagDefinitionData, + type DocumentTagFilter, + deleteDocumentTagDefinitionsContract, + deleteKnowledgeBaseContract, + deleteKnowledgeChunkContract, + deleteKnowledgeDocumentContract, + deleteTagDefinitionContract, + getKnowledgeBaseContract, + getKnowledgeDocumentContract, + type KnowledgeBaseData, + type KnowledgeChunksResponse, + type KnowledgeDocumentsResponse, + type KnowledgeScope, + listDocumentTagDefinitionsContract, + listKnowledgeBasesContract, + listKnowledgeChunksContract, + listKnowledgeDocumentsContract, + listTagDefinitionsContract, + type NextAvailableSlotData, + nextAvailableSlotContract, + restoreKnowledgeBaseContract, + type SaveDocumentTagDefinitionsResult, + saveDocumentTagDefinitionsContract, + type TagDefinitionData, + updateKnowledgeBaseContract, + updateKnowledgeChunkContract, + updateKnowledgeDocumentContract, + updateKnowledgeDocumentTagsContract, +} from '@/lib/api/contracts/knowledge' import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' -import type { - ChunkData, - ChunksPagination, - DocumentData, - DocumentsPagination, - KnowledgeBaseData, -} from '@/lib/knowledge/types' +import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' const logger = createLogger('KnowledgeQueries') -type KnowledgeQueryScope = 'active' | 'archived' | 'all' +type KnowledgeQueryScope = KnowledgeScope + +export type { + DocumentTagDefinitionData, + DocumentTagFilter, + KnowledgeChunksResponse, + KnowledgeDocumentsResponse, + TagDefinitionData, +} export const knowledgeKeys = { all: ['knowledge'] as const, @@ -38,37 +82,22 @@ export async function fetchKnowledgeBases( scope: KnowledgeQueryScope = 'active', signal?: AbortSignal ): Promise { - const url = workspaceId - ? `/api/knowledge?workspaceId=${workspaceId}&scope=${scope}` - : `/api/knowledge?scope=${scope}` - const response = await fetch(url, { signal }) - - if (!response.ok) { - throw new Error(`Failed to fetch knowledge bases: ${response.status} ${response.statusText}`) - } - - const result = await response.json() - if (result?.success === false) { - throw new Error(result.error || 'Failed to fetch knowledge bases') - } + const result = await requestJson(listKnowledgeBasesContract, { + query: { workspaceId, scope }, + signal, + }) - return Array.isArray(result?.data) ? result.data : [] + return result.data } export async function fetchKnowledgeBase( knowledgeBaseId: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/knowledge/${knowledgeBaseId}`, { signal }) - - if (!response.ok) { - throw new Error(`Failed to fetch knowledge base: ${response.status} ${response.statusText}`) - } - - const result = await response.json() - if (!result?.success || !result?.data) { - throw new Error(result?.error || 'Failed to fetch knowledge base') - } + const result = await requestJson(getKnowledgeBaseContract, { + params: { id: knowledgeBaseId }, + signal, + }) return result.data } @@ -78,31 +107,18 @@ export async function fetchDocument( documentId: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, { - signal, - }) - - if (!response.ok) { - if (response.status === 404) { + try { + const result = await requestJson(getKnowledgeDocumentContract, { + params: { id: knowledgeBaseId, documentId }, + signal, + }) + return result.data + } catch (error) { + if (error instanceof ApiClientError && error.status === 404) { throw new Error('Document not found') } - throw new Error(`Failed to fetch document: ${response.status} ${response.statusText}`) - } - - const result = await response.json() - if (!result?.success || !result?.data) { - throw new Error(result?.error || 'Failed to fetch document') + throw error } - - return result.data -} - -export interface DocumentTagFilter { - tagSlot: string - fieldType: 'text' | 'number' | 'date' | 'boolean' - operator: string - value: string - valueTo?: string } export interface KnowledgeDocumentsParams { @@ -110,17 +126,12 @@ export interface KnowledgeDocumentsParams { search?: string limit?: number offset?: number - sortBy?: string - sortOrder?: string + sortBy?: DocumentSortField + sortOrder?: SortOrder enabledFilter?: 'all' | 'enabled' | 'disabled' tagFilters?: DocumentTagFilter[] } -export interface KnowledgeDocumentsResponse { - documents: DocumentData[] - pagination: DocumentsPagination -} - export async function fetchKnowledgeDocuments( { knowledgeBaseId, @@ -134,45 +145,21 @@ export async function fetchKnowledgeDocuments( }: KnowledgeDocumentsParams, signal?: AbortSignal ): Promise { - const params = new URLSearchParams() - if (search) params.set('search', search) - if (sortBy) params.set('sortBy', sortBy) - if (sortOrder) params.set('sortOrder', sortOrder) - params.set('limit', limit.toString()) - params.set('offset', offset.toString()) - if (enabledFilter) params.set('enabledFilter', enabledFilter) - if (tagFilters && tagFilters.length > 0) params.set('tagFilters', JSON.stringify(tagFilters)) - - const url = `/api/knowledge/${knowledgeBaseId}/documents${params.toString() ? `?${params.toString()}` : ''}` - const response = await fetch(url, { signal }) - - if (!response.ok) { - throw new Error(`Failed to fetch documents: ${response.status} ${response.statusText}`) - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to fetch documents') - } - - const documents: DocumentData[] = result.data?.documents ?? result.data ?? [] - const pagination: DocumentsPagination = result.data?.pagination ?? - result.pagination ?? { - total: documents.length, + const result = await requestJson(listKnowledgeDocumentsContract, { + params: { id: knowledgeBaseId }, + query: { + search, + sortBy, + sortOrder, limit, offset, - hasMore: false, - } - - return { - documents, - pagination: { - total: pagination.total ?? documents.length, - limit: pagination.limit ?? limit, - offset: pagination.offset ?? offset, - hasMore: Boolean(pagination.hasMore), + enabledFilter, + tagFilters: tagFilters && tagFilters.length > 0 ? JSON.stringify(tagFilters) : undefined, }, - } + signal, + }) + + return result.data } export interface KnowledgeChunksParams { @@ -186,11 +173,6 @@ export interface KnowledgeChunksParams { sortOrder?: 'asc' | 'desc' } -export interface KnowledgeChunksResponse { - chunks: ChunkData[] - pagination: ChunksPagination -} - export async function fetchKnowledgeChunks( { knowledgeBaseId, @@ -204,29 +186,23 @@ export async function fetchKnowledgeChunks( }: KnowledgeChunksParams, signal?: AbortSignal ): Promise { - const params = new URLSearchParams() - if (search) params.set('search', search) - if (enabledFilter && enabledFilter !== 'all') { - params.set('enabled', enabledFilter === 'enabled' ? 'true' : 'false') - } - if (limit) params.set('limit', limit.toString()) - if (offset) params.set('offset', offset.toString()) - if (sortBy && sortBy !== 'chunkIndex') params.set('sortBy', sortBy) - if (sortOrder && sortOrder !== 'asc') params.set('sortOrder', sortOrder) - - const response = await fetch( - `/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks${params.toString() ? `?${params.toString()}` : ''}`, - { signal } - ) - - if (!response.ok) { - throw new Error(`Failed to fetch chunks: ${response.status} ${response.statusText}`) - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to fetch chunks') - } + const result = await requestJson(listKnowledgeChunksContract, { + params: { id: knowledgeBaseId, documentId }, + query: { + search, + enabled: + enabledFilter && enabledFilter !== 'all' + ? enabledFilter === 'enabled' + ? 'true' + : 'false' + : undefined, + limit, + offset, + sortBy, + sortOrder, + }, + signal, + }) const chunks: ChunkData[] = result.data ?? [] const pagination: ChunksPagination = { @@ -411,28 +387,14 @@ export async function updateChunk({ content, enabled, }: UpdateChunkParams): Promise { - const body: Record = {} + const body: { content?: string; enabled?: boolean } = {} if (content !== undefined) body.content = content if (enabled !== undefined) body.enabled = enabled - const response = await fetch( - `/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunkId}`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - } - ) - - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to update chunk') - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to update chunk') - } + const result = await requestJson(updateKnowledgeChunkContract, { + params: { id: knowledgeBaseId, documentId, chunkId }, + body, + }) return result.data } @@ -464,20 +426,9 @@ export async function deleteChunk({ documentId, chunkId, }: DeleteChunkParams): Promise { - const response = await fetch( - `/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunkId}`, - { method: 'DELETE' } - ) - - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to delete chunk') - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to delete chunk') - } + await requestJson(deleteKnowledgeChunkContract, { + params: { id: knowledgeBaseId, documentId, chunkId }, + }) } export function useDeleteChunk() { @@ -509,22 +460,11 @@ export async function createChunk({ content, enabled = true, }: CreateChunkParams): Promise { - const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content, enabled }), + const result = await requestJson(createKnowledgeChunkContract, { + params: { id: knowledgeBaseId, documentId }, + body: { content, enabled }, }) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to create chunk') - } - - const result = await response.json() - if (!result?.success || !result?.data) { - throw new Error(result?.error || 'Failed to create chunk') - } - return result.data } @@ -560,22 +500,11 @@ export async function updateDocument({ documentId, updates, }: UpdateDocumentParams): Promise { - const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates), + const result = await requestJson(updateKnowledgeDocumentContract, { + params: { id: knowledgeBaseId, documentId }, + body: updates, }) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to update document') - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to update document') - } - return result.data } @@ -604,19 +533,9 @@ export async function deleteDocument({ knowledgeBaseId, documentId, }: DeleteDocumentParams): Promise { - const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, { - method: 'DELETE', + await requestJson(deleteKnowledgeDocumentContract, { + params: { id: knowledgeBaseId, documentId }, }) - - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to delete document') - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to delete document') - } } export function useDeleteDocument() { @@ -640,43 +559,20 @@ export interface BulkDocumentOperationParams { enabledFilter?: 'all' | 'enabled' | 'disabled' } -export interface BulkDocumentOperationResult { - successCount: number - failedCount: number - updatedDocuments?: Array<{ id: string; enabled: boolean }> -} - export async function bulkDocumentOperation({ knowledgeBaseId, operation, documentIds, selectAll, enabledFilter, -}: BulkDocumentOperationParams): Promise { - const body: Record = { operation } - if (selectAll) { - body.selectAll = true - if (enabledFilter) body.enabledFilter = enabledFilter - } else { - body.documentIds = documentIds - } - - const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), +}: BulkDocumentOperationParams): Promise { + const result = await requestJson(bulkKnowledgeDocumentsContract, { + params: { id: knowledgeBaseId }, + body: selectAll + ? { operation, selectAll: true, enabledFilter } + : { operation, documentIds: documentIds ?? [] }, }) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || `Failed to ${operation} documents`) - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || `Failed to ${operation} documents`) - } - return result.data } @@ -709,22 +605,10 @@ export interface CreateKnowledgeBaseParams { export async function createKnowledgeBase( params: CreateKnowledgeBaseParams ): Promise { - const response = await fetch('/api/knowledge', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(params), + const result = await requestJson(createKnowledgeBaseContract, { + body: params, }) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to create knowledge base') - } - - const result = await response.json() - if (!result?.success || !result?.data) { - throw new Error(result?.error || 'Failed to create knowledge base') - } - return result.data } @@ -754,22 +638,11 @@ export async function updateKnowledgeBase({ knowledgeBaseId, updates, }: UpdateKnowledgeBaseParams): Promise { - const response = await fetch(`/api/knowledge/${knowledgeBaseId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates), + const result = await requestJson(updateKnowledgeBaseContract, { + params: { id: knowledgeBaseId }, + body: updates, }) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to update knowledge base') - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to update knowledge base') - } - return result.data } @@ -799,19 +672,9 @@ export interface DeleteKnowledgeBaseParams { export async function deleteKnowledgeBase({ knowledgeBaseId, }: DeleteKnowledgeBaseParams): Promise { - const response = await fetch(`/api/knowledge/${knowledgeBaseId}`, { - method: 'DELETE', + await requestJson(deleteKnowledgeBaseContract, { + params: { id: knowledgeBaseId }, }) - - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to delete knowledge base') - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to delete knowledge base') - } } export function useDeleteKnowledgeBase(workspaceId?: string) { @@ -837,36 +700,17 @@ export interface BulkChunkOperationParams { chunkIds: string[] } -export interface BulkChunkOperationResult { - operation: string - successCount: number - errorCount: number - processed: number - errors: string[] -} - export async function bulkChunkOperation({ knowledgeBaseId, documentId, operation, chunkIds, -}: BulkChunkOperationParams): Promise { - const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ operation, chunkIds }), +}: BulkChunkOperationParams): Promise { + const result = await requestJson(bulkKnowledgeChunksContract, { + params: { id: knowledgeBaseId, documentId }, + body: { operation, chunkIds }, }) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || `Failed to ${operation} chunks`) - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || `Failed to ${operation} chunks`) - } - return result.data } @@ -897,22 +741,11 @@ export async function updateDocumentTags({ documentId, tags, }: UpdateDocumentTagsParams): Promise { - const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(tags), + const result = await requestJson(updateKnowledgeDocumentTagsContract, { + params: { id: knowledgeBaseId, documentId }, + body: tags, }) - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to update document tags') - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to update document tags') - } - return result.data } @@ -932,31 +765,16 @@ export function useUpdateDocumentTags() { }) } -export interface TagDefinitionData { - id: string - tagSlot: string - displayName: string - fieldType: string - createdAt: string - updatedAt: string -} - export async function fetchTagDefinitions( knowledgeBaseId: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-definitions`, { signal }) - - if (!response.ok) { - throw new Error(`Failed to fetch tag definitions: ${response.status} ${response.statusText}`) - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to fetch tag definitions') - } + const result = await requestJson(listTagDefinitionsContract, { + params: { id: knowledgeBaseId }, + signal, + }) - return Array.isArray(result.data) ? result.data : [] + return result.data } export function useTagDefinitionsQuery(knowledgeBaseId?: string | null) { @@ -975,31 +793,14 @@ export interface CreateTagDefinitionParams { fieldType: string } -interface NextAvailableSlotData { - nextAvailableSlot: string | null - fieldType: string - usedSlots: string[] - totalSlots: number - availableSlots: number -} - async function fetchNextAvailableSlotData( knowledgeBaseId: string, fieldType: string ): Promise { - const response = await fetch( - `/api/knowledge/${knowledgeBaseId}/next-available-slot?fieldType=${fieldType}` - ) - - if (!response.ok) { - throw new Error('Failed to get available slot') - } - - const result = await response.json() - if (!result.success || !result.data) { - throw new Error('No available tag slots for this field type') - } - + const result = await requestJson(nextAvailableSlotContract, { + params: { id: knowledgeBaseId }, + query: { fieldType }, + }) return result.data } @@ -1030,22 +831,10 @@ export async function createTagDefinition({ }: CreateTagDefinitionParams): Promise { const tagSlot = await fetchNextAvailableSlot(knowledgeBaseId, fieldType) - const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-definitions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tagSlot, displayName, fieldType }), + const result = await requestJson(createTagDefinitionContract, { + params: { id: knowledgeBaseId }, + body: { tagSlot, displayName, fieldType }, }) - - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to create tag definition') - } - - const result = await response.json() - if (!result?.success || !result?.data) { - throw new Error(result?.error || 'Failed to create tag definition') - } - return result.data } @@ -1071,20 +860,9 @@ export async function deleteTagDefinition({ knowledgeBaseId, tagDefinitionId, }: DeleteTagDefinitionParams): Promise { - const response = await fetch( - `/api/knowledge/${knowledgeBaseId}/tag-definitions/${tagDefinitionId}`, - { method: 'DELETE' } - ) - - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to delete tag definition') - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to delete tag definition') - } + await requestJson(deleteTagDefinitionContract, { + params: { id: knowledgeBaseId, tagId: tagDefinitionId }, + }) } export function useDeleteTagDefinition() { @@ -1100,37 +878,17 @@ export function useDeleteTagDefinition() { }) } -export interface DocumentTagDefinitionData { - id: string - tagSlot: string - displayName: string - fieldType: string - createdAt: string - updatedAt: string -} - export async function fetchDocumentTagDefinitions( knowledgeBaseId: string, documentId: string, signal?: AbortSignal ): Promise { - const response = await fetch( - `/api/knowledge/${knowledgeBaseId}/documents/${documentId}/tag-definitions`, - { signal } - ) - - if (!response.ok) { - throw new Error( - `Failed to fetch document tag definitions: ${response.status} ${response.statusText}` - ) - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to fetch document tag definitions') - } + const result = await requestJson(listDocumentTagDefinitionsContract, { + params: { id: knowledgeBaseId, documentId }, + signal, + }) - return Array.isArray(result.data) ? result.data : [] + return result.data } export function useDocumentTagDefinitionsQuery( @@ -1163,30 +921,15 @@ export async function saveDocumentTagDefinitions({ knowledgeBaseId, documentId, definitions, -}: SaveDocumentTagDefinitionsParams): Promise { +}: SaveDocumentTagDefinitionsParams): Promise { const validDefinitions = (definitions || []).filter( (def) => def?.tagSlot && def.displayName && def.displayName.trim() ) - const response = await fetch( - `/api/knowledge/${knowledgeBaseId}/documents/${documentId}/tag-definitions`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ definitions: validDefinitions }), - } - ) - - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to save document tag definitions') - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to save document tag definitions') - } - + const result = await requestJson(saveDocumentTagDefinitionsContract, { + params: { id: knowledgeBaseId, documentId }, + body: { definitions: validDefinitions }, + }) return result.data } @@ -1215,20 +958,9 @@ export async function deleteDocumentTagDefinitions({ knowledgeBaseId, documentId, }: DeleteDocumentTagDefinitionsParams): Promise { - const response = await fetch( - `/api/knowledge/${knowledgeBaseId}/documents/${documentId}/tag-definitions`, - { method: 'DELETE' } - ) - - if (!response.ok) { - const result = await response.json() - throw new Error(result.error || 'Failed to delete document tag definitions') - } - - const result = await response.json() - if (!result?.success) { - throw new Error(result?.error || 'Failed to delete document tag definitions') - } + await requestJson(deleteDocumentTagDefinitionsContract, { + params: { id: knowledgeBaseId, documentId }, + }) } export function useRestoreKnowledgeBase() { @@ -1236,12 +968,9 @@ export function useRestoreKnowledgeBase() { return useMutation({ mutationFn: async (knowledgeBaseId: string) => { - const res = await fetch(`/api/knowledge/${knowledgeBaseId}/restore`, { method: 'POST' }) - if (!res.ok) { - const data = await res.json().catch(() => ({})) - throw new Error(data.error || 'Failed to restore knowledge base') - } - return res.json() + return requestJson(restoreKnowledgeBaseContract, { + params: { id: knowledgeBaseId }, + }) }, onSettled: () => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }) diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index edfd58f13d5..32d69988852 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -7,14 +7,22 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + cancelWorkflowExecutionContract, + type DashboardStatsResponse, + type ExecutionSnapshotData, + getDashboardStatsContract, + getExecutionSnapshotContract, + getLogDetailContract, + listLogsContract, + type SegmentStats, + type WorkflowLogData, + type WorkflowStats, +} from '@/lib/api/contracts/logs' import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' -import type { - DashboardStatsResponse, - SegmentStats, - WorkflowStats, -} from '@/app/api/logs/stats/route' -import type { LogsResponse, TimeRange, WorkflowLog } from '@/stores/logs/filters/types' +import type { TimeRange, WorkflowLog } from '@/stores/logs/filters/types' export type { DashboardStatsResponse, SegmentStats, WorkflowStats } @@ -45,6 +53,8 @@ interface LogFilters { limit: number } +const toWorkflowLog = (log: WorkflowLogData): WorkflowLog => log as WorkflowLog + /** * Applies common filter parameters to a URLSearchParams object. * Shared between paginated and non-paginated log fetches. @@ -86,16 +96,17 @@ function applyFilterParams(params: URLSearchParams, filters: Omit { - const queryParams = buildQueryParams(workspaceId, filters, page) - const response = await fetch(`/api/logs?${queryParams}`, { signal }) - - if (!response.ok) { - throw new Error('Failed to fetch logs') - } - - const apiData: LogsResponse = await response.json() + const apiData = await requestJson(listLogsContract, { + query: buildQueryParams(workspaceId, filters, page), + signal, + }) const hasMore = apiData.data.length === filters.limit && apiData.page < apiData.totalPages return { - logs: apiData.data || [], + logs: apiData.data.map(toWorkflowLog), hasMore, nextPage: hasMore ? page + 1 : undefined, } } export async function fetchLogDetail(logId: string, signal?: AbortSignal): Promise { - const response = await fetch(`/api/logs/${logId}`, { signal }) - - if (!response.ok) { - throw new Error('Failed to fetch log details') - } - - const { data } = await response.json() - return data + const { data } = await requestJson(getLogDetailContract, { + params: { id: logId }, + signal, + }) + return toWorkflowLog(data) } interface UseLogsListOptions { @@ -194,17 +198,15 @@ async function fetchDashboardStats( signal?: AbortSignal ): Promise { const params = new URLSearchParams() - params.set('workspaceId', workspaceId) - applyFilterParams(params, filters) - const response = await fetch(`/api/logs/stats?${params.toString()}`, { signal }) - - if (!response.ok) { - throw new Error('Failed to fetch dashboard stats') - } - - return response.json() + return requestJson(getDashboardStatsContract, { + query: { + workspaceId, + ...Object.fromEntries(params.entries()), + }, + signal, + }) } interface UseDashboardStatsOptions { @@ -231,36 +233,16 @@ export function useDashboardStats( }) } -export interface ExecutionSnapshotData { - executionId: string - workflowId: string - workflowState: Record - childWorkflowSnapshots?: Record> - executionMetadata: { - trigger: string - startedAt: string - endedAt?: string - totalDurationMs?: number - cost: { - total: number | null - input: number | null - output: number | null - } - totalTokens: number | null - } -} +export type { ExecutionSnapshotData } async function fetchExecutionSnapshot( executionId: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/logs/execution/${executionId}`, { signal }) - - if (!response.ok) { - throw new Error(`Failed to fetch execution snapshot: ${response.statusText}`) - } - - const data = await response.json() + const data = await requestJson(getExecutionSnapshotContract, { + params: { executionId }, + signal, + }) if (!data) { throw new Error('No execution snapshot data returned') } @@ -289,11 +271,9 @@ export function useCancelExecution() { workflowId: string executionId: string }) => { - const res = await fetch(`/api/workflows/${workflowId}/executions/${executionId}/cancel`, { - method: 'POST', + const data = await requestJson(cancelWorkflowExecutionContract, { + params: { id: workflowId, executionId }, }) - if (!res.ok) throw new Error('Failed to cancel run') - const data = await res.json() if (!data.success) throw new Error('Failed to cancel run') return data }, @@ -336,6 +316,7 @@ export function useRetryExecution() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ workflowId, input }: { workflowId: string; input?: unknown }) => { + // boundary-raw-fetch: stream response, body is a ReadableStream consumed one chunk at a time const res = await fetch(`/api/workflows/${workflowId}/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index f1c49ceaf27..c6c02156ded 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -1,6 +1,23 @@ import { useEffect } from 'react' import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { ApiClientError } from '@/lib/api/client/errors' +import { requestJson } from '@/lib/api/client/request' +import { + createMcpServerContract, + deleteMcpServerContract, + discoverMcpToolsContract, + getAllowedMcpDomainsContract, + listMcpServersContract, + listStoredMcpToolsContract, + type McpServer, + type McpServerTestBody, + type McpServerTestResult, + type RefreshMcpServerResult, + refreshMcpServerContract, + testMcpServerConnectionContract, + updateMcpServerContract, +} from '@/lib/api/contracts/mcp' import { sanitizeForHttp, sanitizeHeaders } from '@/lib/mcp/shared' import type { McpServerStatusConfig, McpTool, McpTransport, StoredMcpTool } from '@/lib/mcp/types' import { workflowMcpServerKeys } from '@/hooks/queries/workflow-mcp-servers' @@ -17,32 +34,14 @@ export const mcpKeys = { allowedDomains: () => [...mcpKeys.all, 'allowedDomains'] as const, } -export interface McpServer { - id: string - workspaceId: string - name: string - transport: 'streamable-http' | 'stdio' - url?: string - timeout: number - headers?: Record - enabled: boolean - connectionStatus?: 'connected' | 'disconnected' | 'error' - lastError?: string | null - statusConfig?: McpServerStatusConfig - toolCount?: number - lastToolsRefresh?: string - lastConnected?: string - createdAt: string - updatedAt: string - deletedAt?: string -} +export type { McpServer } /** * Input for creating/updating an MCP server (distinct from McpServerConfig in types.ts) */ export interface McpServerInput { name: string - transport: 'streamable-http' | 'stdio' + transport: McpTransport url?: string timeout: number headers?: Record @@ -50,19 +49,18 @@ export interface McpServerInput { } async function fetchMcpServers(workspaceId: string, signal?: AbortSignal): Promise { - const response = await fetch(`/api/mcp/servers?workspaceId=${workspaceId}`, { signal }) - - if (response.status === 404) { - return [] - } - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to fetch MCP servers') + try { + const data = await requestJson(listMcpServersContract, { + query: { workspaceId }, + signal, + }) + return data.data.servers + } catch (error) { + if (error instanceof ApiClientError && error.status === 404) { + return [] + } + throw error } - - return data.data?.servers || [] } export function useMcpServers(workspaceId: string) { @@ -81,24 +79,18 @@ async function fetchMcpTools( forceRefresh = false, signal?: AbortSignal ): Promise { - const params = new URLSearchParams({ workspaceId }) - if (forceRefresh) { - params.set('refresh', 'true') - } - - const response = await fetch(`/api/mcp/tools/discover?${params.toString()}`, { signal }) - - if (response.status === 404) { - return [] - } - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to fetch MCP tools') + try { + const data = await requestJson(discoverMcpToolsContract, { + query: { workspaceId, refresh: forceRefresh || undefined }, + signal, + }) + return data.data.tools + } catch (error) { + if (error instanceof ApiClientError && error.status === 404) { + return [] + } + throw error } - - return data.data?.tools || [] } export function useMcpToolsQuery(workspaceId: string) { @@ -139,20 +131,12 @@ export function useCreateMcpServer() { workspaceId, } - const response = await fetch('/api/mcp/servers', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(serverData), + const data = await requestJson(createMcpServerContract, { + body: serverData, }) - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to create MCP server') - } - - const serverId = data.data?.serverId - const wasUpdated = data.data?.updated === true + const serverId = data.data.serverId + const wasUpdated = data.data.updated === true logger.info( wasUpdated @@ -216,18 +200,9 @@ export function useDeleteMcpServer() { return useMutation({ mutationFn: async ({ workspaceId, serverId }: DeleteMcpServerParams) => { - const response = await fetch( - `/api/mcp/servers?serverId=${serverId}&workspaceId=${workspaceId}`, - { - method: 'DELETE', - } - ) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to delete MCP server') - } + const data = await requestJson(deleteMcpServerContract, { + query: { serverId, workspaceId }, + }) logger.info(`Deleted MCP server: ${serverId} from workspace: ${workspaceId}`) return data @@ -256,20 +231,14 @@ export function useUpdateMcpServer() { headers: updates.headers ? sanitizeHeaders(updates.headers) : updates.headers, } - const response = await fetch(`/api/mcp/servers/${serverId}?workspaceId=${workspaceId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(sanitizedUpdates), + const data = await requestJson(updateMcpServerContract, { + params: { id: serverId }, + query: { workspaceId }, + body: sanitizedUpdates, }) - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to update MCP server') - } - logger.info(`Updated MCP server: ${serverId} in workspace: ${workspaceId}`) - return data.data?.server + return data.data.server }, onMutate: async ({ workspaceId, serverId, updates }) => { await queryClient.cancelQueries({ queryKey: mcpKeys.servers(workspaceId) }) @@ -306,14 +275,7 @@ interface RefreshMcpServerParams { serverId: string } -export interface RefreshMcpServerResult { - status: 'connected' | 'disconnected' | 'error' - toolCount: number - lastConnected: string | null - error: string | null - workflowsUpdated: number - updatedWorkflowIds: string[] -} +export type { RefreshMcpServerResult } export function useRefreshMcpServer() { const queryClient = useQueryClient() @@ -323,18 +285,10 @@ export function useRefreshMcpServer() { workspaceId, serverId, }: RefreshMcpServerParams): Promise => { - const response = await fetch( - `/api/mcp/servers/${serverId}/refresh?workspaceId=${workspaceId}`, - { - method: 'POST', - } - ) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to refresh MCP server') - } + const data = await requestJson(refreshMcpServerContract, { + params: { id: serverId }, + query: { workspaceId }, + }) logger.info(`Refreshed MCP server: ${serverId}`) return data.data @@ -352,15 +306,11 @@ async function fetchStoredMcpTools( workspaceId: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/mcp/tools/stored?workspaceId=${workspaceId}`, { signal }) - - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'Failed to fetch stored MCP tools') - } - - const data = await response.json() - return data.data?.tools || [] + const data = await requestJson(listStoredMcpToolsContract, { + query: { workspaceId }, + signal, + }) + return data.data.tools } export function useStoredMcpTools(workspaceId: string) { @@ -437,23 +387,14 @@ export function useMcpToolsEvents(workspaceId: string) { }, [workspaceId, queryClient]) } -export interface McpServerTestConfig { - name: string - transport: McpTransport - url?: string - headers?: Record - timeout?: number +export type McpServerTestConfig = McpServerTestBody & { workspaceId: string } -export interface McpServerTestResult { - success: boolean - message: string - error?: string - negotiatedVersion?: string - supportedCapabilities?: string[] - toolCount?: number - warnings?: string[] +export type { McpServerTestResult } + +function isMcpTestErrorBody(body: unknown): body is { data?: McpServerTestResult } { + return Boolean(body) && typeof body === 'object' && 'data' in (body as Record) } async function testMcpServerConnection( @@ -466,28 +407,26 @@ async function testMcpServerConnection( headers: sanitizeHeaders(config.headers) || {}, } - const response = await fetch('/api/mcp/servers/test-connection', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(cleanConfig), - signal, - }) - - const result = await response.json() - - if (!response.ok) { - if (result.data?.error || result.data?.success === false) { - return { - success: false, - message: result.data.error || 'Connection failed', - error: result.data.error, - warnings: result.data.warnings, + try { + const data = await requestJson(testMcpServerConnectionContract, { + body: cleanConfig, + signal, + }) + return data.data + } catch (error) { + if (error instanceof ApiClientError && isMcpTestErrorBody(error.body) && error.body.data) { + const inner = error.body.data + if (inner.error || inner.success === false) { + return { + success: false, + message: inner.error || 'Connection failed', + error: inner.error, + warnings: inner.warnings, + } } } - throw new Error(result.error || 'Connection test failed') + throw error } - - return result.data || result } export function useMcpServerTest() { @@ -522,11 +461,7 @@ export function useMcpServerTest() { * Fetch allowed MCP domains (admin-configured allowlist) */ async function fetchAllowedMcpDomains(signal?: AbortSignal): Promise { - const response = await fetch('/api/settings/allowed-mcp-domains', { signal }) - if (!response.ok) { - return null - } - const data = await response.json() + const data = await requestJson(getAllowedMcpDomainsContract, { signal }) return data.allowedMcpDomains ?? null } diff --git a/apps/sim/hooks/queries/mothership-admin.ts b/apps/sim/hooks/queries/mothership-admin.ts index 83194b9abb7..f4c7d839958 100644 --- a/apps/sim/hooks/queries/mothership-admin.ts +++ b/apps/sim/hooks/queries/mothership-admin.ts @@ -4,13 +4,22 @@ export type MothershipEnv = 'dev' | 'staging' | 'prod' const BASE = '/api/admin/mothership' +/** + * Same-origin proxy to the mothership admin API. Both the request body and + * the response shape vary per upstream `endpoint` query parameter, so a + * single contract cannot capture the union; the proxy returns the upstream + * JSON verbatim. `requestJson` would force a fixed response schema, so this + * hook stays on raw `fetch` and surfaces upstream errors through `adminError`. + */ async function mothershipPost( endpoint: string, environment: MothershipEnv, body?: Record, signal?: AbortSignal ) { - const res = await fetch(`${BASE}?env=${environment}&endpoint=${endpoint}`, { + const qs = new URLSearchParams({ env: environment, endpoint }) + // boundary-raw-fetch: same-origin proxy whose response shape varies per upstream endpoint + const res = await fetch(`${BASE}?${qs.toString()}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, ...(body ? { body: JSON.stringify(body) } : {}), @@ -23,6 +32,10 @@ async function mothershipPost( return res.json() } +/** + * Same-origin proxy GET for the mothership admin API. See `mothershipPost` + * for the rationale on staying with raw `fetch`. + */ async function mothershipGet( endpoint: string, environment: MothershipEnv, @@ -30,6 +43,7 @@ async function mothershipGet( signal?: AbortSignal ) { const qs = new URLSearchParams({ env: environment, endpoint, ...params }) + // boundary-raw-fetch: same-origin proxy whose response shape varies per upstream endpoint const res = await fetch(`${BASE}?${qs.toString()}`, { method: 'GET', signal }) if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })) diff --git a/apps/sim/hooks/queries/notifications.ts b/apps/sim/hooks/queries/notifications.ts index be6894a83ec..e98dfabbb23 100644 --- a/apps/sim/hooks/queries/notifications.ts +++ b/apps/sim/hooks/queries/notifications.ts @@ -1,6 +1,17 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import type { CoreTriggerType } from '@/stores/logs/filters/types' +import { requestJson } from '@/lib/api/client/request' +import { + type ContractBodyInput, + createNotificationContract, + deleteNotificationContract, + listNotificationsContract, + type NotificationSubscription, + testNotificationContract, + updateNotificationContract, +} from '@/lib/api/contracts' + +export type { NotificationSubscription } const logger = createLogger('NotificationQueries') @@ -17,62 +28,6 @@ export const notificationKeys = { [...notificationKeys.details(), workspaceId, notificationId] as const, } -type NotificationType = 'webhook' | 'email' | 'slack' -type LogLevel = 'info' | 'error' -type TriggerType = CoreTriggerType - -type AlertRuleType = - | 'consecutive_failures' - | 'failure_rate' - | 'latency_threshold' - | 'latency_spike' - | 'cost_threshold' - | 'no_activity' - | 'error_count' - -interface AlertConfig { - rule: AlertRuleType - consecutiveFailures?: number - failureRatePercent?: number - windowHours?: number - durationThresholdMs?: number - latencySpikePercent?: number - costThresholdDollars?: number - inactivityHours?: number - errorCountThreshold?: number -} - -interface WebhookConfig { - url: string - secret?: string -} - -interface SlackConfig { - channelId: string - channelName: string - accountId: string -} - -export interface NotificationSubscription { - id: string - notificationType: NotificationType - workflowIds: string[] - allWorkflows: boolean - levelFilter: LogLevel[] - triggerFilter: TriggerType[] - includeFinalOutput: boolean - includeTraceSpans: boolean - includeRateLimits: boolean - includeUsageData: boolean - webhookConfig?: WebhookConfig | null - emailRecipients?: string[] | null - slackConfig?: SlackConfig | null - alertConfig?: AlertConfig | null - active: boolean - createdAt: string - updatedAt: string -} - /** * Fetch notifications for a workspace */ @@ -80,12 +35,11 @@ async function fetchNotifications( workspaceId: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/workspaces/${workspaceId}/notifications`, { signal }) - if (!response.ok) { - throw new Error('Failed to fetch notifications') - } - const data = await response.json() - return data.data || [] + const data = await requestJson(listNotificationsContract, { + params: { id: workspaceId }, + signal, + }) + return data.data } /** @@ -103,21 +57,7 @@ export function useNotifications(workspaceId?: string) { interface CreateNotificationParams { workspaceId: string - data: { - notificationType: NotificationType - workflowIds: string[] - allWorkflows: boolean - levelFilter: LogLevel[] - triggerFilter: TriggerType[] - includeFinalOutput: boolean - includeTraceSpans: boolean - includeRateLimits: boolean - includeUsageData: boolean - alertConfig?: AlertConfig | null - webhookConfig?: WebhookConfig - emailRecipients?: string[] - slackConfig?: SlackConfig - } + data: ContractBodyInput } /** @@ -128,16 +68,10 @@ export function useCreateNotification() { return useMutation({ mutationFn: async ({ workspaceId, data }: CreateNotificationParams) => { - const response = await fetch(`/api/workspaces/${workspaceId}/notifications`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), + return requestJson(createNotificationContract, { + params: { id: workspaceId }, + body: data, }) - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new Error(error.error || 'Failed to create notification') - } - return response.json() }, onSuccess: (_, { workspaceId }) => { queryClient.invalidateQueries({ queryKey: notificationKeys.list(workspaceId) }) @@ -151,7 +85,7 @@ export function useCreateNotification() { interface UpdateNotificationParams { workspaceId: string notificationId: string - data: Partial & { active?: boolean } + data: ContractBodyInput } /** @@ -162,19 +96,10 @@ export function useUpdateNotification() { return useMutation({ mutationFn: async ({ workspaceId, notificationId, data }: UpdateNotificationParams) => { - const response = await fetch( - `/api/workspaces/${workspaceId}/notifications/${notificationId}`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - } - ) - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new Error(error.error || 'Failed to update notification') - } - return response.json() + return requestJson(updateNotificationContract, { + params: { id: workspaceId, notificationId }, + body: data, + }) }, onSuccess: (_, { workspaceId }) => { queryClient.invalidateQueries({ queryKey: notificationKeys.list(workspaceId) }) @@ -198,16 +123,9 @@ export function useDeleteNotification() { return useMutation({ mutationFn: async ({ workspaceId, notificationId }: DeleteNotificationParams) => { - const response = await fetch( - `/api/workspaces/${workspaceId}/notifications/${notificationId}`, - { - method: 'DELETE', - } - ) - if (!response.ok) { - throw new Error('Failed to delete notification') - } - return response.json() + return requestJson(deleteNotificationContract, { + params: { id: workspaceId, notificationId }, + }) }, onSuccess: (_, { workspaceId }) => { queryClient.invalidateQueries({ queryKey: notificationKeys.list(workspaceId) }) @@ -229,15 +147,9 @@ interface TestNotificationParams { export function useTestNotification() { return useMutation({ mutationFn: async ({ workspaceId, notificationId }: TestNotificationParams) => { - const response = await fetch( - `/api/workspaces/${workspaceId}/notifications/${notificationId}/test`, - { method: 'POST' } - ) - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new Error(error.error || 'Failed to send test notification') - } - return response.json() + return requestJson(testNotificationContract, { + params: { id: workspaceId, notificationId }, + }) }, onError: (error) => { logger.error('Failed to test notification', { error }) diff --git a/apps/sim/hooks/queries/oauth/oauth-connections.ts b/apps/sim/hooks/queries/oauth/oauth-connections.ts index c67391a7ce1..14efe5a1573 100644 --- a/apps/sim/hooks/queries/oauth/oauth-connections.ts +++ b/apps/sim/hooks/queries/oauth/oauth-connections.ts @@ -1,5 +1,14 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type ConnectedAccount, + disconnectOAuthContract, + listConnectedAccountsContract, + listOAuthConnectionsContract, + type OAuthAccountSummary, + type OAuthConnection, +} from '@/lib/api/contracts/oauth-connections' import { client } from '@/lib/auth/auth-client' import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth' @@ -20,17 +29,10 @@ export interface ServiceInfo extends OAuthServiceConfig { id: string isConnected: boolean lastConnected?: string - accounts?: { id: string; name: string }[] + accounts?: OAuthAccountSummary[] } -/** OAuth connection data returned from the API. */ -interface OAuthConnectionResponse { - provider: string - baseProvider?: string - accounts?: { id: string; name: string }[] - lastConnected?: string - scopes?: string[] -} +type OAuthConnectionResponse = OAuthConnection function defineServices(): ServiceInfo[] { const servicesList: ServiceInfo[] = [] @@ -53,17 +55,7 @@ async function fetchOAuthConnections(signal?: AbortSignal): Promise { @@ -74,7 +66,7 @@ async function fetchOAuthConnections(signal?: AbortSignal): Promise 0, + isConnected: (connection.accounts?.length ?? 0) > 0, accounts: connection.accounts || [], lastConnected: connection.lastConnected, } @@ -96,7 +88,7 @@ async function fetchOAuthConnections(signal?: AbortSignal): Promise 0, + isConnected: (connectionWithScopes.accounts?.length ?? 0) > 0, accounts: connectionWithScopes.accounts || [], lastConnected: connectionWithScopes.lastConnected, } @@ -185,23 +177,13 @@ export function useDisconnectOAuthService() { return useMutation({ mutationFn: async ({ provider, providerId, accountId }: DisconnectServiceParams) => { - const response = await fetch('/api/auth/oauth/disconnect', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + return requestJson(disconnectOAuthContract, { + body: { provider, providerId, accountId, - }), + }, }) - - if (!response.ok) { - throw new Error('Failed to disconnect service') - } - - return response.json() }, onMutate: async ({ serviceId, accountId }) => { await queryClient.cancelQueries({ queryKey: oauthConnectionsKeys.connections() }) @@ -243,26 +225,17 @@ export function useDisconnectOAuthService() { } /** Connected OAuth account for a specific provider. */ -export interface ConnectedAccount { - id: string - accountId: string - providerId: string - displayName?: string -} +export type { ConnectedAccount } async function fetchConnectedAccounts( provider: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/auth/accounts?provider=${provider}`, { signal }) - - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || `Failed to load ${provider} accounts`) - } - - const data = await response.json() - return data.accounts || [] + const data = await requestJson(listConnectedAccountsContract, { + query: { provider }, + signal, + }) + return data.accounts } /** diff --git a/apps/sim/hooks/queries/oauth/oauth-credentials.ts b/apps/sim/hooks/queries/oauth/oauth-credentials.ts index 4302d2f4432..046cf3ae1d5 100644 --- a/apps/sim/hooks/queries/oauth/oauth-credentials.ts +++ b/apps/sim/hooks/queries/oauth/oauth-credentials.ts @@ -1,17 +1,10 @@ import { useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { listOAuthCredentialsContract } from '@/lib/api/contracts' import type { Credential } from '@/lib/oauth' import { CREDENTIAL_SET } from '@/executor/constants' import { useCredentialSetDetail } from '@/hooks/queries/credential-sets' import { useWorkspaceCredential } from '@/hooks/queries/credentials' -import { fetchJson } from '@/hooks/selectors/helpers' - -interface CredentialListResponse { - credentials?: Credential[] -} - -interface CredentialDetailResponse { - credentials?: Credential[] -} export const oauthCredentialKeys = { all: ['oauthCredentials'] as const, @@ -39,9 +32,9 @@ export async function fetchOAuthCredentials( ): Promise { const { providerId, workspaceId, workflowId } = params if (!providerId) return [] - const data = await fetchJson('/api/auth/oauth/credentials', { + const data = await requestJson(listOAuthCredentialsContract, { signal, - searchParams: { + query: { provider: providerId, workspaceId, workflowId, @@ -56,9 +49,9 @@ export async function fetchOAuthCredentialDetail( signal?: AbortSignal ): Promise { if (!credentialId) return [] - const data = await fetchJson('/api/auth/oauth/credentials', { + const data = await requestJson(listOAuthCredentialsContract, { signal, - searchParams: { + query: { credentialId, workflowId, }, diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index 6022baac6bf..af0353e5ea1 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -1,5 +1,40 @@ import { createLogger } from '@sim/logger' -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + keepPreviousData, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { ApiClientError } from '@/lib/api/client/errors' +import { requestJson } from '@/lib/api/client/request' +import type { ContractBodyInput, ContractQueryInput } from '@/lib/api/contracts' +import { + cancelInvitationContract, + resendInvitationContract, + updateInvitationContract, +} from '@/lib/api/contracts/invitations' +import { + createOrganizationContract, + getOrganizationRosterContract, + inviteOrganizationMembersContract, + listOrganizationMembersContract, + type OrganizationMembersResponse, + type OrganizationRoster, + type RosterMember, + type RosterPendingInvitation, + type RosterWorkspaceAccess, + removeOrganizationMemberContract, + transferOwnershipContract, + updateOrganizationContract, + updateOrganizationMemberRoleContract, + updateOrganizationUsageLimitContract, + updateSeatsContract, +} from '@/lib/api/contracts/organization' +import { + getOrganizationBillingContract, + type OrganizationBillingApiResponse, +} from '@/lib/api/contracts/subscription' import { client } from '@/lib/auth/auth-client' import { isEnterprise, isPaid, isTeam } from '@/lib/billing/plan-helpers' import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils' @@ -10,6 +45,50 @@ import { workspaceKeys } from '@/hooks/queries/workspace' const logger = createLogger('OrganizationQueries') const invitationListsKey = ['invitations', 'list'] as const +type OrganizationSubscriptionCandidate = { + id: string + referenceId: string + status: string + plan: string + cancelAtPeriodEnd?: boolean + periodEnd?: number | Date + trialEnd?: number | Date +} + +type OrganizationBillingQueryResult = UseQueryResult + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isOrganizationSubscriptionCandidate( + value: unknown +): value is OrganizationSubscriptionCandidate { + if (!isRecord(value)) return false + return ( + typeof value.id === 'string' && + typeof value.referenceId === 'string' && + typeof value.status === 'string' && + typeof value.plan === 'string' && + (value.cancelAtPeriodEnd === undefined || typeof value.cancelAtPeriodEnd === 'boolean') && + (value.periodEnd === undefined || + typeof value.periodEnd === 'number' || + value.periodEnd instanceof Date) && + (value.trialEnd === undefined || + typeof value.trialEnd === 'number' || + value.trialEnd instanceof Date) + ) +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === 'number') return value + if (typeof value === 'string') { + const parsed = Number.parseFloat(value) + return Number.isFinite(parsed) ? parsed : undefined + } + return undefined +} + /** * Query key factories for organization-related queries * This ensures consistent cache invalidation across the app @@ -26,41 +105,7 @@ export const organizationKeys = { roster: (id: string) => [...organizationKeys.detail(id), 'roster'] as const, } -export type RosterWorkspaceAccess = { - workspaceId: string - workspaceName: string - permission: 'admin' | 'write' | 'read' -} - -export type RosterMember = { - memberId: string - userId: string - role: 'owner' | 'admin' | 'member' | 'external' - createdAt: string - name: string - email: string - image: string | null - workspaces: RosterWorkspaceAccess[] -} - -export type RosterPendingInvitation = { - id: string - email: string - role: string - kind: 'organization' | 'workspace' - membershipIntent?: 'internal' | 'external' - createdAt: string - expiresAt: string - inviteeName: string | null - inviteeImage: string | null - workspaces: RosterWorkspaceAccess[] -} - -export type OrganizationRoster = { - members: RosterMember[] - pendingInvitations: RosterPendingInvitation[] - workspaces: Array<{ id: string; name: string }> -} +export type { OrganizationRoster, RosterMember, RosterPendingInvitation, RosterWorkspaceAccess } async function fetchOrganizationRoster( orgId: string, @@ -68,13 +113,18 @@ async function fetchOrganizationRoster( ): Promise { if (!orgId) return null - const response = await fetch(`/api/organizations/${orgId}/roster`, { signal }) - if (response.status === 403 || response.status === 404) return null - if (!response.ok) { - throw new Error('Failed to fetch organization roster') + try { + const payload = await requestJson(getOrganizationRosterContract, { + params: { id: orgId }, + signal, + }) + return payload.data + } catch (error) { + if (error instanceof ApiClientError && (error.status === 403 || error.status === 404)) { + return null + } + throw error } - const payload = await response.json() - return payload.data as OrganizationRoster } export function useOrganizationRoster(orgId: string | undefined | null) { @@ -157,12 +207,13 @@ async function fetchOrganizationSubscription(orgId: string, _signal?: AbortSigna // Priority: Enterprise > Team > Pro (matches `getHighestPrioritySubscription`). // This intentionally includes `pro_*` plans that have been transferred // to the org — they are pooled org-scoped subscriptions. - const entitled = (response.data || []).filter( - (sub: any) => hasPaidSubscriptionStatus(sub.status) && isPaid(sub.plan) - ) - const enterpriseSubscription = entitled.find((sub: any) => isEnterprise(sub.plan)) - const teamSubscription = entitled.find((sub: any) => isTeam(sub.plan)) - const proSubscription = entitled.find((sub: any) => !isEnterprise(sub.plan) && !isTeam(sub.plan)) + const rawSubscriptions: unknown = response.data + const entitled = (Array.isArray(rawSubscriptions) ? rawSubscriptions : []) + .filter(isOrganizationSubscriptionCandidate) + .filter((sub) => hasPaidSubscriptionStatus(sub.status) && isPaid(sub.plan)) + const enterpriseSubscription = entitled.find((sub) => isEnterprise(sub.plan)) + const teamSubscription = entitled.find((sub) => isTeam(sub.plan)) + const proSubscription = entitled.find((sub) => !isEnterprise(sub.plan) && !isTeam(sub.plan)) const activeSubscription = enterpriseSubscription || teamSubscription || proSubscription return activeSubscription || null @@ -185,23 +236,30 @@ export function useOrganizationSubscription(orgId: string) { /** * Fetch organization billing data */ -async function fetchOrganizationBilling(orgId: string, signal?: AbortSignal) { - const response = await fetch(`/api/billing?context=organization&id=${orgId}`, { signal }) - - if (response.status === 404) { - return null - } - - if (!response.ok) { - throw new Error('Failed to fetch organization billing data') +async function fetchOrganizationBilling( + orgId: string, + signal?: AbortSignal +): Promise { + try { + return await requestJson(getOrganizationBillingContract, { + query: { context: 'organization', id: orgId }, + signal, + }) + } catch (error) { + if (error instanceof ApiClientError && error.status === 404) { + return null + } + throw error } - return response.json() } /** * Hook to fetch organization billing data */ -export function useOrganizationBilling(orgId: string, options?: { enabled?: boolean }) { +export function useOrganizationBilling( + orgId: string, + options?: { enabled?: boolean } +): OrganizationBillingQueryResult { return useQuery({ queryKey: organizationKeys.billing(orgId), queryFn: ({ signal }) => fetchOrganizationBilling(orgId, signal), @@ -215,17 +273,28 @@ export function useOrganizationBilling(orgId: string, options?: { enabled?: bool /** * Fetch organization member usage data */ -async function fetchOrganizationMembers(orgId: string, signal?: AbortSignal) { - const response = await fetch(`/api/organizations/${orgId}/members?include=usage`, { signal }) - - if (response.status === 404) { - return { members: [] } - } - - if (!response.ok) { - throw new Error('Failed to fetch organization members') +async function fetchOrganizationMembers( + orgId: string, + signal?: AbortSignal +): Promise { + try { + return await requestJson(listOrganizationMembersContract, { + params: { id: orgId }, + query: { include: 'usage' }, + signal, + }) + } catch (error) { + if (error instanceof ApiClientError && error.status === 404) { + return { + success: true, + data: [], + total: 0, + userRole: 'member', + hasAdminAccess: false, + } + } + throw error } - return response.json() } /** @@ -244,28 +313,19 @@ export function useOrganizationMembers(orgId: string) { /** * Update organization usage limit mutation with optimistic updates */ -interface UpdateOrganizationUsageLimitParams { - organizationId: string - limit: number -} +type UpdateOrganizationUsageLimitParams = Pick< + ContractBodyInput, + 'organizationId' | 'limit' +> export function useUpdateOrganizationUsageLimit() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ organizationId, limit }: UpdateOrganizationUsageLimitParams) => { - const response = await fetch('/api/usage', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ context: 'organization', organizationId, limit }), + return requestJson(updateOrganizationUsageLimitContract, { + body: { context: 'organization', organizationId, limit }, }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.message || error.error || 'Failed to update usage limit') - } - - return response.json() }, onMutate: async ({ organizationId, limit }) => { await queryClient.cancelQueries({ queryKey: organizationKeys.billing(organizationId) }) @@ -276,25 +336,33 @@ export function useUpdateOrganizationUsageLimit() { organizationKeys.subscription(organizationId) ) - queryClient.setQueryData(organizationKeys.billing(organizationId), (old: any) => { - if (!old) return old - const currentUsage = old.data?.currentUsage || old.data?.usage?.current || 0 - const newPercentUsed = limit > 0 ? (currentUsage / limit) * 100 : 0 - - return { - ...old, - data: { - ...old.data, - totalUsageLimit: limit, - usage: { - ...old.data?.usage, - limit, + queryClient.setQueryData( + organizationKeys.billing(organizationId), + (old: unknown) => { + if (!isRecord(old) || !isRecord(old.data)) return old + const usage = isRecord(old.data.usage) ? old.data.usage : {} + const currentUsage = + readNumber(old.data.currentUsage) ?? + readNumber(usage.current) ?? + readNumber(old.data.totalCurrentUsage) ?? + 0 + const newPercentUsed = limit > 0 ? (currentUsage / limit) * 100 : 0 + + return { + ...old, + data: { + ...old.data, + totalUsageLimit: limit, + usage: { + ...usage, + limit, + percentUsed: newPercentUsed, + }, percentUsed: newPercentUsed, }, - percentUsed: newPercentUsed, - }, + } } - }) + ) return { previousBillingData, previousSubscriptionData, organizationId } }, @@ -326,9 +394,10 @@ export function useUpdateOrganizationUsageLimit() { /** * Invite member mutation */ -interface InviteMemberParams { - emails: string[] - workspaceInvitations?: Array<{ workspaceId: string; permission: 'admin' | 'write' | 'read' }> +type InviteMemberParams = Pick< + ContractBodyInput, + 'emails' | 'workspaceInvitations' +> & { orgId: string } @@ -337,21 +406,15 @@ export function useInviteMember() { return useMutation({ mutationFn: async ({ emails, workspaceInvitations, orgId }: InviteMemberParams) => { - const response = await fetch(`/api/organizations/${orgId}/invitations?batch=true`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const result = await requestJson(inviteOrganizationMembersContract, { + params: { id: orgId }, + query: { batch: true }, + body: { emails, workspaceInvitations, - }), + }, }) - const result = await response.json() - - if (!response.ok) { - throw new Error(result.error || result.message || 'Failed to invite member') - } - if (result.success === false) { throw new Error(result.error || result.message || 'Failed to invite member') } @@ -374,7 +437,9 @@ export function useInviteMember() { interface RemoveMemberParams { memberId: string orgId: string - shouldReduceSeats?: boolean + shouldReduceSeats?: ContractQueryInput< + typeof removeOrganizationMemberContract + >['shouldReduceSeats'] } export function useRemoveMember() { @@ -382,19 +447,10 @@ export function useRemoveMember() { return useMutation({ mutationFn: async ({ memberId, orgId, shouldReduceSeats }: RemoveMemberParams) => { - const response = await fetch( - `/api/organizations/${orgId}/members/${memberId}?shouldReduceSeats=${shouldReduceSeats}`, - { - method: 'DELETE', - } - ) - - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new Error(error.error || error.message || 'Failed to remove member') - } - - return response.json() + return requestJson(removeOrganizationMemberContract, { + params: { id: orgId, memberId }, + query: { shouldReduceSeats }, + }) }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) @@ -414,7 +470,7 @@ export function useRemoveMember() { interface UpdateMemberRoleParams { orgId: string userId: string - role: 'admin' | 'member' + role: ContractBodyInput['role'] } export function useUpdateOrganizationMemberRole() { @@ -422,16 +478,10 @@ export function useUpdateOrganizationMemberRole() { return useMutation({ mutationFn: async ({ orgId, userId, role }: UpdateMemberRoleParams) => { - const response = await fetch(`/api/organizations/${orgId}/members/${userId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ role }), + return requestJson(updateOrganizationMemberRoleContract, { + params: { id: orgId, memberId: userId }, + body: { role }, }) - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new Error(error.error || error.message || 'Failed to update role') - } - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) @@ -440,33 +490,19 @@ export function useUpdateOrganizationMemberRole() { }) } -interface TransferOwnershipParams { +type TransferOwnershipParams = { orgId: string - newOwnerUserId: string - alsoLeave?: boolean -} +} & ContractBodyInput export function useTransferOwnership() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ orgId, newOwnerUserId, alsoLeave = false }: TransferOwnershipParams) => { - const response = await fetch(`/api/organizations/${orgId}/transfer-ownership`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ newOwnerUserId, alsoLeave }), + return requestJson(transferOwnershipContract, { + params: { id: orgId }, + body: { newOwnerUserId, alsoLeave }, }) - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new Error(error.error || error.message || 'Failed to transfer ownership') - } - return response.json() as Promise<{ - success: boolean - transferred: boolean - left: boolean - warning?: string - details?: Record - }> }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) @@ -480,28 +516,20 @@ export function useTransferOwnership() { }) } -interface UpdateInvitationParams { +type UpdateInvitationParams = { orgId: string invitationId: string - role?: 'admin' | 'member' - grants?: Array<{ workspaceId: string; permission: 'read' | 'write' | 'admin' }> -} +} & ContractBodyInput export function useUpdateInvitation() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ invitationId, role, grants }: UpdateInvitationParams) => { - const response = await fetch(`/api/invitations/${invitationId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ role, grants }), + return requestJson(updateInvitationContract, { + params: { id: invitationId }, + body: { role, grants }, }) - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new Error(error.error || error.message || 'Failed to update invitation') - } - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) @@ -523,16 +551,9 @@ export function useCancelInvitation() { return useMutation({ mutationFn: async ({ invitationId }: CancelInvitationParams) => { - const response = await fetch(`/api/invitations/${invitationId}`, { - method: 'DELETE', + return requestJson(cancelInvitationContract, { + params: { id: invitationId }, }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.message || error.error || 'Failed to cancel invitation') - } - - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) @@ -557,17 +578,9 @@ export function useResendInvitation() { return useMutation({ mutationFn: async ({ invitationId }: ResendInvitationParams) => { - const response = await fetch(`/api/invitations/${invitationId}/resend`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + return requestJson(resendInvitationContract, { + params: { id: invitationId }, }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.message || error.error || 'Failed to resend invitation') - } - - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) @@ -579,28 +592,19 @@ export function useResendInvitation() { /** * Update seats mutation (handles both add and reduce) */ -interface UpdateSeatsParams { +type UpdateSeatsParams = { orgId: string - seats: number -} +} & ContractBodyInput export function useUpdateSeats() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ seats, orgId }: UpdateSeatsParams) => { - const response = await fetch(`/api/organizations/${orgId}/seats`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ seats }), + return requestJson(updateSeatsContract, { + params: { id: orgId }, + body: { seats }, }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to update seats') - } - - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) @@ -615,30 +619,19 @@ export function useUpdateSeats() { /** * Update organization settings mutation */ -interface UpdateOrganizationParams { +type UpdateOrganizationParams = { orgId: string - name?: string - slug?: string - logo?: string | null -} +} & ContractBodyInput export function useUpdateOrganization() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ orgId, ...updates }: UpdateOrganizationParams) => { - const response = await fetch(`/api/organizations/${orgId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates), + return requestJson(updateOrganizationContract, { + params: { id: orgId }, + body: updates, }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.message || 'Failed to update organization') - } - - return response.json() }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) @@ -650,9 +643,11 @@ export function useUpdateOrganization() { /** * Create organization mutation */ -interface CreateOrganizationParams { +type CreateOrganizationParams = Pick< + ContractBodyInput, + 'slug' +> & { name: string - slug?: string } export function useCreateOrganization() { @@ -660,22 +655,13 @@ export function useCreateOrganization() { return useMutation({ mutationFn: async ({ name, slug }: CreateOrganizationParams) => { - const response = await fetch('/api/organizations', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const data = await requestJson(createOrganizationContract, { + body: { name, slug: slug || name.toLowerCase().replace(/\s+/g, '-'), - }), + }, }) - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new Error(error.error || error.message || 'Failed to create organization') - } - - const data = await response.json() - await client.organization.setActive({ organizationId: data.organizationId, }) diff --git a/apps/sim/hooks/queries/providers.ts b/apps/sim/hooks/queries/providers.ts index 7dc18894243..63786c71e5d 100644 --- a/apps/sim/hooks/queries/providers.ts +++ b/apps/sim/hooks/queries/providers.ts @@ -1,22 +1,18 @@ import { createLogger } from '@sim/logger' import { useQuery } from '@tanstack/react-query' -import type { OpenRouterModelInfo, ProviderName } from '@/stores/providers' +import { requestJson } from '@/lib/api/client/request' +import { + getBaseProviderModelsContract, + getFireworksProviderModelsContract, + getOllamaProviderModelsContract, + getOpenRouterProviderModelsContract, + getVllmProviderModelsContract, + type ProviderModelsResponse, +} from '@/lib/api/contracts/providers' +import type { ProviderName } from '@/stores/providers' const logger = createLogger('ProviderModelsQuery') -const providerEndpoints: Record = { - base: '/api/providers/base/models', - ollama: '/api/providers/ollama/models', - vllm: '/api/providers/vllm/models', - openrouter: '/api/providers/openrouter/models', - fireworks: '/api/providers/fireworks/models', -} - -interface ProviderModelsResponse { - models: string[] - modelInfo?: Record -} - export const providerKeys = { all: ['provider-models'] as const, models: (provider: string, workspaceId?: string) => @@ -28,28 +24,42 @@ async function fetchProviderModels( signal?: AbortSignal, workspaceId?: string ): Promise { - let url = providerEndpoints[provider] - if (provider === 'fireworks' && workspaceId) { - url = `${url}?workspaceId=${encodeURIComponent(workspaceId)}` - } - - const response = await fetch(url, { signal }) + try { + const data = await requestProviderModels(provider, signal, workspaceId) + const models: string[] = Array.isArray(data.models) ? data.models : [] + const uniqueModels = provider === 'openrouter' ? Array.from(new Set(models)) : models - if (!response.ok) { + return { + models: uniqueModels, + modelInfo: data.modelInfo, + } + } catch (error) { logger.warn(`Failed to fetch ${provider} models`, { - status: response.status, - statusText: response.statusText, + error: error instanceof Error ? error.message : 'Unknown error', }) - throw new Error(`Failed to fetch ${provider} models`) + throw error } +} - const data = await response.json() - const models: string[] = Array.isArray(data.models) ? data.models : [] - const uniqueModels = provider === 'openrouter' ? Array.from(new Set(models)) : models - - return { - models: uniqueModels, - modelInfo: data.modelInfo, +async function requestProviderModels( + provider: ProviderName, + signal?: AbortSignal, + workspaceId?: string +): Promise { + switch (provider) { + case 'base': + return requestJson(getBaseProviderModelsContract, { signal }) + case 'ollama': + return requestJson(getOllamaProviderModelsContract, { signal }) + case 'vllm': + return requestJson(getVllmProviderModelsContract, { signal }) + case 'openrouter': + return requestJson(getOpenRouterProviderModelsContract, { signal }) + case 'fireworks': + return requestJson(getFireworksProviderModelsContract, { + query: { workspaceId }, + signal, + }) } } diff --git a/apps/sim/hooks/queries/schedules.ts b/apps/sim/hooks/queries/schedules.ts index 265a36aca05..d71ba3f3388 100644 --- a/apps/sim/hooks/queries/schedules.ts +++ b/apps/sim/hooks/queries/schedules.ts @@ -1,5 +1,22 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { isApiClientError } from '@/lib/api/client/errors' +import { requestJson } from '@/lib/api/client/request' +import { deployWorkflowContract } from '@/lib/api/contracts/deployments' +import { + type CreateScheduleBody, + createScheduleContract, + deleteScheduleContract, + disableScheduleContract, + getScheduleContract, + listWorkspaceSchedulesContract, + reactivateScheduleContract, + type ScheduleLifecycle, + type UpdateScheduleBody, + updateScheduleContract, + type WorkflowScheduleRow, + type WorkspaceScheduleRow, +} from '@/lib/api/contracts/schedules' import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils' import { deploymentKeys } from '@/hooks/queries/deployments' @@ -14,32 +31,12 @@ export const scheduleKeys = { [...scheduleKeys.details(), workflowId, blockId] as const, } -export interface ScheduleData { - id: string - status: 'active' | 'disabled' | 'completed' - cronExpression: string | null - nextRunAt: string | null - lastRanAt: string | null - timezone: string - failedCount: number -} - -export interface WorkspaceScheduleData extends ScheduleData { - workflowId: string | null - workflowName: string | null - workflowColor: string | null - sourceType: 'workflow' | 'job' - jobTitle: string | null - prompt: string | null - sourceTaskName: string | null - lifecycle: string | null - runCount: number | null - maxRuns: number | null -} +export type ScheduleData = WorkflowScheduleRow +export type WorkspaceScheduleData = WorkspaceScheduleRow export interface ScheduleInfo { id: string - status: 'active' | 'disabled' | 'completed' + status: ScheduleData['status'] scheduleTiming: string nextRunAt: string | null lastRanAt: string | null @@ -56,19 +53,16 @@ async function fetchSchedule( blockId: string, signal?: AbortSignal ): Promise { - const params = new URLSearchParams({ workflowId, blockId }) - const response = await fetch(`/api/schedules?${params}`, { - signal, - cache: 'no-store', - headers: { 'Cache-Control': 'no-cache' }, - }) - - if (!response.ok) { - return null + try { + const data = await requestJson(getScheduleContract, { + query: { workflowId, blockId }, + signal, + }) + return data.schedule || null + } catch (error) { + if (isApiClientError(error) && error.status === 404) return null + throw error } - - const data = await response.json() - return data.schedule || null } /** @@ -80,19 +74,11 @@ export function useWorkspaceSchedules(workspaceId?: string) { queryFn: async ({ signal }) => { if (!workspaceId) throw new Error('Workspace ID required') - const res = await fetch(`/api/schedules?workspaceId=${encodeURIComponent(workspaceId)}`, { + const data = await requestJson(listWorkspaceSchedulesContract, { + query: { workspaceId }, signal, - cache: 'no-store', - headers: { 'Cache-Control': 'no-cache' }, }) - - if (!res.ok) { - const error = await res.json().catch(() => ({})) - throw new Error(error.error || 'Failed to fetch schedules') - } - - const data = await res.json() - return (data.schedules || []) as WorkspaceScheduleData[] + return data.schedules || [] }, enabled: Boolean(workspaceId), staleTime: 30 * 1000, @@ -180,16 +166,11 @@ export function useReactivateSchedule() { blockId: string workspaceId?: string }) => { - const response = await fetch(`/api/schedules/${scheduleId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'reactivate' }), + await requestJson(reactivateScheduleContract, { + params: { id: scheduleId }, + body: { action: 'reactivate' }, }) - if (!response.ok) { - throw new Error('Failed to reactivate schedule') - } - return { workflowId, blockId, workspaceId } }, onSuccess: ({ workflowId, blockId, workspaceId }) => { @@ -221,17 +202,11 @@ export function useDisableSchedule() { scheduleId: string workspaceId: string }) => { - const response = await fetch(`/api/schedules/${scheduleId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'disable' }), + await requestJson(disableScheduleContract, { + params: { id: scheduleId }, + body: { action: 'disable' }, }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'Failed to disable schedule') - } - return { workspaceId } }, onSuccess: ({ workspaceId }) => { @@ -258,15 +233,10 @@ export function useDeleteSchedule() { scheduleId: string workspaceId: string }) => { - const response = await fetch(`/api/schedules/${scheduleId}`, { - method: 'DELETE', + await requestJson(deleteScheduleContract, { + params: { id: scheduleId }, }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'Failed to delete schedule') - } - return { workspaceId } }, onSuccess: ({ workspaceId }) => { @@ -293,24 +263,12 @@ export function useUpdateSchedule() { }: { scheduleId: string workspaceId: string - title?: string - prompt?: string - cronExpression?: string - timezone?: string - lifecycle?: 'persistent' | 'until_complete' - maxRuns?: number | null - }) => { - const response = await fetch(`/api/schedules/${scheduleId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'update', ...updates }), + } & Omit) => { + await requestJson(updateScheduleContract, { + params: { id: scheduleId }, + body: { action: 'update', ...updates }, }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'Failed to update schedule') - } - return { workspaceId } }, onSuccess: ({ workspaceId }) => { @@ -339,20 +297,12 @@ export function useCreateSchedule() { lifecycle, maxRuns, startDate, - }: { - workspaceId: string - title: string - prompt: string - cronExpression: string + }: CreateScheduleBody & { timezone: string - lifecycle: 'persistent' | 'until_complete' - maxRuns?: number - startDate?: string + lifecycle: ScheduleLifecycle }) => { - const response = await fetch('/api/schedules', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + return requestJson(createScheduleContract, { + body: { workspaceId, title, prompt, @@ -361,15 +311,8 @@ export function useCreateSchedule() { lifecycle, maxRuns, startDate, - }), + }, }) - - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'Failed to create schedule') - } - - return response.json() }, onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: scheduleKeys.list(variables.workspaceId) }) @@ -388,17 +331,10 @@ export function useRedeployWorkflowSchedule() { return useMutation({ mutationFn: async ({ workflowId, blockId }: { workflowId: string; blockId: string }) => { - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ deployChatEnabled: false }), + await requestJson(deployWorkflowContract, { + params: { id: workflowId }, }) - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to redeploy workflow') - } - return { workflowId, blockId } }, onSuccess: ({ workflowId, blockId }) => { diff --git a/apps/sim/hooks/queries/skills.ts b/apps/sim/hooks/queries/skills.ts index b4c16bfc389..4fc4b3baf11 100644 --- a/apps/sim/hooks/queries/skills.ts +++ b/apps/sim/hooks/queries/skills.ts @@ -1,19 +1,16 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + deleteSkillContract, + listSkillsContract, + type Skill, + upsertSkillsContract, +} from '@/lib/api/contracts' const logger = createLogger('SkillsQueries') -const API_ENDPOINT = '/api/skills' -export interface SkillDefinition { - id: string - workspaceId: string | null - userId: string | null - name: string - description: string - content: string - createdAt: string - updatedAt?: string -} +export type SkillDefinition = Skill /** * Query key factories for skills queries @@ -28,29 +25,11 @@ export const skillsKeys = { * Fetch skills for a workspace */ async function fetchSkills(workspaceId: string, signal?: AbortSignal): Promise { - const response = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`, { signal }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || `Failed to fetch skills: ${response.statusText}`) - } - - const { data } = await response.json() - - if (!Array.isArray(data)) { - throw new Error('Invalid response format') - } - - return data.map((s: Record) => ({ - id: s.id as string, - workspaceId: (s.workspaceId as string) ?? null, - userId: (s.userId as string) ?? null, - name: s.name as string, - description: s.description as string, - content: s.content as string, - createdAt: (s.createdAt as string) ?? new Date().toISOString(), - updatedAt: s.updatedAt as string | undefined, - })) + const { data } = await requestJson(listSkillsContract, { + query: { workspaceId }, + signal, + }) + return data } /** @@ -85,27 +64,15 @@ export function useCreateSkill() { mutationFn: async ({ workspaceId, skill: s }: CreateSkillParams) => { logger.info(`Creating skill: ${s.name} in workspace ${workspaceId}`) - const response = await fetch(API_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const { data } = await requestJson(upsertSkillsContract, { + body: { skills: [{ name: s.name, description: s.description, content: s.content }], workspaceId, - }), + }, }) - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to create skill') - } - - if (!data.data || !Array.isArray(data.data)) { - throw new Error('Invalid API response: missing skills data') - } - logger.info(`Created skill: ${s.name}`) - return data.data + return data }, onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) }) @@ -142,10 +109,8 @@ export function useUpdateSkill() { throw new Error('Skill not found') } - const response = await fetch(API_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const { data } = await requestJson(upsertSkillsContract, { + body: { skills: [ { id: skillId, @@ -155,21 +120,11 @@ export function useUpdateSkill() { }, ], workspaceId, - }), + }, }) - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to update skill') - } - - if (!data.data || !Array.isArray(data.data)) { - throw new Error('Invalid API response: missing skills data') - } - logger.info(`Updated skill: ${skillId}`) - return data.data + return data }, onMutate: async ({ workspaceId, skillId, updates }) => { await queryClient.cancelQueries({ queryKey: skillsKeys.list(workspaceId) }) @@ -222,16 +177,10 @@ export function useDeleteSkill() { mutationFn: async ({ workspaceId, skillId }: DeleteSkillParams) => { logger.info(`Deleting skill: ${skillId}`) - const response = await fetch(`${API_ENDPOINT}?id=${skillId}&workspaceId=${workspaceId}`, { - method: 'DELETE', + const data = await requestJson(deleteSkillContract, { + query: { id: skillId, workspaceId }, }) - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to delete skill') - } - logger.info(`Deleted skill: ${skillId}`) return data }, diff --git a/apps/sim/hooks/queries/status.ts b/apps/sim/hooks/queries/status.ts index 50527cc19ce..80e6a40540d 100644 --- a/apps/sim/hooks/queries/status.ts +++ b/apps/sim/hooks/queries/status.ts @@ -1,5 +1,7 @@ import { useQuery } from '@tanstack/react-query' -import type { StatusResponse } from '@/app/api/status/types' +import { requestJson } from '@/lib/api/client/request' +import type { ContractJsonResponse } from '@/lib/api/contracts' +import { getStatusContract } from '@/lib/api/contracts' /** * Query key factories for status-related queries @@ -14,14 +16,10 @@ export const statusKeys = { * Fetch current system status from the API * The API proxies incident.io and caches for 2 minutes server-side */ -async function fetchStatus(signal?: AbortSignal): Promise { - const response = await fetch('/api/status', { signal }) - - if (!response.ok) { - throw new Error('Failed to fetch status') - } - - return response.json() +async function fetchStatus( + signal?: AbortSignal +): Promise> { + return requestJson(getStatusContract, { signal }) } /** diff --git a/apps/sim/hooks/queries/subscription.ts b/apps/sim/hooks/queries/subscription.ts index aa40845a119..a10cf182fb9 100644 --- a/apps/sim/hooks/queries/subscription.ts +++ b/apps/sim/hooks/queries/subscription.ts @@ -1,73 +1,21 @@ import type { QueryClient } from '@tanstack/react-query' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import type { ContractBodyInput } from '@/lib/api/contracts' +import { + type BillingUsageData, + createBillingPortalContract, + getUserBillingContract, + getUserUsageLimitContract, + purchaseCreditsContract, + type SubscriptionApiResponse, + type SubscriptionBillingData, + updateUsageLimitContract, +} from '@/lib/api/contracts/subscription' import { organizationKeys } from '@/hooks/queries/organization' import { workspaceKeys } from '@/hooks/queries/workspace' -/** - * Shape of the usage object returned from the billing API (user context) - */ -export interface BillingUsageData { - current: number - limit: number - percentUsed: number - isWarning: boolean - isExceeded: boolean - billingPeriodStart: string | null - billingPeriodEnd: string | null - lastPeriodCost: number - lastPeriodCopilotCost: number - daysRemaining: number - copilotCost: number -} - -/** - * Shape of the billing data returned for the user context - */ -export interface SubscriptionBillingData { - type: 'individual' | 'organization' - plan: string - currentUsage: number - usageLimit: number - percentUsed: number - isWarning: boolean - isExceeded: boolean - daysRemaining: number - creditBalance: number - billingInterval: 'month' | 'year' - isPaid: boolean - isPro: boolean - isTeam: boolean - isEnterprise: boolean - /** - * Whether the subscription is attached to an organization. Includes - * `pro_*` plans that have been transferred to an org; use this for - * scope-based decisions instead of `isTeam` / `isEnterprise`. - */ - isOrgScoped: boolean - /** Present when `isOrgScoped` is true. */ - organizationId: string | null - status: string | null - seats: number | null - /** Raw subscription metadata JSON from Stripe (e.g. billingInterval). */ - metadata: unknown - stripeSubscriptionId: string | null - periodEnd: string | null - cancelAtPeriodEnd?: boolean - usage: BillingUsageData - billingBlocked?: boolean - billingBlockedReason?: 'payment_failed' | 'dispute' | null - blockedByOrgOwner?: boolean - organization?: { id: string; role: 'owner' | 'admin' | 'member' } -} - -/** - * Shape of the full API response from GET /api/billing?context=user - */ -export interface SubscriptionApiResponse { - success: boolean - context: string - data: SubscriptionBillingData -} +export type { BillingUsageData, SubscriptionApiResponse, SubscriptionBillingData } /** * Query key factories for subscription-related queries @@ -87,14 +35,10 @@ async function fetchSubscriptionData( includeOrg = false, signal?: AbortSignal ): Promise { - const params = new URLSearchParams({ context: 'user' }) - if (includeOrg) params.set('includeOrg', 'true') - - const response = await fetch(`/api/billing?${params}`, { signal }) - if (!response.ok) { - throw new Error('Failed to fetch subscription data') - } - return response.json() + return requestJson(getUserBillingContract, { + query: { context: 'user', includeOrg }, + signal, + }) } interface UseSubscriptionDataOptions { @@ -140,11 +84,10 @@ export function prefetchSubscriptionData(queryClient: QueryClient) { * For actual usage data (current, limit, percentUsed), use useSubscriptionData() instead */ async function fetchUsageLimitData(signal?: AbortSignal) { - const response = await fetch('/api/usage?context=user', { signal }) - if (!response.ok) { - throw new Error('Failed to fetch usage limit data') - } - return response.json() + return requestJson(getUserUsageLimitContract, { + query: { context: 'user' }, + signal, + }) } interface UseUsageLimitDataOptions { @@ -172,7 +115,7 @@ export function useUsageLimitData(options: UseUsageLimitDataOptions = {}) { * Update usage limit mutation */ interface UpdateUsageLimitParams { - limit: number + limit: ContractBodyInput['limit'] } export function useUpdateUsageLimit() { @@ -180,18 +123,9 @@ export function useUpdateUsageLimit() { return useMutation({ mutationFn: async ({ limit }: UpdateUsageLimitParams) => { - const response = await fetch('/api/usage?context=user', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ limit }), + return requestJson(updateUsageLimitContract, { + body: { context: 'user', limit }, }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.message || 'Failed to update usage limit') - } - - return response.json() }, onMutate: async ({ limit }) => { await queryClient.cancelQueries({ queryKey: subscriptionKeys.all }) @@ -200,7 +134,7 @@ export function useUpdateUsageLimit() { const previousSubscriptionDataWithOrg = queryClient.getQueryData(subscriptionKeys.user(true)) const previousUsageData = queryClient.getQueryData(subscriptionKeys.usage()) - const updateSubscriptionData = (old: any) => { + const updateSubscriptionData = (old: SubscriptionApiResponse | undefined) => { if (!old) return old const currentUsage = old.data?.usage?.current || 0 const newPercentUsed = limit > 0 ? (currentUsage / limit) * 100 : 0 @@ -218,19 +152,28 @@ export function useUpdateUsageLimit() { } } - queryClient.setQueryData(subscriptionKeys.user(false), updateSubscriptionData) - queryClient.setQueryData(subscriptionKeys.user(true), updateSubscriptionData) - - queryClient.setQueryData(subscriptionKeys.usage(), (old: any) => { - if (!old) return old - return { - ...old, - data: { - ...old.data, - currentLimit: limit, - }, + queryClient.setQueryData( + subscriptionKeys.user(false), + updateSubscriptionData + ) + queryClient.setQueryData( + subscriptionKeys.user(true), + updateSubscriptionData + ) + + queryClient.setQueryData> | undefined>( + subscriptionKeys.usage(), + (old) => { + if (!old) return old + return { + ...old, + data: { + ...old.data, + currentLimit: limit, + }, + } } - }) + ) return { previousSubscriptionData, previousSubscriptionDataWithOrg, previousUsageData } }, @@ -289,8 +232,8 @@ export function useUpgradeSubscription() { * Purchase credits mutation */ interface PurchaseCreditsParams { - amount: number - requestId: string + amount: ContractBodyInput['amount'] + requestId: ContractBodyInput['requestId'] orgId?: string } @@ -299,19 +242,9 @@ export function usePurchaseCredits() { return useMutation({ mutationFn: async ({ amount, requestId }: PurchaseCreditsParams) => { - const response = await fetch('/api/billing/credits', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ amount, requestId }), + return requestJson(purchaseCreditsContract, { + body: { amount, requestId }, }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to purchase credits') - } - - return data }, onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() }) @@ -327,28 +260,16 @@ export function usePurchaseCredits() { /** * Open billing portal mutation */ -interface OpenBillingPortalParams { - context: 'user' | 'organization' - organizationId?: string - returnUrl: string -} +type OpenBillingPortalParams = ContractBodyInput export function useOpenBillingPortal() { return useMutation({ - mutationFn: async ({ context, organizationId, returnUrl }: OpenBillingPortalParams) => { - const response = await fetch('/api/billing/portal', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ context, organizationId, returnUrl }), + mutationFn: async (body: OpenBillingPortalParams) => { + const data = await requestJson(createBillingPortalContract, { + body, }) - const data = await response.json() - - if (!response.ok || !data?.url) { - throw new Error(data?.error || 'Failed to start billing portal') - } - - return data as { url: string } + return data }, }) } diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index c0b6abb4bef..5f8ef28b900 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -5,6 +5,37 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from '@/components/emcn' +import { requestJson } from '@/lib/api/client/request' +import type { ContractJsonResponse } from '@/lib/api/contracts' +import { + addTableColumnContract, + type BatchInsertTableRowsBodyInput, + type BatchUpdateTableRowsBodyInput, + batchCreateTableRowsContract, + batchUpdateTableRowsContract, + type CreateTableBodyInput, + type CreateTableColumnBodyInput, + createTableContract, + createTableRowContract, + deleteTableColumnContract, + deleteTableContract, + deleteTableRowContract, + deleteTableRowsContract, + getTableContract, + type InsertTableRowBodyInput, + listTableRowsContract, + listTablesContract, + renameTableContract, + restoreTableContract, + type TableIdParamsInput, + type TableRowParamsInput, + type TableRowsQueryInput, + type UpdateTableColumnBodyInput, + type UpdateTableRowBodyInput, + updateTableColumnContract, + updateTableMetadataContract, + updateTableRowContract, +} from '@/lib/api/contracts/tables' import type { CsvHeaderMapping, Filter, @@ -31,36 +62,31 @@ export const tableKeys = { [...tableKeys.rowsRoot(tableId), paramsKey] as const, } -interface TableRowsParams { - workspaceId: string - tableId: string - limit: number - offset: number - filter?: Filter | null - sort?: Sort | null - /** When `false`, skip the server-side `COUNT(*)` and receive `totalCount: null`. */ - includeTotal?: boolean -} +type TableRowsParams = Omit & + TableIdParamsInput & { + filter?: Filter | null + sort?: Sort | null + } -interface TableRowsResponse { - rows: TableRow[] - /** `null` when the request opted out of the count via `includeTotal: false`. */ - totalCount: number | null -} +type TableRowsResponse = Pick< + ContractJsonResponse['data'], + 'rows' | 'totalCount' +> interface RowMutationContext { workspaceId: string tableId: string } -interface UpdateTableRowParams { - rowId: string - data: Record -} +type UpdateTableRowParams = Pick & + Omit & { + data: Record + } -interface TableRowsDeleteResult { - deletedRowIds: string[] -} +type TableRowsDeleteResult = Pick< + ContractJsonResponse['data'], + 'deletedRowIds' +> function createRowsParamsKey({ limit, @@ -83,17 +109,12 @@ async function fetchTable( tableId: string, signal?: AbortSignal ): Promise { - const res = await fetch(`/api/table/${tableId}?workspaceId=${encodeURIComponent(workspaceId)}`, { + const response = await requestJson(getTableContract, { + params: { tableId }, + query: { workspaceId }, signal, }) - if (!res.ok) { - const error = await res.json().catch(() => ({})) - throw new Error(error.error || 'Failed to fetch table') - } - - const json: { data?: { table: TableDefinition }; table?: TableDefinition } = await res.json() - const data = json.data || json - return (data as { table: TableDefinition }).table + return response.data.table } async function fetchTableRows({ @@ -106,40 +127,22 @@ async function fetchTableRows({ includeTotal, signal, }: TableRowsParams & { signal?: AbortSignal }): Promise { - const searchParams = new URLSearchParams({ - workspaceId, - limit: String(limit), - offset: String(offset), + const response = await requestJson(listTableRowsContract, { + params: { tableId }, + query: { + workspaceId, + limit, + offset, + filter: filter ?? undefined, + sort: sort ?? undefined, + includeTotal, + }, + signal, }) - - if (filter) { - searchParams.set('filter', JSON.stringify(filter)) - } - - if (sort) { - searchParams.set('sort', JSON.stringify(sort)) - } - - if (includeTotal === false) { - searchParams.set('includeTotal', 'false') - } - - const res = await fetch(`/api/table/${tableId}/rows?${searchParams}`, { signal }) - if (!res.ok) { - const error = await res.json().catch(() => ({})) - throw new Error(error.error || 'Failed to fetch rows') - } - - const json: { - data?: { rows: TableRow[]; totalCount: number | null } - rows?: TableRow[] - totalCount?: number | null - } = await res.json() - - const data = json.data || json + const { rows, totalCount } = response.data return { - rows: (data.rows || []) as TableRow[], - totalCount: data.totalCount ?? null, + rows, + totalCount, } } @@ -176,20 +179,11 @@ export function useTablesList(workspaceId?: string, scope: TableQueryScope = 'ac queryFn: async ({ signal }) => { if (!workspaceId) throw new Error('Workspace ID required') - const res = await fetch( - `/api/table?workspaceId=${encodeURIComponent(workspaceId)}&scope=${scope}`, - { - signal, - } - ) - - if (!res.ok) { - const error = await res.json() - throw new Error(error.error || 'Failed to fetch tables') - } - - const response = await res.json() - return (response.data?.tables || []) as TableDefinition[] + const response = await requestJson(listTablesContract, { + query: { workspaceId, scope }, + signal, + }) + return response.data.tables }, enabled: Boolean(workspaceId), staleTime: 30 * 1000, @@ -250,24 +244,10 @@ export function useCreateTable(workspaceId: string) { const queryClient = useQueryClient() return useMutation({ - mutationFn: async (params: { - name: string - description?: string - schema: { columns: Array<{ name: string; type: string; required?: boolean }> } - initialRowCount?: number - }) => { - const res = await fetch('/api/table', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...params, workspaceId }), + mutationFn: async (params: Omit) => { + return requestJson(createTableContract, { + body: { ...params, workspaceId }, }) - - if (!res.ok) { - const error = await res.json() - throw new Error(error.error || 'Failed to create table') - } - - return res.json() }, onSettled: () => { queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) @@ -282,25 +262,11 @@ export function useAddTableColumn({ workspaceId, tableId }: RowMutationContext) const queryClient = useQueryClient() return useMutation({ - mutationFn: async (column: { - name: string - type: string - required?: boolean - unique?: boolean - position?: number - }) => { - const res = await fetch(`/api/table/${tableId}/columns`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId, column }), + mutationFn: async (column: CreateTableColumnBodyInput['column']) => { + return requestJson(addTableColumnContract, { + params: { tableId }, + body: { workspaceId, column }, }) - - if (!res.ok) { - const error = await res.json() - throw new Error(error.error || 'Failed to add column') - } - - return res.json() }, onSettled: () => { invalidateTableSchema(queryClient, workspaceId, tableId) @@ -316,18 +282,10 @@ export function useRenameTable(workspaceId: string) { return useMutation({ mutationFn: async ({ tableId, name }: { tableId: string; name: string }) => { - const res = await fetch(`/api/table/${tableId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId, name }), + return requestJson(renameTableContract, { + params: { tableId }, + body: { workspaceId, name }, }) - - if (!res.ok) { - const error = await res.json().catch(() => ({})) - throw new Error(error.error || 'Failed to rename table') - } - - return res.json() }, onError: (error) => { toast.error(error.message, { duration: 5000 }) @@ -347,19 +305,10 @@ export function useDeleteTable(workspaceId: string) { return useMutation({ mutationFn: async (tableId: string) => { - const res = await fetch( - `/api/table/${tableId}?workspaceId=${encodeURIComponent(workspaceId)}`, - { - method: 'DELETE', - } - ) - - if (!res.ok) { - const error = await res.json() - throw new Error(error.error || 'Failed to delete table') - } - - return res.json() + return requestJson(deleteTableContract, { + params: { tableId }, + query: { workspaceId }, + }) }, onSettled: (_data, _error, tableId) => { queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) @@ -378,22 +327,18 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext) const queryClient = useQueryClient() return useMutation({ - mutationFn: async (variables: { data: Record; position?: number }) => { - const res = await fetch(`/api/table/${tableId}/rows`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId, data: variables.data, position: variables.position }), - }) - - if (!res.ok) { - const error = await res.json().catch(() => ({})) - throw new Error(error.error || 'Failed to add row') + mutationFn: async ( + variables: Omit & { + data: Record } - - return res.json() + ) => { + return requestJson(createTableRowContract, { + params: { tableId }, + body: { workspaceId, data: variables.data as RowData, position: variables.position }, + }) }, onSuccess: (response) => { - const row = (response as { data?: { row?: TableRow } })?.data?.row as TableRow | undefined + const row = response.data.row if (!row) return queryClient.setQueriesData( @@ -419,19 +364,11 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext) }) } -interface BatchCreateTableRowsParams { +type BatchCreateTableRowsParams = Omit & { rows: Array> - positions?: number[] } -interface BatchCreateTableRowsResponse { - success: boolean - data?: { - rows: TableRow[] - insertedCount: number - message: string - } -} +type BatchCreateTableRowsResponse = ContractJsonResponse /** * Batch create rows in a table. Supports optional per-row positions for undo restore. @@ -443,22 +380,14 @@ export function useBatchCreateTableRows({ workspaceId, tableId }: RowMutationCon mutationFn: async ( variables: BatchCreateTableRowsParams ): Promise => { - const res = await fetch(`/api/table/${tableId}/rows`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + return requestJson(batchCreateTableRowsContract, { + params: { tableId }, + body: { workspaceId, - rows: variables.rows, + rows: variables.rows as RowData[], positions: variables.positions, - }), + }, }) - - if (!res.ok) { - const error = await res.json().catch(() => ({})) - throw new Error(error.error || 'Failed to create rows') - } - - return res.json() }, onSettled: () => { invalidateRowCount(queryClient, workspaceId, tableId) @@ -475,18 +404,10 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) return useMutation({ mutationFn: async ({ rowId, data }: UpdateTableRowParams) => { - const res = await fetch(`/api/table/${tableId}/rows/${rowId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId, data }), + return requestJson(updateTableRowContract, { + params: { tableId, rowId }, + body: { workspaceId, data: data as RowData }, }) - - if (!res.ok) { - const error = await res.json().catch(() => ({})) - throw new Error(error.error || 'Failed to update row') - } - - return res.json() }, onMutate: ({ rowId, data }) => { void queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) }) @@ -523,7 +444,7 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) }) } -interface BatchUpdateTableRowsParams { +type BatchUpdateTableRowsParams = Omit & { updates: Array<{ rowId: string; data: Record }> } @@ -535,18 +456,13 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon return useMutation({ mutationFn: async ({ updates }: BatchUpdateTableRowsParams) => { - const res = await fetch(`/api/table/${tableId}/rows`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId, updates }), + return requestJson(batchUpdateTableRowsContract, { + params: { tableId }, + body: { + workspaceId, + updates: updates.map((update) => ({ ...update, data: update.data as RowData })), + }, }) - - if (!res.ok) { - const error = await res.json().catch(() => ({})) - throw new Error(error.error || 'Failed to update rows') - } - - return res.json() }, onMutate: ({ updates }) => { void queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) }) @@ -595,18 +511,10 @@ export function useDeleteTableRow({ workspaceId, tableId }: RowMutationContext) return useMutation({ mutationFn: async (rowId: string) => { - const res = await fetch(`/api/table/${tableId}/rows/${rowId}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId }), + return requestJson(deleteTableRowContract, { + params: { tableId, rowId }, + body: { workspaceId }, }) - - if (!res.ok) { - const error = await res.json().catch(() => ({})) - throw new Error(error.error || 'Failed to delete row') - } - - return res.json() }, onSettled: () => { invalidateRowCount(queryClient, workspaceId, tableId) @@ -625,27 +533,17 @@ export function useDeleteTableRows({ workspaceId, tableId }: RowMutationContext) mutationFn: async (rowIds: string[]): Promise => { const uniqueRowIds = Array.from(new Set(rowIds)) - const res = await fetch(`/api/table/${tableId}/rows`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId, rowIds: uniqueRowIds }), + const response = await requestJson(deleteTableRowsContract, { + params: { tableId }, + body: { workspaceId, rowIds: uniqueRowIds }, }) - const json: { - error?: string - data?: { deletedRowIds?: string[]; missingRowIds?: string[]; requestedCount?: number } - } = await res.json().catch(() => ({})) - - if (!res.ok) { - throw new Error(json.error || 'Failed to delete rows') - } - - const deletedRowIds = json.data?.deletedRowIds || [] - const missingRowIds = json.data?.missingRowIds || [] + const deletedRowIds = response.data.deletedRowIds || [] + const missingRowIds = response.data.missingRowIds || [] if (missingRowIds.length > 0) { const failureCount = missingRowIds.length - const totalCount = json.data?.requestedCount ?? uniqueRowIds.length + const totalCount = response.data.requestedCount ?? uniqueRowIds.length const successCount = deletedRowIds.length const firstMissing = missingRowIds[0] throw new Error( @@ -661,15 +559,7 @@ export function useDeleteTableRows({ workspaceId, tableId }: RowMutationContext) }) } -interface UpdateColumnParams { - columnName: string - updates: { - name?: string - type?: string - required?: boolean - unique?: boolean - } -} +type UpdateColumnParams = Omit /** * Update a column (rename, type change, or constraint update). @@ -679,18 +569,10 @@ export function useUpdateColumn({ workspaceId, tableId }: RowMutationContext) { return useMutation({ mutationFn: async ({ columnName, updates }: UpdateColumnParams) => { - const res = await fetch(`/api/table/${tableId}/columns`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId, columnName, updates }), + return requestJson(updateTableColumnContract, { + params: { tableId }, + body: { workspaceId, columnName, updates }, }) - - if (!res.ok) { - const error = await res.json().catch(() => ({})) - throw new Error(error.error || 'Failed to update column') - } - - return res.json() }, onError: (error) => { toast.error(error.message, { duration: 5000 }) @@ -710,18 +592,10 @@ export function useUpdateTableMetadata({ workspaceId, tableId }: RowMutationCont return useMutation({ mutationFn: async (metadata: TableMetadata) => { - const res = await fetch(`/api/table/${tableId}/metadata`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId, metadata }), + return requestJson(updateTableMetadataContract, { + params: { tableId }, + body: { workspaceId, metadata }, }) - - if (!res.ok) { - const error = await res.json().catch(() => ({})) - throw new Error((error as { error?: string }).error || 'Failed to update metadata') - } - - return res.json() }, onMutate: async (metadata) => { await queryClient.cancelQueries({ queryKey: tableKeys.detail(tableId) }) @@ -756,12 +630,9 @@ export function useRestoreTable() { return useMutation({ mutationFn: async (tableId: string) => { - const res = await fetch(`/api/table/${tableId}/restore`, { method: 'POST' }) - if (!res.ok) { - const data = await res.json().catch(() => ({})) - throw new Error(data.error || 'Failed to restore table') - } - return res.json() + return requestJson(restoreTableContract, { + params: { tableId }, + }) }, onSettled: () => { queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) @@ -786,6 +657,7 @@ export function useUploadCsvToTable() { formData.append('file', file) formData.append('workspaceId', workspaceId) + // 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, @@ -817,7 +689,7 @@ interface ImportCsvIntoTableParams { mapping?: CsvHeaderMapping } -interface ImportCsvIntoTableResponse { +interface ImportCsvIntoTableOutcome { success: boolean data?: { tableId: string @@ -846,7 +718,7 @@ export function useImportCsvIntoTable() { file, mode, mapping, - }: ImportCsvIntoTableParams): Promise => { + }: ImportCsvIntoTableParams): Promise => { const formData = new FormData() formData.append('file', file) formData.append('workspaceId', workspaceId) @@ -855,6 +727,7 @@ export function useImportCsvIntoTable() { formData.append('mapping', JSON.stringify(mapping)) } + // boundary-raw-fetch: multipart/form-data CSV upload, requestJson only supports JSON bodies const response = await fetch(`/api/table/${tableId}/import-csv`, { method: 'POST', body: formData, @@ -882,18 +755,10 @@ export function useDeleteColumn({ workspaceId, tableId }: RowMutationContext) { return useMutation({ mutationFn: async (columnName: string) => { - const res = await fetch(`/api/table/${tableId}/columns`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId, columnName }), + return requestJson(deleteTableColumnContract, { + params: { tableId }, + body: { workspaceId, columnName }, }) - - if (!res.ok) { - const error = await res.json().catch(() => ({})) - throw new Error(error.error || 'Failed to delete column') - } - - return res.json() }, onSettled: () => { invalidateTableSchema(queryClient, workspaceId, tableId) diff --git a/apps/sim/hooks/queries/tasks.test.ts b/apps/sim/hooks/queries/tasks.test.ts index ccfb3bf91c2..0edae3bb93b 100644 --- a/apps/sim/hooks/queries/tasks.test.ts +++ b/apps/sim/hooks/queries/tasks.test.ts @@ -89,9 +89,7 @@ describe('tasks query boundary parsing', () => { }) ) - await expect(fetchTasks('ws-1')).rejects.toThrow( - 'Invalid tasks response: data[0].id must be a string' - ) + await expect(fetchTasks('ws-1')).rejects.toThrow('Response failed contract validation') }) it('parses valid mothership chat history responses', async () => { @@ -169,6 +167,6 @@ describe('tasks query boundary parsing', () => { chatId: 'chat-1', resource: { type: 'file', id: 'file-1', title: 'Spec.md' }, }) - ).rejects.toThrow('Invalid chat resources response: resources must be an array') + ).rejects.toThrow('Response failed contract validation') }) }) diff --git a/apps/sim/hooks/queries/tasks.ts b/apps/sim/hooks/queries/tasks.ts index 5ce71a8ee03..0fed20e871f 100644 --- a/apps/sim/hooks/queries/tasks.ts +++ b/apps/sim/hooks/queries/tasks.ts @@ -1,4 +1,14 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + addMothershipChatResourceContract, + deleteMothershipChatContract, + listMothershipChatsContract, + type MothershipTask, + removeMothershipChatResourceContract, + reorderMothershipChatResourcesContract, + updateMothershipChatContract, +} from '@/lib/api/contracts/mothership-tasks' import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' import { @@ -37,14 +47,6 @@ export const taskKeys = { detail: (chatId: string | undefined) => [...taskKeys.details(), chatId ?? ''] as const, } -interface TaskResponse { - id: string - title: string | null - updatedAt: string - activeStreamId: string | null - lastSeenAt: string | null -} - type ChatHistorySource = 'copilot' | 'mothership' function isRecord(value: unknown): value is Record { @@ -61,10 +63,6 @@ function isNullableString(value: unknown): value is string | null { return value === null || typeof value === 'string' } -function isValidDateString(value: unknown): value is string { - return typeof value === 'string' && !Number.isNaN(Date.parse(value)) -} - function isResourceType(value: unknown): value is MothershipResource['type'] { return ( typeof value === 'string' && @@ -110,46 +108,6 @@ function normalizeMessages(value: unknown): PersistedMessage[] { return value.filter(isRecord).map((message) => normalizeMessage(message)) } -function parseTaskResponse(value: unknown, index: number): TaskResponse { - assertValid(isRecord(value), `Invalid tasks response: data[${index}] must be an object`) - assertValid( - typeof value.id === 'string', - `Invalid tasks response: data[${index}].id must be a string` - ) - assertValid( - isNullableString(value.title), - `Invalid tasks response: data[${index}].title must be a string or null` - ) - assertValid( - isValidDateString(value.updatedAt), - `Invalid tasks response: data[${index}].updatedAt must be a valid date string` - ) - assertValid( - isNullableString(value.activeStreamId), - `Invalid tasks response: data[${index}].activeStreamId must be a string or null` - ) - assertValid( - isNullableString(value.lastSeenAt) && - (value.lastSeenAt === null || isValidDateString(value.lastSeenAt)), - `Invalid tasks response: data[${index}].lastSeenAt must be a valid date string or null` - ) - - return { - id: value.id, - title: value.title, - updatedAt: value.updatedAt, - activeStreamId: value.activeStreamId, - lastSeenAt: value.lastSeenAt, - } -} - -function parseTaskListResponse(value: unknown): TaskResponse[] { - assertValid(isRecord(value), 'Invalid tasks response: body must be an object') - assertValid(Array.isArray(value.data), 'Invalid tasks response: data must be an array') - - return value.data.map((task, index) => parseTaskResponse(task, index)) -} - function parseResource(value: unknown, context: string): MothershipResource { assertValid(isRecord(value), `${context} must be an object`) assertValid(isResourceType(value.type), `${context}.type is invalid`) @@ -219,7 +177,7 @@ function parseChatResourcesResponse(value: unknown): { resources: MothershipReso } } -function mapTask(chat: TaskResponse): TaskMetadata { +function mapTask(chat: MothershipTask): TaskMetadata { const updatedAt = new Date(chat.updatedAt) return { id: chat.id, @@ -236,13 +194,11 @@ export async function fetchTasks( workspaceId: string, signal?: AbortSignal ): Promise { - const response = await fetch(`/api/mothership/chats?workspaceId=${workspaceId}`, { signal }) - - if (!response.ok) { - throw new Error('Failed to fetch tasks') - } - - return parseTaskListResponse(await response.json()).map(mapTask) + const data = await requestJson(listMothershipChatsContract, { + query: { workspaceId }, + signal, + }) + return data.data.map(mapTask) } /** @@ -262,12 +218,14 @@ export async function fetchChatHistory( chatId: string, signal?: AbortSignal ): Promise { + // boundary-raw-fetch: mothership chat GET returns variable shape with optional stream snapshots const mothershipRes = await fetch(`/api/mothership/chats/${chatId}`, { signal }) if (mothershipRes.ok) { return parseChatHistory(await mothershipRes.json(), 'mothership') } + // boundary-raw-fetch: copilot chat fallback returns a different mothership/copilot lifecycle shape const copilotRes = await fetch(`/api/mothership/chat?chatId=${encodeURIComponent(chatId)}`, { signal, }) @@ -293,12 +251,9 @@ export function useChatHistory(chatId: string | undefined) { } async function deleteTask(chatId: string): Promise { - const response = await fetch(`/api/mothership/chats/${chatId}`, { - method: 'DELETE', + await requestJson(deleteMothershipChatContract, { + params: { chatId }, }) - if (!response.ok) { - throw new Error('Failed to delete task') - } } /** @@ -334,14 +289,10 @@ export function useDeleteTasks(workspaceId?: string) { } async function renameTask({ chatId, title }: { chatId: string; title: string }): Promise { - const response = await fetch(`/api/mothership/chats/${chatId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title }), + await requestJson(updateMothershipChatContract, { + params: { chatId }, + body: { title }, }) - if (!response.ok) { - throw new Error('Failed to rename task') - } } /** @@ -378,13 +329,10 @@ async function addChatResource(params: { chatId: string resource: MothershipResource }): Promise<{ resources: MothershipResource[] }> { - const response = await fetch('/api/mothership/chat/resources', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chatId: params.chatId, resource: params.resource }), + const data = await requestJson(addMothershipChatResourceContract, { + body: { chatId: params.chatId, resource: params.resource }, }) - if (!response.ok) throw new Error('Failed to add resource') - return parseChatResourcesResponse(await response.json()) + return parseChatResourcesResponse(data) } export function useAddChatResource(chatId?: string) { @@ -425,13 +373,10 @@ async function reorderChatResources(params: { chatId: string resources: MothershipResource[] }): Promise<{ resources: MothershipResource[] }> { - const response = await fetch('/api/mothership/chat/resources', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chatId: params.chatId, resources: params.resources }), + const data = await requestJson(reorderMothershipChatResourcesContract, { + body: { chatId: params.chatId, resources: params.resources }, }) - if (!response.ok) throw new Error('Failed to reorder resources') - return parseChatResourcesResponse(await response.json()) + return parseChatResourcesResponse(data) } export function useReorderChatResources(chatId?: string) { @@ -468,13 +413,10 @@ async function removeChatResource(params: { resourceType: string resourceId: string }): Promise<{ resources: MothershipResource[] }> { - const response = await fetch('/api/mothership/chat/resources', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(params), + const data = await requestJson(removeMothershipChatResourceContract, { + body: params, }) - if (!response.ok) throw new Error('Failed to remove resource') - return parseChatResourcesResponse(await response.json()) + return parseChatResourcesResponse(data) } export function useRemoveChatResource(chatId?: string) { @@ -511,25 +453,17 @@ export function useRemoveChatResource(chatId?: string) { } async function markTaskRead(chatId: string): Promise { - const response = await fetch(`/api/mothership/chats/${chatId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ isUnread: false }), + await requestJson(updateMothershipChatContract, { + params: { chatId }, + body: { isUnread: false }, }) - if (!response.ok) { - throw new Error('Failed to mark task as read') - } } async function markTaskUnread(chatId: string): Promise { - const response = await fetch(`/api/mothership/chats/${chatId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ isUnread: true }), + await requestJson(updateMothershipChatContract, { + params: { chatId }, + body: { isUnread: true }, }) - if (!response.ok) { - throw new Error('Failed to mark task as unread') - } } /** diff --git a/apps/sim/hooks/queries/templates.ts b/apps/sim/hooks/queries/templates.ts index 3c236c0b8c7..9e3679f7a78 100644 --- a/apps/sim/hooks/queries/templates.ts +++ b/apps/sim/hooks/queries/templates.ts @@ -1,5 +1,23 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type CreateTemplateInput, + createTemplateContract, + deleteTemplateContract, + getTemplateContract, + listTemplatesContract, + starTemplateContract, + type TemplateContractData, + type TemplateCreator, + type TemplateDetailContractResponse, + type TemplateListFilters, + type TemplatesContractResponse, + type UpdateTemplateInput as UpdateTemplateContractInput, + unstarTemplateContract, + updateTemplateContract, +} from '@/lib/api/contracts/templates' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('TemplateQueries') @@ -13,143 +31,61 @@ export const templateKeys = { byWorkflow: (workflowId?: string) => [...templateKeys.byWorkflows(), workflowId ?? ''] as const, } -export interface TemplateListFilters { - search?: string - status?: 'pending' | 'approved' | 'rejected' - workflowId?: string - limit?: number - offset?: number - includeAllStatuses?: boolean -} +export type { TemplateCreator, TemplateListFilters } -export interface TemplateCreator { - id: string - name: string - referenceType: 'user' | 'organization' - referenceId: string - email?: string - website?: string - profileImageUrl?: string | null - verified?: boolean - details?: { - about?: string - xUrl?: string - linkedinUrl?: string - websiteUrl?: string - contactEmail?: string - } | null - createdAt: string - updatedAt: string -} +type TemplateApi = TemplateContractData -export interface Template { - id: string - workflowId: string - name: string - details?: { - tagline?: string - about?: string - } - creatorId?: string - creator?: TemplateCreator - views: number - stars: number - status: 'pending' | 'approved' | 'rejected' - tags: string[] - requiredCredentials: Record - state: any - createdAt: string - updatedAt: string - isStarred?: boolean - isSuperUser?: boolean +export interface Template extends Omit { + state: WorkflowState } -export interface TemplatesResponse { +export interface TemplateListData extends Omit { data: Template[] - pagination: { - total: number - limit: number - offset: number - page: number - totalPages: number - } } -export interface TemplateDetailResponse { +export interface TemplateDetailData extends Omit { data: Template } -export interface CreateTemplateInput { - workflowId: string - name: string - details?: { - tagline?: string - about?: string - } - creatorId?: string - tags?: string[] -} - -export interface UpdateTemplateInput { - name?: string - details?: { - tagline?: string - about?: string - } - creatorId?: string - tags?: string[] - updateState?: boolean -} +export type { CreateTemplateInput } +export type UpdateTemplateInput = Omit async function fetchTemplates( filters?: TemplateListFilters, signal?: AbortSignal -): Promise { - const params = new URLSearchParams() - - if (filters?.search) params.set('search', filters.search) - if (filters?.status) params.set('status', filters.status) - if (filters?.workflowId) params.set('workflowId', filters.workflowId) - if (filters?.includeAllStatuses) params.set('includeAllStatuses', 'true') - params.set('limit', (filters?.limit ?? 50).toString()) - params.set('offset', (filters?.offset ?? 0).toString()) - - const response = await fetch(`/api/templates?${params.toString()}`, { signal }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || 'Failed to fetch templates') - } +): Promise { + const response = await requestJson(listTemplatesContract, { + query: { + search: filters?.search, + status: filters?.status, + workflowId: filters?.workflowId, + includeAllStatuses: filters?.includeAllStatuses, + limit: filters?.limit, + offset: filters?.offset, + }, + signal, + }) - return response.json() + return response as TemplateListData } async function fetchTemplate( templateId: string, signal?: AbortSignal -): Promise { - const response = await fetch(`/api/templates/${templateId}`, { signal }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || 'Failed to fetch template') - } +): Promise { + const response = await requestJson(getTemplateContract, { + params: { id: templateId }, + signal, + }) - return response.json() + return response as TemplateDetailData } async function fetchTemplateByWorkflow( workflowId: string, signal?: AbortSignal ): Promise