diff --git a/.agents/skills/add-integration/SKILL.md b/.agents/skills/add-integration/SKILL.md index ee7d85e8f0e..25983f357fd 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 } 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,9 @@ 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) + const parsed = await parseRequest({service}UploadContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body let fileBuffer: Buffer let fileName: string @@ -624,22 +650,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/.agents/skills/emcn-design-review/SKILL.md b/.agents/skills/emcn-design-review/SKILL.md index 415a85046dd..04e39da1f8e 100644 --- a/.agents/skills/emcn-design-review/SKILL.md +++ b/.agents/skills/emcn-design-review/SKILL.md @@ -234,7 +234,7 @@ Use for context menus and action menus: @@ -281,19 +281,21 @@ Rules: - Stack multiple skeletons for lists ### Icons -Standard sizing — `h-[14px] w-[14px]` is the dominant pattern (400+ uses): +Standard sizing — use the `size-*` shorthand. `size-[14px]` is the dominant pattern: ```tsx - + ``` -Size scale by frequency: -1. `h-[14px] w-[14px]` — default for inline icons (most common) -2. `h-[16px] w-[16px]` — slightly larger inline icons -3. `h-3 w-3` (12px) — compact/tight spaces -4. `h-4 w-4` (16px) — Tailwind equivalent, also common -5. `h-3.5 w-3.5` (14px) — Tailwind equivalent of 14px -6. `h-5 w-5` (20px) — larger icons, section headers +Always prefer `size-*` over the legacy `h-* w-*` pair. `size-[14px]` is canonical; treat any `h-[Npx] w-[Npx]` or `h-N w-N` pair as a refactor target. + +Size scale (most common first): +1. `size-[14px]` — default for inline icons +2. `size-[16px]` — slightly larger inline icons +3. `size-3` (12px) — compact/tight spaces +4. `size-4` (16px) — Tailwind equivalent +5. `size-3.5` (14px) — Tailwind equivalent of 14px +6. `size-5` (20px) — larger icons, section headers Use `text-[var(--text-icon)]` for icon color (113+ uses in codebase). @@ -332,4 +334,5 @@ Use `text-[var(--text-icon)]` for icon color (113+ uses in codebase). - Importing from emcn subpaths instead of barrel export - Using arbitrary z-index (`z-50`, `z-[9999]`) instead of z-index tokens - Custom shadows instead of shadow tokens -- Icon sizes that don't follow the established scale (default to `h-[14px] w-[14px]`) +- Icon sizes that don't follow the established scale (default to `size-[14px]`) +- Splitting equal height/width into `h-* w-*` pairs instead of the `size-*` shorthand diff --git a/.agents/skills/memory-load-check/SKILL.md b/.agents/skills/memory-load-check/SKILL.md new file mode 100644 index 00000000000..5a46fce78ca --- /dev/null +++ b/.agents/skills/memory-load-check/SKILL.md @@ -0,0 +1,138 @@ +--- +name: memory-load-check +description: Review PRs and diffs for unbounded memory loading, concurrency explosions, oversized payload materialization, and missing pagination or byte caps. Use when reviewing cleanup jobs, background jobs, data imports/exports, file parsing, API fan-out, workflow execution payloads, large arrays/files, or any change that reads many rows, files, responses, logs, or external API pages into process memory. +--- + +# Memory Load Check + +Use this skill when a PR or diff could load unbounded data into a Node/Bun process, especially in cron routes, background tasks, API routes, workflow execution, file parsing, cleanup jobs, migrations, import/export flows, and external API integrations. + +## Review Goal + +Prove each changed path has explicit bounds for: +- rows held in memory +- bytes held in memory +- concurrent promises, DB queries, HTTP calls, storage operations, and jobs +- number of pages, batches, chunks, retries, and retained intermediate objects + +If any bound depends only on current production size or "probably small" data, treat it as a finding. + +## References + +Read these when doing a deeper pass: +- Node.js streams/backpressure: https://nodejs.org/learn/modules/backpressuring-in-streams +- Node.js stream usage: https://nodejs.org/en/learn/modules/how-to-use-streams +- Keyset/cursor pagination over offset scans: https://blog.sequinstream.com/keyset-cursors-not-offsets-for-postgres-pagination/ +- Postgres pagination tradeoffs: https://www.citusdata.com/blog/2016/03/30/five-ways-to-paginate/ + +## Sim Helpers To Prefer + +- `apps/sim/lib/cleanup/batch-delete.ts` + - `chunkedBatchDelete`: bounded SELECT -> optional side effect -> DELETE loop. + - `batchDeleteByWorkspaceAndTimestamp`: common workspace/timestamp cleanup wrapper. + - `selectRowsByIdChunks`: chunks large ID sets and enforces an overall row cap. + - `chunkArray`: use only after the input set itself is already bounded. +- `apps/sim/lib/core/utils/stream-limits.ts` + - `PayloadSizeLimitError` + - `assertKnownSizeWithinLimit` + - `assertContentLengthWithinLimit` + - `readStreamToBufferWithLimit` + - `readNodeStreamToBufferWithLimit` + - `readResponseToBufferWithLimit` + - `readResponseTextWithLimit` +- Cleanup dispatcher pattern in `apps/sim/lib/billing/cleanup-dispatcher.ts` + - page active workspaces with `WHERE id > afterId ORDER BY id LIMIT N` + - dispatch concrete chunks (`workspaceIds`, retention, label) instead of one giant scope + - prefer Trigger.dev queue/concurrency keys when available + - execute inline fallback chunks sequentially, not with unbounded `Promise.all` +- File parse route pattern in `apps/sim/app/api/files/parse/route.ts` + - cap downloads and parsed output separately + - preserve partial results when a later item exceeds the cap + - never read untrusted response bodies without a byte cap +- Large workflow value payloads + - prefer durable references/manifests over inlining large arrays or files + - materialize refs only behind an explicit byte budget + +## Review Workflow + +1. Identify every changed data source: + - database queries + - storage lists/downloads/uploads + - external API pagination + - file reads and HTTP responses + - workflow logs, snapshots, payloads, arrays, and manifests + - queues, cron routes, and background jobs +2. For each source, write down the maximum cardinality and maximum bytes. If the code does not enforce one, it is unbounded. +3. Trace whether data is processed incrementally or accumulated: + - arrays from `select`, `findMany`, `Promise.all`, `map`, `filter`, `flatMap` + - maps/sets keyed by all users, workspaces, executions, files, or rows + - `Buffer.concat`, `response.arrayBuffer()`, `response.text()`, `JSON.stringify`, `JSON.parse` + - queues of promises or job payloads built before dispatch +4. Check concurrency separately from memory: + - no `Promise.all(items.map(...))` unless `items` is already small and bounded + - use chunks, sequential loops, queue concurrency, or a concurrency limiter + - align concurrency with DB pool size, storage/API limits, and task queue semantics +5. Verify SQL shape: + - every bulk query has `LIMIT` + - large pagination uses cursor/keyset style (`id > afterId`, timestamps plus unique ID), not deep `OFFSET` + - `IN (...)` lists are chunked + - side-effect rows selected before delete have per-batch and per-run caps +6. Verify byte safety: + - check `Content-Length` when available + - stream with cumulative byte accounting + - cap both input bytes and expanded output bytes + - reject or reference oversized values before serializing large JSON responses +7. Confirm failure behavior: + - exceeding a cap should stop before loading more data + - partial successful work should be preserved when the API contract expects it + - retries should not duplicate huge in-memory state + - cleanup jobs should make progress over future runs instead of widening one run + +## Red Flags + +- loads all active workspaces, users, executions, logs, files, messages, or subscriptions before filtering +- builds a full `Map` or `Set` for a platform-wide scope +- uses `Promise.all` over rows from an unbounded query +- fetches all pages from an external API before processing +- reads an entire file, HTTP response, or stream without a max byte budget +- checks size only after `Buffer.concat`, `arrayBuffer`, `text`, `JSON.parse`, or parse expansion +- chunks only after loading the complete dataset +- paginates with unbounded/deep `OFFSET` on a mutable or large table +- creates one queue job per row without batching or a queue-level concurrency key +- accumulates per-row errors/results with no maximum +- adds a cache, singleton, or module-level collection without eviction or size limits + +## Preferred Fixes + +- Move filters into SQL/API requests and select only needed columns. +- Replace full-table loads with cursor/keyset pagination and a deterministic order. +- Process one page/batch at a time; do not keep previous pages unless needed. +- Add per-batch and per-run row caps so long backlogs drain across repeated jobs. +- Split large ID lists with `selectRowsByIdChunks` or `chunkArray` after bounding the source. +- Use `chunkedBatchDelete` for cleanup loops with row side effects. +- Use stream-limit helpers for file/HTTP/body reads. +- Store large workflow values as refs/manifests and materialize only within a caller budget. +- Replace unbounded `Promise.all` with sequential chunk loops, queue concurrency, or a small limiter. +- Include tests that prove caps stop work early and partial results or progress are preserved. + +## Findings Format + +Lead with concrete findings, ordered by risk: + +```markdown +## Findings + +- **P1 Unbounded workspace load in cleanup dispatch** (`path/to/file.ts`) + The new path calls `select().from(workspace)` without a limit, then builds maps for every row before dispatch. In production this scales with all active workspaces and can exhaust the app process. Page by `workspace.id` with a fixed limit and dispatch bounded chunks. + +## Good Signals + +- Uses `readResponseToBufferWithLimit` for external downloads. +- Inline fallback processes chunks sequentially. + +## Residual Risk + +- The row cap is explicit, but no test currently proves the loop stops at the cap. +``` + +Only say "good to go" when every changed source has explicit row, byte, and concurrency bounds or the boundedness is proven by a stable invariant. diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md new file mode 100644 index 00000000000..85fefb30ea6 --- /dev/null +++ b/.agents/skills/ship/SKILL.md @@ -0,0 +1,83 @@ +--- +name: ship +description: Commit, push, and open a PR to staging in one shot +--- + +# Ship Command + +You help ship code by creating commits, pushing to the remote branch, and creating PRs in the user's voice. + +## Your Task + +When the user runs `/ship`: + +1. **Check git status** - See what files have changed +2. **Generate a commit message** following this format: `type(scope): description` + - Types: `fix`, `feat`, `improvement`, `chore` + - Scope: short identifier (e.g., `undo-redo`, `api`, `ui`) + - Keep it concise +3. **Run pre-ship checks** from the repo root before staging: + - `bun run lint` to fix formatting issues + - `bun run check:api-validation:strict` to catch boundary contract failures before CI +4. **Stage and commit** the changes with the generated message +5. **Push to origin** using the current branch name +6. **Create a PR** to staging with a description in the user's voice + +## Commit Message Format + +Based on the repo's commit history: + +``` +fix(scope): description for bug fixes +feat(scope): description for new features +improvement(scope): description for enhancements +chore(scope): description for maintenance +``` + +## PR Description Format + +Use this exact template in the user's voice (concise, bullet points): + +```markdown +## Summary +- bullet point describing what changed +- another bullet point if needed + +## Type of Change +- [x] Bug fix (or appropriate type) + +## Testing +Tested manually (or describe testing) + +## Checklist +- [x] Code follows project style guidelines +- [x] Self-reviewed my changes +- [ ] Tests added/updated and passing +- [x] No new warnings introduced +- [x] I confirm that I have read and agree to the terms outlined in the [Contributor License Agreement (CLA)](./CONTRIBUTING.md#contributor-license-agreement-cla) +``` + +## PR Creation Command + +Use this command structure: + +```bash +gh pr create --base staging --title "COMMIT_MESSAGE" --body "PR_BODY" +``` + +## Important Notes + +- Always confirm the commit message and PR description with the user before executing +- The PR should be created against `staging` branch +- Keep descriptions concise and in active voice +- Match the user's previous PR style: direct, no fluff, bullet points +- **DO NOT add "Co-Authored-By" lines to commits** - keep commit messages clean + +## User's Voice Characteristics (based on previous PRs) + +- Short, direct bullet points +- No unnecessary explanation +- "Tested manually" is acceptable for testing section; include lint and boundary validation results when run +- Checkboxes filled in appropriately +- No screenshots section unless UI changes + diff --git a/.agents/skills/validate-integration/SKILL.md b/.agents/skills/validate-integration/SKILL.md index d8d243c5012..7a5ea8e7caf 100644 --- a/.agents/skills/validate-integration/SKILL.md +++ b/.agents/skills/validate-integration/SKILL.md @@ -232,13 +232,23 @@ If any tools support pagination: - [ ] Pagination response fields (`nextToken`, `cursor`, etc.) are included in tool outputs - [ ] Pagination subBlocks are set to `mode: 'advanced'` -## Step 7: Validate Error Handling +## Step 7: Validate Memory Load Safety + +If any tool lists, searches, exports, imports, downloads, uploads, paginates, batches, transforms arrays, or reads file/HTTP bodies, read `.agents/skills/memory-load-check/SKILL.md` and apply it to the integration. + +- [ ] List/search tools expose API limits and do not auto-fetch every page into memory +- [ ] Transform logic does not build unbounded arrays, maps, sets, or `Promise.all` fan-outs +- [ ] File and HTTP body reads use explicit byte caps or existing stream-limit helpers +- [ ] Large result payloads are summarized, paginated, referenced, or capped rather than raw-dumped +- [ ] Pagination and download tests cover caps, early stop behavior, or partial-result preservation when relevant + +## Step 8: Validate Error Handling - [ ] `transformResponse` checks for error conditions before accessing data - [ ] Error responses include meaningful messages (not just generic "failed") - [ ] HTTP error status codes are handled (check `response.ok` or status codes) -## Step 8: Report and Fix +## Step 9: Report and Fix ### Report Format @@ -297,6 +307,7 @@ After fixing, confirm: - [ ] Validated OAuth scopes use centralized utilities (getScopesForService, getCanonicalScopesForProvider) — no hardcoded arrays - [ ] Validated scope descriptions exist in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for all scopes - [ ] Validated pagination consistency across tools and block +- [ ] Validated memory load safety using `.agents/skills/memory-load-check/SKILL.md` when tools list/search/download/import/export/batch data - [ ] Validated error handling (error checks, meaningful messages) - [ ] Validated registry entries (tools and block, alphabetical, correct imports) - [ ] Reported all issues grouped by severity diff --git a/.claude/commands/add-model.md b/.claude/commands/add-model.md new file mode 100644 index 00000000000..1fcf828537c --- /dev/null +++ b/.claude/commands/add-model.md @@ -0,0 +1,159 @@ +--- +description: Add a new LLM model to apps/sim/providers/models.ts with specs verified against the provider's live API docs (no hallucination) +argument-hint: [docs-url] +--- + +# Add Model Skill + +You add a new model entry to `apps/sim/providers/models.ts`. **Every numeric and capability claim MUST be derived from a live web fetch of the provider's official docs in this session.** Marketing emails, training data, and your prior knowledge are not sources of truth — they routinely hallucinate pricing, context windows, and capability lists. + +## Hard rules (do not skip) + +1. **Live-fetch or refuse.** Before writing the entry, you must successfully WebFetch the provider's official models/pricing page in this session. If you cannot reach an authoritative source for any field, **mark the field as UNVERIFIED in your report and ask the user before guessing**. Never fill in pricing or capabilities from memory. +2. **Two-source rule for pricing.** Cross-check input/output/cached pricing against at least one secondary source (OpenRouter, Artificial Analysis, CloudPrice, mem0, intuitionlabs). If sources disagree, the provider's own docs win — but flag the disagreement. +3. **Read the code before setting capability flags.** Capability flags are dead unless the provider's implementation under `apps/sim/providers/{provider}/` actually consumes them (see Consumption Matrix below). Setting a flag the provider ignores is a silent bug. +4. **Cite every fact.** Your final report must list the URL each value came from. No URL → not verified. + +## Your Task + +1. Identify provider and model id from user args +2. Live-fetch official docs + pricing page + capability/parameter pages + at least one secondary source +3. Apply the Consumption Matrix to know which capability flags are real +4. Read 2-3 sibling entries in `models.ts` and match their pattern exactly +5. Insert the entry, run `bun run lint`, print the verification report + +## Step 1: Live source-of-truth lookup + +In priority order — fetch all that exist for the provider: + +| Provider | Models index | Pricing | Reasoning/parameter caveats | +|---|---|---|---| +| OpenAI | platform.openai.com/docs/models | openai.com/api/pricing | platform.openai.com/docs/guides/reasoning | +| Anthropic | docs.anthropic.com/en/docs/about-claude/models | anthropic.com/pricing | docs.anthropic.com/en/docs/build-with-claude/extended-thinking | +| Google (Gemini) | ai.google.dev/gemini-api/docs/models | ai.google.dev/pricing | ai.google.dev/gemini-api/docs/thinking | +| xAI | docs.x.ai/developers/models | docs.x.ai/developers/models (per-model detail page) | docs.x.ai/developers/model-capabilities/text/reasoning | +| Mistral | docs.mistral.ai/getting-started/models/models_overview | mistral.ai/pricing | n/a | +| DeepSeek | api-docs.deepseek.com/quick_start/pricing | same | api-docs.deepseek.com/guides/reasoning_model | +| Groq | console.groq.com/docs/models | groq.com/pricing | n/a | +| Cerebras | inference-docs.cerebras.ai/models | cerebras.ai/pricing | n/a | + +Secondary verification (use at least one): `openrouter.ai//`, `artificialanalysis.ai/models/`, `cloudprice.net/models/-`. + +Use a precise WebFetch prompt: *"Extract for {model_id}: exact model id string, context window in tokens, input price per 1M, cached input price per 1M, output price per 1M, max output tokens, supported reasoning effort levels, accepted parameters (temperature, top_p), release date. Do not fill in fields you cannot find."* + +## Step 2: Consumption Matrix (which provider honors which capability) + +| Capability | Honored by | Effect if set elsewhere | +|---|---|---| +| `temperature` | All providers (passed through if set) | Safe but inert on always-reasoning models that reject it | +| `toolUsageControl` | All providers (provider-level, not per-model) | n/a — set on `ProviderDefinition`, not models | +| `reasoningEffort` | `openai/core.ts`, `azure-openai`, `anthropic/core.ts` (mapped to thinking), `gemini/core.ts` | **Dead on xai, deepseek, mistral, groq, cerebras, openrouter, fireworks, bedrock, vertex** unless their core consumes it — re-grep before assuming | +| `verbosity` | `openai/core.ts`, `azure-openai/index.ts` only | Dead elsewhere | +| `thinking` | `anthropic/core.ts`, `gemini/core.ts` | Dead elsewhere | +| `nativeStructuredOutputs` | `anthropic/core.ts`, `fireworks/index.ts`, `openrouter/index.ts` | Dead on openai, xai, google, vertex, bedrock, azure-openai, deepseek, mistral, groq, cerebras | +| `maxOutputTokens` | Read by UI + executor for token estimation | Always meaningful — set if provider documents a cap | +| `computerUse` | `anthropic/core.ts` | Dead elsewhere | +| `deepResearch` | UI flag for routing to deep-research SKUs | Set only on actual deep-research model IDs | +| `memory: false` | Conversation persistence opt-out | Set only when model genuinely cannot maintain history (e.g., deep-research) | + +**Always re-grep before relying on this table** — the codebase moves: + +```bash +rg "reasoningEffort|reasoning_effort" apps/sim/providers// +rg "verbosity" apps/sim/providers// +rg "request\.thinking|thinking:" apps/sim/providers// +rg "supportsNativeStructuredOutputs|nativeStructuredOutputs" apps/sim/providers// +``` + +## Step 3: Match the provider's existing entry pattern + +Open `apps/sim/providers/models.ts`, find `PROVIDER_DEFINITIONS[].models`, read 2-3 sibling entries. Match field order exactly: + +```ts +{ + id: '', + pricing: { + input: , + cachedInput: , // omit if provider doesn't offer caching + output: , + updatedAt: '', + }, + capabilities: { + // only flags the provider actually consumes — see matrix + }, + contextWindow: , + releaseDate: '', + recommended: true, // only if new flagship; ask user before swapping + speedOptimized: true, // only on smallest/fastest tier + deprecated: true, // only on retired models +} +``` + +### Reseller providers (azure-openai, azure-anthropic, vertex, bedrock, openrouter) + +Model id MUST be prefixed: `azure/`, `azure-anthropic/`, `vertex/`, `bedrock/`, `openrouter/`. Pricing usually mirrors the upstream provider but verify on the reseller's own pricing page. + +### Insertion order + +Within a family, newest first (matches existing convention: GPT-5.5 above GPT-5.4 above GPT-5.2). Across families, biggest/flagship at top of list. + +### `recommended` / `speedOptimized` + +- At most one or two `recommended: true` per provider — the current flagship(s). +- If you're adding a new flagship, ask the user before removing `recommended` from the previous flagship. Never silently flip it. +- `speedOptimized: true` only on the smallest/fastest tier (nano, flash-lite, haiku class). + +## Step 4: Write, lint + +```bash +bun run lint +``` + +Lint must pass before reporting done. **If lint fails:** read the error, fix the syntax/typing issue in the entry you just wrote (do not delete the entry — it's the work product), re-run lint, and note the fix in a "Lint adjustments" line in the verification report. Never report done with lint failing. + +## Step 5: Verification report (mandatory format) + +End with this exact structure: + +```markdown +### Verification — + +| Field | Value | Source URL | Status | +|---|---|---|---| +| `id` | `grok-4.3` | https://docs.x.ai/... | ✓ verified | +| `contextWindow` | 1,000,000 | https://docs.x.ai/... + https://openrouter.ai/... | ✓ verified (2 sources agree) | +| `input` | $1.25/M | https://docs.x.ai/... | ✓ verified | +| `cachedInput` | $0.20/M | https://cloudprice.net/... | ⚠️ single source | +| `output` | $2.50/M | https://docs.x.ai/... + https://openrouter.ai/... | ✓ verified | +| `capabilities.temperature` | `{ min: 0, max: 1 }` | matches sibling entries | — pattern-match only | +| `capabilities.reasoningEffort` | NOT SET | provider docs say API rejects it for this model | ✓ correctly omitted | +| `releaseDate` | 2026-04-30 | https://docs.x.ai/... announcement | ✓ verified | + +**Disagreements** +- _none_ OR _OpenRouter says X, provider docs say Y — used Y per provider rule_ + +**Unverified fields** +- _none_ OR _: could not find authoritative source — left as based on sibling pattern; please confirm_ +``` + +If any row is ⚠️ single-source or "unverified," **state it plainly to the user and ask whether to proceed**. Do not silently merge. + +## What to do if you cannot find a source + +Omitting a field is **not the same as verifying it**. Any field you cannot confirm from a live fetch must be **both** omitted from the entry **and** listed as ❓ UNVERIFIED in the report's "Unverified fields" section, with the URLs you attempted. Then ask the user to confirm before merging. + +- Pricing missing → do NOT guess. Omit `cachedInput`. Mark ❓ UNVERIFIED. Ask the user for the price or the docs URL. +- Context window missing → do NOT guess. Ask the user; mark ❓ UNVERIFIED. +- Release date missing → omit the field; mark ❓ UNVERIFIED in the report. +- Capability uncertain → omit the flag (safer than setting a dead/wrong one); mark ❓ UNVERIFIED so the user knows you didn't confirm it either way. + +## Anti-patterns this skill exists to prevent + +- ❌ Trusting a marketing email (xAI's grok-4.3 email claimed "3 reasoning efforts" but the API rejects `reasoning_effort` — verified by official docs only) +- ❌ Setting `nativeStructuredOutputs: true` on xai/openai/google (dead — only anthropic/fireworks/openrouter consume it) +- ❌ Setting `thinking` on non-Anthropic/non-Gemini providers +- ❌ Setting `verbosity` on anything other than OpenAI gpt-5.x +- ❌ Copying `pricing.updatedAt` from a sibling instead of using today's date +- ❌ Inventing a `cachedInput` price by dividing input by 4 (varies by provider — find an explicit number) +- ❌ Stamping `recommended: true` on the new model without removing it from the previous flagship +- ❌ Reporting "done" with any UNVERIFIED row in the table diff --git a/.claude/commands/emcn-design-review.md b/.claude/commands/emcn-design-review.md index ecfad5bcc20..e1e3860ecc7 100644 --- a/.claude/commands/emcn-design-review.md +++ b/.claude/commands/emcn-design-review.md @@ -65,7 +65,7 @@ Modal `size="sm"`, title "Delete/Remove {ItemType}", `variant="destructive"` act ## Icons -Default: `h-[14px] w-[14px]` (400+ uses). Color: `text-[var(--text-icon)]`. Scale: 14px > 16px > 12px > 20px. +Default: `size-[14px]`. Color: `text-[var(--text-icon)]`. Scale: 14px > 16px > 12px > 20px. Use the `size-*` shorthand — flag `h-[Npx] w-[Npx]` and `h-N w-N` pairs as refactor targets. ## Anti-patterns to flag diff --git a/.claude/commands/ship.md b/.claude/commands/ship.md new file mode 100644 index 00000000000..6141fa01c1a --- /dev/null +++ b/.claude/commands/ship.md @@ -0,0 +1,84 @@ +--- +description: Commit, push, and open a PR to staging in one shot +argument-hint: [optional context or scope notes] +--- + +# Ship Command + +You help ship code by creating commits, pushing to the remote branch, and creating PRs in the user's voice. + +## Your Task + +When the user runs `/ship`: + +1. **Check git status** - See what files have changed +2. **Generate a commit message** following this format: `type(scope): description` + - Types: `fix`, `feat`, `improvement`, `chore` + - Scope: short identifier (e.g., `undo-redo`, `api`, `ui`) + - Keep it concise + +3. **Run pre-ship checks** from the repo root before staging: + - `bun run lint` to fix formatting issues + - `bun run check:api-validation:strict` to catch boundary contract failures before CI + +4. **Stage and commit** the changes with the generated message + +5. **Push to origin** using the current branch name + +6. **Create a PR** to staging with a description in the user's voice + +## Commit Message Format + +Based on the repo's commit history: +``` +fix(scope): description for bug fixes +feat(scope): description for new features +improvement(scope): description for enhancements +chore(scope): description for maintenance +``` + +## PR Description Format + +Use this exact template in the user's voice (concise, bullet points): + +```markdown +## Summary +- bullet point describing what changed +- another bullet point if needed + +## Type of Change +- [x] Bug fix (or appropriate type) + +## Testing +Tested manually (or describe testing) + +## Checklist +- [x] Code follows project style guidelines +- [x] Self-reviewed my changes +- [ ] Tests added/updated and passing +- [x] No new warnings introduced +- [x] I confirm that I have read and agree to the terms outlined in the [Contributor License Agreement (CLA)](./CONTRIBUTING.md#contributor-license-agreement-cla) +``` + +## PR Creation Command + +Use this command structure: +```bash +gh pr create --base staging --title "COMMIT_MESSAGE" --body "PR_BODY" +``` + +## Important Notes + +- Always confirm the commit message and PR description with the user before executing +- The PR should be created against `staging` branch +- Keep descriptions concise and in active voice +- Match the user's previous PR style: direct, no fluff, bullet points +- **DO NOT add "Co-Authored-By" lines to commits** - keep commit messages clean + +## User's Voice Characteristics (based on previous PRs) + +- Short, direct bullet points +- No unnecessary explanation +- "Tested manually" is acceptable for testing section; include lint and boundary validation results when run +- Checkboxes filled in appropriately +- No screenshots section unless UI changes diff --git a/.claude/commands/validate-model.md b/.claude/commands/validate-model.md new file mode 100644 index 00000000000..10c6aaa0b27 --- /dev/null +++ b/.claude/commands/validate-model.md @@ -0,0 +1,166 @@ +--- +description: Validate a model entry (or every model in a provider) in apps/sim/providers/models.ts against the provider's live API docs (no hallucination — reports what cannot be verified) +argument-hint: [model-id] +--- + +# Validate Model Skill + +You audit one or more model entries in `apps/sim/providers/models.ts` against the provider's official live API docs. **Hallucinated pricing and capabilities are the #1 failure mode in this file.** Every numeric and capability claim must be re-derived from a live web fetch in this session — not from memory, not from training data, not from the user's marketing email. + +## Hard rules (do not skip) + +1. **Live-fetch or report unverified.** Each field must be backed by a live WebFetch in this session. If you cannot reach an authoritative URL for a field, mark it **UNVERIFIED** in the report — do not silently confirm it from memory. +2. **Cite every fact.** Every value in the report must show the source URL it was checked against. No URL → mark UNVERIFIED. +3. **Two-source rule for pricing.** Cross-check input/output/cached against at least one secondary source (OpenRouter, Artificial Analysis, CloudPrice). If sources disagree, the provider's own docs win — flag the disagreement. +4. **Inspect provider implementation before flagging capability mismatches.** A capability flag in `models.ts` is dead unless the provider's code under `apps/sim/providers/{provider}/` consumes it (see Consumption Matrix below). Setting a flag the provider ignores is a warning, not a critical. +5. **Never auto-fix without printing the diff.** Show the user the proposed diff before applying. Get confirmation. + +## Your Task + +When invoked as `/validate-model [model-id]`: + +1. Read the target entries from `models.ts` +2. Live-fetch the provider's official models, pricing, and capability/reasoning pages + at least one secondary source for pricing +3. Inspect the provider implementation to know which flags are actually consumed +4. Run the checklist below per model +5. Report findings (critical / warning / suggestion / unverified) with every cell linked to its source URL +6. Offer to fix; on confirm, edit `models.ts` in a single pass and re-lint + +If `model-id` is omitted, validate every model in the provider. + +## Step 1: Read entries from `models.ts` + +Capture per model: `id`, full `pricing`, full `capabilities`, `contextWindow`, `releaseDate`, `recommended`, `speedOptimized`, `deprecated`. + +## Step 2: Live-fetch authoritative sources + +Use the canonical provider URL table in `add-model.md` (Step 1) as the single source of truth — fetch the models index, pricing, and reasoning/parameter caveats pages listed there for the target provider. If you update one table, update the other in the same change. + +Secondary cross-check (use at least one): OpenRouter, Artificial Analysis, CloudPrice. + +If a fetch fails (404, timeout, paywall), record the URL attempted and mark dependent fields UNVERIFIED. + +## Step 3: Build the consumption map for this provider + +Re-grep before trusting the snapshot below: + +```bash +rg "reasoningEffort|reasoning_effort" apps/sim/providers// +rg "verbosity" apps/sim/providers// +rg "request\.thinking|thinking:" apps/sim/providers// +rg "supportsNativeStructuredOutputs|nativeStructuredOutputs" apps/sim/providers// +``` + +Snapshot (verify before relying): + +| Capability | Consumed by | +|---|---| +| `reasoningEffort` | `openai/core.ts`, `azure-openai`, `anthropic/core.ts` (mapped via thinking), `gemini/core.ts` | +| `verbosity` | `openai/core.ts`, `azure-openai/index.ts` | +| `thinking` | `anthropic/core.ts`, `gemini/core.ts` | +| `nativeStructuredOutputs` | `anthropic/core.ts`, `fireworks/index.ts`, `openrouter/index.ts` | +| `computerUse` | `anthropic/core.ts` | +| `temperature` | All providers (passthrough) | + +A flag set in `models.ts` but not in the consumption list for this provider = **warning: dead flag**. + +## Step 4: Run the checklist + +For each model, evaluate every row. Statuses: ✓ matches docs, ✗ disagrees, ⚠️ single-source, ❓ UNVERIFIED (could not fetch). + +### Identity +- [ ] `id` exactly matches provider's API model identifier (case, dots, dashes, prefix for resellers) +- [ ] `releaseDate` matches launch announcement +- [ ] `deprecated: true` set if provider has announced retirement (or removed from active list) + +### Pricing (per 1M tokens, USD) +- [ ] `pricing.input` matches provider pricing page +- [ ] `pricing.output` matches provider pricing page +- [ ] `pricing.cachedInput` matches provider's documented cached/prompt-cache rate (or is correctly omitted if no caching offered) +- [ ] `pricing.updatedAt` is recent — warn if older than 60 days + +### Context & output limits +- [ ] `contextWindow` matches docs (in tokens) +- [ ] `capabilities.maxOutputTokens` matches documented output cap (or is correctly omitted if "no output limit") + +### Capabilities (each must be DOCUMENTED-AS-SUPPORTED **and** CONSUMED-BY-PROVIDER-CODE) +- [ ] `temperature` — provider accepts it for this model (reasoning-always-on models often reject) +- [ ] `reasoningEffort.values` — list matches docs; **omitted** for always-reasoning models that reject the parameter (e.g., grok-4.3, where xAI docs explicitly state `reasoning_effort` is not supported). Verify per model — some always-reasoning models (e.g., OpenAI's o-series) DO accept `reasoning_effort` and should keep the flag. +- [ ] `verbosity.values` — only on OpenAI gpt-5.x family; values match docs +- [ ] `thinking.levels` + `thinking.default` — only on Anthropic/Gemini; values match docs +- [ ] `nativeStructuredOutputs` — only on anthropic/fireworks/openrouter; provider must document Structured Outputs / JSON-mode for this model +- [ ] `toolUsageControl` — provider supports `tool_choice` semantics +- [ ] `computerUse` — provider implements computer-use loop AND model is a computer-use SKU +- [ ] `deepResearch` — only on actual deep-research SKUs +- [ ] `memory: false` — only when the model genuinely cannot maintain conversation history + +### Flags +- [ ] `recommended: true` — at most one or two per provider; should be current flagship +- [ ] `speedOptimized: true` — only on smallest/fastest tier (nano / flash-lite / haiku class) + +## Step 5: Report (mandatory format) + +For each model, emit a table with one row per checklist item. Every row that claims ✓ must have a URL. + +```markdown +### Validation — + +| Field | Repo | Live docs | Source URL | Status | +|---|---|---|---|---| +| `input` | $1.25/M | $1.25/M | https://docs.x.ai/... | ✓ | +| `cachedInput` | $0.50/M | $0.20/M | https://cloudprice.net/... | ✗ stale (price cut not picked up) | +| `reasoningEffort` | low/medium/high | rejected by API | https://docs.x.ai/.../reasoning | ✗ inert — selecting silently no-ops | +| `contextWindow` | 1,000,000 | 1,000,000 | https://docs.x.ai/... + https://openrouter.ai/... | ✓ (2 sources) | +| `releaseDate` | 2026-04-30 | not found in scraped pages | _attempted: docs.x.ai, x.ai/news_ | ❓ UNVERIFIED | + +**Findings** +- 🔴 critical — `cachedInput` is wrong: docs say $0.20/M, repo has $0.50/M +- 🟡 warning — `reasoningEffort` is set but provider rejects it for this model (xAI docs explicitly: "reasoning_effort is not supported by grok-4.3") +- 🔵 suggestion — `pricing.updatedAt` is 90 days old; refresh +- ❓ unverified — `releaseDate` could not be confirmed from any fetched page; ask user + +**Disagreements between sources** +- _none_ OR _OpenRouter says $X, provider docs say $Y — went with provider docs_ +``` + +End each multi-model run with a summary count: `N models checked · X critical · Y warnings · Z suggestions · W unverified`. + +## Step 6: Offer to fix + +After reporting, ask: *"Want me to fix the critical and warning items? I'll print the diff first."* On yes: + +1. Print the proposed diff (do not apply yet) +2. Get user confirmation +3. Edit `models.ts` in a single pass +4. Run `bun run lint` +5. Re-run only the failed rows of the checklist on the new state + +## Severity definitions + +- 🔴 **critical** — wrong number or wrong identifier that misleads users about cost or breaks API calls. Examples: incorrect pricing, wrong model id, wrong context window, capability the API rejects. +- 🟡 **warning** — dead code or internal inconsistency. Examples: capability flag the provider ignores, multiple `recommended: true` per provider, `pricing.updatedAt` >60 days old, missing `deprecated: true` on retired model. +- 🔵 **suggestion** — style/consistency. Examples: field order, missing `speedOptimized` on a clearly smallest-tier model. +- ❓ **unverified** — could not fetch an authoritative source for this field. Surface it; never silently confirm. + +## Common bugs this skill catches + +- Pricing drift after a provider price cut (very common — providers cut quarterly) +- `reasoningEffort` set on always-reasoning models that reject the parameter (grok-4.3, o3-pro pattern) +- `nativeStructuredOutputs` set on providers that don't consume the flag (dead) +- `thinking` set on non-Anthropic/non-Gemini providers +- `verbosity` set on non-gpt-5.x models +- Wrong context window (e.g., 128k claimed vs 200k actual) +- Stale `pricing.updatedAt` +- Multiple `recommended: true` per provider after a flagship swap +- Missing `deprecated: true` on retired models (e.g., the xAI batch retiring May 15, 2026) + +## What "I cannot verify this" looks like + +If, after fetching the documented sources, a field cannot be confirmed: + +- Mark the row ❓ UNVERIFIED with the URL(s) attempted +- Surface it in the **Findings** section with severity ❓ +- Do NOT mark the validation as passed +- Ask the user for a docs URL or guidance before changing anything + +The skill is allowed to say *"I could not verify the cached input price for grok-4.3 from the official xAI docs in this session — I attempted [URLs] without finding the value. Third-party sources [URL1, URL2] both report $0.20/M. Confirm before I update."* That is correct behavior. Hallucinating a number is not. diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md index 011a3280f4c..53373bc9236 100644 --- a/.claude/rules/emcn-components.md +++ b/.claude/rules/emcn-components.md @@ -32,4 +32,5 @@ function Label({ className, ...props }) { - Export component and variants (if using CVA) - TSDoc with usage examples - Consistent tokens: `font-medium`, `text-[12px]`, `rounded-[4px]` +- Equal height/width → `size-*` (e.g. `size-[14px]`, `size-4`), never `h-[Npx] w-[Npx]` or `h-N w-N`. Default icon size is `size-[14px]` - `transition-colors` for hover states diff --git a/.claude/rules/global.md b/.claude/rules/global.md index b5bc94ec1b2..4d47c82f60a 100644 --- a/.claude/rules/global.md +++ b/.claude/rules/global.md @@ -36,22 +36,31 @@ const tiny = generateShortId(8) ## Common Utilities Use shared helpers from `@sim/utils` instead of writing inline implementations: -- `sleep(ms)` — async delay. Never write `new Promise(resolve => setTimeout(resolve, ms))` -- `toError(value)` — normalize unknown caught values to `Error`. Never write `e instanceof Error ? e : new Error(String(e))` -- `toError(value).message` — get error message safely. Never write `e instanceof Error ? e.message : String(e)` +- `sleep(ms)` from `@sim/utils/helpers` — async delay. Never write `new Promise(resolve => setTimeout(resolve, ms))` +- `toError(value)` from `@sim/utils/errors` — normalize unknown caught values to `Error`. Never write `e instanceof Error ? e : new Error(String(e))` +- `getErrorMessage(value, fallback?)` from `@sim/utils/errors` — extract error message string. Never write `e instanceof Error ? e.message : 'fallback'` +- `structuredClone(value)` — built-in deep clone, no import needed. Never write `JSON.parse(JSON.stringify(obj))` +- `omit(obj, keys)` from `@sim/utils/object` — remove keys from object +- `filterUndefined(obj)` from `@sim/utils/object` — strip undefined-valued keys. Never write `Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))` +- `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — safe string truncation with ellipsis +- `backoffWithJitter(attempt, retryAfterMs, options?)` from `@sim/utils/retry` — exponential backoff with jitter +- `parseRetryAfter(header)` from `@sim/utils/retry` — parse HTTP `Retry-After` header to milliseconds ```typescript // ✗ Bad await new Promise(resolve => setTimeout(resolve, 1000)) -const msg = error instanceof Error ? error.message : String(error) -const err = error instanceof Error ? error : new Error(String(error)) +const msg = error instanceof Error ? error.message : 'Unknown error' +const clone = JSON.parse(JSON.stringify(obj)) +const filtered = Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) // ✓ Good import { sleep } from '@sim/utils/helpers' -import { toError } from '@sim/utils/errors' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { filterUndefined } from '@sim/utils/object' await sleep(1000) -const msg = toError(error).message -const err = toError(error) +const msg = getErrorMessage(error, 'Unknown error') +const clone = structuredClone(obj) +const filtered = filterUndefined(obj) ``` ## Package Manager diff --git a/.claude/rules/sim-styling.md b/.claude/rules/sim-styling.md index 1b8c384a703..65911694a4a 100644 --- a/.claude/rules/sim-styling.md +++ b/.claude/rules/sim-styling.md @@ -11,7 +11,8 @@ paths: 1. **No inline styles** - Use Tailwind classes 2. **No duplicate dark classes** - Skip `dark:` when value matches light mode 3. **Exact values** - `text-[14px]`, `h-[26px]` -4. **Transitions** - `transition-colors` for interactive states +4. **Equal h/w → `size-*`** - Use `size-[14px]` / `size-4`, never `h-[14px] w-[14px]` or `h-4 w-4`. Default icon size is `size-[14px]` +5. **Transitions** - `transition-colors` for interactive states ## Conditional Classes diff --git a/.cursor/commands/emcn-design-review.md b/.cursor/commands/emcn-design-review.md index a68e96eb9b4..e51e5bc4445 100644 --- a/.cursor/commands/emcn-design-review.md +++ b/.cursor/commands/emcn-design-review.md @@ -60,7 +60,7 @@ Modal `size="sm"`, title "Delete/Remove {ItemType}", `variant="destructive"` act ## Icons -Default: `h-[14px] w-[14px]` (400+ uses). Color: `text-[var(--text-icon)]`. Scale: 14px > 16px > 12px > 20px. +Default: `size-[14px]`. Color: `text-[var(--text-icon)]`. Scale: 14px > 16px > 12px > 20px. Use the `size-*` shorthand — flag `h-[Npx] w-[Npx]` and `h-N w-N` pairs as refactor targets. ## Anti-patterns to flag diff --git a/.cursor/commands/ship.md b/.cursor/commands/ship.md new file mode 100644 index 00000000000..2530e463ee3 --- /dev/null +++ b/.cursor/commands/ship.md @@ -0,0 +1,79 @@ +# Ship Command + +You help ship code by creating commits, pushing to the remote branch, and creating PRs in the user's voice. + +## Your Task + +When the user runs `/ship`: + +1. **Check git status** - See what files have changed +2. **Generate a commit message** following this format: `type(scope): description` + - Types: `fix`, `feat`, `improvement`, `chore` + - Scope: short identifier (e.g., `undo-redo`, `api`, `ui`) + - Keep it concise + +3. **Run pre-ship checks** from the repo root before staging: + - `bun run lint` to fix formatting issues + - `bun run check:api-validation:strict` to catch boundary contract failures before CI + +4. **Stage and commit** the changes with the generated message + +5. **Push to origin** using the current branch name + +6. **Create a PR** to staging with a description in the user's voice + +## Commit Message Format + +Based on the repo's commit history: +``` +fix(scope): description for bug fixes +feat(scope): description for new features +improvement(scope): description for enhancements +chore(scope): description for maintenance +``` + +## PR Description Format + +Use this exact template in the user's voice (concise, bullet points): + +```markdown +## Summary +- bullet point describing what changed +- another bullet point if needed + +## Type of Change +- [x] Bug fix (or appropriate type) + +## Testing +Tested manually (or describe testing) + +## Checklist +- [x] Code follows project style guidelines +- [x] Self-reviewed my changes +- [ ] Tests added/updated and passing +- [x] No new warnings introduced +- [x] I confirm that I have read and agree to the terms outlined in the [Contributor License Agreement (CLA)](./CONTRIBUTING.md#contributor-license-agreement-cla) +``` + +## PR Creation Command + +Use this command structure: +```bash +gh pr create --base staging --title "COMMIT_MESSAGE" --body "PR_BODY" +``` + +## Important Notes + +- Always confirm the commit message and PR description with the user before executing +- The PR should be created against `staging` branch +- Keep descriptions concise and in active voice +- Match the user's previous PR style: direct, no fluff, bullet points +- **DO NOT add "Co-Authored-By" lines to commits** - keep commit messages clean + +## User's Voice Characteristics (based on previous PRs) + +- Short, direct bullet points +- No unnecessary explanation +- "Tested manually" is acceptable for testing section; include lint and boundary validation results when run +- Checkboxes filled in appropriately +- No screenshots section unless UI changes diff --git a/.cursor/rules/sim-architecture.mdc b/.cursor/rules/sim-architecture.mdc index 6ebd0581b1d..a927e4971d8 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`, `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. Four 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), `// double-cast-allowed: ` (placed immediately above an `as unknown as X` cast outside test files), `// boundary-raw-json: ` (placed immediately above a raw `await request.json()` / `await req.json()` read in a route handler that cannot go through `parseRequest` — JSON-RPC envelopes, tolerant `.catch(() => ({}))` parses), and `// untyped-response: ` (placed immediately above a `schema: z.unknown()` response declaration in a contract file when the response body is genuinely opaque). 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/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ef1aa5fc34e..201719f0f00 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM oven/bun:1.3.11-alpine +FROM oven/bun:1.3.13-alpine # Install necessary packages for development RUN apk add --no-cache \ diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 01277709375..f3482d665d4 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -21,7 +21,7 @@ services: - COPILOT_API_KEY=${COPILOT_API_KEY} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002} + - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} - BUN_INSTALL_CACHE_DIR=/home/bun/.bun/cache depends_on: db: diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 40d1324323e..f4e3df0d31c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -2,8 +2,15 @@ Thank you for your interest in contributing to Sim! Our goal is to provide developers with a powerful, user-friendly platform for building, testing, and optimizing agentic workflows. We welcome contributions in all forms—from bug fixes and design improvements to brand-new features. -> **Project Overview:** -> Sim is a monorepo using Turborepo, containing the main application (`apps/sim/`), documentation (`apps/docs/`), and shared packages (`packages/`). The main application is built with Next.js (app router), ReactFlow, Zustand, Shadcn, and Tailwind CSS. Please ensure your contributions follow our best practices for clarity, maintainability, and consistency. +> **Project Overview:** +> Sim is a Turborepo monorepo with two deployable apps and a set of shared packages: +> +> - `apps/sim/` — the main Next.js application (App Router, ReactFlow, Zustand, Shadcn, Tailwind CSS). +> - `apps/realtime/` — a small Bun + Socket.IO server that powers the collaborative canvas. Shares DB and Better Auth secrets with `apps/sim` via `@sim/*` packages. +> - `apps/docs/` — Fumadocs-based documentation site. +> - `packages/` — shared workspace packages (`@sim/db`, `@sim/auth`, `@sim/audit`, `@sim/workflow-types`, `@sim/workflow-persistence`, `@sim/workflow-authz`, `@sim/realtime-protocol`, `@sim/security`, `@sim/logger`, `@sim/utils`, `@sim/testing`, `@sim/tsconfig`). +> +> Strict one-way dependency flow: `apps/* → packages/*`. Packages never import from apps. Please ensure your contributions follow this and our best practices for clarity, maintainability, and consistency. --- @@ -24,14 +31,17 @@ Thank you for your interest in contributing to Sim! Our goal is to provide devel We strive to keep our workflow as simple as possible. To contribute: -1. **Fork the Repository** +1. **Fork the Repository** Click the **Fork** button on GitHub to create your own copy of the project. 2. **Clone Your Fork** + ```bash git clone https://github.com//sim.git + cd sim ``` -3. **Create a Feature Branch** + +3. **Create a Feature Branch** Create a new branch with a descriptive name: ```bash @@ -40,21 +50,23 @@ We strive to keep our workflow as simple as possible. To contribute: Use a clear naming convention to indicate the type of work (e.g., `feat/`, `fix/`, `docs/`). -4. **Make Your Changes** +4. **Make Your Changes** Ensure your changes are small, focused, and adhere to our coding guidelines. -5. **Commit Your Changes** +5. **Commit Your Changes** Write clear, descriptive commit messages that follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#specification) specification. This allows us to maintain a coherent project history and generate changelogs automatically. For example: + - `feat(api): add new endpoint for user authentication` - `fix(ui): resolve button alignment issue` - `docs: update contribution guidelines` + 6. **Push Your Branch** ```bash git push origin feat/your-feature-name ``` -7. **Create a Pull Request** +7. **Create a Pull Request** Open a pull request against the `staging` branch on GitHub. Please provide a clear description of the changes and reference any relevant issues (e.g., `fixes #123`). --- @@ -65,7 +77,7 @@ If you discover a bug or have a feature request, please open an issue in our Git - Provide a clear, descriptive title. - Include as many details as possible (steps to reproduce, screenshots, etc.). -- **Tag Your Issue Appropriately:** +- **Tag Your Issue Appropriately:** Use the following labels to help us categorize your issue: - **active:** Actively working on it right now. - **bug:** Something isn't working. @@ -82,12 +94,11 @@ If you discover a bug or have a feature request, please open an issue in our Git Before creating a pull request: -- **Ensure Your Branch Is Up-to-Date:** +- **Ensure Your Branch Is Up-to-Date:** Rebase your branch onto the latest `staging` branch to prevent merge conflicts. -- **Follow the Guidelines:** +- **Follow the Guidelines:** Make sure your changes are well-tested, follow our coding standards, and include relevant documentation if necessary. - -- **Reference Issues:** +- **Reference Issues:** If your PR addresses an existing issue, include `refs #` or `fixes #` in your PR description. Our maintainers will review your pull request and provide feedback. We aim to make the review process as smooth and timely as possible. @@ -166,27 +177,27 @@ To use local models with Sim: 1. Install Ollama and pull models: -```bash -# Install Ollama (if not already installed) -curl -fsSL https://ollama.ai/install.sh | sh + ```bash + # Install Ollama (if not already installed) + curl -fsSL https://ollama.ai/install.sh | sh -# Pull a model (e.g., gemma3:4b) -ollama pull gemma3:4b -``` + # Pull a model (e.g., gemma3:4b) + ollama pull gemma3:4b + ``` 2. Start Sim with local model support: -```bash -# With NVIDIA GPU support -docker compose --profile local-gpu -f docker-compose.ollama.yml up -d + ```bash + # With NVIDIA GPU support + docker compose --profile local-gpu -f docker-compose.ollama.yml up -d -# Without GPU (CPU only) -docker compose --profile local-cpu -f docker-compose.ollama.yml up -d + # Without GPU (CPU only) + docker compose --profile local-cpu -f docker-compose.ollama.yml up -d -# If hosting on a server, update the environment variables in the docker-compose.prod.yml file -# to include the server's public IP then start again (OLLAMA_URL to i.e. http://1.1.1.1:11434) -docker compose -f docker-compose.prod.yml up -d -``` + # If hosting on a server, update the environment variables in the docker-compose.prod.yml file + # to include the server's public IP then start again (OLLAMA_URL to i.e. http://1.1.1.1:11434) + docker compose -f docker-compose.prod.yml up -d + ``` ### Option 3: Using VS Code / Cursor Dev Containers @@ -201,61 +212,104 @@ Dev Containers provide a consistent and easy-to-use development environment: 2. **Setup Steps:** - Clone the repository: + ```bash git clone https://github.com//sim.git cd sim ``` - - Open the project in VS Code/Cursor - - When prompted, click "Reopen in Container" (or press F1 and select "Remote-Containers: Reopen in Container") - - Wait for the container to build and initialize + + - Open the project in VS Code/Cursor. + - When prompted, click "Reopen in Container" (or press F1 and select "Remote-Containers: Reopen in Container"). + - Wait for the container to build and initialize. 3. **Start Developing:** - - Run `bun run dev:full` in the terminal or use the `sim-start` alias - - This starts both the main application and the realtime socket server - - All dependencies and configurations are automatically set up - - Your changes will be automatically hot-reloaded + - Run `bun run dev:full` in the terminal or use the `sim-start` alias. + - This starts both the main application and the realtime socket server. + - All dependencies and configurations are automatically set up. + - Your changes will be automatically hot-reloaded. 4. **GitHub Codespaces:** - - This setup also works with GitHub Codespaces if you prefer development in the browser - - Just click "Code" → "Codespaces" → "Create codespace on staging" + + - This setup also works with GitHub Codespaces if you prefer development in the browser. + - Just click "Code" → "Codespaces" → "Create codespace on staging". ### Option 4: Manual Setup -If you prefer not to use Docker or Dev Containers: +If you prefer not to use Docker or Dev Containers. **All commands run from the repository root unless explicitly noted.** + +1. **Clone and Install:** -1. **Clone the Repository:** ```bash git clone https://github.com//sim.git cd sim bun install ``` -2. **Set Up Environment:** + Bun workspaces handle dependency resolution for all apps and packages from the root `bun install`. - - Navigate to the app directory: - ```bash - cd apps/sim - ``` - - Copy `.env.example` to `.env` - - Configure required variables (DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL) +2. **Set Up Environment Files:** + + We use **per-app `.env` files** (the Turborepo-canonical pattern), not a single root `.env`. Three files are needed for local dev: + + ```bash + # Main app — large, app-specific (OAuth secrets, LLM keys, Stripe, etc.) + cp apps/sim/.env.example apps/sim/.env + + # Realtime server — small, only the values shared with the main app + cp apps/realtime/.env.example apps/realtime/.env + + # DB tooling (drizzle-kit, db:migrate) + cp packages/db/.env.example packages/db/.env + ``` + + At minimum, each `.env` needs `DATABASE_URL`. `apps/sim/.env` and `apps/realtime/.env` additionally need matching values for `BETTER_AUTH_URL`, `BETTER_AUTH_SECRET`, `INTERNAL_API_SECRET`, and `NEXT_PUBLIC_APP_URL`. `apps/sim/.env` also needs `ENCRYPTION_KEY` and `API_ENCRYPTION_KEY`. Generate any 32-char secrets with `openssl rand -hex 32`. + + The same `BETTER_AUTH_SECRET`, `INTERNAL_API_SECRET`, and `DATABASE_URL` must appear in both `apps/sim/.env` and `apps/realtime/.env` so the two services share auth and DB. After editing `apps/sim/.env`, you can mirror the shared subset into the realtime env in one shot: -3. **Set Up Database:** + ```bash + grep -E '^(DATABASE_URL|BETTER_AUTH_URL|BETTER_AUTH_SECRET|INTERNAL_API_SECRET|NEXT_PUBLIC_APP_URL|REDIS_URL)=' apps/sim/.env > apps/realtime/.env + grep -E '^DATABASE_URL=' apps/sim/.env > packages/db/.env + ``` + +3. **Run Database Migrations:** + + Migrations live in `packages/db/migrations/`. Run them via the dedicated workspace script: ```bash - bunx drizzle-kit push + cd packages/db && bun run db:migrate && cd ../.. ``` -4. **Run the Development Server:** + For ad-hoc schema iteration during development you can also use `bun run db:push` from `packages/db`, but `db:migrate` is the canonical command for both local and CI/CD setups. + +4. **Run the Development Servers:** ```bash bun run dev:full ``` - This command starts both the main application and the realtime socket server required for full functionality. + This launches both apps with coloured prefixes: + + - `[App]` — Next.js on `http://localhost:3000` + - `[Realtime]` — Socket.IO on `http://localhost:3002` + + Or run them separately: + + ```bash + bun run dev # Next.js app only + bun run dev:sockets # realtime server only + ``` 5. **Make Your Changes and Test Locally.** + Before opening a PR, run the same checks CI runs: + + ```bash + bun run type-check # TypeScript across every workspace + bun run lint:check # Biome lint across every workspace + bun run test # Vitest across every workspace + ``` + ### Email Template Development When working on email templates, you can preview them using a local email preview server: @@ -263,18 +317,19 @@ When working on email templates, you can preview them using a local email previe 1. **Run the Email Preview Server:** ```bash - bun run email:dev + cd apps/sim && bun run email:dev ``` 2. **Access the Preview:** - - Open `http://localhost:3000` in your browser - - You'll see a list of all email templates - - Click on any template to view and test it with various parameters + - Open `http://localhost:3000` in your browser. + - You'll see a list of all email templates. + - Click on any template to view and test it with various parameters. 3. **Templates Location:** - - Email templates are located in `sim/app/emails/` - - After making changes to templates, they will automatically update in the preview + + - Email templates live in `apps/sim/components/emails/`. + - Changes hot-reload automatically in the preview. --- @@ -282,28 +337,41 @@ When working on email templates, you can preview them using a local email previe Sim is built in a modular fashion where blocks and tools extend the platform's functionality. To maintain consistency and quality, please follow the guidelines below when adding a new block or tool. +> **Use the skill guides for step-by-step recipes.** The repository ships opinionated, end-to-end guides under `.agents/skills/` that cover the exact file layout, conventions, registry wiring, and gotchas for each kind of contribution. Read the relevant SKILL.md before you start writing code: +> +> | Adding… | Read | +> | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +> | A new integration end-to-end (tools + block + icon + optional triggers + all registrations) | [`.agents/skills/add-integration/SKILL.md`](../.agents/skills/add-integration/SKILL.md) | +> | Just a block (or aligning an existing block with its tools) | [`.agents/skills/add-block/SKILL.md`](../.agents/skills/add-block/SKILL.md) | +> | Just tool configs for a service | [`.agents/skills/add-tools/SKILL.md`](../.agents/skills/add-tools/SKILL.md) | +> | A webhook trigger for a service | [`.agents/skills/add-trigger/SKILL.md`](../.agents/skills/add-trigger/SKILL.md) | +> | A knowledge-base connector (sync docs from an external source) | [`.agents/skills/add-connector/SKILL.md`](../.agents/skills/add-connector/SKILL.md) | +> +> The shorter overview below is a high-level reference; the SKILL.md files are the authoritative source of truth and stay in sync with the codebase. + ### Where to Add Your Code -- **Blocks:** Create your new block file under the `/apps/sim/blocks/blocks` directory. The name of the file should match the provider name (e.g., `pinecone.ts`). -- **Tools:** Create a new directory under `/apps/sim/tools` with the same name as the provider (e.g., `/apps/sim/tools/pinecone`). +- **Blocks:** Create your new block file under the `apps/sim/blocks/blocks/` directory. The name of the file should match the provider name (e.g., `pinecone.ts`). +- **Tools:** Create a new directory under `apps/sim/tools/` with the same name as the provider (e.g., `apps/sim/tools/pinecone`). In addition, you will need to update the registries: -- **Block Registry:** Update the blocks index (`/apps/sim/blocks/index.ts`) to include your new block. -- **Tool Registry:** Update the tools registry (`/apps/sim/tools/index.ts`) to add your new tool. +- **Block Registry:** Add your block to `apps/sim/blocks/registry.ts`. (`apps/sim/blocks/index.ts` re-exports lookups from the registry; you do not need to edit it.) +- **Tool Registry:** Add your tool to `apps/sim/tools/index.ts`. ### How to Create a New Block -1. **Create a New File:** - Create a file for your block named after the provider (e.g., `pinecone.ts`) in the `/apps/sim/blocks/blocks` directory. +1. **Create a New File:** + Create a file for your block named after the provider (e.g., `pinecone.ts`) in the `apps/sim/blocks/blocks/` directory. 2. **Create a New Icon:** - Create a new icon for your block in the `/apps/sim/components/icons.tsx` file. The icon should follow the same naming convention as the block (e.g., `PineconeIcon`). + Create a new icon for your block in `apps/sim/components/icons.tsx`. The icon should follow the same naming convention as the block (e.g., `PineconeIcon`). -3. **Define the Block Configuration:** +3. **Define the Block Configuration:** Your block should export a constant of type `BlockConfig`. For example: - ```typescript:/apps/sim/blocks/blocks/pinecone.ts + ```typescript + // apps/sim/blocks/blocks/pinecone.ts import { PineconeIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import type { PineconeResponse } from '@/tools/pinecone/types' @@ -321,7 +389,7 @@ In addition, you will need to update the registries: { id: 'operation', title: 'Operation', - type: 'dropdown' + type: 'dropdown', required: true, options: [ { label: 'Generate Embeddings', id: 'generate' }, @@ -332,7 +400,7 @@ In addition, you will need to update the registries: { id: 'apiKey', title: 'API Key', - type: 'short-input' + type: 'short-input', placeholder: 'Your Pinecone API key', password: true, required: true, @@ -370,10 +438,11 @@ In addition, you will need to update the registries: } ``` -4. **Register Your Block:** - Add your block to the blocks registry (`/apps/sim/blocks/registry.ts`): +4. **Register Your Block:** + Add your block to the blocks registry (`apps/sim/blocks/registry.ts`): - ```typescript:/apps/sim/blocks/registry.ts + ```typescript + // apps/sim/blocks/registry.ts import { PineconeBlock } from '@/blocks/blocks/pinecone' // Registry of all available blocks @@ -385,24 +454,25 @@ In addition, you will need to update the registries: The block will be automatically available to the application through the registry. -5. **Test Your Block:** +5. **Test Your Block:** Ensure that the block displays correctly in the UI and that its functionality works as expected. ### How to Create a New Tool -1. **Create a New Directory:** - Create a directory under `/apps/sim/tools` with the same name as the provider (e.g., `/apps/sim/tools/pinecone`). +1. **Create a New Directory:** + Create a directory under `apps/sim/tools/` with the same name as the provider (e.g., `apps/sim/tools/pinecone`). -2. **Create Tool Files:** +2. **Create Tool Files:** Create separate files for each tool functionality with descriptive names (e.g., `fetch.ts`, `generate_embeddings.ts`, `search_text.ts`) in your tool directory. -3. **Create a Types File:** +3. **Create a Types File:** Create a `types.ts` file in your tool directory to define and export all types related to your tools. -4. **Create an Index File:** +4. **Create an Index File:** Create an `index.ts` file in your tool directory that imports and exports all tools: - ```typescript:/apps/sim/tools/pinecone/index.ts + ```typescript + // apps/sim/tools/pinecone/index.ts import { fetchTool } from './fetch' import { generateEmbeddingsTool } from './generate_embeddings' import { searchTextTool } from './search_text' @@ -410,10 +480,11 @@ In addition, you will need to update the registries: export { fetchTool, generateEmbeddingsTool, searchTextTool } ``` -5. **Define the Tool Configuration:** +5. **Define the Tool Configuration:** Your tool should export a constant with a naming convention of `{toolName}Tool`. The tool ID should follow the format `{provider}_{tool_name}`. For example: - ```typescript:/apps/sim/tools/pinecone/fetch.ts + ```typescript + // apps/sim/tools/pinecone/fetch.ts import { ToolConfig, ToolResponse } from '@/tools/types' import { PineconeParams, PineconeResponse } from '@/tools/pinecone/types' @@ -449,11 +520,12 @@ In addition, you will need to update the registries: } ``` -6. **Register Your Tool:** - Update the tools registry in `/apps/sim/tools/index.ts` to include your new tool: +6. **Register Your Tool:** + Update the tools registry in `apps/sim/tools/index.ts` to include your new tool: - ```typescript:/apps/sim/tools/index.ts - import { fetchTool, generateEmbeddingsTool, searchTextTool } from '/@tools/pinecone' + ```typescript + // apps/sim/tools/index.ts + import { fetchTool, generateEmbeddingsTool, searchTextTool } from '@/tools/pinecone' // ... other imports export const tools: Record = { @@ -464,13 +536,14 @@ In addition, you will need to update the registries: } ``` -7. **Test Your Tool:** +7. **Test Your Tool:** Ensure that your tool functions correctly by making test requests and verifying the responses. -8. **Generate Documentation:** - Run the documentation generator to create docs for your new tool: +8. **Generate Documentation:** + Run the documentation generator (from `apps/sim`) to create docs for your new tool: + ```bash - ./scripts/generate-docs.sh + cd apps/sim && bun run generate-docs ``` ### Naming Conventions @@ -480,7 +553,7 @@ Maintaining consistent naming across the codebase is critical for auto-generatio - **Block Files:** Name should match the provider (e.g., `pinecone.ts`) - **Block Export:** Should be named `{Provider}Block` (e.g., `PineconeBlock`) - **Icons:** Should be named `{Provider}Icon` (e.g., `PineconeIcon`) -- **Tool Directories:** Should match the provider name (e.g., `/tools/pinecone/`) +- **Tool Directories:** Should match the provider name (e.g., `tools/pinecone/`) - **Tool Files:** Should be named after their function (e.g., `fetch.ts`, `search_text.ts`) - **Tool Exports:** Should be named `{toolName}Tool` (e.g., `fetchTool`) - **Tool IDs:** Should follow the format `{provider}_{tool_name}` (e.g., `pinecone_fetch`) @@ -489,12 +562,12 @@ Maintaining consistent naming across the codebase is critical for auto-generatio Sim implements a sophisticated parameter visibility system that controls how parameters are exposed to users and LLMs in agent workflows. Each parameter can have one of four visibility levels: -| Visibility | User Sees | LLM Sees | How It Gets Set | -|-------------|-----------|----------|--------------------------------| -| `user-only` | ✅ Yes | ❌ No | User provides in UI | -| `user-or-llm` | ✅ Yes | ✅ Yes | User provides OR LLM generates | -| `llm-only` | ❌ No | ✅ Yes | LLM generates only | -| `hidden` | ❌ No | ❌ No | Application injects at runtime | +| Visibility | User Sees | LLM Sees | How It Gets Set | +| ------------- | --------- | -------- | ------------------------------ | +| `user-only` | ✅ Yes | ❌ No | User provides in UI | +| `user-or-llm` | ✅ Yes | ✅ Yes | User provides OR LLM generates | +| `llm-only` | ❌ No | ✅ Yes | LLM generates only | +| `hidden` | ❌ No | ❌ No | Application injects at runtime | #### Visibility Guidelines diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4605c9227c0..c1dc73e648f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,7 @@ jobs: uses: actions/checkout@v4 - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ secrets.DEV_AWS_ROLE_TO_ASSUME }} aws-region: ${{ secrets.DEV_AWS_REGION }} @@ -80,7 +80,7 @@ jobs: uses: aws-actions/amazon-ecr-login@v2 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -135,7 +135,7 @@ jobs: uses: actions/checkout@v6 - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }} aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }} @@ -145,14 +145,14 @@ jobs: uses: aws-actions/amazon-ecr-login@v2 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR if: github.ref == 'refs/heads/main' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -234,7 +234,7 @@ jobs: uses: actions/checkout@v6 - name: Login to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -286,7 +286,7 @@ jobs: steps: - name: Login to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.repository_owner }} diff --git a/.github/workflows/docs-embeddings.yml b/.github/workflows/docs-embeddings.yml index 3e4de08e19f..13e2febbd31 100644 --- a/.github/workflows/docs-embeddings.yml +++ b/.github/workflows/docs-embeddings.yml @@ -20,15 +20,15 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.11 + bun-version: 1.3.13 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: latest - name: Cache Bun dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.bun/install/cache diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index 2eab817d009..5f7b1dd0016 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -23,10 +23,10 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.11 + bun-version: 1.3.13 - name: Cache Bun dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.bun/install/cache @@ -122,10 +122,10 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.11 + bun-version: 1.3.13 - name: Cache Bun dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.bun/install/cache diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 853ebc6881a..f1ed176d350 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -34,7 +34,7 @@ jobs: uses: actions/checkout@v6 - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }} aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_REGION || secrets.STAGING_AWS_REGION }} @@ -44,14 +44,14 @@ jobs: uses: aws-actions/amazon-ecr-login@v2 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR if: github.ref == 'refs/heads/main' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -120,7 +120,7 @@ jobs: uses: actions/checkout@v6 - name: Login to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -160,7 +160,7 @@ jobs: steps: - name: Login to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.repository_owner }} diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 245023ab86f..4a6aab428ad 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -19,10 +19,10 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.11 + bun-version: 1.3.13 - name: Cache Bun dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.bun/install/cache @@ -39,4 +39,4 @@ jobs: working-directory: ./packages/db env: DATABASE_URL: ${{ github.ref == 'refs/heads/main' && secrets.DATABASE_URL || github.ref == 'refs/heads/dev' && secrets.DEV_DATABASE_URL || secrets.STAGING_DATABASE_URL }} - run: bunx drizzle-kit migrate --config=./drizzle.config.ts \ No newline at end of file + run: bun run ./scripts/migrate.ts \ No newline at end of file diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index ceb124c8230..d7bae3e7828 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -19,16 +19,16 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.11 + bun-version: 1.3.13 - name: Setup Node.js for npm publishing - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '18' registry-url: 'https://registry.npmjs.org/' - name: Cache Bun dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.bun/install/cache diff --git a/.github/workflows/publish-ts-sdk.yml b/.github/workflows/publish-ts-sdk.yml index 1032ce7442a..2a527b7b42a 100644 --- a/.github/workflows/publish-ts-sdk.yml +++ b/.github/workflows/publish-ts-sdk.yml @@ -19,16 +19,16 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.11 + bun-version: 1.3.13 - name: Setup Node.js for npm publishing - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '22' registry-url: 'https://registry.npmjs.org/' - name: Cache Bun dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.bun/install/cache diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 8bcd240011c..e59102ebd58 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -19,10 +19,10 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.11 + bun-version: 1.3.13 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: latest @@ -45,7 +45,7 @@ jobs: path: ./.turbo - name: Restore Next.js build cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ./apps/sim/.next/cache key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }} @@ -90,7 +90,7 @@ jobs: echo "✅ All feature flags are properly configured" - - name: Check subblock ID stability + - name: Check block registry invariants run: | if [ "${{ github.event_name }}" = "pull_request" ]; then BASE_REF="origin/${{ github.base_ref }}" @@ -98,7 +98,7 @@ jobs: else BASE_REF="HEAD~1" fi - bun run apps/sim/scripts/check-subblock-id-stability.ts "$BASE_REF" + bun run apps/sim/scripts/check-block-registry.ts "$BASE_REF" - name: Lint code run: bun run lint:check @@ -106,6 +106,12 @@ jobs: - name: Enforce monorepo boundaries run: bun run check:boundaries + - name: API contract boundary audit + run: bun run check:api-validation:strict + + - name: Zustand v5 selector audit + run: bun run check:zustand-v5 + - name: Verify realtime prune graph run: bun run check:realtime-prune diff --git a/.gitignore b/.gitignore index 61566eeeafd..c38b288a683 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ # bun specific bun-debug.log* +# this repo uses bun.lock; package-lock.json files are accidental +package-lock.json + # testing /coverage /apps/**/coverage @@ -80,3 +83,8 @@ i18n.cache ## Claude Code .claude/launch.json .claude/worktrees/ +.claude/scheduled_tasks.lock +.deepsec/ + +# Personal Cursor Skills +.cursor/skills/ask-sim/ diff --git a/AGENTS.md b/AGENTS.md index 025ad5a3883..cfa04710cf1 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 @@ -13,12 +14,14 @@ You are a professional software engineer. All code must follow best practices: a ## Architecture ### Core Principles + 1. Single Responsibility: Each component, hook, store has one clear purpose 2. Composition Over Complexity: Break down complex logic into smaller pieces 3. Type Safety First: TypeScript interfaces for all props, state, return types 4. Predictable State: Zustand for global state, useState for UI-only concerns ### Root Structure + ``` apps/ ├── sim/ # Next.js app (UI + API routes + workflow editor) @@ -51,12 +54,14 @@ packages/ ``` ### Package boundaries + - `apps/* → packages/*` only. Packages never import from `apps/*`. - Each package has explicit subpath `exports` maps; no barrels that accidentally pull in heavy halves. - `apps/realtime` intentionally avoids Next.js, React, the block/tool registry, provider SDKs, and the executor. CI enforces this via `scripts/check-monorepo-boundaries.ts` and `scripts/check-realtime-prune-graph.ts`. - Auth is shared across services via the Better Auth "Shared Database Session" pattern: both apps read the same `BETTER_AUTH_SECRET` and point at the same DB via `@sim/db`. ### Naming Conventions + - Components: PascalCase (`WorkflowList`) - Hooks: `use` prefix (`useWorkflowOperations`) - Files: kebab-case (`workflow-list.tsx`) @@ -79,6 +84,7 @@ import { useWorkflowStore } from '../../../stores/workflows/store' Use barrel exports (`index.ts`) when a folder has 3+ exports. Do not re-export from non-barrel files; import directly from the source. ### Import Order + 1. React/core libraries 2. External libraries 3. UI components (`@/components/emcn`, `@/components/ui`) @@ -115,6 +121,97 @@ 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 four 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 +- `// boundary-raw-json: ` — placed on the line directly above a raw `await request.json()` / `await req.json()` read in a route handler. Use only when the body is a JSON-RPC envelope, a tolerant `.catch(() => ({}))` parse, or otherwise cannot go through `parseRequest` +- `// untyped-response: ` — placed on the line directly above a `schema: z.unknown()` response declaration in a contract file. Use only when the response body is genuinely opaque (user-supplied data, third-party passthrough) + +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, options?)` — fully contract-bound routes; parses params, query, body, and headers in one call. Pass `{}` for `context` on routes without route params, or the route's `context` argument when route params exist. Returns a discriminated union; check `parsed.success` and return `parsed.response` on failure +- `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 } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('FoldersAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const parsed = await parseRequest(createFolderContract, request, {}) + if (!parsed.success) return parsed.response + const { body } = parsed.data + logger.info('Creating folder', { workspaceId: body.workspaceId }) + 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. + +### Adding a new boundary feature end-to-end + +When adding a new route + client surface, follow this order. Each step has one place it lives. + +1. **Author the contract first** in `apps/sim/lib/api/contracts/.ts` (or a subdirectory for large domains: `knowledge/`, `selectors/`, `tools/`). Define one schema per request slice (`params`, `query`, `body`, `headers`) and one for the response, then wrap with `defineRouteContract`. Export named type aliases (`z.input` for inputs, `z.output` for outputs). +2. **Implement the route** in `apps/sim/app/api//route.ts`. Auth always runs **before** `parseRequest` — never validate untrusted input before authenticating the caller. The route returns exactly the shape declared in `contract.response.schema`. +3. **Add the React Query hook** in `apps/sim/hooks/queries/.ts`. Use `requestJson(contract, input)` for the call. Build a hierarchical query-key factory (`all` → `lists()` → `list(workspaceId)` → `details()` → `detail(id)`) so invalidations can target prefixes. +4. **Use the hook in the component**. The mutation's `data` and `error` are fully typed from the contract; surface `error.message` (already extracted from the response body's `error` or `message` field by `requestJson`). + +### Schema review checklist (read the contract diff like a DB migration) + +LLMs will write contracts that compile but are sloppy. The human reviewer should optimize attention on: + +- **`required` vs `optional` vs `nullable` is correct**. `optional()` allows omission; `nullable()` allows `null`; chaining both creates a tri-state that's almost never what you want. +- **Response schema matches the route's actual JSON output**. The most common drift bug — route emits a field the schema doesn't declare, or omits a required field. Walk every `NextResponse.json(...)` callsite against the schema. +- **Error messages are descriptive**. `'fileName cannot be empty'` beats `'Required'`. Use the second arg of `min(1, '...')`, `nonempty('...')`, etc. For cross-field refines, use `superRefine` with a `path` and a message that names the failing field. +- **Bounds are set** on arrays (`.min(1)`, `.max(N)`), strings (`.min(1).max(N)` for IDs/names), and numbers (`.min().max()` for limits/sizes). +- **`z.unknown()` is a smell** unless the data is genuinely arbitrary (provider passthrough, user-defined tool result, JSON-RPC envelope). When kept, must be annotated `// untyped-response: ` in a `schema:` slot. +- **Discriminated unions over plain unions** when the wire has a discriminant field — gives clients exhaustive narrowing. + +CI (`bun run check:api-validation:strict`) catches structural violations (Zod imports in routes, raw `request.json()`, double casts, missing annotations). It does **not** catch these schema-quality judgments — that's the human's job in PR review. + ## Hooks ```typescript @@ -160,6 +257,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: @@ -228,6 +357,12 @@ Use Tailwind only, no inline styles. Use `cn()` from `@/lib/utils` for condition
``` +For equal height and width, use the `size-*` shorthand — never `h-[Npx] w-[Npx]` or `h-N w-N`. Default icon size is `size-[14px]`. + +```typescript + +``` + ## EMCN Components Import from `@/components/emcn`, never from subpaths (except CSS files). Use CVA when 2+ variants exist. @@ -302,6 +437,7 @@ tools/{service}/ ``` **Tool structure:** + ```typescript export const serviceTool: ToolConfig = { id: 'service_action', @@ -340,6 +476,7 @@ Register in `blocks/registry.ts` (alphabetically). **Important:** `tools.config.tool` runs during serialization (before variable resolution). Never do `Number()` or other type coercions there — dynamic references like `` will be destroyed. Use `tools.config.params` for type coercions (it runs during execution, after variables are resolved). **SubBlock Properties:** + ```typescript { id: 'field', title: 'Label', type: 'short-input', placeholder: '...', @@ -351,6 +488,7 @@ Register in `blocks/registry.ts` (alphabetically). ``` **condition examples:** + - `{ field: 'op', value: 'send' }` - show when op === 'send' - `{ field: 'op', value: ['a','b'] }` - show when op is 'a' OR 'b' - `{ field: 'op', value: 'x', not: true }` - show when op !== 'x' @@ -359,6 +497,7 @@ Register in `blocks/registry.ts` (alphabetically). **dependsOn:** `['field']` or `{ all: ['a'], any: ['b', 'c'] }` **File Input Pattern (basic/advanced mode):** + ```typescript // Basic: file-upload UI { id: 'uploadFile', type: 'file-upload', canonicalParamId: 'file', mode: 'basic' }, @@ -367,6 +506,7 @@ Register in `blocks/registry.ts` (alphabetically). ``` In `tools.config.tool`, normalize with: + ```typescript import { normalizeFileInput } from '@/blocks/utils' const file = normalizeFileInput(params.uploadFile || params.fileRef, { single: true }) @@ -396,12 +536,13 @@ Register in `triggers/registry.ts`. ### Integration Checklist -- [ ] Look up API docs -- [ ] Create `tools/{service}/` with types and tools -- [ ] Register tools in `tools/registry.ts` -- [ ] Add icon to `components/icons.tsx` -- [ ] Create block in `blocks/blocks/{service}.ts` -- [ ] Register block in `blocks/registry.ts` -- [ ] (Optional) Create and register triggers -- [ ] (If file uploads) Create internal API route with `downloadFileFromStorage` -- [ ] (If file uploads) Use `normalizeFileInput` in block config +- Look up API docs +- Create `tools/{service}/` with types and tools +- Register tools in `tools/registry.ts` +- Add icon to `components/icons.tsx` +- Create block in `blocks/blocks/{service}.ts` +- Register block in `blocks/registry.ts` +- (Optional) Create and register triggers +- (If file uploads) Create internal API route with `downloadFileFromStorage` +- (If file uploads) Use `normalizeFileInput` in block config + diff --git a/CLAUDE.md b/CLAUDE.md index bc4797c8314..1b6a602ef22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,23 +4,33 @@ 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 - **Styling**: Never update global styles. Keep all styling local to components - **ID Generation**: Never use `crypto.randomUUID()`, `nanoid`, or `uuid` package. Use `generateId()` (UUID v4) or `generateShortId()` (compact) from `@sim/utils/id` -- **Common Utilities**: Use shared helpers from `@sim/utils` instead of inline implementations. `sleep(ms)` from `@sim/utils/helpers` for delays, `toError(e)` from `@sim/utils/errors` to normalize caught values. +- **Common Utilities**: Use shared helpers from `@sim/utils` instead of inline implementations: + - `sleep(ms)` from `@sim/utils/helpers` — never `new Promise(resolve => setTimeout(resolve, ms))` + - `toError(e)` from `@sim/utils/errors` — normalize caught values to `Error` + - `getErrorMessage(e, fallback?)` from `@sim/utils/errors` — extract message string from unknown caught value; never write `e instanceof Error ? e.message : 'fallback'` + - `structuredClone(value)` — built-in deep clone; never `JSON.parse(JSON.stringify(...))` + - `omit(obj, keys)` / `filterUndefined(obj)` from `@sim/utils/object` — object trimming; never `Object.fromEntries(Object.entries(...).filter(...))` + - `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — never inline slice + ellipsis + - `backoffWithJitter(attempt, retryAfterMs, options?)` / `parseRetryAfter(header)` from `@sim/utils/retry` — shared retry pacing; never reimplement exponential backoff inline - **Package Manager**: Use `bun` and `bunx`, not `npm` and `npx` ## Architecture ### Core Principles + 1. Single Responsibility: Each component, hook, store has one clear purpose 2. Composition Over Complexity: Break down complex logic into smaller pieces 3. Type Safety First: TypeScript interfaces for all props, state, return types 4. Predictable State: Zustand for global state, useState for UI-only concerns ### Root Structure + ``` apps/sim/ ├── app/ # Next.js app router (pages, API routes) @@ -36,6 +46,7 @@ apps/sim/ ``` ### Naming Conventions + - Components: PascalCase (`WorkflowList`) - Hooks: `use` prefix (`useWorkflowOperations`) - Files: kebab-case (`workflow-list.tsx`) @@ -58,6 +69,7 @@ import { useWorkflowStore } from '../../../stores/workflows/store' Use barrel exports (`index.ts`) when a folder has 3+ exports. Do not re-export from non-barrel files; import directly from the source. ### Import Order + 1. React/core libraries 2. External libraries 3. UI components (`@/components/emcn`, `@/components/ui`) @@ -94,41 +106,109 @@ 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 four 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 +- `// boundary-raw-json: ` — placed on the line directly above a raw `await request.json()` / `await req.json()` read in a route handler. Use only when the body is a JSON-RPC envelope, a tolerant `.catch(() => ({}))` parse, or otherwise cannot go through `parseRequest` +- `// untyped-response: ` — placed on the line directly above a `schema: z.unknown()` response declaration in a contract file. Use only when the response body is genuinely opaque (user-supplied data, third-party passthrough) + +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, options?)` — fully contract-bound routes; parses params, query, body, and headers in one call. Pass `{}` for `context` on routes without route params, or the route's `context` argument when route params exist. Returns a discriminated union; check `parsed.success` and return `parsed.response` on failure +- `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 } 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) => { + const parsed = await parseRequest(createFolderContract, request, {}) + if (!parsed.success) return parsed.response + const { body } = parsed.data + logger.info('Creating folder', { workspaceId: body.workspaceId }) + return NextResponse.json({ ok: true }) }) +``` -// Route with params -export const DELETE = withRouteHandler(async ( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) => { - const { id } = await params - return NextResponse.json({ deleted: id }) -}) +### Composing with other middleware -// Composing with other middleware (withRouteHandler wraps the outermost layer) +```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(...)`. +### Adding a new boundary feature end-to-end + +When adding a new route + client surface, follow this order. Each step has one place it lives. + +1. **Author the contract first** in `apps/sim/lib/api/contracts/.ts` (or a subdirectory for large domains: `knowledge/`, `selectors/`, `tools/`). Define one schema per request slice (`params`, `query`, `body`, `headers`) and one for the response, then wrap with `defineRouteContract`. Export named type aliases (`z.input` for inputs, `z.output` for outputs). +2. **Implement the route** in `apps/sim/app/api//route.ts`. Auth always runs **before** `parseRequest` — never validate untrusted input before authenticating the caller. The route returns exactly the shape declared in `contract.response.schema`. +3. **Add the React Query hook** in `apps/sim/hooks/queries/.ts`. Use `requestJson(contract, input)` for the call. Build a hierarchical query-key factory (`all` → `lists()` → `list(workspaceId)` → `details()` → `detail(id)`) so invalidations can target prefixes. +4. **Use the hook in the component**. The mutation's `data` and `error` are fully typed from the contract; surface `error.message` (already extracted from the response body's `error` or `message` field by `requestJson`). + +### Schema review checklist (read the contract diff like a DB migration) + +LLMs will write contracts that compile but are sloppy. The human reviewer should optimize attention on: + +- **`required` vs `optional` vs `nullable` is correct**. `optional()` allows omission; `nullable()` allows `null`; chaining both creates a tri-state that's almost never what you want. +- **Response schema matches the route's actual JSON output**. The most common drift bug — route emits a field the schema doesn't declare, or omits a required field. Walk every `NextResponse.json(...)` callsite against the schema. +- **Error messages are descriptive**. `'fileName cannot be empty'` beats `'Required'`. Use the second arg of `min(1, '...')`, `nonempty('...')`, etc. For cross-field refines, use `superRefine` with a `path` and a message that names the failing field. +- **Bounds are set** on arrays (`.min(1)`, `.max(N)`), strings (`.min(1).max(N)` for IDs/names), and numbers (`.min().max()` for limits/sizes). +- **`z.unknown()` is a smell** unless the data is genuinely arbitrary (provider passthrough, user-defined tool result, JSON-RPC envelope). When kept, must be annotated `// untyped-response: ` in a `schema:` slot. +- **Discriminated unions over plain unions** when the wire has a discriminant field — gives clients exhaustive narrowing. + +CI (`bun run check:api-validation:strict`) catches structural violations (Zod imports in routes, raw `request.json()`, double casts, missing annotations). It does **not** catch these schema-quality judgments — that's the human's job in PR review. + ## Hooks ```typescript @@ -174,6 +254,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: @@ -242,6 +354,12 @@ Use Tailwind only, no inline styles. Use `cn()` from `@/lib/utils` for condition
``` +For equal height and width, use the `size-*` shorthand — never `h-[Npx] w-[Npx]` or `h-N w-N`. Default icon size is `size-[14px]`. + +```typescript + +``` + ## EMCN Components Import from `@/components/emcn`, never from subpaths (except CSS files). Use CVA when 2+ variants exist. @@ -316,6 +434,7 @@ tools/{service}/ ``` **Tool structure:** + ```typescript export const serviceTool: ToolConfig = { id: 'service_action', @@ -354,6 +473,7 @@ Register in `blocks/registry.ts` (alphabetically). **Important:** `tools.config.tool` runs during serialization (before variable resolution). Never do `Number()` or other type coercions there — dynamic references like `` will be destroyed. Use `tools.config.params` for type coercions (it runs during execution, after variables are resolved). **SubBlock Properties:** + ```typescript { id: 'field', title: 'Label', type: 'short-input', placeholder: '...', @@ -365,6 +485,7 @@ Register in `blocks/registry.ts` (alphabetically). ``` **condition examples:** + - `{ field: 'op', value: 'send' }` - show when op === 'send' - `{ field: 'op', value: ['a','b'] }` - show when op is 'a' OR 'b' - `{ field: 'op', value: 'x', not: true }` - show when op !== 'x' @@ -373,6 +494,7 @@ Register in `blocks/registry.ts` (alphabetically). **dependsOn:** `['field']` or `{ all: ['a'], any: ['b', 'c'] }` **File Input Pattern (basic/advanced mode):** + ```typescript // Basic: file-upload UI { id: 'uploadFile', type: 'file-upload', canonicalParamId: 'file', mode: 'basic' }, @@ -381,6 +503,7 @@ Register in `blocks/registry.ts` (alphabetically). ``` In `tools.config.tool`, normalize with: + ```typescript import { normalizeFileInput } from '@/blocks/utils' const file = normalizeFileInput(params.uploadFile || params.fileRef, { single: true }) @@ -410,12 +533,13 @@ Register in `triggers/registry.ts`. ### Integration Checklist -- [ ] Look up API docs -- [ ] Create `tools/{service}/` with types and tools -- [ ] Register tools in `tools/registry.ts` -- [ ] Add icon to `components/icons.tsx` -- [ ] Create block in `blocks/blocks/{service}.ts` -- [ ] Register block in `blocks/registry.ts` -- [ ] (Optional) Create and register triggers -- [ ] (If file uploads) Create internal API route with `downloadFileFromStorage` -- [ ] (If file uploads) Use `normalizeFileInput` in block config +- Look up API docs +- Create `tools/{service}/` with types and tools +- Register tools in `tools/registry.ts` +- Add icon to `components/icons.tsx` +- Create block in `blocks/blocks/{service}.ts` +- Register block in `blocks/registry.ts` +- (Optional) Create and register triggers +- (If file uploads) Create internal API route with `downloadFileFromStorage` +- (If file uploads) Use `normalizeFileInput` in block config + 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/docs/app/[lang]/[[...slug]]/page.tsx b/apps/docs/app/[lang]/[[...slug]]/page.tsx index 8bf0c5fd806..6fb69448f8b 100644 --- a/apps/docs/app/[lang]/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/[[...slug]]/page.tsx @@ -48,7 +48,7 @@ const APIPage = createAPIPage(openapi, { {slots.header} {slots.apiPlayground} {slots.authSchemes &&
{slots.authSchemes}
} - {slots.paremeters} + {slots.parameters} {slots.body &&
{slots.body}
} {slots.responses} {slots.callbacks} @@ -306,9 +306,12 @@ export async function generateMetadata(props: { siteName: 'Sim Documentation', type: 'article', locale: OG_LOCALE_MAP[lang] ?? 'en_US', - alternateLocale: i18n.languages - .filter((l) => l !== lang) - .map((l) => OG_LOCALE_MAP[l] ?? 'en_US'), + alternateLocale: i18n.languages.reduce((locales, l) => { + if (l !== lang) { + locales.push(OG_LOCALE_MAP[l] ?? 'en_US') + } + return locales + }, []), images: [ { url: ogImageUrl, diff --git a/apps/docs/app/[lang]/layout.tsx b/apps/docs/app/[lang]/layout.tsx index 4f32bf20765..8c25b2f411f 100644 --- a/apps/docs/app/[lang]/layout.tsx +++ b/apps/docs/app/[lang]/layout.tsx @@ -11,6 +11,7 @@ import { import { Navbar } from '@/components/navbar/navbar' import { SimLogoFull } from '@/components/ui/sim-logo' import { i18n } from '@/lib/i18n' +import { serializeJsonLd } from '@/lib/json-ld' import { source } from '@/lib/source' import { DOCS_BASE_URL } from '@/lib/urls' import '../global.css' @@ -78,14 +79,6 @@ export default async function Layout({ children, params }: LayoutProps) { }, }, inLanguage: lang, - potentialAction: { - '@type': 'SearchAction', - target: { - '@type': 'EntryPoint', - urlTemplate: `${DOCS_BASE_URL}/api/search?q={search_term_string}`, - }, - 'query-input': 'required name=search_term_string', - }, } return ( @@ -97,7 +90,7 @@ export default async function Layout({ children, params }: LayoutProps) {

Trello connection failed. Redirecting...

`, + { + status: 400, + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + }, + } + ) +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const parsed = await parseRequest(trelloCallbackContract, request, {}) + if (!parsed.success) return parsed.response + const baseUrl = getBaseUrl() + const queryState = parsed.data.query.state + const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value + + if (!queryState || !cookieState || queryState !== cookieState) { + logger.warn('Trello callback rejected: state mismatch or missing state', { + hasQueryState: Boolean(queryState), + hasCookieState: Boolean(cookieState), + }) + const response = renderErrorPage(baseUrl, 'error=trello_state_mismatch') + response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: '/api/auth/trello' }) + return response + } + + const safeState = escapeForJsString(queryState) return new NextResponse( ` @@ -92,7 +135,7 @@ export const GET = withRouteHandler(async () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ token: token }) + body: JSON.stringify({ token: token, state: '${safeState}' }) }) .then(response => response.json()) .then(data => { diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index abf8c7603a0..156ed9a65d6 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' @@ -14,6 +16,14 @@ const logger = createLogger('TrelloStore') export const dynamic = 'force-dynamic' +const TRELLO_STATE_COOKIE = 'trello_oauth_state' +const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello' + +function clearStateCookie(response: NextResponse) { + response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) + return response +} + export const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -22,11 +32,22 @@ 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 : '' + const parsed = await parseRequest(storeTrelloTokenContract, request, {}) + if (!parsed.success) return parsed.response + const { token, state } = parsed.data.body - if (!token) { - return NextResponse.json({ success: false, error: 'Token required' }, { status: 400 }) + const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value + if (!cookieState || cookieState !== state) { + logger.warn('Trello store rejected: state mismatch', { + hasCookieState: Boolean(cookieState), + userId: session.user.id, + }) + return clearStateCookie( + NextResponse.json( + { success: false, error: 'Invalid or expired authorization state' }, + { status: 400 } + ) + ) } const apiKey = env.TRELLO_API_KEY @@ -124,7 +145,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } - return NextResponse.json({ success: true }) + return clearStateCookie(NextResponse.json({ success: true })) } catch (error) { logger.error('Error storing Trello token:', error) return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/app/api/billing/credits/route.ts b/apps/sim/app/api/billing/credits/route.ts index 7f7e5390221..2a35f5cd43e 100644 --- a/apps/sim/app/api/billing/credits/route.ts +++ b/apps/sim/app/api/billing/credits/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 { purchaseCreditsContract } from '@/lib/api/contracts/subscription' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getCreditBalance } from '@/lib/billing/credits/balance' import { purchaseCredits } from '@/lib/billing/credits/purchase' @@ -9,11 +10,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) { @@ -39,20 +35,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const validation = PurchaseSchema.safeParse(body) - - if (!validation.success) { - return NextResponse.json( - { error: 'Invalid amount. Must be between $10 and $1000' }, - { status: 400 } - ) - } + const parsed = await parseRequest( + purchaseCreditsContract, + request, + {}, + { + validationErrorResponse: () => + NextResponse.json( + { error: 'Invalid amount. Must be between $10 and $1000' }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response const result = await purchaseCredits({ userId: session.user.id, - amountDollars: validation.data.amount, - requestId: validation.data.requestId, + amountDollars: parsed.data.body.amount, + requestId: parsed.data.body.requestId, }) if (!result.success) { @@ -65,11 +65,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { actorEmail: session.user.email, action: AuditAction.CREDIT_PURCHASED, resourceType: AuditResourceType.BILLING, - resourceId: validation.data.requestId, - description: `Purchased $${validation.data.amount} in credits`, + resourceId: parsed.data.body.requestId, + description: `Purchased $${parsed.data.body.amount} in credits`, metadata: { - amountDollars: validation.data.amount, - requestId: validation.data.requestId, + amountDollars: parsed.data.body.amount, + requestId: parsed.data.body.requestId, }, request, }) 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..33a9f40a082 100644 --- a/apps/sim/app/api/billing/switch-plan/route.ts +++ b/apps/sim/app/api/billing/switch-plan/route.ts @@ -1,10 +1,11 @@ import { db } from '@sim/db' import { subscription as subscriptionTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' +import { getErrorMessage, toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { billingSwitchPlanContract } from '@/lib/api/contracts/subscription' +import { parseRequest } 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 * @@ -52,16 +48,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Billing is not enabled' }, { status: 400 }) } - const body = await request.json() - const parsed = switchPlanSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: 'Invalid request', details: parsed.error.flatten() }, - { status: 400 } - ) - } + const parsed = await parseRequest(billingSwitchPlanContract, request, {}) + if (!parsed.success) return parsed.response - const { targetPlanName, interval } = parsed.data + const { targetPlanName, interval } = parsed.data.body const userId = session.user.id const sub = await getHighestPrioritySubscription(userId) @@ -191,7 +181,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { error: toError(error).message, }) return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to switch plan' }, + { error: getErrorMessage(error, 'Failed to switch plan') }, { status: 500 } ) } diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 4a3bbc46a92..008706ba8ad 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -1,8 +1,10 @@ +import type { Span } from '@opentelemetry/api' 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 { billingUpdateCostContract } from '@/lib/api/contracts/subscription' +import { parseRequest } 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 +19,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) @@ -50,10 +40,7 @@ export const POST = withRouteHandler((req: NextRequest) => ) ) -async function updateCostInner( - req: NextRequest, - span: import('@opentelemetry/api').Span -): Promise { +async function updateCostInner(req: NextRequest, span: Span): Promise { const requestId = generateRequestId() const startTime = Date.now() let claim: AtomicClaimResult | null = null @@ -91,27 +78,41 @@ async function updateCostInner( ) } - const body = await req.json() - const validation = UpdateCostSchema.safeParse(body) - - if (!validation.success) { - logger.warn(`[${requestId}] Invalid request body`, { - errors: validation.error.issues, - }) - span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.InvalidBody) - span.setAttribute(TraceAttr.HttpStatusCode, 400) - return NextResponse.json( - { - success: false, - error: 'Invalid request body', - details: validation.error.issues, + const parsed = await parseRequest( + billingUpdateCostContract, + req, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request body`, { + errors: error.issues, + }) + span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.InvalidBody) + span.setAttribute(TraceAttr.HttpStatusCode, 400) + return NextResponse.json( + { + success: false, + error: 'Invalid request body', + details: error.issues, + }, + { status: 400 } + ) }, - { status: 400 } - ) - } + invalidJsonResponse: () => { + span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.InvalidBody) + span.setAttribute(TraceAttr.HttpStatusCode, 400) + return NextResponse.json( + { success: false, error: 'Request body must be valid JSON' }, + { status: 400 } + ) + }, + } + ) + + if (!parsed.success) return parsed.response const { userId, cost, model, inputTokens, outputTokens, source, idempotencyKey } = - validation.data + parsed.data.body const isMcp = source === 'mcp_copilot' span.setAttributes({ 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 8069757ea79..a860df8eb28 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts @@ -26,7 +26,6 @@ const { mockDbUpdate, mockSendEmail, mockRenderOTPEmail, - mockAddCorsHeaders, mockSetChatAuthCookie, mockGetStorageMethod, mockZodParse, @@ -50,7 +49,6 @@ const { const mockDbUpdate = vi.fn() const mockSendEmail = vi.fn() const mockRenderOTPEmail = vi.fn() - const mockAddCorsHeaders = vi.fn() const mockSetChatAuthCookie = vi.fn() const mockGetStorageMethod = vi.fn() const mockZodParse = vi.fn() @@ -69,7 +67,6 @@ const { mockDbUpdate, mockSendEmail, mockRenderOTPEmail, - mockAddCorsHeaders, mockSetChatAuthCookie, mockGetStorageMethod, mockZodParse, @@ -112,6 +109,16 @@ vi.mock('@/lib/core/storage', () => ({ getStorageMethod: mockGetStorageMethod, })) +const { mockCheckRateLimitDirect } = vi.hoisted(() => ({ + mockCheckRateLimitDirect: vi.fn(), +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mockCheckRateLimitDirect + }, +})) + vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: mockSendEmail, })) @@ -121,7 +128,6 @@ vi.mock('@/components/emails', () => ({ })) vi.mock('@/lib/core/security/deployment', () => ({ - addCorsHeaders: mockAddCorsHeaders, isEmailAllowed: (email: string, allowedEmails: string[]) => { if (allowedEmails.includes(email)) return true const atIndex = email.indexOf('@') @@ -157,18 +163,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 + }, + } + ), } }) @@ -223,7 +244,6 @@ describe('Chat OTP API Route', () => { mockSendEmail.mockResolvedValue({ success: true }) mockRenderOTPEmail.mockResolvedValue('OTP Email') - mockAddCorsHeaders.mockImplementation((response: unknown) => response) mockCreateSuccessResponse.mockImplementation((data: unknown) => ({ json: () => Promise.resolve(data), status: 200, @@ -234,6 +254,13 @@ describe('Chat OTP API Route', () => { })) requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('req-123') + requestUtilsMockFns.mockGetClientIp.mockReturnValue('1.2.3.4') + + mockCheckRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 10, + resetAt: new Date(Date.now() + 60_000), + }) mockZodParse.mockImplementation((data: unknown) => data) @@ -283,6 +310,137 @@ describe('Chat OTP API Route', () => { }) }) + describe('POST - Rate limiting', () => { + const buildDeploymentSelect = () => + mockDbSelect.mockImplementationOnce(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: mockChatId, + authType: 'email', + allowedEmails: [mockEmail], + title: 'Test Chat', + }, + ]), + }), + }), + })) + + it('returns 429 with Retry-After when IP rate limit is exceeded', async () => { + mockCheckRateLimitDirect.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date(Date.now() + 900_000), + retryAfterMs: 900_000, + }) + + const headerSet = vi.fn() + mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({ + json: () => Promise.resolve({ error: message }), + status, + headers: { set: headerSet }, + })) + + const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + const response = await POST(request, { + params: Promise.resolve({ identifier: mockIdentifier }), + }) + + expect(response.status).toBe(429) + expect(headerSet).toHaveBeenCalledWith('Retry-After', '900') + expect(mockSendEmail).not.toHaveBeenCalled() + expect(mockDbSelect).not.toHaveBeenCalled() + }) + + it('returns 429 with Retry-After when email rate limit is exceeded', async () => { + mockCheckRateLimitDirect + .mockResolvedValueOnce({ + allowed: true, + remaining: 9, + resetAt: new Date(Date.now() + 60_000), + }) + .mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date(Date.now() + 900_000), + retryAfterMs: 900_000, + }) + + const headerSet = vi.fn() + mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({ + json: () => Promise.resolve({ error: message }), + status, + headers: { set: headerSet }, + })) + + buildDeploymentSelect() + + const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + const response = await POST(request, { + params: Promise.resolve({ identifier: mockIdentifier }), + }) + + expect(response.status).toBe(429) + expect(headerSet).toHaveBeenCalledWith('Retry-After', '900') + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('falls back to refill interval when retryAfterMs is missing', async () => { + mockCheckRateLimitDirect.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date(Date.now() + 900_000), + }) + + const headerSet = vi.fn() + mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({ + json: () => Promise.resolve({ error: message }), + status, + headers: { set: headerSet }, + })) + + const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(headerSet).toHaveBeenCalledWith('Retry-After', '900') + }) + + it('folds spoofed `unknown` client IPs into a single shared bucket', async () => { + requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce('unknown') + buildDeploymentSelect() + + const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(2) + expect(mockCheckRateLimitDirect).toHaveBeenCalledWith( + expect.stringMatching(/^chat-otp:ip:.*:unknown$/), + expect.any(Object) + ) + expect(mockCheckRateLimitDirect).toHaveBeenCalledWith( + expect.stringContaining('chat-otp:email:'), + expect.any(Object) + ) + }) + }) + describe('POST - Store OTP (Database path)', () => { beforeEach(() => { mockGetStorageMethod.mockReturnValue('database') diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index 433159ff600..aeb1b69f450 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -1,16 +1,25 @@ -import { randomInt } from 'crypto' import { db } from '@sim/db' -import { chat, verification } from '@sim/db/schema' +import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq, gt, isNull } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { z } from 'zod' import { renderOTPEmail } from '@/components/emails' -import { getRedisClient } from '@/lib/core/config/redis' -import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment' -import { getStorageMethod } from '@/lib/core/storage' -import { generateRequestId } from '@/lib/core/utils/request' +import { requestChatEmailOtpContract, verifyChatEmailOtpContract } from '@/lib/api/contracts/chats' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { RateLimiter } from '@/lib/core/rate-limiter' +import { isEmailAllowed } from '@/lib/core/security/deployment' +import { + decodeOTPValue, + deleteOTP, + generateOTP, + getOTP, + incrementOTPAttempts, + MAX_OTP_ATTEMPTS, + OTP_EMAIL_RATE_LIMIT, + OTP_IP_RATE_LIMIT, + storeOTP, +} from '@/lib/core/security/otp' +import { generateRequestId, getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' import { setChatAuthCookie } from '@/app/api/chat/utils' @@ -18,204 +27,35 @@ import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/ const logger = createLogger('ChatOtpAPI') -function generateOTP(): string { - return randomInt(100000, 1000000).toString() -} - -const OTP_EXPIRY = 15 * 60 // 15 minutes -const OTP_EXPIRY_MS = OTP_EXPIRY * 1000 -const MAX_OTP_ATTEMPTS = 5 - -/** - * OTP values are stored as "code:attempts" (e.g. "654321:0"). - * This keeps the attempt counter in the same key/row as the OTP itself. - */ -function encodeOTPValue(otp: string, attempts: number): string { - return `${otp}:${attempts}` -} - -function decodeOTPValue(value: string): { otp: string; attempts: number } { - const lastColon = value.lastIndexOf(':') - if (lastColon === -1) return { otp: value, attempts: 0 } - const attempts = Number.parseInt(value.slice(lastColon + 1), 10) - return { otp: value.slice(0, lastColon), attempts: Number.isNaN(attempts) ? 0 : attempts } -} - -/** - * Stores OTP in Redis or database depending on storage method. - * Uses the verification table for database storage. - */ -async function storeOTP(email: string, chatId: string, otp: string): Promise { - const identifier = `chat-otp:${chatId}:${email}` - const storageMethod = getStorageMethod() - const value = encodeOTPValue(otp, 0) - - if (storageMethod === 'redis') { - const redis = getRedisClient() - if (!redis) { - throw new Error('Redis configured but client unavailable') - } - await redis.set(`otp:${email}:${chatId}`, value, 'EX', OTP_EXPIRY) - } else { - const now = new Date() - const expiresAt = new Date(now.getTime() + OTP_EXPIRY_MS) - - await db.transaction(async (tx) => { - await tx.delete(verification).where(eq(verification.identifier, identifier)) - await tx.insert(verification).values({ - id: generateId(), - identifier, - value, - expiresAt, - createdAt: now, - updatedAt: now, - }) - }) - } -} - -async function getOTP(email: string, chatId: string): Promise { - const identifier = `chat-otp:${chatId}:${email}` - const storageMethod = getStorageMethod() - - if (storageMethod === 'redis') { - const redis = getRedisClient() - if (!redis) { - throw new Error('Redis configured but client unavailable') - } - return redis.get(`otp:${email}:${chatId}`) - } - - const now = new Date() - const [record] = await db - .select({ value: verification.value }) - .from(verification) - .where(and(eq(verification.identifier, identifier), gt(verification.expiresAt, now))) - .limit(1) - - return record?.value ?? null -} - -/** - * Lua script for atomic OTP attempt increment. - * Returns: "LOCKED" if max attempts reached (key deleted), new encoded value otherwise, nil if key missing. - */ -const ATOMIC_INCREMENT_SCRIPT = ` -local val = redis.call('GET', KEYS[1]) -if not val then return nil end -local colon = val:find(':([^:]*$)') -local otp, attempts -if colon then - otp = val:sub(1, colon - 1) - attempts = tonumber(val:sub(colon + 1)) or 0 -else - otp = val - attempts = 0 -end -attempts = attempts + 1 -if attempts >= tonumber(ARGV[1]) then - redis.call('DEL', KEYS[1]) - return 'LOCKED' -end -local newVal = otp .. ':' .. attempts -local ttl = redis.call('TTL', KEYS[1]) -if ttl > 0 then - redis.call('SET', KEYS[1], newVal, 'EX', ttl) -else - redis.call('SET', KEYS[1], newVal) -end -return newVal -` - -/** - * Atomically increments OTP attempts. Returns 'locked' if max reached, 'incremented' otherwise. - */ -async function incrementOTPAttempts( - email: string, - chatId: string, - currentValue: string -): Promise<'locked' | 'incremented'> { - const identifier = `chat-otp:${chatId}:${email}` - const storageMethod = getStorageMethod() - - if (storageMethod === 'redis') { - const redis = getRedisClient() - if (!redis) { - throw new Error('Redis configured but client unavailable') - } - const key = `otp:${email}:${chatId}` - const result = await redis.eval(ATOMIC_INCREMENT_SCRIPT, 1, key, MAX_OTP_ATTEMPTS) - if (result === null || result === 'LOCKED') return 'locked' - return 'incremented' - } - - // DB path: optimistic locking with retry on conflict - const MAX_RETRIES = 3 - let value = currentValue - - for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { - const { otp, attempts } = decodeOTPValue(value) - const newAttempts = attempts + 1 - - if (newAttempts >= MAX_OTP_ATTEMPTS) { - await db.delete(verification).where(eq(verification.identifier, identifier)) - return 'locked' - } - - const newValue = encodeOTPValue(otp, newAttempts) - const updated = await db - .update(verification) - .set({ value: newValue, updatedAt: new Date() }) - .where(and(eq(verification.identifier, identifier), eq(verification.value, value))) - .returning({ id: verification.id }) - - if (updated.length > 0) return 'incremented' - - // Conflict: another request already incremented — re-read and retry - const fresh = await getOTP(email, chatId) - if (!fresh) return 'locked' - value = fresh - } - - // Exhausted retries — re-read final state to determine outcome - const final = await getOTP(email, chatId) - if (!final) return 'locked' - const { attempts: finalAttempts } = decodeOTPValue(final) - return finalAttempts >= MAX_OTP_ATTEMPTS ? 'locked' : 'incremented' -} - -async function deleteOTP(email: string, chatId: string): Promise { - const identifier = `chat-otp:${chatId}:${email}` - const storageMethod = getStorageMethod() - - if (storageMethod === 'redis') { - const redis = getRedisClient() - if (!redis) { - throw new Error('Redis configured but client unavailable') - } - await redis.del(`otp:${email}:${chatId}`) - } else { - await db.delete(verification).where(eq(verification.identifier, identifier)) - } -} - -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'), -}) +const rateLimiter = new RateLimiter() export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ identifier: string }> }) => { - const { identifier } = await params + async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => { + const { identifier } = await context.params const requestId = generateRequestId() try { - const body = await request.json() - const { email } = otpRequestSchema.parse(body) + const ip = getClientIp(request) + const ipRateLimit = await rateLimiter.checkRateLimitDirect( + `chat-otp:ip:${identifier}:${ip}`, + OTP_IP_RATE_LIMIT + ) + if (!ipRateLimit.allowed) { + logger.warn(`[${requestId}] OTP IP rate limit exceeded for ${identifier} from ${ip}`) + const retryAfter = Math.ceil( + (ipRateLimit.retryAfterMs ?? OTP_IP_RATE_LIMIT.refillIntervalMs) / 1000 + ) + const response = createErrorResponse('Too many requests. Please try again later.', 429) + response.headers.set('Retry-After', String(retryAfter)) + return response + } + + const parsed = await parseRequest(requestChatEmailOtpContract, request, context, { + validationErrorResponse: (error) => + createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400), + }) + if (!parsed.success) return parsed.response + const { email } = parsed.data.body const deploymentResult = await db .select({ @@ -232,16 +72,13 @@ export const POST = withRouteHandler( if (deploymentResult.length === 0) { logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + return createErrorResponse('Chat not found', 404) } const deployment = deploymentResult[0] if (deployment.authType !== 'email') { - return addCorsHeaders( - createErrorResponse('This chat does not use email authentication', 400), - request - ) + return createErrorResponse('This chat does not use email authentication', 400) } const allowedEmails: string[] = Array.isArray(deployment.allowedEmails) @@ -249,14 +86,30 @@ export const POST = withRouteHandler( : [] if (!isEmailAllowed(email, allowedEmails)) { - return addCorsHeaders( - createErrorResponse('Email not authorized for this chat', 403), - request + return createErrorResponse('Email not authorized for this chat', 403) + } + + const emailRateLimit = await rateLimiter.checkRateLimitDirect( + `chat-otp:email:${deployment.id}:${email.toLowerCase()}`, + OTP_EMAIL_RATE_LIMIT + ) + if (!emailRateLimit.allowed) { + logger.warn( + `[${requestId}] OTP email rate limit exceeded for ${email} on chat ${deployment.id}` ) + const retryAfter = Math.ceil( + (emailRateLimit.retryAfterMs ?? OTP_EMAIL_RATE_LIMIT.refillIntervalMs) / 1000 + ) + const response = createErrorResponse( + 'Too many verification code requests. Please try again later.', + 429 + ) + response.headers.set('Retry-After', String(retryAfter)) + return response } const otp = generateOTP() - await storeOTP(email, deployment.id, otp) + await storeOTP('chat', deployment.id, email, otp) const emailHtml = await renderOTPEmail( otp, @@ -273,38 +126,30 @@ export const POST = withRouteHandler( if (!emailResult.success) { logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message) - return addCorsHeaders( - createErrorResponse('Failed to send verification email', 500), - request - ) + return createErrorResponse('Failed to send verification email', 500) } 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 - ) - } + return createSuccessResponse({ message: 'Verification code sent' }) + } catch (error) { logger.error(`[${requestId}] Error processing OTP request:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to process request', 500), - request - ) + return createErrorResponse('Failed to process request', 500) } } ) export const PUT = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ identifier: string }> }) => { - const { identifier } = await params + async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => { + const { identifier } = await context.params const requestId = generateRequestId() try { - const body = await request.json() - const { email, otp } = otpVerifySchema.parse(body) + const parsed = await parseRequest(verifyChatEmailOtpContract, request, context, { + validationErrorResponse: (error) => + createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400), + }) + if (!parsed.success) return parsed.response + const { email, otp } = parsed.data.body const deploymentResult = await db .select({ @@ -324,70 +169,49 @@ export const PUT = withRouteHandler( if (deploymentResult.length === 0) { logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + return createErrorResponse('Chat not found', 404) } const deployment = deploymentResult[0] - const storedValue = await getOTP(email, deployment.id) + const storedValue = await getOTP('chat', deployment.id, email) if (!storedValue) { - return addCorsHeaders( - createErrorResponse('No verification code found, request a new one', 400), - request - ) + return createErrorResponse('No verification code found, request a new one', 400) } const { otp: storedOTP, attempts } = decodeOTPValue(storedValue) if (attempts >= MAX_OTP_ATTEMPTS) { - await deleteOTP(email, deployment.id) + await deleteOTP('chat', deployment.id, email) logger.warn(`[${requestId}] OTP already locked out for ${email}`) - return addCorsHeaders( - createErrorResponse('Too many failed attempts. Please request a new code.', 429), - request - ) + return createErrorResponse('Too many failed attempts. Please request a new code.', 429) } if (storedOTP !== otp) { - const result = await incrementOTPAttempts(email, deployment.id, storedValue) + const result = await incrementOTPAttempts('chat', deployment.id, email, storedValue) if (result === 'locked') { logger.warn(`[${requestId}] OTP invalidated after max failed attempts for ${email}`) - return addCorsHeaders( - createErrorResponse('Too many failed attempts. Please request a new code.', 429), - request - ) + return createErrorResponse('Too many failed attempts. Please request a new code.', 429) } - return addCorsHeaders(createErrorResponse('Invalid verification code', 400), request) + return createErrorResponse('Invalid verification code', 400) } - await deleteOTP(email, deployment.id) + await deleteOTP('chat', deployment.id, email) - const response = addCorsHeaders( - createSuccessResponse({ - id: deployment.id, - title: deployment.title, - description: deployment.description, - customizations: deployment.customizations, - authType: deployment.authType, - outputConfigs: deployment.outputConfigs, - }), - request - ) + const response = createSuccessResponse({ + id: deployment.id, + title: deployment.title, + description: deployment.description, + customizations: deployment.customizations, + authType: deployment.authType, + outputConfigs: deployment.outputConfigs, + }) setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) return response - } catch (error: any) { - if (error instanceof z.ZodError) { - return addCorsHeaders( - createErrorResponse(error.errors[0]?.message || 'Invalid request', 400), - request - ) - } + } catch (error) { logger.error(`[${requestId}] Error verifying OTP:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to process request', 500), - request - ) + return createErrorResponse('Failed to process request', 500) } } ) diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts index bddc7d99dde..b1b36d60b6d 100644 --- a/apps/sim/app/api/chat/[identifier]/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/route.test.ts @@ -29,9 +29,12 @@ function createMockNextRequest( ...headers, }) + const parsedUrl = new URL(url) + return { method, headers: headersObj, + nextUrl: parsedUrl, cookies: { get: vi.fn().mockReturnValue(undefined), }, @@ -60,13 +63,11 @@ const createMockStream = () => { }) } -const { mockAddCorsHeaders, mockValidateChatAuth, mockSetChatAuthCookie, mockValidateAuthToken } = - vi.hoisted(() => ({ - mockAddCorsHeaders: vi.fn().mockImplementation((response: Response) => response), - mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }), - mockSetChatAuthCookie: vi.fn(), - mockValidateAuthToken: vi.fn().mockReturnValue(false), - })) +const { mockValidateChatAuth, mockSetChatAuthCookie, mockValidateAuthToken } = vi.hoisted(() => ({ + mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }), + mockSetChatAuthCookie: vi.fn(), + mockValidateAuthToken: vi.fn().mockReturnValue(false), +})) const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse @@ -78,7 +79,6 @@ vi.mock('@sim/db', () => ({ })) vi.mock('@/lib/core/security/deployment', () => ({ - addCorsHeaders: mockAddCorsHeaders, validateAuthToken: mockValidateAuthToken, setDeploymentAuthCookie: vi.fn(), isEmailAllowed: vi.fn().mockReturnValue(false), @@ -178,7 +178,6 @@ describe('Chat Identifier API Route', () => { }, }) - mockAddCorsHeaders.mockImplementation((response: Response) => response) mockValidateChatAuth.mockResolvedValue({ authorized: true }) mockValidateAuthToken.mockReturnValue(false) mockCreateErrorResponse.mockImplementation((message: string, status: number, code?: string) => { @@ -441,6 +440,8 @@ describe('Chat Identifier API Route', () => { const req = { method: 'POST', headers: new Headers(), + nextUrl: new URL('http://localhost:3000/api/test'), + cookies: { get: vi.fn().mockReturnValue(undefined) }, json: vi.fn().mockRejectedValue(new Error('Invalid JSON')), } as any diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 959f8d27e78..7a4a12d5754 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -4,8 +4,9 @@ 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 { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment' +import { deployedChatPostContract } from '@/lib/api/contracts/chats' +import { parseRequest } from '@/lib/api/server' +import { validateAuthToken } from '@/lib/core/security/deployment' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { preprocessExecution } from '@/lib/execution/preprocessing' @@ -36,51 +37,24 @@ 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' export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ identifier: string }> }) => { - const { identifier } = await params + async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => { + const { identifier } = await context.params const requestId = generateRequestId() try { - let parsedBody - try { - const rawBody = await request.json() - const validation = chatPostBodySchema.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) { - return addCorsHeaders(createErrorResponse('Invalid request body', 400), request) - } + const parsed = await parseRequest(deployedChatPostContract, request, context, { + validationErrorResponse: (err) => { + const message = err.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ') + return createErrorResponse(`Invalid request body: ${message}`, 400, 'VALIDATION_ERROR') + }, + invalidJsonResponse: () => createErrorResponse('Invalid request body', 400), + }) + if (!parsed.success) return parsed.response + const parsedBody = parsed.data.body const deploymentResult = await db .select({ @@ -102,7 +76,7 @@ export const POST = withRouteHandler( if (deploymentResult.length === 0) { logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + return createErrorResponse('Chat not found', 404) } const deployment = deploymentResult[0] @@ -121,10 +95,7 @@ export const POST = withRouteHandler( logger.warn( `[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace` ) - return addCorsHeaders( - createErrorResponse('This chat is currently unavailable', 403), - request - ) + return createErrorResponse('This chat is currently unavailable', 403) } const executionId = generateId() @@ -149,35 +120,28 @@ export const POST = withRouteHandler( traceSpans: [], }) - return addCorsHeaders( - createErrorResponse('This chat is currently unavailable', 403), - request - ) + return createErrorResponse('This chat is currently unavailable', 403) } const authResult = await validateChatAuth(requestId, deployment, request, parsedBody) if (!authResult.authorized) { - return addCorsHeaders( - createErrorResponse(authResult.error || 'Authentication required', 401), - request - ) + return createErrorResponse(authResult.error || 'Authentication required', 401) } const { input, password, email, conversationId, files } = parsedBody if ((password || email) && !input) { - const response = addCorsHeaders( - createSuccessResponse(toChatConfigResponse(deployment)), - request - ) + const response = createSuccessResponse(toChatConfigResponse(deployment)) - setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) + if (deployment.authType !== 'sso') { + setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) + } return response } if (!input && (!files || files.length === 0)) { - return addCorsHeaders(createErrorResponse('No input provided', 400), request) + return createErrorResponse('No input provided', 400) } const executionId = generateId() @@ -202,12 +166,9 @@ export const POST = withRouteHandler( if (!preprocessResult.success) { logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`) - return addCorsHeaders( - createErrorResponse( - preprocessResult.error?.message || 'Failed to process request', - preprocessResult.error?.statusCode || 500 - ), - request + return createErrorResponse( + preprocessResult.error?.message || 'Failed to process request', + preprocessResult.error?.statusCode || 500 ) } @@ -216,10 +177,7 @@ export const POST = withRouteHandler( const workspaceId = workflowRecord?.workspaceId if (!workspaceId) { logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`) - return addCorsHeaders( - createErrorResponse('Workflow has no associated workspace', 500), - request - ) + return createErrorResponse('Workflow has no associated workspace', 500) } try { @@ -294,6 +252,9 @@ export const POST = withRouteHandler( workflowTriggerType: 'chat', }, executionId, + workspaceId, + workflowId: deployment.workflowId, + userId: workspaceOwnerId, executeFn: async ({ onStream, onBlockComplete, abortSignal }) => executeWorkflow( workflowForExecution, @@ -319,20 +280,14 @@ export const POST = withRouteHandler( status: 200, headers: SSE_HEADERS, }) - return addCorsHeaders(streamResponse, request) + return streamResponse } catch (error: any) { logger.error(`[${requestId}] Error processing chat request:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to process request', 500), - request - ) + return createErrorResponse(error.message || 'Failed to process request', 500) } } catch (error: any) { logger.error(`[${requestId}] Error processing chat request:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to process request', 500), - request - ) + return createErrorResponse(error.message || 'Failed to process request', 500) } } ) @@ -362,17 +317,14 @@ export const GET = withRouteHandler( if (deploymentResult.length === 0) { logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + return createErrorResponse('Chat not found', 404) } const deployment = deploymentResult[0] if (!deployment.isActive) { logger.warn(`[${requestId}] Chat is not active: ${identifier}`) - return addCorsHeaders( - createErrorResponse('This chat is currently unavailable', 403), - request - ) + return createErrorResponse('This chat is currently unavailable', 403) } const cookieName = `chat_auth_${deployment.id}` @@ -380,10 +332,11 @@ export const GET = withRouteHandler( if ( deployment.authType !== 'public' && + deployment.authType !== 'sso' && authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password) ) { - return addCorsHeaders(createSuccessResponse(toChatConfigResponse(deployment)), request) + return createSuccessResponse(toChatConfigResponse(deployment)) } const authResult = await validateChatAuth(requestId, deployment, request) @@ -391,19 +344,13 @@ export const GET = withRouteHandler( logger.info( `[${requestId}] Authentication required for chat: ${identifier}, type: ${deployment.authType}` ) - return addCorsHeaders( - createErrorResponse(authResult.error || 'Authentication required', 401), - request - ) + return createErrorResponse(authResult.error || 'Authentication required', 401) } - return addCorsHeaders(createSuccessResponse(toChatConfigResponse(deployment)), request) + return createSuccessResponse(toChatConfigResponse(deployment)) } catch (error: any) { logger.error(`[${requestId}] Error fetching chat info:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to fetch chat information', 500), - request - ) + return createErrorResponse(error.message || 'Failed to fetch chat information', 500) } } ) diff --git a/apps/sim/app/api/chat/[identifier]/sso/route.ts b/apps/sim/app/api/chat/[identifier]/sso/route.ts new file mode 100644 index 00000000000..c6ab98cfe94 --- /dev/null +++ b/apps/sim/app/api/chat/[identifier]/sso/route.ts @@ -0,0 +1,76 @@ +import { db } from '@sim/db' +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 { chatSSOContract } from '@/lib/api/contracts/chats' +import { parseRequest } from '@/lib/api/server' +import type { TokenBucketConfig } from '@/lib/core/rate-limiter' +import { RateLimiter } from '@/lib/core/rate-limiter' +import { isEmailAllowed } from '@/lib/core/security/deployment' +import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' + +const logger = createLogger('ChatSSOAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const rateLimiter = new RateLimiter() + +const SSO_IP_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 20, + refillRate: 20, + refillIntervalMs: 15 * 60_000, +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => { + const requestId = generateRequestId() + + const ip = getClientIp(request) + const ipRateLimit = await rateLimiter.checkRateLimitDirect( + `chat-sso:ip:${ip}`, + SSO_IP_RATE_LIMIT + ) + if (!ipRateLimit.allowed) { + logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`) + const retryAfter = Math.ceil( + (ipRateLimit.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs) / 1000 + ) + const response = createErrorResponse('Too many requests. Please try again later.', 429) + response.headers.set('Retry-After', String(retryAfter)) + return response + } + + const parsed = await parseRequest(chatSSOContract, request, context) + if (!parsed.success) return parsed.response + + const { identifier } = parsed.data.params + const { email } = parsed.data.body + + const [deployment] = await db + .select({ + authType: chat.authType, + allowedEmails: chat.allowedEmails, + isActive: chat.isActive, + }) + .from(chat) + .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) + .limit(1) + + if (!deployment || !deployment.isActive) { + logger.warn(`[${requestId}] SSO check on missing/inactive chat: ${identifier}`) + return createErrorResponse('Chat not found', 404) + } + + if (deployment.authType !== 'sso') { + return createErrorResponse('Chat is not configured for SSO authentication', 400) + } + + const eligible = isEmailAllowed(email, (deployment.allowedEmails as string[]) || []) + + return createSuccessResponse({ eligible }) + } +) diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index 32f3a6a8311..e11b1b548e7 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -16,7 +16,6 @@ import { workflowsOrchestrationMock, workflowsOrchestrationMockFns, workflowsPersistenceUtilsMock, - workflowsPersistenceUtilsMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -28,7 +27,7 @@ const { mockCheckChatAccess } = vi.hoisted(() => ({ const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse const mockEncryptSecret = encryptionMockFns.mockEncryptSecret -const mockDeployWorkflow = workflowsPersistenceUtilsMockFns.mockDeployWorkflow +const mockPerformFullDeploy = workflowsOrchestrationMockFns.mockPerformFullDeploy const mockPerformChatUndeploy = workflowsOrchestrationMockFns.mockPerformChatUndeploy const mockNotifySocketDeploymentChanged = workflowsOrchestrationMockFns.mockNotifySocketDeploymentChanged @@ -73,7 +72,7 @@ describe('Chat Edit API Route', () => { }) mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' }) - mockDeployWorkflow.mockResolvedValue({ success: true, version: 1 }) + mockPerformFullDeploy.mockResolvedValue({ success: true, version: 1 }) mockNotifySocketDeploymentChanged.mockResolvedValue(undefined) }) diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 4f937d75258..0115be7099b 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -2,58 +2,31 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { z } from 'zod' +import { chatIdParamsSchema, updateChatContract } from '@/lib/api/contracts/chats' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { isDev } from '@/lib/core/config/feature-flags' import { encryptSecret } from '@/lib/core/security/encryption' import { getEmailDomain } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { notifySocketDeploymentChanged, performChatUndeploy } from '@/lib/workflows/orchestration' -import { deployWorkflow } from '@/lib/workflows/persistence/utils' +import { performChatUndeploy, performFullDeploy } from '@/lib/workflows/orchestration' import { checkChatAccess } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' export const dynamic = 'force-dynamic' +export const maxDuration = 120 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(), -}) - /** * 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 +55,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) } } ) @@ -93,10 +66,7 @@ export const GET = withRouteHandler( * PATCH endpoint to update an existing chat deployment */ export const PATCH = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id } = await params - const chatId = id - + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { try { const session = await getSession() @@ -104,166 +74,175 @@ export const PATCH = withRouteHandler( return createErrorResponse('Unauthorized', 401) } - const body = await request.json() + const parsed = await parseRequest(updateChatContract, request, context, { + validationErrorResponse: (error) => + createErrorResponse(getValidationErrorMessage(error), 400, 'VALIDATION_ERROR'), + }) + if (!parsed.success) return parsed.response - try { - const validatedData = chatUpdateSchema.parse(body) + const { id: chatId } = parsed.data.params + const validatedData = parsed.data.body - const { - hasAccess, - chat: existingChatRecord, - workspaceId: chatWorkspaceId, - } = await checkChatAccess(chatId, session.user.id) + const { + hasAccess, + chat: existingChatRecord, + workspaceId: chatWorkspaceId, + } = await checkChatAccess(chatId, session.user.id) - if (!hasAccess || !existingChatRecord) { - return createErrorResponse('Chat not found or access denied', 404) - } + if (!hasAccess || !existingChatRecord) { + return createErrorResponse('Chat not found or access denied', 404) + } - const existingChat = [existingChatRecord] - - const { - workflowId, - identifier, - title, - description, - customizations, - authType, - password, - allowedEmails, - outputConfigs, - } = validatedData - - if (identifier && identifier !== existingChat[0].identifier) { - const existingIdentifier = await db - .select() - .from(chat) - .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) - .limit(1) - - if (existingIdentifier.length > 0 && existingIdentifier[0].id !== chatId) { - return createErrorResponse('Identifier already in use', 400) - } - } + const existingChat = [existingChatRecord] + + const { + workflowId, + identifier, + title, + description, + customizations, + authType, + password, + allowedEmails, + outputConfigs, + } = validatedData + + if (workflowId && workflowId !== existingChat[0].workflowId) { + return createErrorResponse('Changing the workflow of a chat deployment is not allowed', 400) + } - // Redeploy the workflow to ensure latest version is active - const deployResult = await deployWorkflow({ - workflowId: existingChat[0].workflowId, - deployedBy: session.user.id, - }) - - if (!deployResult.success) { - logger.warn( - `Failed to redeploy workflow for chat update: ${deployResult.error}, continuing with chat update` - ) - } else { - logger.info( - `Redeployed workflow ${existingChat[0].workflowId} for chat update (v${deployResult.version})` - ) - await notifySocketDeploymentChanged(existingChat[0].workflowId) - } + if (identifier && identifier !== existingChat[0].identifier) { + const existingIdentifier = await db + .select() + .from(chat) + .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) + .limit(1) - let encryptedPassword - - if (password) { - const { encrypted } = await encryptSecret(password) - encryptedPassword = encrypted - logger.info('Password provided, will be updated') - } else if (authType === 'password' && !password) { - if (existingChat[0].authType !== 'password' || !existingChat[0].password) { - return createErrorResponse('Password is required when using password protection', 400) - } - logger.info('Keeping existing password') + if (existingIdentifier.length > 0 && existingIdentifier[0].id !== chatId) { + return createErrorResponse('Identifier already in use', 400) } + } - const updateData: any = { - updatedAt: new Date(), - } + let encryptedPassword - if (workflowId) updateData.workflowId = workflowId - if (identifier) updateData.identifier = identifier - if (title) updateData.title = title - if (description !== undefined) updateData.description = description - if (customizations) updateData.customizations = customizations - - if (authType) { - updateData.authType = authType - - if (authType === 'public') { - updateData.password = null - updateData.allowedEmails = [] - } else if (authType === 'password') { - updateData.allowedEmails = [] - } else if (authType === 'email' || authType === 'sso') { - updateData.password = null - } + if (password) { + const { encrypted } = await encryptSecret(password) + encryptedPassword = encrypted + logger.info('Password provided, will be updated') + } else if (authType === 'password' && !password) { + if (existingChat[0].authType !== 'password' || !existingChat[0].password) { + return createErrorResponse('Password is required when using password protection', 400) } + logger.info('Keeping existing password') + } - if (encryptedPassword) { - updateData.password = encryptedPassword - } + // Redeploy the workflow to ensure latest version is active + const deployResult = await performFullDeploy({ + workflowId: existingChat[0].workflowId, + userId: session.user.id, + request, + }) - if (allowedEmails) { - updateData.allowedEmails = allowedEmails - } + if (!deployResult.success) { + logger.warn(`Failed to redeploy workflow for chat update: ${deployResult.error}`) + const status = + deployResult.errorCode === 'validation' + ? 400 + : deployResult.errorCode === 'not_found' + ? 404 + : 500 + return createErrorResponse(deployResult.error || 'Failed to redeploy workflow', status) + } + logger.info( + `Redeployed workflow ${existingChat[0].workflowId} for chat update (v${deployResult.version})` + ) - if (outputConfigs) { - updateData.outputConfigs = outputConfigs - } + const updateData: Record = { + updatedAt: new Date(), + } - 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, - }) - - await db.update(chat).set(updateData).where(eq(chat.id, chatId)) - - const updatedIdentifier = identifier || existingChat[0].identifier - - const baseDomain = getEmailDomain() - const protocol = isDev ? 'http' : 'https' - const chatUrl = `${protocol}://${baseDomain}/chat/${updatedIdentifier}` - - logger.info(`Chat "${chatId}" updated successfully`) - - recordAudit({ - workspaceId: chatWorkspaceId || null, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CHAT_UPDATED, - resourceType: AuditResourceType.CHAT, - resourceId: chatId, - resourceName: title || existingChatRecord.title, - description: `Updated chat deployment "${title || existingChatRecord.title}"`, - metadata: { - identifier: updatedIdentifier, - authType: updateData.authType || existingChatRecord.authType, - workflowId: workflowId || existingChatRecord.workflowId, - chatUrl, - }, - request, - }) - - return createSuccessResponse({ - id: chatId, - chatUrl, - 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 (identifier) updateData.identifier = identifier + if (title) updateData.title = title + if (description !== undefined) updateData.description = description + if (customizations) updateData.customizations = customizations + + if (authType) { + updateData.authType = authType + + if (authType === 'public') { + updateData.password = null + updateData.allowedEmails = [] + } else if (authType === 'password') { + updateData.allowedEmails = [] + } else if (authType === 'email' || authType === 'sso') { + updateData.password = null } - throw validationError } - } catch (error: any) { + + if (encryptedPassword) { + updateData.password = encryptedPassword + } + + if (allowedEmails) { + updateData.allowedEmails = allowedEmails + } + + if (outputConfigs) { + 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, + outputConfigsCount, + }) + + await db.update(chat).set(updateData).where(eq(chat.id, chatId)) + + const updatedIdentifier = identifier || existingChat[0].identifier + + const baseDomain = getEmailDomain() + const protocol = isDev ? 'http' : 'https' + const chatUrl = `${protocol}://${baseDomain}/chat/${updatedIdentifier}` + + logger.info(`Chat "${chatId}" updated successfully`) + + recordAudit({ + workspaceId: chatWorkspaceId || null, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CHAT_UPDATED, + resourceType: AuditResourceType.CHAT, + resourceId: chatId, + resourceName: title || existingChatRecord.title, + description: `Updated chat deployment "${title || existingChatRecord.title}"`, + metadata: { + identifier: updatedIdentifier, + authType: updateData.authType || existingChatRecord.authType, + workflowId: workflowId || existingChatRecord.workflowId, + chatUrl, + }, + request, + }) + + return createSuccessResponse({ + id: chatId, + chatUrl, + message: 'Chat deployment updated successfully', + }) + } 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 +252,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 +284,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..ca1f8019fe7 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -1,9 +1,11 @@ import { db } from '@sim/db' import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { z } from 'zod' +import { createChatContract } from '@/lib/api/contracts/chats' +import { getValidationErrorMessage, parseRequest } 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,33 +14,6 @@ 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([]), -}) - export const GET = withRouteHandler(async (_request: NextRequest) => { try { const session = await getSession() @@ -54,9 +29,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) } }) @@ -68,92 +43,90 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return createErrorResponse('Unauthorized', 401) } - const body = await request.json() - - try { - const validatedData = chatSchema.parse(body) - - // Extract validated data - const { - workflowId, - identifier, - title, - description = '', - customizations, - authType = 'public', - password, - allowedEmails = [], - outputConfigs = [], - } = validatedData - - // Perform additional validation specific to auth types - if (authType === 'password' && !password) { - return createErrorResponse('Password is required when using password protection', 400) - } - - if (authType === 'email' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) { - return createErrorResponse( - 'At least one email or domain is required when using email access control', - 400 - ) + const parsed = await parseRequest( + createChatContract, + request, + {}, + { + validationErrorResponse: (error) => + createErrorResponse(getValidationErrorMessage(error), 400, 'VALIDATION_ERROR'), } + ) + if (!parsed.success) return parsed.response + + const { + workflowId, + identifier, + title, + description = '', + customizations, + authType = 'public', + password, + allowedEmails = [], + outputConfigs = [], + } = parsed.data.body + + if (authType === 'password' && !password) { + return createErrorResponse('Password is required when using password protection', 400) + } - if (authType === 'sso' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) { - return createErrorResponse( - 'At least one email or domain is required when using SSO access control', - 400 - ) - } + if (authType === 'email' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) { + return createErrorResponse( + 'At least one email or domain is required when using email access control', + 400 + ) + } - const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([ - db - .select() - .from(chat) - .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) - .limit(1), - checkWorkflowAccessForChatCreation(workflowId, session.user.id), - ]) - - if (existingIdentifier.length > 0) { - return createErrorResponse('Identifier already in use', 400) - } + if (authType === 'sso' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) { + return createErrorResponse( + 'At least one email or domain is required when using SSO access control', + 400 + ) + } - if (!hasAccess || !workflowRecord) { - return createErrorResponse('Workflow not found or access denied', 404) - } + const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([ + db + .select() + .from(chat) + .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) + .limit(1), + checkWorkflowAccessForChatCreation(workflowId, session.user.id), + ]) + + if (existingIdentifier.length > 0) { + return createErrorResponse('Identifier already in use', 400) + } - const result = await performChatDeploy({ - workflowId, - userId: session.user.id, - identifier, - title, - description, - customizations, - authType, - password, - allowedEmails, - outputConfigs, - workspaceId: workflowRecord.workspaceId, - }) - - if (!result.success) { - return createErrorResponse(result.error || 'Failed to deploy chat', 500) - } + if (!hasAccess || !workflowRecord) { + return createErrorResponse('Workflow not found or access denied', 404) + } - return createSuccessResponse({ - id: 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') - } - throw validationError + const result = await performChatDeploy({ + workflowId, + userId: session.user.id, + identifier, + title, + description, + customizations, + authType, + password, + allowedEmails, + outputConfigs, + workspaceId: workflowRecord.workspaceId, + }) + + if (!result.success) { + return createErrorResponse(result.error || 'Failed to deploy chat', 500) } - } catch (error: any) { + + return createSuccessResponse({ + id: result.chatId, + chatId: result.chatId, + chatUrl: result.chatUrl, + message: 'Chat deployment created successfully', + }) + } 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/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 31401d6b5ec..86e46340a92 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -17,15 +17,20 @@ const { mockMergeSubBlockValues, mockValidateAuthToken, mockSetDeploymentAuthCookie, - mockAddCorsHeaders, mockIsEmailAllowed, + mockGetSession, } = vi.hoisted(() => ({ mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}), mockMergeSubBlockValues: vi.fn().mockReturnValue({}), mockValidateAuthToken: vi.fn().mockReturnValue(false), mockSetDeploymentAuthCookie: vi.fn(), - mockAddCorsHeaders: vi.fn((response: unknown) => response), mockIsEmailAllowed: vi.fn(), + mockGetSession: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, })) const mockDecryptSecret = encryptionMockFns.mockDecryptSecret @@ -50,7 +55,6 @@ vi.mock('@/lib/core/security/encryption', () => encryptionMock) vi.mock('@/lib/core/security/deployment', () => ({ validateAuthToken: mockValidateAuthToken, setDeploymentAuthCookie: mockSetDeploymentAuthCookie, - addCorsHeaders: mockAddCorsHeaders, isEmailAllowed: mockIsEmailAllowed, })) @@ -285,6 +289,68 @@ describe('Chat API Utils', () => { expect(result3.authorized).toBe(false) expect(result3.error).toBe('Email not authorized') }) + + describe('SSO auth', () => { + const ssoDeployment = { + id: 'chat-id', + authType: 'sso', + allowedEmails: ['user@example.com', '@company.com'], + } + + const postRequest = { + method: 'POST', + cookies: { get: vi.fn().mockReturnValue(null) }, + } as any + + it('rejects when no session is present', async () => { + mockGetSession.mockResolvedValue(null) + + const result = await validateChatAuth('request-id', ssoDeployment, postRequest, { + input: 'hello', + }) + + expect(result.authorized).toBe(false) + expect(result.error).toBe('auth_required_sso') + }) + + it('ignores body-supplied email and uses the session email', async () => { + mockGetSession.mockResolvedValue({ user: { email: 'session@example.com' } }) + mockIsEmailAllowed.mockReturnValue(true) + + await validateChatAuth('request-id', ssoDeployment, postRequest, { + email: 'attacker@evil.com', + input: 'hello', + }) + + expect(mockIsEmailAllowed).toHaveBeenCalledWith( + 'session@example.com', + ssoDeployment.allowedEmails + ) + }) + + it('authorizes execution when session email is allowlisted', async () => { + mockGetSession.mockResolvedValue({ user: { email: 'user@example.com' } }) + mockIsEmailAllowed.mockReturnValue(true) + + const result = await validateChatAuth('request-id', ssoDeployment, postRequest, { + input: 'hello', + }) + + expect(result.authorized).toBe(true) + }) + + it('rejects execution when session email is not allowlisted', async () => { + mockGetSession.mockResolvedValue({ user: { email: 'stranger@other.com' } }) + mockIsEmailAllowed.mockReturnValue(false) + + const result = await validateChatAuth('request-id', ssoDeployment, postRequest, { + input: 'hello', + }) + + expect(result.authorized).toBe(false) + expect(result.error).toBe('Your email is not authorized to access this chat') + }) + }) }) describe('Execution Result Processing', () => { diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 3909dd599fe..5a3d0750e8d 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -95,11 +95,13 @@ export async function validateChatAuth( return { authorized: true } } - const cookieName = `chat_auth_${deployment.id}` - const authCookie = request.cookies.get(cookieName) + if (authType !== 'sso') { + const cookieName = `chat_auth_${deployment.id}` + const authCookie = request.cookies.get(cookieName) - if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) { - return { authorized: true } + if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) { + return { authorized: true } + } } if (authType === 'password') { @@ -173,35 +175,11 @@ export async function validateChatAuth( } if (authType === 'sso') { - if (request.method === 'GET') { - return { authorized: false, error: 'auth_required_sso' } - } - try { - if (!parsedBody) { + if (request.method !== 'GET' && !parsedBody) { return { authorized: false, error: 'SSO authentication is required' } } - const { email, input, checkSSOAccess } = parsedBody - - if (input && !checkSSOAccess) { - return { authorized: false, error: 'auth_required_sso' } - } - - if (checkSSOAccess) { - if (!email) { - return { authorized: false, error: 'Email is required' } - } - - const allowedEmails = deployment.allowedEmails || [] - - if (isEmailAllowed(email, allowedEmails)) { - return { authorized: true } - } - - return { authorized: false, error: 'Email not authorized for SSO access' } - } - const { getSession } = await import('@/lib/auth') const session = await getSession() diff --git a/apps/sim/app/api/chat/validate/route.ts b/apps/sim/app/api/chat/validate/route.ts index 59dd09df902..c982a9131ba 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 } 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 = identifierValidationQuerySchema.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/contact/route.ts b/apps/sim/app/api/contact/route.ts index 23963a24728..a481f1e48c8 100644 --- a/apps/sim/app/api/contact/route.ts +++ b/apps/sim/app/api/contact/route.ts @@ -1,6 +1,12 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { renderHelpConfirmationEmail } from '@/components/emails' +import { + getContactTopicLabel, + mapContactTopicToHelpType, + submitContactBodySchema, +} from '@/lib/api/contracts/contact' +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' @@ -10,11 +16,6 @@ import { getEmailDomain, SITE_URL } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' -import { - contactRequestSchema, - getContactTopicLabel, - mapContactTopicToHelpType, -} from '@/app/(landing)/components/contact/consts' const logger = createLogger('ContactAPI') const rateLimiter = new RateLimiter() @@ -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, @@ -118,13 +120,16 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } } - const validationResult = contactRequestSchema.safeParse(body) + const validationResult = submitContactBodySchema.safeParse(body) 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..014b3de8849 100644 --- a/apps/sim/app/api/copilot/api-keys/generate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/generate/route.ts @@ -1,16 +1,13 @@ import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { generateCopilotApiKeyContract } from '@/lib/api/contracts' +import { parseRequest } 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' import { fetchGo } from '@/lib/copilot/request/go/fetch' +import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url' 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() @@ -19,23 +16,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } const userId = session.user.id + const mothershipBaseURL = await getMothershipBaseURL({ userId }) - const body = await req.json().catch(() => ({})) - const validationResult = GenerateApiKeySchema.safeParse(body) - - if (!validationResult.success) { - return NextResponse.json( - { - error: 'Invalid request body', - details: validationResult.error.errors, - }, - { status: 400 } - ) - } + const parsed = await parseRequest(generateCopilotApiKeyContract, req, {}) + if (!parsed.success) return parsed.response - const { name } = validationResult.data + const { name } = parsed.data.body - const res = await fetchGo(`${SIM_AGENT_API_URL}/api/validate-key/generate`, { + const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', 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..e6c4b6cab60 100644 --- a/apps/sim/app/api/copilot/api-keys/route.test.ts +++ b/apps/sim/app/api/copilot/api-keys/route.test.ts @@ -7,13 +7,20 @@ import { authMockFns, createEnvMock } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockFetch } = vi.hoisted(() => ({ +const { mockFetch, mockGetMothershipBaseURL } = vi.hoisted(() => ({ mockFetch: vi.fn(), + mockGetMothershipBaseURL: vi.fn(), })) 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/copilot/server/agent-url', () => ({ + getMothershipBaseURL: mockGetMothershipBaseURL, })) vi.mock('@/lib/core/config/env', () => createEnvMock({ COPILOT_API_KEY: 'test-api-key' })) @@ -39,6 +46,7 @@ function buildMockResponse(init: { describe('Copilot API Keys API Route', () => { beforeEach(() => { vi.clearAllMocks() + mockGetMothershipBaseURL.mockResolvedValue('https://agent.sim.example.com') global.fetch = mockFetch }) diff --git a/apps/sim/app/api/copilot/api-keys/route.ts b/apps/sim/app/api/copilot/api-keys/route.ts index 9048e8a2c82..b2c0399a531 100644 --- a/apps/sim/app/api/copilot/api-keys/route.ts +++ b/apps/sim/app/api/copilot/api-keys/route.ts @@ -1,8 +1,9 @@ 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' import { fetchGo } from '@/lib/copilot/request/go/fetch' +import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -14,8 +15,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const userId = session.user.id + const mothershipBaseURL = await getMothershipBaseURL({ userId }) - const res = await fetchGo(`${SIM_AGENT_API_URL}/api/validate-key/get-api-keys`, { + const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/get-api-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -66,13 +68,16 @@ 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 mothershipBaseURL = await getMothershipBaseURL({ userId }) + 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`, { + const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json', 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..8e280d40000 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 { validateCopilotApiKeyContract } from '@/lib/api/contracts/copilot' +import { parseRequest, validationErrorResponse } 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,14 +15,12 @@ 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 -// falls back to a new root span. +/** + * 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 falls back to a + * new root span. + */ export const POST = withRouteHandler((req: NextRequest) => withIncomingGoSpan( req.headers, @@ -42,22 +41,37 @@ export const POST = withRouteHandler((req: NextRequest) => return new NextResponse(null, { status: 401 }) } - const body = await req.json().catch(() => null) - const validationResult = ValidateApiKeySchema.safeParse(body) - if (!validationResult.success) { - logger.warn('Invalid validation request', { errors: validationResult.error.errors }) - span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.InvalidBody) - span.setAttribute(TraceAttr.HttpStatusCode, 400) - return NextResponse.json( - { - error: 'userId is required', - details: validationResult.error.errors, + const parsed = await parseRequest( + validateCopilotApiKeyContract, + req, + {}, + { + validationErrorResponse: (error) => { + logger.warn('Invalid validation request', { errors: error.issues }) + span.setAttribute( + TraceAttr.CopilotValidateOutcome, + CopilotValidateOutcome.InvalidBody + ) + span.setAttribute(TraceAttr.HttpStatusCode, 400) + return validationErrorResponse(error, 'userId is required') }, - { status: 400 } - ) - } + invalidJsonResponse: () => { + logger.warn('Invalid validation request: invalid JSON') + span.setAttribute( + TraceAttr.CopilotValidateOutcome, + CopilotValidateOutcome.InvalidBody + ) + span.setAttribute(TraceAttr.HttpStatusCode, 400) + return NextResponse.json( + { error: 'userId is required', details: [] }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response - const { userId } = validationResult.data + const { userId } = parsed.data.body span.setAttribute(TraceAttr.UserId, userId) const [existingUser] = await db.select().from(user).where(eq(user.id, userId)).limit(1) 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..024ec0ace88 100644 --- a/apps/sim/app/api/copilot/auto-allowed-tools/route.ts +++ b/apps/sim/app/api/copilot/auto-allowed-tools/route.ts @@ -1,9 +1,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { + addCopilotAutoAllowedToolContract, + removeCopilotAutoAllowedToolContract, +} from '@/lib/api/contracts/copilot' +import { parseRequest } 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' import { fetchGo } from '@/lib/copilot/request/go/fetch' +import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -32,9 +37,10 @@ export const GET = withRouteHandler(async () => { } const userId = session.user.id + const mothershipBaseURL = await getMothershipBaseURL({ userId }) const res = await fetchGo( - `${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed?userId=${encodeURIComponent(userId)}`, + `${mothershipBaseURL}/api/tool-preferences/auto-allowed?userId=${encodeURIComponent(userId)}`, { method: 'GET', headers: copilotHeaders(), @@ -69,19 +75,28 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = session.user.id - const body = await request.json() - - if (!body.toolId || typeof body.toolId !== 'string') { - return NextResponse.json({ error: 'toolId must be a string' }, { status: 400 }) - } + const mothershipBaseURL = await getMothershipBaseURL({ userId }) + const parsed = await parseRequest( + addCopilotAutoAllowedToolContract, + request, + {}, + { + validationErrorResponse: () => + NextResponse.json({ error: 'toolId must be a string' }, { status: 400 }), + invalidJsonResponse: () => + NextResponse.json({ error: 'toolId must be a string' }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response + const { toolId } = parsed.data.body - const res = await fetchGo(`${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed`, { + const res = await fetchGo(`${mothershipBaseURL}/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,15 +127,21 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { } const userId = session.user.id - const { searchParams } = new URL(request.url) - const toolId = searchParams.get('toolId') - - if (!toolId) { - return NextResponse.json({ error: 'toolId query parameter is required' }, { status: 400 }) - } + const mothershipBaseURL = await getMothershipBaseURL({ userId }) + const parsed = await parseRequest( + removeCopilotAutoAllowedToolContract, + request, + {}, + { + validationErrorResponse: () => + NextResponse.json({ error: 'toolId query parameter is required' }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response + const { toolId } = parsed.data.query const res = await fetchGo( - `${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed?userId=${encodeURIComponent(userId)}&toolId=${encodeURIComponent(toolId)}`, + `${mothershipBaseURL}/api/tool-preferences/auto-allowed?userId=${encodeURIComponent(userId)}&toolId=${encodeURIComponent(toolId)}`, { method: 'DELETE', headers: copilotHeaders(), diff --git a/apps/sim/app/api/copilot/chat/abort/route.ts b/apps/sim/app/api/copilot/chat/abort/route.ts index 17a0ec10d2f..25416137472 100644 --- a/apps/sim/app/api/copilot/chat/abort/route.ts +++ b/apps/sim/app/api/copilot/chat/abort/route.ts @@ -1,7 +1,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { copilotChatAbortBodySchema } from '@/lib/api/contracts/copilot' +import { validationErrorResponse } 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' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' @@ -9,6 +11,7 @@ import { fetchGo } from '@/lib/copilot/request/go/fetch' import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http' import { withCopilotSpan, withIncomingGoSpan } from '@/lib/copilot/request/otel' import { abortActiveStream, waitForPendingChatStream } from '@/lib/copilot/request/session' +import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -34,12 +37,17 @@ export const POST = withRouteHandler((request: NextRequest) => const body = await request.json().catch((err) => { logger.warn('Abort request body parse failed; continuing with empty object', { - error: err instanceof Error ? err.message : String(err), + error: getErrorMessage(err), }) return {} }) - const streamId = typeof body.streamId === 'string' ? body.streamId : '' - let chatId = typeof body.chatId === 'string' ? body.chatId : '' + const validation = copilotChatAbortBodySchema.safeParse(body) + if (!validation.success) { + rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.MissingStreamId) + return validationErrorResponse(validation.error, 'Invalid request body') + } + const { streamId, chatId: parsedChatId } = validation.data + let chatId = parsedChatId if (!streamId) { rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.MissingStreamId) @@ -54,7 +62,7 @@ export const POST = withRouteHandler((request: NextRequest) => const run = await getLatestRunForStream(streamId, authenticatedUserId).catch((err) => { logger.warn('getLatestRunForStream failed while resolving chatId for abort', { streamId, - error: err instanceof Error ? err.message : String(err), + error: getErrorMessage(err), }) return null }) @@ -78,12 +86,14 @@ export const POST = withRouteHandler((request: NextRequest) => if (env.COPILOT_API_KEY) { headers['x-api-key'] = env.COPILOT_API_KEY } + Object.assign(headers, getMothershipSourceEnvHeaders()) const controller = new AbortController() const timeout = setTimeout( () => controller.abort('timeout:go_explicit_abort_fetch'), GO_EXPLICIT_ABORT_TIMEOUT_MS ) - const response = await fetchGo(`${SIM_AGENT_API_URL}/api/streams/explicit-abort`, { + const mothershipBaseURL = await getMothershipBaseURL({ userId: authenticatedUserId }) + const response = await fetchGo(`${mothershipBaseURL}/api/streams/explicit-abort`, { method: 'POST', headers, signal: controller.signal, @@ -106,7 +116,7 @@ export const POST = withRouteHandler((request: NextRequest) => } catch (err) { logger.warn('Explicit abort marker request failed after local abort', { streamId, - error: err instanceof Error ? err.message : String(err), + error: getErrorMessage(err), }) } rootSpan.setAttribute(TraceAttr.CopilotAbortGoMarkerOk, goAbortOk) 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..ccabaf7c521 100644 --- a/apps/sim/app/api/copilot/chat/delete/route.test.ts +++ b/apps/sim/app/api/copilot/chat/delete/route.test.ts @@ -7,14 +7,16 @@ import { authMockFns, dbChainMock, dbChainMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetAccessibleCopilotChat } = vi.hoisted(() => ({ +const { mockGetAccessibleCopilotChat, mockGetAccessibleCopilotChatAuth } = vi.hoisted(() => ({ mockGetAccessibleCopilotChat: vi.fn(), + mockGetAccessibleCopilotChatAuth: vi.fn(), })) vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/copilot/chat/lifecycle', () => ({ getAccessibleCopilotChat: mockGetAccessibleCopilotChat, + getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChatAuth, })) vi.mock('@/lib/copilot/tasks', () => ({ @@ -39,6 +41,7 @@ describe('Copilot Chat Delete API Route', () => { dbChainMockFns.returning.mockResolvedValue([{ workspaceId: 'ws-1' }]) mockGetAccessibleCopilotChat.mockResolvedValue({ id: 'chat-123', userId: 'user-123' }) + mockGetAccessibleCopilotChatAuth.mockResolvedValue({ id: 'chat-123', userId: 'user-123' }) }) afterEach(() => { @@ -77,19 +80,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 +101,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 () => { @@ -140,7 +143,7 @@ describe('Copilot Chat Delete API Route', () => { it('should delete chat even if it does not exist (idempotent)', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockGetAccessibleCopilotChat.mockResolvedValueOnce(null) + mockGetAccessibleCopilotChatAuth.mockResolvedValueOnce(null) const req = createMockRequest('DELETE', { chatId: 'non-existent-chat', diff --git a/apps/sim/app/api/copilot/chat/delete/route.ts b/apps/sim/app/api/copilot/chat/delete/route.ts index 519d038b6a2..fd06735a91f 100644 --- a/apps/sim/app/api/copilot/chat/delete/route.ts +++ b/apps/sim/app/api/copilot/chat/delete/route.ts @@ -3,18 +3,15 @@ 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 { deleteCopilotChatContract } from '@/lib/api/contracts/copilot' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' +import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' import { taskPubSub } from '@/lib/copilot/tasks' 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() @@ -22,10 +19,18 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const parsed = DeleteChatSchema.parse(body) - - const chat = await getAccessibleCopilotChat(parsed.chatId, session.user.id) + const validated = await parseRequest( + deleteCopilotChatContract, + request, + {}, + { + invalidJson: 'throw', + } + ) + if (!validated.success) return validated.response + const parsed = validated.data.body + + const chat = await getAccessibleCopilotChatAuth(parsed.chatId, session.user.id) if (!chat) { return NextResponse.json({ success: true }) } diff --git a/apps/sim/app/api/copilot/chat/queries.ts b/apps/sim/app/api/copilot/chat/queries.ts index bc475a8bc09..55d8f5acad0 100644 --- a/apps/sim/app/api/copilot/chat/queries.ts +++ b/apps/sim/app/api/copilot/chat/queries.ts @@ -12,13 +12,17 @@ import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, + createForbiddenResponse, createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' import { readFilePreviewSessions } from '@/lib/copilot/request/session' import { readEvents } from '@/lib/copilot/request/session/buffer' import { toStreamBatchEvent } from '@/lib/copilot/request/session/types' -import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { + assertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotChatAPI') @@ -51,6 +55,21 @@ function transformChat(chat: { } } +type CopilotChatListRow = Pick< + typeof copilotChats.$inferSelect, + 'id' | 'title' | 'model' | 'createdAt' | 'updatedAt' +> + +function transformChatListItem(chat: CopilotChatListRow) { + return { + id: chat.id, + title: chat.title, + model: chat.model, + createdAt: chat.createdAt, + updatedAt: chat.updatedAt, + } +} + export async function GET(req: NextRequest) { try { const { searchParams } = new URL(req.url) @@ -166,9 +185,6 @@ export async function GET(req: NextRequest) { id: copilotChats.id, title: copilotChats.title, model: copilotChats.model, - messages: copilotChats.messages, - planArtifact: copilotChats.planArtifact, - config: copilotChats.config, createdAt: copilotChats.createdAt, updatedAt: copilotChats.updatedAt, }) @@ -181,9 +197,12 @@ export async function GET(req: NextRequest) { return NextResponse.json({ success: true, - chats: chats.map(transformChat), + chats: chats.map(transformChatListItem), }) } catch (error) { + if (isWorkspaceAccessDeniedError(error)) { + return createForbiddenResponse('Workspace access denied') + } logger.error('Error fetching copilot chats:', error) return createInternalServerErrorResponse('Failed to fetch chats') } diff --git a/apps/sim/app/api/copilot/chat/rename/route.ts b/apps/sim/app/api/copilot/chat/rename/route.ts index 49d8a616bfe..6886f568be6 100644 --- a/apps/sim/app/api/copilot/chat/rename/route.ts +++ b/apps/sim/app/api/copilot/chat/rename/route.ts @@ -3,19 +3,15 @@ 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 { renameCopilotChatContract } from '@/lib/api/contracts/copilot' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' +import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' import { taskPubSub } from '@/lib/copilot/tasks' 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() @@ -23,10 +19,18 @@ export const PATCH = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { chatId, title } = RenameChatSchema.parse(body) + const parsed = await parseRequest( + renameCopilotChatContract, + request, + {}, + { + validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'), + } + ) + if (!parsed.success) return parsed.response + const { chatId, title } = parsed.data.body - const chat = await getAccessibleCopilotChat(chatId, session.user.id) + const chat = await getAccessibleCopilotChatAuth(chatId, session.user.id) if (!chat) { return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) } @@ -54,12 +58,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..d8b221a7875 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 { + addCopilotChatResourceContract, + removeCopilotChatResourceContract, + reorderCopilotChatResourcesContract, +} from '@/lib/api/contracts/copilot' +import { parseRequest } 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() @@ -59,8 +38,17 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return createUnauthorizedResponse() } - const body = await req.json() - const { chatId, resource } = AddResourceSchema.parse(body) + const parsed = await parseRequest( + addCopilotChatResourceContract, + req, + {}, + { + validationErrorResponse: (error) => + createBadRequestResponse(error.issues.map((e) => e.message).join(', ')), + } + ) + if (!parsed.success) return parsed.response + const { chatId, resource } = parsed.data.body // Ephemeral UI tab (client does not POST this; guard for old clients / bugs). if (resource.id === 'streaming-file') { @@ -101,15 +89,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') } @@ -122,8 +107,17 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => { return createUnauthorizedResponse() } - const body = await req.json() - const { chatId, resources: newOrder } = ReorderResourcesSchema.parse(body) + const parsed = await parseRequest( + reorderCopilotChatResourcesContract, + req, + {}, + { + validationErrorResponse: (error) => + createBadRequestResponse(error.issues.map((e) => e.message).join(', ')), + } + ) + if (!parsed.success) return parsed.response + const { chatId, resources: newOrder } = parsed.data.body const [chat] = await db .select({ resources: copilotChats.resources }) @@ -146,15 +140,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') } @@ -167,8 +158,17 @@ export const DELETE = withRouteHandler(async (req: NextRequest) => { return createUnauthorizedResponse() } - const body = await req.json() - const { chatId, resourceType, resourceId } = RemoveResourceSchema.parse(body) + const parsed = await parseRequest( + removeCopilotChatResourceContract, + req, + {}, + { + validationErrorResponse: (error) => + createBadRequestResponse(error.issues.map((e) => e.message).join(', ')), + } + ) + if (!parsed.success) return parsed.response + const { chatId, resourceType, resourceId } = parsed.data.body const [updated] = await db .update(copilotChats) @@ -193,9 +193,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..1b020709711 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -1,2 +1,15 @@ -export { handleUnifiedChatPost as POST, maxDuration } from '@/lib/copilot/chat/post' -export { GET } from './queries' +import type { NextRequest } from 'next/server' +import { copilotChatGetContract } from '@/lib/api/contracts/copilot' +import { parseRequest } 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 async function GET(request: NextRequest) { + const parsed = await parseRequest(copilotChatGetContract, request, {}) + if (!parsed.success) return parsed.response + return getChat(request) +} diff --git a/apps/sim/app/api/copilot/chat/stop/route.test.ts b/apps/sim/app/api/copilot/chat/stop/route.test.ts index 0ac05257bf6..bab5465507d 100644 --- a/apps/sim/app/api/copilot/chat/stop/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stop/route.test.ts @@ -10,29 +10,63 @@ const { mockFrom, mockWhereSelect, mockLimit, + mockForUpdate, mockUpdate, mockSet, mockWhereUpdate, mockReturning, mockPublishStatusChanged, mockSql, -} = vi.hoisted(() => ({ - mockSelect: vi.fn(), - mockFrom: vi.fn(), - mockWhereSelect: vi.fn(), - mockLimit: vi.fn(), - mockUpdate: vi.fn(), - mockSet: vi.fn(), - mockWhereUpdate: vi.fn(), - mockReturning: vi.fn(), - mockPublishStatusChanged: vi.fn(), - mockSql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })), + mockTransaction, +} = vi.hoisted(() => { + const mockSelect = vi.fn() + const mockFrom = vi.fn() + const mockWhereSelect = vi.fn() + const mockLimit = vi.fn() + const mockForUpdate = vi.fn() + const mockUpdate = vi.fn() + const mockSet = vi.fn() + const mockWhereUpdate = vi.fn() + const mockReturning = vi.fn() + const mockPublishStatusChanged = vi.fn() + const mockSql = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + strings, + values, + })) + const mockTransaction = vi.fn( + (callback: (tx: { select: typeof mockSelect; update: typeof mockUpdate }) => unknown) => + callback({ select: mockSelect, update: mockUpdate }) + ) + + return { + mockSelect, + mockFrom, + mockWhereSelect, + mockLimit, + mockForUpdate, + mockUpdate, + mockSet, + mockWhereUpdate, + mockReturning, + mockPublishStatusChanged, + mockSql, + mockTransaction, + } +}) + +vi.mock('@sim/db/schema', () => ({ + copilotChats: { + id: 'copilotChats.id', + userId: 'copilotChats.userId', + workspaceId: 'copilotChats.workspaceId', + messages: 'copilotChats.messages', + conversationId: 'copilotChats.conversationId', + }, })) vi.mock('@sim/db', () => ({ db: { - select: mockSelect, - update: mockUpdate, + transaction: mockTransaction, }, })) @@ -68,9 +102,11 @@ describe('copilot chat stop route', () => { { workspaceId: 'ws-1', messages: [{ id: 'stream-1', role: 'user', content: 'hello' }], + conversationId: 'stream-1', }, ]) - mockWhereSelect.mockReturnValue({ limit: mockLimit }) + mockForUpdate.mockReturnValue({ limit: mockLimit }) + mockWhereSelect.mockReturnValue({ for: mockForUpdate }) mockFrom.mockReturnValue({ where: mockWhereSelect }) mockSelect.mockReturnValue({ from: mockFrom }) @@ -140,6 +176,74 @@ describe('copilot chat stop route', () => { workspaceId: 'ws-1', chatId: 'chat-1', type: 'completed', + streamId: 'stream-1', + }) + }) + + it('appends a stopped assistant message if the stream marker was already cleared', async () => { + mockLimit.mockResolvedValueOnce([ + { + workspaceId: 'ws-1', + messages: [{ id: 'stream-1', role: 'user', content: 'hello' }], + conversationId: null, + }, + ]) + + const response = await POST( + createRequest({ + chatId: 'chat-1', + streamId: 'stream-1', + content: 'partial', + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true }) + + const setArg = mockSet.mock.calls[0]?.[0] + expect(setArg.messages).toBeTruthy() + const appendedPayload = JSON.parse(setArg.messages.values[1] as string) + expect(appendedPayload[0]).toMatchObject({ + role: 'assistant', + content: 'partial', + }) + + expect(mockPublishStatusChanged).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + chatId: 'chat-1', + type: 'completed', + streamId: 'stream-1', + }) + }) + + it('republishes completed status when the assistant was already persisted', async () => { + mockLimit.mockResolvedValueOnce([ + { + workspaceId: 'ws-1', + messages: [ + { id: 'stream-1', role: 'user', content: 'hello' }, + { id: 'assistant-1', role: 'assistant', content: 'partial' }, + ], + conversationId: null, + }, + ]) + + const response = await POST( + createRequest({ + chatId: 'chat-1', + streamId: 'stream-1', + content: 'partial', + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true }) + expect(mockUpdate).not.toHaveBeenCalled() + expect(mockPublishStatusChanged).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + chatId: 'chat-1', + type: 'completed', + streamId: 'stream-1', }) }) }) diff --git a/apps/sim/app/api/copilot/chat/stop/route.ts b/apps/sim/app/api/copilot/chat/stop/route.ts index 5feed89a58e..05d7303d94c 100644 --- a/apps/sim/app/api/copilot/chat/stop/route.ts +++ b/apps/sim/app/api/copilot/chat/stop/route.ts @@ -1,13 +1,19 @@ -import { db } from '@sim/db' -import { copilotChats } from '@sim/db/schema' 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 { copilotChatStopContract } from '@/lib/api/contracts/copilot' +import { parseRequest } 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' +import { + normalizeMessage, + type PersistedMessage, + withStoppedContentBlock, +} from '@/lib/copilot/chat/persisted-message' +import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state' +import { + CopilotChatFinalizeOutcome, + CopilotStopOutcome, +} from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' @@ -16,56 +22,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 +34,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 parsed = await parseRequest(copilotChatStopContract, req, {}) + if (!parsed.success) { + span.setAttribute(TraceAttr.CopilotStopOutcome, CopilotStopOutcome.ValidationError) + return parsed.response + } + const { chatId, streamId, content, contentBlocks, requestId } = parsed.data.body span.setAttributes({ [TraceAttr.ChatId]: chatId, [TraceAttr.StreamId]: streamId, @@ -90,89 +49,51 @@ export const POST = withRouteHandler((req: NextRequest) => ...(requestId ? { [TraceAttr.RequestId]: requestId } : {}), }) - const [row] = await db - .select({ - workspaceId: copilotChats.workspaceId, - messages: copilotChats.messages, - }) - .from(copilotChats) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id))) - .limit(1) - - if (!row) { - span.setAttribute(TraceAttr.CopilotStopOutcome, CopilotStopOutcome.ChatNotFound) - return NextResponse.json({ success: true }) - } - - const messages: Record[] = Array.isArray(row.messages) ? row.messages : [] - const userIdx = messages.findIndex((message) => message.id === streamId) - const alreadyHasResponse = - userIdx >= 0 && - userIdx + 1 < messages.length && - (messages[userIdx + 1] as Record)?.role === 'assistant' - const canAppendAssistant = - userIdx >= 0 && userIdx === messages.length - 1 && !alreadyHasResponse - - const updateWhere = and( - eq(copilotChats.id, chatId), - eq(copilotChats.userId, session.user.id), - eq(copilotChats.conversationId, streamId) - ) - - const setClause: Record = { - conversationId: null, - updatedAt: new Date(), - } - const hasContent = content.trim().length > 0 const hasBlocks = Array.isArray(contentBlocks) && contentBlocks.length > 0 - const synthesizedStoppedBlocks = hasBlocks + const assistantBlocks = hasBlocks ? contentBlocks : hasContent - ? [{ type: 'text', channel: 'assistant', content }, { type: 'stopped' }] - : [{ type: 'stopped' }] - if (canAppendAssistant) { - const normalized = normalizeMessage({ + ? [{ type: 'text', channel: 'assistant', content }] + : [] + const assistantMessage: PersistedMessage = withStoppedContentBlock( + normalizeMessage({ id: generateId(), role: 'assistant', content, timestamp: new Date().toISOString(), - contentBlocks: synthesizedStoppedBlocks, - // Persist so the UI copy-request-id button survives refetch. + contentBlocks: assistantBlocks, ...(requestId ? { requestId } : {}), }) - const assistantMessage: PersistedMessage = normalized - setClause.messages = sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb` - } - span.setAttribute(TraceAttr.CopilotStopAppendedAssistant, canAppendAssistant) - - const [updated] = await db - .update(copilotChats) - .set(setClause) - .where(updateWhere) - .returning({ workspaceId: copilotChats.workspaceId }) + ) + const result = await finalizeAssistantTurn({ + chatId, + userId: session.user.id, + userMessageId: streamId, + assistantMessage, + streamMarkerPolicy: 'active-or-cleared', + }) + span.setAttribute(TraceAttr.CopilotStopAppendedAssistant, result.appendedAssistant) + const stopOutcome = !result.found + ? CopilotStopOutcome.ChatNotFound + : result.updated || result.outcome === CopilotChatFinalizeOutcome.AssistantAlreadyPersisted + ? CopilotStopOutcome.Persisted + : CopilotStopOutcome.NoMatchingRow + const shouldPublishCompleted = + result.updated || result.outcome === CopilotChatFinalizeOutcome.AssistantAlreadyPersisted - if (updated?.workspaceId) { + if (shouldPublishCompleted && result.workspaceId) { taskPubSub?.publishStatusChanged({ - workspaceId: updated.workspaceId, + workspaceId: result.workspaceId, chatId, type: 'completed', + streamId, }) } - span.setAttribute( - TraceAttr.CopilotStopOutcome, - updated ? CopilotStopOutcome.Persisted : CopilotStopOutcome.NoMatchingRow - ) + span.setAttribute(TraceAttr.CopilotStopOutcome, stopOutcome) 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.test.ts b/apps/sim/app/api/copilot/chat/stream/route.test.ts index 803f91af2ca..aa7c85b250f 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.test.ts @@ -38,6 +38,7 @@ vi.mock('@/lib/copilot/request/session', () => ({ }), encodeSSEEnvelope: (event: Record) => new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`), + encodeSSEComment: (comment: string) => new TextEncoder().encode(`: ${comment}\n\n`), SSE_RESPONSE_HEADERS: { 'Content-Type': 'text/event-stream', }, @@ -132,6 +133,7 @@ describe('copilot chat stream replay route', () => { ) const chunks = await readAllChunks(response) + expect(chunks[0]).toBe(': accepted\n\n') expect(chunks.join('')).toContain( JSON.stringify({ status: MothershipStreamV1CompletionStatus.cancelled, diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index 45a4c3c9875..bd7f5465685 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -1,7 +1,10 @@ -import { context as otelContext, trace } from '@opentelemetry/api' +import { type Context, context as otelContext, type Span, trace } from '@opentelemetry/api' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { type NextRequest, NextResponse } from 'next/server' +import { copilotChatStreamContract } from '@/lib/api/contracts/copilot' +import { parseRequest } from '@/lib/api/server' import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' import { MothershipStreamV1CompletionStatus, @@ -19,6 +22,7 @@ import { getCopilotTracer, markSpanForError } from '@/lib/copilot/request/otel' import { checkForReplayGap, createEvent, + encodeSSEComment, encodeSSEEnvelope, readEvents, readFilePreviewSessions, @@ -31,6 +35,7 @@ export const maxDuration = 3600 const logger = createLogger('CopilotChatStreamAPI') const POLL_INTERVAL_MS = 250 +const REPLAY_KEEPALIVE_INTERVAL_MS = 15_000 const MAX_STREAM_MS = 60 * 60 * 1000 function extractCanonicalRequestId(value: unknown): string { @@ -117,10 +122,9 @@ 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 parsed = await parseRequest(copilotChatStreamContract, request, {}) + if (!parsed.success) return parsed.response + const { streamId, after: afterCursor, batch: batchMode } = parsed.data.query if (!streamId) { return NextResponse.json({ error: 'streamId is required' }, { status: 400 }) @@ -190,13 +194,13 @@ async function handleResumeRequestBody({ afterCursor: string batchMode: boolean authenticatedUserId: string - rootSpan: import('@opentelemetry/api').Span - rootContext: import('@opentelemetry/api').Context + rootSpan: Span + rootContext: Context }) { const run = await getLatestRunForStream(streamId, authenticatedUserId).catch((err) => { logger.warn('Failed to fetch latest run for stream', { streamId, - error: err instanceof Error ? err.message : String(err), + error: getErrorMessage(err), }) return null }) @@ -221,7 +225,7 @@ async function handleResumeRequestBody({ readFilePreviewSessions(streamId).catch((error) => { logger.warn('Failed to read preview sessions for stream batch', { streamId, - error: error instanceof Error ? error.message : String(error), + error: getErrorMessage(error), }) return [] }), @@ -245,6 +249,7 @@ async function handleResumeRequestBody({ events: batchEvents, previewSessions, status: run.status, + ...(run.chatId ? { chatId: run.chatId } : {}), }) } @@ -266,6 +271,7 @@ async function handleResumeRequestBody({ let controllerClosed = false let sawTerminalEvent = false let currentRequestId = extractRunRequestId(run) + let lastWriteTime = Date.now() // Stamp the logical request id + chat id on the resume root as soon // as we resolve them from the run row, so TraceQL joins work on // resume legs the same way they do on the original POST. @@ -291,6 +297,19 @@ async function handleResumeRequestBody({ if (controllerClosed) return false try { controller.enqueue(encodeSSEEnvelope(payload)) + lastWriteTime = Date.now() + return true + } catch { + controllerClosed = true + return false + } + } + + const enqueueComment = (comment: string) => { + if (controllerClosed) return false + try { + controller.enqueue(encodeSSEComment(comment)) + lastWriteTime = Date.now() return true } catch { controllerClosed = true @@ -306,7 +325,6 @@ async function handleResumeRequestBody({ const flushEvents = async () => { const events = await readEvents(streamId, cursor) if (events.length > 0) { - totalEventsFlushed += events.length logger.debug('[Resume] Flushing events', { streamId, afterCursor: cursor, @@ -314,14 +332,15 @@ async function handleResumeRequestBody({ }) } for (const envelope of events) { + if (!enqueueEvent(envelope)) { + break + } + totalEventsFlushed += 1 cursor = envelope.stream.cursor ?? String(envelope.seq) currentRequestId = extractEnvelopeRequestId(envelope) || currentRequestId if (envelope.type === MothershipStreamV1EventType.complete) { sawTerminalEvent = true } - if (!enqueueEvent(envelope)) { - break - } } } @@ -341,21 +360,30 @@ async function handleResumeRequestBody({ reason: options?.reason, requestId: currentRequestId, })) { + if (!enqueueEvent(envelope)) { + break + } cursor = envelope.stream.cursor ?? String(envelope.seq) if (envelope.type === MothershipStreamV1EventType.complete) { sawTerminalEvent = true } - if (!enqueueEvent(envelope)) { - break - } } } try { + enqueueComment('accepted') + const gap = await checkForReplayGap(streamId, afterCursor, currentRequestId) if (gap) { for (const envelope of gap.envelopes) { - enqueueEvent(envelope) + if (!enqueueEvent(envelope)) { + break + } + cursor = envelope.stream.cursor ?? String(envelope.seq) + currentRequestId = extractEnvelopeRequestId(envelope) || currentRequestId + if (envelope.type === MothershipStreamV1EventType.complete) { + sawTerminalEvent = true + } } return } @@ -368,7 +396,7 @@ async function handleResumeRequestBody({ (err) => { logger.warn('Failed to poll latest run for stream', { streamId, - error: err instanceof Error ? err.message : String(err), + error: getErrorMessage(err), }) return null } @@ -408,6 +436,10 @@ async function handleResumeRequestBody({ break } + if (Date.now() - lastWriteTime >= REPLAY_KEEPALIVE_INTERVAL_MS) { + enqueueComment('keepalive') + } + await sleep(POLL_INTERVAL_MS) } if (!controllerClosed && Date.now() - startTime >= MAX_STREAM_MS) { @@ -421,7 +453,7 @@ async function handleResumeRequestBody({ if (!controllerClosed && !request.signal.aborted) { logger.warn('Stream replay failed', { streamId, - error: error instanceof Error ? error.message : String(error), + error: getErrorMessage(error), }) emitTerminalIfMissing(MothershipStreamV1CompletionStatus.error, { message: 'The stream replay failed before completion.', 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..7107883f2d1 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 { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' +import { updateCopilotMessagesContract } from '@/lib/api/contracts/copilot' +import { parseRequest } from '@/lib/api/server' +import { getAccessibleCopilotChatAuth } 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() @@ -65,13 +27,21 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return createUnauthorizedResponse() } - const body = await req.json() + const parsed = await parseRequest( + updateCopilotMessagesContract, + req, + {}, + { + invalidJson: 'throw', + } + ) + if (!parsed.success) return parsed.response + const { chatId, messages, planArtifact, config } = parsed.data.body - // Debug: Log what we received - const lastMsg = body.messages?.[body.messages.length - 1] + const lastMsg = messages[messages.length - 1] if (lastMsg?.role === 'assistant') { logger.info(`[${tracker.requestId}] Received messages to save`, { - messageCount: body.messages?.length, + messageCount: messages.length, lastMsgId: lastMsg.id, lastMsgContentLength: lastMsg.content?.length || 0, lastMsgContentBlockCount: lastMsg.contentBlocks?.length || 0, @@ -79,7 +49,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) } - const { chatId, messages, planArtifact, config } = UpdateMessagesSchema.parse(body) const normalizedMessages: PersistedMessage[] = messages.map((message) => normalizeMessage(message as Record) ) @@ -97,7 +66,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } // Verify that the chat belongs to the user - const chat = await getAccessibleCopilotChat(chatId, userId) + const chat = await getAccessibleCopilotChatAuth(chatId, userId) if (!chat) { return createNotFoundResponse('Chat not found or unauthorized') diff --git a/apps/sim/app/api/copilot/chats/route.ts b/apps/sim/app/api/copilot/chats/route.ts index 07b6974ed45..05e4c7773db 100644 --- a/apps/sim/app/api/copilot/chats/route.ts +++ b/apps/sim/app/api/copilot/chats/route.ts @@ -4,25 +4,25 @@ 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 { createWorkflowCopilotChatContract } from '@/lib/api/contracts/copilot' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, + createForbiddenResponse, createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' import { taskPubSub } from '@/lib/copilot/tasks' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { + assertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError, +} 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) => { @@ -96,8 +96,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return createUnauthorizedResponse() } - const body = await request.json() - const { workspaceId, workflowId } = CreateWorkflowCopilotChatSchema.parse(body) + const parsed = await parseRequest( + createWorkflowCopilotChatContract, + request, + {}, + { + validationErrorResponse: (error) => + validationErrorResponse(error, 'workspaceId and workflowId are required'), + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, workflowId } = parsed.data.body await assertActiveWorkspaceAccess(workspaceId, userId) @@ -133,8 +142,8 @@ 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') + if (isWorkspaceAccessDeniedError(error)) { + return createForbiddenResponse('Workspace access denied') } 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..251578bbd80 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts @@ -36,6 +36,7 @@ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) vi.mock('@/lib/copilot/chat/lifecycle', () => ({ getAccessibleCopilotChat: mockGetAccessibleCopilotChat, + getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChat, })) vi.mock('@sim/db', () => ({ @@ -137,7 +138,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 +149,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 +165,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..5ccda51212d 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.ts @@ -4,8 +4,10 @@ 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 { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' +import { revertCopilotCheckpointContract } from '@/lib/api/contracts/copilot' +import type { CleanedWorkflowState } from '@/lib/api/contracts/workflows' +import { parseRequest } from '@/lib/api/server' +import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' import { authenticateCopilotRequestSessionOnly, createInternalServerErrorResponse, @@ -19,10 +21,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 @@ -36,8 +34,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return createUnauthorizedResponse() } - const body = await request.json() - const { checkpointId } = RevertCheckpointSchema.parse(body) + const parsed = await parseRequest( + revertCopilotCheckpointContract, + request, + {}, + { + invalidJson: 'throw', + } + ) + if (!parsed.success) return parsed.response + const { checkpointId } = parsed.data.body logger.info(`[${tracker.requestId}] Reverting to checkpoint ${checkpointId}`) @@ -51,7 +57,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return createNotFoundResponse('Checkpoint not found or access denied') } - const chat = await getAccessibleCopilotChat(checkpoint.chatId, userId) + const chat = await getAccessibleCopilotChatAuth(checkpoint.chatId, userId) if (!chat) { return createNotFoundResponse('Checkpoint not found or access denied') } @@ -75,20 +81,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return createUnauthorizedResponse() } - const checkpointState = checkpoint.workflowState as any // Cast to any for property access - - const cleanedState = { - blocks: checkpointState?.blocks || {}, - edges: checkpointState?.edges || [], - loops: checkpointState?.loops || {}, - parallels: checkpointState?.parallels || {}, - isDeployed: checkpointState?.isDeployed || false, + const checkpointState: Record = + checkpoint.workflowState && typeof checkpoint.workflowState === 'object' + ? (checkpoint.workflowState as Record) + : {} + + const rawBlocks = checkpointState.blocks + const rawEdges = checkpointState.edges + const rawLoops = checkpointState.loops + const rawParallels = checkpointState.parallels + const rawDeployedAt = checkpointState.deployedAt + + const parsedDeployedAt = + rawDeployedAt === null || rawDeployedAt === undefined + ? null + : new Date(rawDeployedAt as string | number | Date) + + const cleanedState: CleanedWorkflowState = { + blocks: (rawBlocks ?? {}) as Record, + edges: (rawEdges ?? []) as unknown[], + loops: (rawLoops ?? {}) as Record, + parallels: (rawParallels ?? {}) as Record, + isDeployed: Boolean(checkpointState.isDeployed), lastSaved: Date.now(), - ...(checkpointState?.deployedAt && - checkpointState.deployedAt !== null && - checkpointState.deployedAt !== undefined && - !Number.isNaN(new Date(checkpointState.deployedAt).getTime()) - ? { deployedAt: new Date(checkpointState.deployedAt) } + ...(parsedDeployedAt && !Number.isNaN(parsedDeployedAt.getTime()) + ? { deployedAt: parsedDeployedAt } : {}), } diff --git a/apps/sim/app/api/copilot/checkpoints/route.test.ts b/apps/sim/app/api/copilot/checkpoints/route.test.ts index e3da1f258f4..f7dc748786b 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.test.ts @@ -44,6 +44,7 @@ vi.mock('drizzle-orm', () => ({ vi.mock('@/lib/copilot/chat/lifecycle', () => ({ getAccessibleCopilotChat: mockGetAccessibleCopilotChat, + getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChat, })) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) @@ -105,7 +106,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 +115,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..985a95a71a3 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.ts @@ -4,8 +4,12 @@ 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 { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' +import { + createCopilotCheckpointContract, + listCopilotCheckpointsContract, +} from '@/lib/api/contracts/copilot' +import { getValidationErrorMessage, parseRequest, validationErrorResponse } from '@/lib/api/server' +import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, @@ -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 @@ -37,8 +34,20 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return createUnauthorizedResponse() } - const body = await req.json() - const { workflowId, chatId, messageId, workflowState } = CreateCheckpointSchema.parse(body) + const parsed = await parseRequest( + createCopilotCheckpointContract, + req, + {}, + { + validationErrorResponse: (error) => + validationErrorResponse( + error, + getValidationErrorMessage(error, 'Invalid checkpoint payload') + ), + } + ) + if (!parsed.success) return parsed.response + const { workflowId, chatId, messageId, workflowState } = parsed.data.body logger.info(`[${tracker.requestId}] Creating workflow checkpoint`, { userId, @@ -51,7 +60,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) // Verify that the chat belongs to the user - const chat = await getAccessibleCopilotChat(chatId, userId) + const chat = await getAccessibleCopilotChatAuth(chatId, userId) if (!chat) { return createBadRequestResponse('Chat not found or unauthorized') @@ -133,19 +142,24 @@ export const GET = withRouteHandler(async (req: NextRequest) => { return createUnauthorizedResponse() } - const { searchParams } = new URL(req.url) - const chatId = searchParams.get('chatId') - - if (!chatId) { - return createBadRequestResponse('chatId is required') - } + const parsed = await parseRequest( + listCopilotCheckpointsContract, + req, + {}, + { + validationErrorResponse: (error) => + validationErrorResponse(error, getValidationErrorMessage(error)), + } + ) + if (!parsed.success) return parsed.response + const { chatId } = parsed.data.query logger.info(`[${tracker.requestId}] Fetching workflow checkpoints for chat`, { userId, chatId, }) - const chat = await getAccessibleCopilotChat(chatId, userId) + const chat = await getAccessibleCopilotChatAuth(chatId, userId) if (!chat) { return createBadRequestResponse('Chat not found or unauthorized') } diff --git a/apps/sim/app/api/copilot/confirm/route.test.ts b/apps/sim/app/api/copilot/confirm/route.test.ts index 714a125f2d2..a58458ab409 100644 --- a/apps/sim/app/api/copilot/confirm/route.test.ts +++ b/apps/sim/app/api/copilot/confirm/route.test.ts @@ -189,8 +189,9 @@ describe('Copilot Confirm API Route', () => { ) expect(acceptedResponse.status).toBe(400) - expect(await acceptedResponse.json()).toEqual({ + expect(await acceptedResponse.json()).toMatchObject({ error: 'Invalid request data: Invalid notification status', + details: expect.any(Array), }) const rejectedResponse = await POST( @@ -201,8 +202,9 @@ describe('Copilot Confirm API Route', () => { ) expect(rejectedResponse.status).toBe(400) - expect(await rejectedResponse.json()).toEqual({ + expect(await rejectedResponse.json()).toMatchObject({ error: 'Invalid request data: Invalid notification status', + details: expect.any(Array), }) }) diff --git a/apps/sim/app/api/copilot/confirm/route.ts b/apps/sim/app/api/copilot/confirm/route.ts index f1fea4c4388..1dd5bd98a12 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 { getErrorMessage, toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { copilotConfirmContract } from '@/lib/api/contracts/copilot' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { ASYNC_TOOL_CONFIRMATION_STATUS, ASYNC_TOOL_STATUS, @@ -20,7 +21,6 @@ import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { publishToolConfirmation } from '@/lib/copilot/persistence/tool-confirm' import { authenticateCopilotRequestSessionOnly, - createBadRequestResponse, createInternalServerErrorResponse, createNotFoundResponse, createRequestTracker, @@ -31,22 +31,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. * @@ -138,8 +122,25 @@ export const POST = withRouteHandler((req: NextRequest) => { return createUnauthorizedResponse() } - const body = await req.json() - const { toolCallId, status, message, data } = ConfirmationSchema.parse(body) + const parsed = await parseRequest( + copilotConfirmContract, + req, + {}, + { + validationErrorResponse: (error) => { + span.setAttribute( + TraceAttr.CopilotConfirmOutcome, + CopilotConfirmOutcome.ValidationError + ) + return validationErrorResponse( + error, + `Invalid request data: ${error.issues.map((e) => e.message).join(', ')}` + ) + }, + } + ) + if (!parsed.success) return parsed.response + const { toolCallId, status, message, data } = parsed.data.body span.setAttributes({ [TraceAttr.ToolCallId]: toolCallId, [TraceAttr.ToolConfirmationStatus]: status, @@ -149,7 +150,7 @@ export const POST = withRouteHandler((req: NextRequest) => { const existing = await getAsyncToolCall(toolCallId).catch((err) => { logger.warn('Failed to fetch async tool call', { toolCallId, - error: err instanceof Error ? err.message : String(err), + error: getErrorMessage(err), }) return null }) @@ -164,7 +165,7 @@ export const POST = withRouteHandler((req: NextRequest) => { const run = await getRunSegment(existing.runId).catch((err) => { logger.warn('Failed to fetch run segment', { runId: existing.runId, - error: err instanceof Error ? err.message : String(err), + error: getErrorMessage(err), }) return null }) @@ -202,27 +203,14 @@ 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', + error: getErrorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : undefined, }) span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.InternalError) - return createInternalServerErrorResponse( - error instanceof Error ? error.message : 'Internal server error' - ) + return createInternalServerErrorResponse(getErrorMessage(error, 'Internal server error')) } } ) diff --git a/apps/sim/app/api/copilot/credentials/route.ts b/apps/sim/app/api/copilot/credentials/route.ts index 4d570157d60..6d598984225 100644 --- a/apps/sim/app/api/copilot/credentials/route.ts +++ b/apps/sim/app/api/copilot/credentials/route.ts @@ -1,4 +1,7 @@ +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { copilotCredentialsContract } from '@/lib/api/contracts/copilot' +import { parseRequest } 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 +11,10 @@ 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 parsed = await parseRequest(copilotCredentialsContract, req, {}) + if (!parsed.success) return parsed.response + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -21,7 +27,7 @@ export const GET = withRouteHandler(async (_req: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Failed to load credentials', + error: getErrorMessage(error, 'Failed to load credentials'), }, { status: 500 } ) diff --git a/apps/sim/app/api/copilot/feedback/route.ts b/apps/sim/app/api/copilot/feedback/route.ts index 66e124c2402..e14cff30b49 100644 --- a/apps/sim/app/api/copilot/feedback/route.ts +++ b/apps/sim/app/api/copilot/feedback/route.ts @@ -1,12 +1,13 @@ import { db } from '@sim/db' import { copilotFeedback } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { submitCopilotFeedbackContract } from '@/lib/api/contracts' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { authenticateCopilotRequestSessionOnly, - createBadRequestResponse, createInternalServerErrorResponse, createRequestTracker, createUnauthorizedResponse, @@ -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 @@ -42,9 +33,25 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return createUnauthorizedResponse() } - const body = await req.json() + const parsed = await parseRequest( + submitCopilotFeedbackContract, + req, + {}, + { + invalidJson: 'throw', + validationErrorResponse: (error) => { + logger.error(`[${tracker.requestId}] Validation error:`, { + duration: tracker.getDuration(), + errors: error.issues, + }) + return validationErrorResponse(error, 'Invalid request data') + }, + } + ) + if (!parsed.success) return parsed.response + const { chatId, userQuery, agentResponse, isPositiveFeedback, feedback, workflowYaml } = - FeedbackSchema.parse(body) + parsed.data.body logger.info(`[${tracker.requestId}] Processing copilot feedback submission`, { userId: authenticatedUserId, @@ -96,19 +103,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } catch (error) { const duration = tracker.getDuration() - if (error instanceof z.ZodError) { - logger.error(`[${tracker.requestId}] Validation error:`, { - duration, - errors: error.errors, - }) - return createBadRequestResponse( - `Invalid request data: ${error.errors.map((e) => e.message).join(', ')}` - ) - } - logger.error(`[${tracker.requestId}] Error submitting copilot feedback:`, { duration, - error: error instanceof Error ? error.message : 'Unknown error', + error: getErrorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : undefined, }) diff --git a/apps/sim/app/api/copilot/models/route.ts b/apps/sim/app/api/copilot/models/route.ts index a9f3a36ec6c..7dadeef7ee2 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 { SIM_AGENT_API_URL } from '@/lib/copilot/constants' +import { copilotModelsContract } from '@/lib/api/contracts/copilot' +import { parseRequest } from '@/lib/api/server' import { fetchGo } from '@/lib/copilot/request/go/fetch' import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http' +import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url' +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,10 @@ function isRawAvailableModel(item: unknown): item is RawAvailableModel { ) } -export const GET = withRouteHandler(async (_req: NextRequest) => { +export const GET = withRouteHandler(async (req: NextRequest) => { + const parsed = await parseRequest(copilotModelsContract, req, {}) + if (!parsed.success) return parsed.response + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -46,7 +50,8 @@ export const GET = withRouteHandler(async (_req: NextRequest) => { } try { - const response = await fetchGo(`${SIM_AGENT_API_URL}/api/get-available-models`, { + const mothershipBaseURL = await getMothershipBaseURL({ userId }) + const response = await fetchGo(`${mothershipBaseURL}/api/get-available-models`, { method: 'GET', headers, cache: 'no-store', diff --git a/apps/sim/app/api/copilot/stats/route.test.ts b/apps/sim/app/api/copilot/stats/route.test.ts index 28ddffda9c7..a3b2f9b0a1a 100644 --- a/apps/sim/app/api/copilot/stats/route.test.ts +++ b/apps/sim/app/api/copilot/stats/route.test.ts @@ -7,8 +7,9 @@ import { copilotHttpMock, copilotHttpMockFns, createEnvMock, createMockRequest } import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockFetch } = vi.hoisted(() => ({ +const { mockFetch, mockGetMothershipBaseURL } = vi.hoisted(() => ({ mockFetch: vi.fn(), + mockGetMothershipBaseURL: vi.fn(), })) vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) @@ -16,6 +17,12 @@ 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/copilot/server/agent-url', () => ({ + getMothershipBaseURL: mockGetMothershipBaseURL, })) vi.mock('@/lib/core/config/env', () => createEnvMock({ COPILOT_API_KEY: 'test-api-key' })) @@ -41,6 +48,7 @@ function buildMockResponse(init: { describe('Copilot Stats API Route', () => { beforeEach(() => { vi.clearAllMocks() + mockGetMothershipBaseURL.mockResolvedValue('https://agent.sim.example.com') global.fetch = mockFetch }) diff --git a/apps/sim/app/api/copilot/stats/route.ts b/apps/sim/app/api/copilot/stats/route.ts index a42e318d42a..f19b9e8df47 100644 --- a/apps/sim/app/api/copilot/stats/route.ts +++ b/apps/sim/app/api/copilot/stats/route.ts @@ -1,23 +1,17 @@ import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' +import { copilotStatsContract } from '@/lib/api/contracts/copilot' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { fetchGo } from '@/lib/copilot/request/go/fetch' import { authenticateCopilotRequestSessionOnly, - createBadRequestResponse, createInternalServerErrorResponse, createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/request/http' +import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url' 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 { @@ -26,22 +20,32 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return createUnauthorizedResponse() } - const json = await req.json().catch(() => ({})) - const parsed = BodySchema.safeParse(json) - if (!parsed.success) { - return createBadRequestResponse('Invalid request body for copilot stats') - } + const parsed = await parseRequest( + copilotStatsContract, + req, + {}, + { + validationErrorResponse: (error) => + validationErrorResponse(error, 'Invalid request body for copilot stats'), + invalidJsonResponse: () => + NextResponse.json( + { error: 'Invalid request body for copilot stats', details: [] }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response - const { messageId, diffCreated, diffAccepted } = parsed.data as any + const { messageId, diffCreated, diffAccepted } = parsed.data.body - // Build outgoing payload for Sim Agent with only required fields const payload: Record = { messageId, diffCreated, diffAccepted, } - const agentRes = await fetchGo(`${SIM_AGENT_API_URL}/api/stats`, { + const mothershipBaseURL = await getMothershipBaseURL({ userId }) + const agentRes = await fetchGo(`${mothershipBaseURL}/api/stats`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -52,7 +56,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { operation: 'stats_ingest', }) - // Prefer not to block clients; still relay status let agentJson: any = null try { agentJson = await agentRes.json() diff --git a/apps/sim/app/api/copilot/training/examples/route.ts b/apps/sim/app/api/copilot/training/examples/route.ts index 1e6a5aa6574..e69cfc5ecd1 100644 --- a/apps/sim/app/api/copilot/training/examples/route.ts +++ b/apps/sim/app/api/copilot/training/examples/route.ts @@ -1,10 +1,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { - authenticateCopilotRequestSessionOnly, - createUnauthorizedResponse, -} from '@/lib/copilot/request/http' +import { copilotTrainingExampleContract } from '@/lib/api/contracts/copilot' +import { parseRequest, validationErrorResponse } 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' @@ -13,16 +12,9 @@ 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 { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !userId) { + const auth = checkInternalApiKey(request) + if (!auth.success) { return createUnauthorizedResponse() } @@ -39,22 +31,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - - const validationResult = TrainingExampleSchema.safeParse(body) - - 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, + const parsed = await parseRequest( + copilotTrainingExampleContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn('Invalid training example format', { errors: error.issues }) + return validationErrorResponse(error, 'Invalid training example format') }, - { status: 400 } - ) - } - - const validatedData = validationResult.data + } + ) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info('Sending workflow example to agent indexer', { hasJsonField: typeof validatedData.json === 'string', @@ -86,7 +75,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { headers: { 'content-type': 'application/json' }, }) } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to add example' + const errorMessage = getErrorMessage(err, 'Failed to add example') logger.error('Failed to send workflow example', { error: err }) return NextResponse.json({ error: errorMessage }, { status: 502 }) } diff --git a/apps/sim/app/api/copilot/training/route.ts b/apps/sim/app/api/copilot/training/route.ts index 1c1e64ab0e9..100b53b77a3 100644 --- a/apps/sim/app/api/copilot/training/route.ts +++ b/apps/sim/app/api/copilot/training/route.ts @@ -1,34 +1,17 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { - authenticateCopilotRequestSessionOnly, - createUnauthorizedResponse, -} from '@/lib/copilot/request/http' +import { copilotTrainingDataContract } from '@/lib/api/contracts/copilot' +import { parseRequest, validationErrorResponse } 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 { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !userId) { + const auth = checkInternalApiKey(request) + if (!auth.success) { return createUnauthorizedResponse() } @@ -48,21 +31,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validationResult = TrainingDataSchema.safeParse(body) - - 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, + const parsed = await parseRequest( + copilotTrainingDataContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn('Invalid training data format', { errors: error.issues }) + return validationErrorResponse(error, 'Invalid training data format') }, - { status: 400 } - ) - } - - const { title, prompt, input, output, operations } = validationResult.data + } + ) + if (!parsed.success) return parsed.response + const { title, prompt, input, output, operations } = parsed.data.body logger.info('Sending training data to agent indexer', { title, @@ -105,7 +86,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.error('Failed to send training data to agent indexer', { error }) return NextResponse.json( { - error: error instanceof Error ? error.message : 'Failed to send training data', + error: getErrorMessage(error, 'Failed to send training data'), }, { status: 502 } ) diff --git a/apps/sim/app/api/creators/[id]/route.ts b/apps/sim/app/api/creators/[id]/route.ts index d1e6508caf6..b6ce9c781ae 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, + updateCreatorProfileContract, +} from '@/lib/api/contracts/creator-profile' +import { parseRequest, 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 }) } @@ -76,19 +76,26 @@ export const GET = withRouteHandler( // PUT /api/creators/[id] - Update a creator profile export const PUT = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params try { const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized update attempt for profile: ${id}`) + logger.warn(`[${requestId}] Unauthorized update attempt`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = UpdateCreatorProfileSchema.parse(body) + const parsed = await parseRequest(updateCreatorProfileContract, request, context, { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid update data`, { errors: error.issues }) + return validationErrorResponse(error, 'Invalid update data') + }, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const data = parsed.data.body // Check if profile exists const existing = await db @@ -97,7 +104,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 +130,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,18 +155,8 @@ 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) { - logger.warn(`[${requestId}] Invalid update data for profile: ${id}`, { - errors: error.errors, - }) - return NextResponse.json( - { error: 'Invalid update data', details: error.errors }, - { status: 400 } - ) - } - - logger.error(`[${requestId}] Error updating creator profile: ${id}`, error) + } catch (error) { + logger.error(`[${requestId}] Error updating creator profile`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } @@ -166,9 +164,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 +186,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 +203,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..8ae64c05773 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, + createCreatorProfileContract, + listCreatorProfilesQuerySchema, +} from '@/lib/api/contracts/creator-profile' +import { parseRequest, 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 }) } @@ -93,10 +106,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = CreateCreatorProfileSchema.parse(body) + const parsed = await parseRequest( + createCreatorProfileContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid profile data`, { errors: error.issues }) + return validationErrorResponse(error, 'Invalid profile data') + }, + } + ) + if (!parsed.success) return parsed.response + const data = parsed.data.body - // Validate permissions if (data.referenceType === 'user') { if (data.referenceId !== session.user.id) { logger.warn(`[${requestId}] User tried to create profile for another user`) @@ -165,6 +188,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,15 +199,7 @@ 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) { logger.error(`[${requestId}] Error creating creator profile`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } 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..4bfadcd2300 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, + createCredentialSetInvitationContract, +} from '@/lib/api/contracts/credential-sets' +import { getValidationErrorMessage, parseRequest } 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({ @@ -78,7 +78,7 @@ export const GET = withRouteHandler( ) export const POST = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { const session = await getSession() if (!session?.user?.id) { @@ -94,9 +94,16 @@ export const POST = withRouteHandler( ) } - const { id } = await params - try { + const parsed = await parseRequest(createCredentialSetInvitationContract, req, context, { + validationErrorResponse: (error) => + NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { email } = parsed.data.body + const result = await getCredentialSetWithAccess(id, session.user.id) if (!result) { @@ -107,9 +114,6 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) } - const body = await req.json() - const { email } = createInviteSchema.parse(body) - const token = generateId() const expiresAt = new Date() expiresAt.setDate(expiresAt.getDate() + 7) @@ -207,9 +211,6 @@ export const POST = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) - } logger.error('Error creating invitation', error) return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 }) } @@ -235,12 +236,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..8e7241382ee 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 { updateCredentialSetContract } from '@/lib/api/contracts/credential-sets' +import { parseRequest } 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( @@ -74,7 +89,7 @@ export const GET = withRouteHandler( ) export const PUT = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { const session = await getSession() if (!session?.user?.id) { @@ -90,7 +105,7 @@ export const PUT = withRouteHandler( ) } - const { id } = await params + const { id } = await context.params try { const result = await getCredentialSetWithAccess(id, session.user.id) @@ -103,8 +118,9 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) } - const body = await req.json() - const updates = updateCredentialSetSchema.parse(body) + const parsed = await parseRequest(updateCredentialSetContract, req, context) + if (!parsed.success) return parsed.response + const updates = parsed.data.body if (updates.name) { const existingSet = await db @@ -162,9 +178,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 2d8e1b77a63..43bd6df4e38 100644 --- a/apps/sim/app/api/credential-sets/invite/[token]/route.ts +++ b/apps/sim/app/api/credential-sets/invite/[token]/route.ts @@ -10,15 +10,23 @@ 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 { + acceptCredentialSetInvitationContract, + getCredentialSetInvitationContract, +} from '@/lib/api/contracts/credential-sets' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { normalizeEmail } from '@/lib/invitations/core' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' const logger = createLogger('CredentialSetInviteToken') export const GET = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ token: string }> }) => { - const { token } = await params + async (req: NextRequest, context: { params: Promise<{ token: string }> }) => { + const parsed = await parseRequest(getCredentialSetInvitationContract, req, context) + if (!parsed.success) return parsed.response + const { token } = parsed.data.params const [invitation] = await db .select({ @@ -67,8 +75,10 @@ export const GET = withRouteHandler( ) export const POST = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ token: string }> }) => { - const { token } = await params + async (req: NextRequest, context: { params: Promise<{ token: string }> }) => { + const parsed = await parseRequest(acceptCredentialSetInvitationContract, req, context) + if (!parsed.success) return parsed.response + const { token } = parsed.data.params const session = await getSession() if (!session?.user?.id) { @@ -111,6 +121,21 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 }) } + if (invitation.email) { + const sessionEmail = session.user.email + if (!sessionEmail || normalizeEmail(sessionEmail) !== normalizeEmail(invitation.email)) { + logger.warn('Rejected credential set invitation accept due to email mismatch', { + invitationId: invitation.id, + credentialSetId: invitation.credentialSetId, + userId: session.user.id, + }) + return NextResponse.json( + { error: 'This invitation was sent to a different email address' }, + { status: 403 } + ) + } + } + const existingMember = await db .select() .from(credentialSetMember) diff --git a/apps/sim/app/api/credential-sets/memberships/route.ts b/apps/sim/app/api/credential-sets/memberships/route.ts index 1e3846bd0d7..ee3a5ec3e41 100644 --- a/apps/sim/app/api/credential-sets/memberships/route.ts +++ b/apps/sim/app/api/credential-sets/memberships/route.ts @@ -2,9 +2,12 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { credentialSet, credentialSetMember, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, 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 +58,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) @@ -123,7 +133,7 @@ export const DELETE = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ success: true }) } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to leave credential set' + const message = getErrorMessage(error, 'Failed to leave credential set') logger.error('Error leaving credential set', error) return NextResponse.json({ error: message }, { status: 500 }) } diff --git a/apps/sim/app/api/credential-sets/route.ts b/apps/sim/app/api/credential-sets/route.ts index cc5ba887999..0221ca44b93 100644 --- a/apps/sim/app/api/credential-sets/route.ts +++ b/apps/sim/app/api/credential-sets/route.ts @@ -4,21 +4,18 @@ import { credentialSet, credentialSetMember, member, organization, user } from ' 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 { type NextRequest, NextResponse } from 'next/server' +import { + createCredentialSetContract, + listCredentialSetsQuerySchema, +} from '@/lib/api/contracts/credential-sets' +import { getValidationErrorMessage, parseRequest } 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) @@ -91,7 +95,7 @@ export const GET = withRouteHandler(async (req: Request) => { return NextResponse.json({ credentialSets: setsWithCounts }) }) -export const POST = withRouteHandler(async (req: Request) => { +export const POST = withRouteHandler(async (req: NextRequest) => { const session = await getSession() if (!session?.user?.id) { @@ -108,8 +112,18 @@ export const POST = withRouteHandler(async (req: Request) => { } try { - const body = await req.json() - const { organizationId, name, description, providerId } = createCredentialSetSchema.parse(body) + const parsed = await parseRequest( + createCredentialSetContract, + req, + {}, + { + validationErrorResponse: (error) => + NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response + + const { organizationId, name, description, providerId } = parsed.data.body const membership = await db .select({ id: member.id, role: member.role }) @@ -184,11 +198,18 @@ 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 }) - } 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..8b1768ac2be 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 { upsertWorkspaceCredentialMemberContract } from '@/lib/api/contracts/credentials' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -57,7 +58,7 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route .limit(1) if (!cred) { - return NextResponse.json({ members: [] }, { status: 200 }) + return NextResponse.json({ error: 'Not found' }, { status: 404 }) } const callerPerm = await getUserEntityPermissions( @@ -66,7 +67,7 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route cred.workspaceId ) if (callerPerm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + return NextResponse.json({ error: 'Not found' }, { status: 404 }) } const members = await db @@ -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,13 +105,10 @@ 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) - if (!parsed.success) { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) - } + const parsed = await parseRequest(upsertWorkspaceCredentialMemberContract, request, context) + if (!parsed.success) return parsed.response - const { userId, role } = parsed.data + const { userId, role } = parsed.data.body const now = new Date() const [existing] = await db @@ -127,10 +120,36 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route .limit(1) if (existing) { - await db - .update(credentialMember) - .set({ role, status: 'active', updatedAt: now }) - .where(eq(credentialMember.id, existing.id)) + const ok = await db.transaction(async (tx) => { + const [current] = await tx + .select({ role: credentialMember.role, status: credentialMember.status }) + .from(credentialMember) + .where(eq(credentialMember.id, existing.id)) + .limit(1) + .for('update') + if (current?.role === 'admin' && current?.status === 'active' && role !== 'admin') { + const activeAdmins = await tx + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, credentialId), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active') + ) + ) + .for('update') + if (activeAdmins.length <= 1) return false + } + await tx + .update(credentialMember) + .set({ role, status: 'active', updatedAt: now }) + .where(eq(credentialMember.id, existing.id)) + return true + }) + if (!ok) { + return NextResponse.json({ error: 'Cannot demote the last admin' }, { status: 400 }) + } return NextResponse.json({ success: true }) } @@ -202,6 +221,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou eq(credentialMember.status, 'active') ) ) + .for('update') if (activeAdmins.length <= 1) { return false diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index a85a32a72c4..f846a88d3fb 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -1,41 +1,17 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { credential, credentialMember, environment, workspaceEnvironment } from '@sim/db/schema' +import { credential, credentialMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateWorkspaceCredentialContract } from '@/lib/api/contracts/credentials' +import { getValidationErrorMessage, parseRequest } 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' import { getCredentialActorContext } from '@/lib/credentials/access' -import { - deleteWorkspaceEnvCredentials, - syncPersonalEnvCredentialsForUser, -} from '@/lib/credentials/environment' -import { captureServerEvent } from '@/lib/posthog/server' +import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration' 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({ @@ -93,110 +69,49 @@ export const GET = withRouteHandler( ) export const PUT = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id } = await params - try { - const parseResult = updateCredentialSchema.safeParse(await request.json()) - if (!parseResult.success) { - return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 }) - } - - const access = await getCredentialActorContext(id, session.user.id) - if (!access.credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - if (!access.hasWorkspaceAccess || !access.isAdmin) { - return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 }) - } - - const updates: Record = {} - - if (parseResult.data.description !== undefined) { - updates.description = parseResult.data.description ?? null - } - - if ( - parseResult.data.displayName !== undefined && - (access.credential.type === 'oauth' || access.credential.type === 'service_account') - ) { - updates.displayName = parseResult.data.displayName - } - - if ( - parseResult.data.serviceAccountJson !== undefined && - access.credential.type === 'service_account' - ) { - let parsed: Record - try { - parsed = JSON.parse(parseResult.data.serviceAccountJson) - } catch { - return NextResponse.json({ error: 'Invalid JSON format' }, { status: 400 }) - } - if ( - parsed.type !== 'service_account' || - typeof parsed.client_email !== 'string' || - typeof parsed.private_key !== 'string' || - typeof parsed.project_id !== 'string' - ) { - return NextResponse.json({ error: 'Invalid service account JSON key' }, { status: 400 }) - } - const { encrypted } = await encryptSecret(parseResult.data.serviceAccountJson) - updates.encryptedServiceAccountKey = encrypted - } - - if (Object.keys(updates).length === 0) { - if (access.credential.type === 'oauth' || access.credential.type === 'service_account') { - return NextResponse.json( - { - error: 'No updatable fields provided.', - }, - { status: 400 } - ) - } - return NextResponse.json( - { - error: - 'Environment credentials cannot be updated via this endpoint. Use the environment value editor in credentials settings.', - }, - { status: 400 } - ) - } + const parsed = await parseRequest(updateWorkspaceCredentialContract, request, context, { + validationErrorResponse: (error) => + NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), + }) + if (!parsed.success) return parsed.response - updates.updatedAt = new Date() - await db.update(credential).set(updates).where(eq(credential.id, id)) + const { id } = parsed.data.params + const body = parsed.data.body - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: session.user.id, + const result = await performUpdateCredential({ + credentialId: id, + userId: session.user.id, actorName: session.user.name, actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_UPDATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: id, - resourceName: access.credential.displayName, - description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, - metadata: { - credentialType: access.credential.type, - updatedFields: Object.keys(updates).filter((k) => k !== 'updatedAt'), - }, + displayName: body.displayName, + description: body.description, + serviceAccountJson: body.serviceAccountJson, request, }) + if (!result.success) { + const status = + result.errorCode === 'not_found' + ? 404 + : result.errorCode === 'forbidden' + ? 403 + : result.errorCode === 'conflict' + ? 409 + : result.errorCode === 'validation' + ? 400 + : 500 + return NextResponse.json({ error: result.error }, { status }) + } const row = await getCredentialResponse(id, session.user.id) return NextResponse.json({ credential: row }, { status: 200 }) } catch (error) { - if (error instanceof Error && error.message.includes('unique')) { - return NextResponse.json( - { error: 'A service account credential with this name already exists in the workspace' }, - { status: 409 } - ) - } logger.error('Failed to update credential', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } @@ -213,173 +128,24 @@ export const DELETE = withRouteHandler( const { id } = await params try { - const access = await getCredentialActorContext(id, session.user.id) - if (!access.credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - if (!access.hasWorkspaceAccess || !access.isAdmin) { - return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 }) - } - - if (access.credential.type === 'env_personal' && access.credential.envKey) { - const ownerUserId = access.credential.envOwnerUserId - if (!ownerUserId) { - return NextResponse.json({ error: 'Invalid personal secret owner' }, { status: 400 }) - } - - const [personalRow] = await db - .select({ variables: environment.variables }) - .from(environment) - .where(eq(environment.userId, ownerUserId)) - .limit(1) - - const current = ((personalRow?.variables as Record | null) ?? {}) as Record< - string, - string - > - if (access.credential.envKey in current) { - delete current[access.credential.envKey] - } - - await db - .insert(environment) - .values({ - id: ownerUserId, - userId: ownerUserId, - variables: current, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [environment.userId], - set: { variables: current, updatedAt: new Date() }, - }) - - await syncPersonalEnvCredentialsForUser({ - userId: ownerUserId, - envKeys: Object.keys(current), - }) - - captureServerEvent( - session.user.id, - 'credential_deleted', - { - credential_type: 'env_personal', - provider_id: access.credential.envKey, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: id, - resourceName: access.credential.displayName, - description: `Deleted personal env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_personal', envKey: access.credential.envKey }, - request, - }) - - return NextResponse.json({ success: true }, { status: 200 }) - } - - if (access.credential.type === 'env_workspace' && access.credential.envKey) { - const [workspaceRow] = await db - .select({ - id: workspaceEnvironment.id, - createdAt: workspaceEnvironment.createdAt, - variables: workspaceEnvironment.variables, - }) - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId)) - .limit(1) - - const current = ((workspaceRow?.variables as Record | null) ?? - {}) as Record - if (access.credential.envKey in current) { - delete current[access.credential.envKey] - } - - await db - .insert(workspaceEnvironment) - .values({ - id: workspaceRow?.id || generateId(), - workspaceId: access.credential.workspaceId, - variables: current, - createdAt: workspaceRow?.createdAt || new Date(), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [workspaceEnvironment.workspaceId], - set: { variables: current, updatedAt: new Date() }, - }) - - await deleteWorkspaceEnvCredentials({ - workspaceId: access.credential.workspaceId, - removedKeys: [access.credential.envKey], - }) - - captureServerEvent( - session.user.id, - 'credential_deleted', - { - credential_type: 'env_workspace', - provider_id: access.credential.envKey, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: id, - resourceName: access.credential.displayName, - description: `Deleted workspace env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_workspace', envKey: access.credential.envKey }, - request, - }) - - return NextResponse.json({ success: true }, { status: 200 }) - } - - await db.delete(credential).where(eq(credential.id, id)) - - captureServerEvent( - session.user.id, - 'credential_deleted', - { - credential_type: access.credential.type as 'oauth' | 'service_account', - provider_id: access.credential.providerId ?? id, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: session.user.id, + const result = await performDeleteCredential({ + credentialId: id, + userId: session.user.id, actorName: session.user.name, actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: id, - resourceName: access.credential.displayName, - description: `Deleted ${access.credential.type} credential "${access.credential.displayName}"`, - metadata: { - credentialType: access.credential.type, - providerId: access.credential.providerId, - }, request, }) + if (!result.success) { + const status = + result.errorCode === 'not_found' + ? 404 + : result.errorCode === 'forbidden' + ? 403 + : result.errorCode === 'validation' + ? 400 + : 500 + return NextResponse.json({ error: result.error }, { status }) + } return NextResponse.json({ success: true }, { status: 200 }) } catch (error) { diff --git a/apps/sim/app/api/credentials/draft/route.ts b/apps/sim/app/api/credentials/draft/route.ts index 8fb66fea56f..9efb27f2619 100644 --- a/apps/sim/app/api/credentials/draft/route.ts +++ b/apps/sim/app/api/credentials/draft/route.ts @@ -3,8 +3,9 @@ import { credential, credentialMember, pendingCredentialDraft } from '@sim/db/sc 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 { type NextRequest, NextResponse } from 'next/server' +import { createCredentialDraftContract } from '@/lib/api/contracts/credentials' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -13,28 +14,17 @@ 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) => { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const parsed = createDraftSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) - } + const parsed = await parseRequest(createCredentialDraftContract, request, {}) + if (!parsed.success) return parsed.response - const { workspaceId, providerId, displayName, description, credentialId } = parsed.data + const { workspaceId, providerId, displayName, description, credentialId } = parsed.data.body const userId = session.user.id const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) 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..64a3d3f9511 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -2,156 +2,51 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { account, credential, credentialMember, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + createWorkspaceCredentialContract, + credentialsListGetQuerySchema, + normalizeCredentialEnvKey, + serviceAccountJsonSchema, +} from '@/lib/api/contracts/credentials' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + AtlassianValidationError, + normalizeAtlassianDomain, + validateAtlassianServiceAccount, +} from '@/lib/credentials/atlassian-service-account' import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { getServiceConfigByProviderId } from '@/lib/oauth' +import { + ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, +} from '@/lib/oauth/types' 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 +/** + * Thrown by the inner duplicate guard inside the create transaction when a + * concurrent request slipped a row in between the outer existence check and + * our INSERT. The catch maps this to a 409 with a typed `code` so the UI can + * map to a friendly message. + */ +class DuplicateCredentialError extends Error { + constructor() { + super('duplicate_display_name') + this.name = 'DuplicateCredentialError' + } } -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' @@ -162,11 +57,16 @@ interface ExistingCredentialSourceParams { providerId?: string | null } -async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) { +type DbOrTx = typeof db | Parameters[0]>[0] + +async function findExistingCredentialBySourceWith( + exec: DbOrTx, + params: ExistingCredentialSourceParams +) { const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params if (type === 'oauth' && accountId) { - const [row] = await db + const [row] = await exec .select() .from(credential) .where( @@ -181,7 +81,7 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa } if (type === 'env_workspace' && envKey) { - const [row] = await db + const [row] = await exec .select() .from(credential) .where( @@ -196,7 +96,7 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa } if (type === 'env_personal' && envKey && envOwnerUserId) { - const [row] = await db + const [row] = await exec .select() .from(credential) .where( @@ -212,7 +112,7 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa } if (type === 'service_account' && displayName && providerId) { - const [row] = await db + const [row] = await exec .select() .from(credential) .where( @@ -230,6 +130,17 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa return null } +async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) { + return findExistingCredentialBySourceWith(db, params) +} + +async function findExistingCredentialBySourceTx( + tx: Parameters[0]>[0], + params: ExistingCredentialSourceParams +) { + return findExistingCredentialBySourceWith(tx, params) +} + export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const session = await getSession() @@ -244,7 +155,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 +167,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 @@ -357,12 +271,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const parseResult = createCredentialSchema.safeParse(body) - - if (!parseResult.success) { - return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 }) - } + const parsed = await parseRequest( + createWorkspaceCredentialContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response const { workspaceId, @@ -374,7 +292,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { envKey, envOwnerUserId, serviceAccountJson, - } = parseResult.data + apiToken, + domain, + } = parsed.data.body const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id) if (!workspaceAccess.canWrite) { @@ -385,9 +305,10 @@ 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 + const extraAuditMetadata: Record = {} if (type === 'oauth') { const [accountRow] = await db @@ -423,32 +344,69 @@ export const POST = withRouteHandler(async (request: NextRequest) => { getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId } } else if (type === 'service_account') { - if (!serviceAccountJson) { - return NextResponse.json( - { error: 'serviceAccountJson is required for service account credentials' }, - { status: 400 } - ) - } + if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { + if (!apiToken || !domain) { + return NextResponse.json( + { error: 'apiToken and domain are required for Atlassian service account credentials' }, + { status: 400 } + ) + } - const jsonParseResult = serviceAccountJsonSchema.safeParse(serviceAccountJson) - if (!jsonParseResult.success) { - return NextResponse.json( - { error: jsonParseResult.error.errors[0]?.message || 'Invalid service account JSON' }, - { status: 400 } - ) - } + const normalizedDomain = normalizeAtlassianDomain(domain) + const validation = await validateAtlassianServiceAccount(apiToken, normalizedDomain) - const parsed = jsonParseResult.data - resolvedProviderId = 'google-service-account' - resolvedAccountId = null - resolvedEnvOwnerUserId = null + resolvedProviderId = ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID + resolvedAccountId = null + resolvedEnvOwnerUserId = null - if (!resolvedDisplayName) { - resolvedDisplayName = parsed.client_email - } + if (!resolvedDisplayName) { + resolvedDisplayName = validation.displayName + } - const { encrypted } = await encryptSecret(serviceAccountJson) - resolvedEncryptedServiceAccountKey = encrypted + const blob = JSON.stringify({ + type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, + apiToken, + domain: normalizedDomain, + cloudId: validation.cloudId, + atlassianAccountId: validation.accountId, + }) + const { encrypted } = await encryptSecret(blob) + resolvedEncryptedServiceAccountKey = encrypted + extraAuditMetadata.atlassianDomain = normalizedDomain + extraAuditMetadata.atlassianCloudId = validation.cloudId + } else { + if (!serviceAccountJson) { + return NextResponse.json( + { error: 'serviceAccountJson is required for service account credentials' }, + { status: 400 } + ) + } + + const jsonParseResult = serviceAccountJsonSchema.safeParse(serviceAccountJson) + if (!jsonParseResult.success) { + return NextResponse.json( + { + error: getValidationErrorMessage( + jsonParseResult.error, + 'Invalid service account JSON' + ), + }, + { status: 400 } + ) + } + + const parsedKey = jsonParseResult.data + resolvedProviderId = 'google-service-account' + resolvedAccountId = null + resolvedEnvOwnerUserId = null + + if (!resolvedDisplayName) { + resolvedDisplayName = parsedKey.client_email + } + + const { encrypted } = await encryptSecret(serviceAccountJson) + resolvedEncryptedServiceAccountKey = encrypted + } } else if (type === 'env_personal') { resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id if (resolvedEnvOwnerUserId !== session.user.id) { @@ -547,6 +505,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { .limit(1) await db.transaction(async (tx) => { + // service_account has no DB-level unique index on (workspaceId, providerId, + // displayName), so we re-check inside the tx. OAuth/env_* are guarded by + // partial unique indexes and fall through to the 23505 handler below. + if (type === 'service_account') { + const innerExisting = await findExistingCredentialBySourceTx(tx, { + workspaceId, + type, + displayName: resolvedDisplayName, + providerId: resolvedProviderId, + }) + if (innerExisting) throw new DuplicateCredentialError() + } + await tx.insert(credential).values({ id: credentialId, workspaceId, @@ -627,36 +598,57 @@ export const POST = withRouteHandler(async (request: NextRequest) => { metadata: { credentialType: type, providerId: resolvedProviderId, + ...extraAuditMetadata, }, request, }) return NextResponse.json({ credential: created }, { status: 201 }) - } catch (error: any) { - if (error?.code === '23505') { + } catch (error: unknown) { + if (error instanceof AtlassianValidationError) { + logger.warn(`[${requestId}] Atlassian credential rejected: ${error.code}`, { + code: error.code, + upstreamStatus: error.status, + ...error.logDetail, + }) + return NextResponse.json({ code: error.code, error: error.code }, { status: 400 }) + } + if (error instanceof DuplicateCredentialError) { + return NextResponse.json( + { + code: 'duplicate_display_name', + error: 'A credential with that name already exists in this workspace.', + }, + { status: 409 } + ) + } + const pgCode = getPostgresErrorCode(error) + if (pgCode === '23505') { return NextResponse.json( { error: 'A credential with this source already exists' }, { status: 409 } ) } - if (error?.code === '23503') { + if (pgCode === '23503') { return NextResponse.json( { error: 'Invalid credential reference or membership target' }, { status: 400 } ) } - if (error?.code === '23514') { + if (pgCode === '23514') { return NextResponse.json( { error: 'Credential source data failed validation checks' }, { status: 400 } ) } + const errAsRecord = + typeof error === 'object' && error !== null ? (error as Record) : {} logger.error(`[${requestId}] Credential create failure details`, { - code: error?.code, - detail: error?.detail, - constraint: error?.constraint, - table: error?.table, - message: error?.message, + code: pgCode, + detail: errAsRecord.detail, + constraint: errAsRecord.constraint, + table: errAsRecord.table, + message: errAsRecord.message, }) logger.error(`[${requestId}] Failed to create credential`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/app/api/cron/run-data-drains/route.ts b/apps/sim/app/api/cron/run-data-drains/route.ts new file mode 100644 index 00000000000..939d75419a6 --- /dev/null +++ b/apps/sim/app/api/cron/run-data-drains/route.ts @@ -0,0 +1,30 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { isBillingEnabled, isDataDrainsEnabled } from '@/lib/core/config/feature-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { dispatchDueDrains } from '@/lib/data-drains/dispatcher' + +const logger = createLogger('CronRunDataDrains') + +export const GET = withRouteHandler(async (request: NextRequest) => { + const authError = verifyCronAuth(request, 'Data drain dispatcher') + if (authError) return authError + + // Self-hosted opt-in: skip dispatch entirely when the deployment hasn't + // enabled drains. Sim Cloud (billing enabled) gates per-org by enterprise + // plan inside the dispatcher's join. + if (!isBillingEnabled && !isDataDrainsEnabled) { + return NextResponse.json({ success: true, dispatched: 0, skipped: 'disabled' }) + } + + try { + const result = await dispatchDueDrains() + logger.info('Data drain dispatcher run complete', result) + return NextResponse.json({ success: true, ...result }) + } catch (error) { + logger.error('Data drain dispatcher run failed', { error: toError(error).message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/demo-requests/route.ts b/apps/sim/app/api/demo-requests/route.ts index d2c27dce409..7553239e7b2 100644 --- a/apps/sim/app/api/demo-requests/route.ts +++ b/apps/sim/app/api/demo-requests/route.ts @@ -1,5 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { + getDemoRequestCompanySizeLabel, + submitDemoRequestContract, +} from '@/lib/api/contracts/demo-requests' +import { parseRequest } 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 +13,6 @@ 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 } from '@/lib/messaging/email/utils' -import { - demoRequestSchema, - getDemoRequestCompanySizeLabel, -} from '@/app/(landing)/components/demo-request/consts' const logger = createLogger('DemoRequestAPI') const rateLimiter = new RateLimiter() @@ -45,21 +46,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - const body = await req.json() - const validationResult = demoRequestSchema.safeParse(body) - - if (!validationResult.success) { - logger.warn(`[${requestId}] Invalid demo request data`, { - errors: validationResult.error.format(), - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationResult.error.format() }, - { status: 400 } - ) + const parsed = await parseRequest(submitDemoRequestContract, req, {}) + if (!parsed.success) { + logger.warn(`[${requestId}] Invalid demo request data`) + return parsed.response } const { firstName, lastName, companyEmail, phoneNumber, companySize, details } = - validationResult.data + parsed.data.body logger.info(`[${requestId}] Processing demo request`, { email: `${companyEmail.substring(0, 3)}***`, diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts index 5905316cbd5..7459448fb98 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 { validationErrorResponse } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const emailTemplates = { @@ -141,9 +143,17 @@ 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 = emailPreviewQuerySchema.safeParse( + Object.fromEntries(searchParams.entries()) + ) + if (!queryValidation.success) return validationErrorResponse(queryValidation.error) + const { template } = queryValidation.data if (!template) { const categories = { @@ -198,7 +208,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 7d74c421262..964a46fcfa5 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 { savePersonalEnvironmentContract } from '@/lib/api/contracts/environment' +import { parseRequest } 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() @@ -29,68 +26,69 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await req.json() + const parsed = await parseRequest( + savePersonalEnvironmentContract, + req, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid environment variables data`, { errors: error.issues }) + return NextResponse.json( + { error: 'Invalid request data', details: error.issues }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response - try { - const { variables } = EnvVarSchema.parse(body) + const { variables } = parsed.data.body - const encryptedVariables = await Promise.all( - Object.entries(variables).map(async ([key, value]) => { - const { encrypted } = await encryptSecret(value) - return [key, encrypted] as const - }) - ).then((entries) => Object.fromEntries(entries)) + const encryptedVariables = await Promise.all( + Object.entries(variables).map(async ([key, value]) => { + const { encrypted } = await encryptSecret(value) + return [key, encrypted] as const + }) + ).then((entries) => Object.fromEntries(entries)) - await db - .insert(environment) - .values({ - id: generateId(), - userId: session.user.id, - variables: encryptedVariables, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [environment.userId], - set: { - variables: encryptedVariables, - updatedAt: new Date(), - }, - }) - - await syncPersonalEnvCredentialsForUser({ + await db + .insert(environment) + .values({ + id: generateId(), userId: session.user.id, - envKeys: Object.keys(variables), + variables: encryptedVariables, + updatedAt: new Date(), }) - - recordAudit({ - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.ENVIRONMENT_UPDATED, - resourceType: AuditResourceType.ENVIRONMENT, - resourceId: session.user.id, - description: `Updated ${Object.keys(variables).length} personal environment variable(s)`, - metadata: { - variableCount: Object.keys(variables).length, - updatedKeys: Object.keys(variables), - scope: 'personal', + .onConflictDoUpdate({ + target: [environment.userId], + set: { + variables: encryptedVariables, + updatedAt: new Date(), }, - request: req, }) - return NextResponse.json({ success: true }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid environment variables data`, { - errors: validationError.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) - } - throw validationError - } + await syncPersonalEnvCredentialsForUser({ + userId: session.user.id, + envKeys: Object.keys(variables), + }) + + recordAudit({ + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.ENVIRONMENT_UPDATED, + resourceType: AuditResourceType.ENVIRONMENT, + resourceId: session.user.id, + description: `Updated ${Object.keys(variables).length} personal environment variable(s)`, + metadata: { + variableCount: Object.keys(variables).length, + updatedKeys: Object.keys(variables), + scope: 'personal', + }, + request: req, + }) + + return NextResponse.json({ success: true }) } catch (error) { logger.error(`[${requestId}] Error updating environment variables`, error) return NextResponse.json({ error: 'Failed to update environment variables' }, { status: 500 }) @@ -120,17 +118,22 @@ export const GET = withRouteHandler(async (request: Request) => { } const encryptedVariables = result[0].variables as Record - const decryptedVariables: Record = {} - - for (const [key, encryptedValue] of Object.entries(encryptedVariables)) { - try { - const { decrypted } = await decryptSecret(encryptedValue) - decryptedVariables[key] = { key, value: decrypted } - } catch (error) { - logger.error(`[${requestId}] Error decrypting variable ${key}`, error) - decryptedVariables[key] = { key, value: '' } - } - } + + const decryptedEntries = await Promise.all( + Object.entries(encryptedVariables).map(async ([key, encryptedValue]) => { + try { + const { decrypted } = await decryptSecret(encryptedValue) + return [key, { key, value: decrypted }] as const + } catch (error) { + logger.error(`[${requestId}] Error decrypting variable ${key}`, error) + return [key, { key, value: '' }] as const + } + }) + ) + const decryptedVariables = Object.fromEntries(decryptedEntries) as Record< + string, + EnvironmentVariable + > return NextResponse.json({ data: decryptedVariables }, { status: 200 }) } catch (error: any) { diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index e9938c14940..07ec36261b4 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { document, knowledgeBase, workspaceFile } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, like, or } from 'drizzle-orm' +import { NextResponse } from 'next/server' import { getFileMetadata } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' import { BLOB_CHAT_CONFIG, S3_CHAT_CONFIG } from '@/lib/uploads/config' @@ -13,7 +14,15 @@ import { isUuid } from '@/executor/constants' const logger = createLogger('FileAuthorization') -export interface AuthorizationResult { +/** Thrown by utility functions when file access is denied, so route handlers can return 404. */ +export class FileAccessDeniedError extends Error { + constructor() { + super('File not found') + this.name = 'FileAccessDeniedError' + } +} + +interface AuthorizationResult { granted: boolean reason: string workspaceId?: string @@ -24,7 +33,7 @@ export interface AuthorizationResult { * @param key Storage key to lookup * @returns Workspace file info or null if not found */ -export async function lookupWorkspaceFileByKey( +async function lookupWorkspaceFileByKey( key: string, options?: { includeDeleted?: boolean } ): Promise<{ workspaceId: string; uploadedBy: string } | null> { @@ -107,10 +116,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) @@ -547,7 +560,7 @@ async function verifyRegularFileAccess( /** * Unified authorization function that returns structured result */ -export async function authorizeFileAccess( +async function authorizeFileAccess( key: string, userId: string, context?: StorageContext, @@ -583,6 +596,32 @@ export async function authorizeFileAccess( } } +/** + * Guard helper for tool routes that download user files from storage. + * + * Validates that `key` is a non-empty string, that `userId` is present, and + * that the authenticated user owns the file. Returns a 404 `NextResponse` on + * any failure so callers can `return` it immediately; returns `null` when + * access is granted. + */ +export async function assertToolFileAccess( + key: unknown, + userId: string, + requestId: string, + routeLogger: ReturnType +): Promise { + if (typeof key !== 'string' || key.length === 0) { + routeLogger.warn(`[${requestId}] File access check rejected: missing key`) + return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) + } + const hasAccess = await verifyFileAccess(key, userId) + if (!hasAccess) { + routeLogger.warn(`[${requestId}] File access denied for user`, { userId, key }) + return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) + } + return null +} + /** * Get chat storage configuration based on current storage provider */ diff --git a/apps/sim/app/api/files/delete/route.test.ts b/apps/sim/app/api/files/delete/route.test.ts index 977902d0be0..eed07748581 100644 --- a/apps/sim/app/api/files/delete/route.test.ts +++ b/apps/sim/app/api/files/delete/route.test.ts @@ -1,28 +1,25 @@ /** * @vitest-environment node */ -import { authMockFns, hybridAuthMockFns } from '@sim/testing' +import { + authMockFns, + hybridAuthMockFns, + storageServiceMock, + storageServiceMockFns, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { const mockVerifyFileAccess = vi.fn() const mockVerifyWorkspaceFileAccess = vi.fn() - const mockDeleteFile = vi.fn() - const mockHasCloudStorage = vi.fn() const mockGetStorageProvider = vi.fn() const mockIsUsingCloudStorage = vi.fn() - const mockUploadFile = vi.fn() - const mockDownloadFile = vi.fn() return { mockVerifyFileAccess, mockVerifyWorkspaceFileAccess, - mockDeleteFile, - mockHasCloudStorage, mockGetStorageProvider, mockIsUsingCloudStorage, - mockUploadFile, - mockDownloadFile, } }) @@ -68,23 +65,18 @@ vi.mock('@/lib/uploads', () => ({ getStorageProvider: mocks.mockGetStorageProvider, isUsingCloudStorage: mocks.mockIsUsingCloudStorage, StorageService: { - uploadFile: mocks.mockUploadFile, - downloadFile: mocks.mockDownloadFile, - deleteFile: mocks.mockDeleteFile, - hasCloudStorage: mocks.mockHasCloudStorage, + uploadFile: storageServiceMockFns.mockUploadFile, + downloadFile: storageServiceMockFns.mockDownloadFile, + deleteFile: storageServiceMockFns.mockDeleteFile, + hasCloudStorage: storageServiceMockFns.mockHasCloudStorage, }, - uploadFile: mocks.mockUploadFile, - downloadFile: mocks.mockDownloadFile, - deleteFile: mocks.mockDeleteFile, - hasCloudStorage: mocks.mockHasCloudStorage, + uploadFile: storageServiceMockFns.mockUploadFile, + downloadFile: storageServiceMockFns.mockDownloadFile, + deleteFile: storageServiceMockFns.mockDeleteFile, + hasCloudStorage: storageServiceMockFns.mockHasCloudStorage, })) -vi.mock('@/lib/uploads/core/storage-service', () => ({ - uploadFile: mocks.mockUploadFile, - downloadFile: mocks.mockDownloadFile, - deleteFile: mocks.mockDeleteFile, - hasCloudStorage: mocks.mockHasCloudStorage, -})) +vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) vi.mock('@/lib/uploads/server/metadata', () => ({ deleteFileMetadata: vi.fn().mockResolvedValue(undefined), @@ -99,7 +91,7 @@ vi.mock('fs/promises', () => ({ })) import { createMockRequest } from '@sim/testing' -import { OPTIONS, POST } from '@/app/api/files/delete/route' +import { POST } from '@/app/api/files/delete/route' describe('File Delete API Route', () => { beforeEach(() => { @@ -117,14 +109,14 @@ describe('File Delete API Route', () => { }) mocks.mockVerifyFileAccess.mockResolvedValue(true) mocks.mockVerifyWorkspaceFileAccess.mockResolvedValue(true) - mocks.mockDeleteFile.mockResolvedValue(undefined) - mocks.mockHasCloudStorage.mockReturnValue(true) + storageServiceMockFns.mockDeleteFile.mockResolvedValue(undefined) + storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true) mocks.mockGetStorageProvider.mockReturnValue('s3') mocks.mockIsUsingCloudStorage.mockReturnValue(true) }) it('should handle local file deletion successfully', async () => { - mocks.mockHasCloudStorage.mockReturnValue(false) + storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false) mocks.mockGetStorageProvider.mockReturnValue('local') mocks.mockIsUsingCloudStorage.mockReturnValue(false) @@ -142,7 +134,7 @@ describe('File Delete API Route', () => { }) it('should handle file not found gracefully', async () => { - mocks.mockHasCloudStorage.mockReturnValue(false) + storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false) mocks.mockGetStorageProvider.mockReturnValue('local') mocks.mockIsUsingCloudStorage.mockReturnValue(false) @@ -170,7 +162,7 @@ describe('File Delete API Route', () => { expect(data).toHaveProperty('success', true) expect(data).toHaveProperty('message', 'File deleted successfully') - expect(mocks.mockDeleteFile).toHaveBeenCalledWith({ + expect(storageServiceMockFns.mockDeleteFile).toHaveBeenCalledWith({ key: 'workspace/test-workspace-id/1234567890-test-file.txt', context: 'workspace', }) @@ -190,7 +182,7 @@ describe('File Delete API Route', () => { expect(data).toHaveProperty('success', true) expect(data).toHaveProperty('message', 'File deleted successfully') - expect(mocks.mockDeleteFile).toHaveBeenCalledWith({ + expect(storageServiceMockFns.mockDeleteFile).toHaveBeenCalledWith({ key: 'workspace/test-workspace-id/1234567890-test-document.pdf', context: 'workspace', }) @@ -206,12 +198,4 @@ describe('File Delete API Route', () => { expect(data).toHaveProperty('error', 'InvalidRequestError') expect(data).toHaveProperty('message', 'No file path provided') }) - - it('should handle CORS preflight requests', async () => { - const response = await OPTIONS() - - expect(response.status).toBe(204) - expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, DELETE, OPTIONS') - expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type') - }) }) diff --git a/apps/sim/app/api/files/delete/route.ts b/apps/sim/app/api/files/delete/route.ts index 61628634573..4eeeb538747 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 { fileDeleteContract } from '@/lib/api/contracts/storage-transfer' +import { getValidationErrorMessage, parseRequest } 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' @@ -10,7 +12,6 @@ import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file import { verifyFileAccess } from '@/app/api/files/authorization' import { createErrorResponse, - createOptionsResponse, createSuccessResponse, extractFilename, FileNotFoundError, @@ -36,8 +37,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = authResult.userId - const requestData = await request.json() - const { filePath, context } = requestData + + const parsed = await parseRequest( + fileDeleteContract, + request, + {}, + { + validationErrorResponse: (error) => + createErrorResponse( + new InvalidRequestError(getValidationErrorMessage(error, 'Invalid request data')) + ), + } + ) + if (!parsed.success) return parsed.response + + const { filePath, context } = parsed.data.body logger.info('File delete request received:', { filePath, context, userId }) @@ -104,10 +118,3 @@ function extractStorageKeyFromPath(filePath: string): string { return extractFilename(filePath) } - -/** - * Handle CORS preflight requests - */ -export const OPTIONS = withRouteHandler(async () => { - return createOptionsResponse() -}) diff --git a/apps/sim/app/api/files/download/route.ts b/apps/sim/app/api/files/download/route.ts index 6463260045b..33f1ce61146 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 { fileDownloadContract } from '@/lib/api/contracts/storage-transfer' +import { getValidationErrorMessage, parseRequest } 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,8 +25,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = authResult.userId - const body = await request.json() - const { key, name, isExecutionFile, context, url } = body + + const parsed = await parseRequest( + fileDownloadContract, + request, + {}, + { + validationErrorResponse: (error) => + createErrorResponse( + new Error(getValidationErrorMessage(error, 'Invalid request data')), + 400 + ), + } + ) + if (!parsed.success) return parsed.response + + const { key, name, isExecutionFile, context, url } = parsed.data.body if (!key) { return createErrorResponse(new Error('File key is required'), 400) @@ -42,7 +58,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 +79,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/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts new file mode 100644 index 00000000000..18c8aafb563 --- /dev/null +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -0,0 +1,167 @@ +import path from 'node:path' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import JSZip from 'jszip' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { fileExportContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } 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' +import { USE_BLOB_STORAGE } from '@/lib/uploads/config' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import { getFileMetadataById } from '@/lib/uploads/server/metadata' +import { verifyFileAccess } from '@/app/api/files/authorization' +import { encodeFilenameForHeader } from '@/app/api/files/utils' + +const logger = createLogger('FilesExportAPI') + +const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown']) +const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown']) +const VIEW_URL_RE = + /\/api\/files\/view\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi +const MAX_EMBEDDED_IMAGES = 50 + +function isMarkdown(originalName: string, contentType: string): boolean { + if (MARKDOWN_MIME_TYPES.has(contentType)) return true + const ext = originalName.split('.').pop()?.toLowerCase() ?? '' + return MARKDOWN_EXTENSIONS.has(ext) +} + +function safeFilename(name: string): string { + return path + .basename(name) + .replace(/["\\]/g, '_') + .replace(/[\r\n\t]/g, '') +} + +function deduplicatedFilename(preferred: string, existing: Set, imageId: string): string { + if (!existing.has(preferred)) return preferred + const ext = path.extname(preferred) + const base = path.basename(preferred, ext) + const short = `${base}_${imageId.slice(0, 8)}${ext}` + if (!existing.has(short)) return short + return `${base}_${imageId}${ext}` +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const parsed = await parseRequest(fileExportContract, request, context) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = authResult.userId + + const record = await getFileMetadataById(id) + if (!record) { + logger.warn('File not found by ID', { id }) + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const hasAccess = await verifyFileAccess(record.key, userId) + if (!hasAccess) { + logger.warn('Unauthorized file export attempt', { id, userId }) + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + if (!isMarkdown(record.originalName, record.contentType)) { + const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3' + const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}` + return NextResponse.redirect(new URL(servePath, request.url), { status: 302 }) + } + + const mdBuffer = await downloadFile({ + key: record.key, + context: record.context as StorageContext, + }) + let mdContent = mdBuffer.toString('utf-8') + + const imageIds = [...new Set([...mdContent.matchAll(VIEW_URL_RE)].map((m) => m[1]))].slice( + 0, + MAX_EMBEDDED_IMAGES + ) + + logger.info('Exporting markdown', { id, imageCount: imageIds.length }) + + if (imageIds.length === 0) { + const mdName = safeFilename(record.originalName) + const mdBytes = Buffer.from(mdContent, 'utf-8') + return new NextResponse(new Uint8Array(mdBytes), { + status: 200, + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Content-Disposition': `attachment; ${encodeFilenameForHeader(mdName)}`, + 'Content-Length': String(mdBytes.length), + }, + }) + } + + const fetchResults = await Promise.allSettled( + imageIds.map(async (imageId) => { + const imgRecord = await getFileMetadataById(imageId) + if (!imgRecord) return null + const imgHasAccess = await verifyFileAccess(imgRecord.key, userId) + if (!imgHasAccess) return null + const imgBuffer = await downloadFile({ + key: imgRecord.key, + context: imgRecord.context as StorageContext, + }) + return { imageId, originalName: imgRecord.originalName, buffer: imgBuffer } + }) + ) + + const assetMap = new Map() + const usedFilenames = new Set() + + for (let i = 0; i < fetchResults.length; i++) { + const result = fetchResults[i] + if (result.status === 'rejected') { + logger.warn('Failed to fetch asset for export', { + imageId: imageIds[i], + error: toError(result.reason).message, + }) + continue + } + if (!result.value) continue + const { imageId, originalName, buffer } = result.value + const preferred = safeFilename(originalName) + const filename = deduplicatedFilename(preferred, usedFilenames, imageId) + usedFilenames.add(filename) + assetMap.set(imageId, { filename, buffer }) + } + + for (const [imageId, asset] of assetMap) { + const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const replacement = `./assets/${asset.filename}` + mdContent = mdContent.replace( + new RegExp(`/api/files/view/${escapedId}`, 'g'), + () => replacement + ) + } + + const zip = new JSZip() + zip.file(safeFilename(record.originalName), mdContent) + const assetsFolder = zip.folder('assets')! + for (const { filename, buffer } of assetMap.values()) { + assetsFolder.file(filename, buffer) + } + + const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) + const zipName = safeFilename(`${record.originalName.replace(/\.[^.]+$/, '')}.zip`) + + return new NextResponse(new Uint8Array(zipBuffer), { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; ${encodeFilenameForHeader(zipName)}`, + 'Content-Length': String(zipBuffer.length), + }, + }) + } +) diff --git a/apps/sim/app/api/files/multipart/route.test.ts b/apps/sim/app/api/files/multipart/route.test.ts new file mode 100644 index 00000000000..520a05dd065 --- /dev/null +++ b/apps/sim/app/api/files/multipart/route.test.ts @@ -0,0 +1,277 @@ +/** + * @vitest-environment node + */ +import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockIsUsingCloudStorage, + mockGetStorageProvider, + mockGetStorageConfig, + mockCompleteS3MultipartUpload, + mockCompleteBlobMultipartUpload, + mockDeriveBlobBlockId, + mockVerifyUploadToken, + mockSignUploadToken, +} = vi.hoisted(() => ({ + mockIsUsingCloudStorage: vi.fn(), + mockGetStorageProvider: vi.fn(), + mockGetStorageConfig: vi.fn(), + mockCompleteS3MultipartUpload: vi.fn(), + mockCompleteBlobMultipartUpload: vi.fn(), + mockDeriveBlobBlockId: vi.fn(), + mockVerifyUploadToken: vi.fn(), + mockSignUploadToken: vi.fn(), +})) + +vi.mock('@/lib/uploads', () => ({ + isUsingCloudStorage: mockIsUsingCloudStorage, + getStorageProvider: mockGetStorageProvider, + getStorageConfig: mockGetStorageConfig, +})) + +vi.mock('@/lib/uploads/core/upload-token', () => ({ + signUploadToken: mockSignUploadToken, + verifyUploadToken: mockVerifyUploadToken, +})) + +vi.mock('@/lib/uploads/providers/s3/client', () => ({ + completeS3MultipartUpload: mockCompleteS3MultipartUpload, + initiateS3MultipartUpload: vi.fn(), + getS3MultipartPartUrls: vi.fn(), + abortS3MultipartUpload: vi.fn(), +})) + +vi.mock('@/lib/uploads/providers/blob/client', () => ({ + completeMultipartUpload: mockCompleteBlobMultipartUpload, + deriveBlobBlockId: mockDeriveBlobBlockId, + initiateMultipartUpload: vi.fn(), + getMultipartPartUrls: vi.fn(), + abortMultipartUpload: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) + +const { mockCheckStorageQuota, mockInitiateS3MultipartUpload } = vi.hoisted(() => ({ + mockCheckStorageQuota: vi.fn(), + mockInitiateS3MultipartUpload: vi.fn(), +})) + +vi.mock('@/lib/billing/storage', () => ({ + checkStorageQuota: mockCheckStorageQuota, +})) + +import { POST } from '@/app/api/files/multipart/route' + +const tokenPayload = { + uploadId: 'upload-1', + key: 'workspace/ws-1/123-abc-file.bin', + userId: 'user-1', + workspaceId: 'ws-1', + context: 'workspace' as const, +} + +const makeRequest = (action: string, body: unknown) => + new NextRequest(`http://localhost/api/files/multipart?action=${action}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + +describe('POST /api/files/multipart action=complete', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + mockIsUsingCloudStorage.mockReturnValue(true) + mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' }) + mockVerifyUploadToken.mockReturnValue({ valid: true, payload: tokenPayload }) + mockSignUploadToken.mockReturnValue('signed-token') + mockCompleteS3MultipartUpload.mockResolvedValue({ + location: 'loc', + path: '/api/files/serve/...', + key: tokenPayload.key, + }) + mockCompleteBlobMultipartUpload.mockResolvedValue({ + location: 'loc', + path: '/api/files/serve/...', + key: tokenPayload.key, + }) + mockDeriveBlobBlockId.mockImplementation( + (n: number) => `block-${n.toString().padStart(6, '0')}` + ) + }) + + it('rejects parts without partNumber', async () => { + mockGetStorageProvider.mockReturnValue('s3') + const res = await POST( + makeRequest('complete', { + uploadToken: 'tok', + parts: [{ etag: 'abc' }], + }) + ) + expect(res.status).toBe(400) + expect(mockCompleteS3MultipartUpload).not.toHaveBeenCalled() + }) + + it('S3 path requires etag and forwards { ETag, PartNumber }', async () => { + mockGetStorageProvider.mockReturnValue('s3') + + const missingEtag = await POST( + makeRequest('complete', { + uploadToken: 'tok', + parts: [{ partNumber: 1 }], + }) + ) + expect(missingEtag.status).toBe(500) + + mockCompleteS3MultipartUpload.mockClear() + + const ok = await POST( + makeRequest('complete', { + uploadToken: 'tok', + parts: [ + { partNumber: 1, etag: 'aaa' }, + { partNumber: 2, etag: 'bbb' }, + ], + }) + ) + expect(ok.status).toBe(200) + expect(mockCompleteS3MultipartUpload).toHaveBeenCalledWith( + tokenPayload.key, + tokenPayload.uploadId, + [ + { ETag: 'aaa', PartNumber: 1 }, + { ETag: 'bbb', PartNumber: 2 }, + ], + expect.any(Object) + ) + }) + + it('Blob path derives blockId from partNumber and ignores etag', async () => { + mockGetStorageProvider.mockReturnValue('blob') + mockGetStorageConfig.mockReturnValue({ + containerName: 'c', + accountName: 'a', + accountKey: 'k', + }) + + const res = await POST( + makeRequest('complete', { + uploadToken: 'tok', + parts: [{ partNumber: 1, etag: 'irrelevant' }, { partNumber: 2 }], + }) + ) + + expect(res.status).toBe(200) + expect(mockDeriveBlobBlockId).toHaveBeenCalledWith(1) + expect(mockDeriveBlobBlockId).toHaveBeenCalledWith(2) + expect(mockCompleteBlobMultipartUpload).toHaveBeenCalledWith( + tokenPayload.key, + [ + { partNumber: 1, blockId: 'block-000001' }, + { partNumber: 2, blockId: 'block-000002' }, + ], + expect.objectContaining({ containerName: 'c' }) + ) + }) + + it('returns 403 when token is invalid', async () => { + mockGetStorageProvider.mockReturnValue('s3') + mockVerifyUploadToken.mockReturnValueOnce({ valid: false }) + const res = await POST( + makeRequest('complete', { + uploadToken: 'bad', + parts: [{ partNumber: 1, etag: 'a' }], + }) + ) + expect(res.status).toBe(403) + }) + + it('batch complete normalizes per upload', async () => { + mockGetStorageProvider.mockReturnValue('s3') + const res = await POST( + makeRequest('complete', { + uploads: [ + { + uploadToken: 'tok-a', + parts: [{ partNumber: 1, etag: 'aaa' }], + }, + { + uploadToken: 'tok-b', + parts: [{ partNumber: 1, etag: 'bbb' }], + }, + ], + }) + ) + expect(res.status).toBe(200) + expect(mockCompleteS3MultipartUpload).toHaveBeenCalledTimes(2) + }) +}) + +describe('POST /api/files/multipart action=initiate quota enforcement', () => { + const makeInitiateRequest = (body: unknown) => + new NextRequest('http://localhost/api/files/multipart?action=initiate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + mockIsUsingCloudStorage.mockReturnValue(true) + mockGetStorageProvider.mockReturnValue('s3') + mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' }) + mockSignUploadToken.mockReturnValue('signed-token') + mockCheckStorageQuota.mockResolvedValue({ allowed: true }) + mockInitiateS3MultipartUpload.mockResolvedValue({ uploadId: 'up-1', key: 'k/file.bin' }) + }) + + it('blocks upload when fileSize: 0 exceeds quota', async () => { + mockCheckStorageQuota.mockResolvedValue({ allowed: false, error: 'Storage limit exceeded' }) + + const res = await makeInitiateRequest({ + fileName: 'file.bin', + contentType: 'application/octet-stream', + fileSize: 0, + workspaceId: 'ws-1', + context: 'knowledge-base', + }) + + const response = await POST(res) + expect(response.status).toBe(413) + const body = await response.json() + expect(body.error).toContain('Storage limit exceeded') + }) + + it('does not check quota for quota-exempt contexts (og-images)', async () => { + const res = await makeInitiateRequest({ + fileName: 'img.png', + contentType: 'image/png', + fileSize: 99999, + workspaceId: 'ws-1', + context: 'og-images', + }) + + const response = await POST(res) + expect(mockCheckStorageQuota).not.toHaveBeenCalled() + }) + + it('rejects logs context — not allowed via the multipart endpoint', async () => { + const res = await makeInitiateRequest({ + fileName: 'exec.log', + contentType: 'text/plain', + fileSize: 1000, + workspaceId: 'ws-1', + context: 'logs', + }) + + const response = await POST(res) + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toMatch(/invalid storage context/i) + }) +}) diff --git a/apps/sim/app/api/files/multipart/route.ts b/apps/sim/app/api/files/multipart/route.ts index ac087025083..8612f0b1f83 100644 --- a/apps/sim/app/api/files/multipart/route.ts +++ b/apps/sim/app/api/files/multipart/route.ts @@ -1,5 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { + abortMultipartUploadContract, + type CompleteMultipartBody, + completeMultipartUploadContract, + getMultipartPartUrlsContract, + initiateMultipartUploadContract, + multipartActionSchema, +} from '@/lib/api/contracts/storage-transfer' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -8,21 +18,68 @@ import { isUsingCloudStorage, type StorageContext, } from '@/lib/uploads' +import { + signUploadToken, + type UploadTokenPayload, + verifyUploadToken, +} from '@/lib/uploads/core/upload-token' +import { QUOTA_EXEMPT_STORAGE_CONTEXTS, type StorageConfig } from '@/lib/uploads/shared/types' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('MultipartUploadAPI') -interface InitiateMultipartRequest { - fileName: string - contentType: string - fileSize: number - context?: StorageContext +const ALLOWED_UPLOAD_CONTEXTS = new Set([ + 'knowledge-base', + 'chat', + 'copilot', + 'mothership', + 'execution', + 'workspace', + 'profile-pictures', + 'og-images', + 'workspace-logos', +]) + +/** + * Unified part identity sent by the client when completing a multipart upload. + * `etag` is required for S3 (CompleteMultipartUpload). For Azure the server + * derives the block id from `partNumber` via {@link deriveBlobBlockId}. + */ +interface ClientCompletedPart { + partNumber: number + etag?: string } -interface GetPartUrlsRequest { - uploadId: string - key: string - partNumbers: number[] - context?: StorageContext +const isClientCompletedParts = (value: unknown): value is ClientCompletedPart[] => + Array.isArray(value) && + value.every( + (p) => + p !== null && + typeof p === 'object' && + typeof (p as ClientCompletedPart).partNumber === 'number' && + ((p as ClientCompletedPart).etag === undefined || + typeof (p as ClientCompletedPart).etag === 'string') + ) + +const buildS3CustomConfig = (config: StorageConfig) => + config.bucket && config.region ? { bucket: config.bucket, region: config.region } : undefined + +const buildBlobCustomConfig = (config: StorageConfig) => ({ + containerName: config.containerName!, + accountName: config.accountName!, + accountKey: config.accountKey, + connectionString: config.connectionString, +}) + +const verifyTokenForUser = (token: string | undefined, userId: string) => { + if (!token || typeof token !== 'string') { + return null + } + const result = verifyUploadToken(token) + if (!result.valid || result.payload.userId !== userId) { + return null + } + return result.payload } export const POST = withRouteHandler(async (request: NextRequest) => { @@ -31,8 +88,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + 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( @@ -45,83 +105,170 @@ export const POST = withRouteHandler(async (request: NextRequest) => { switch (action) { case 'initiate': { - const data: InitiateMultipartRequest = await request.json() - const { fileName, contentType, fileSize, context = 'knowledge-base' } = data + const parsed = await parseRequest( + initiateMultipartUploadContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response - const config = getStorageConfig(context) + const data = parsed.data.body + const { fileName, contentType, fileSize, workspaceId, context = 'knowledge-base' } = data + + if (!workspaceId || typeof workspaceId !== 'string') { + return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) + } + + if (!ALLOWED_UPLOAD_CONTEXTS.has(context as StorageContext)) { + return NextResponse.json({ error: 'Invalid storage context' }, { status: 400 }) + } + const storageContext = context as StorageContext + + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission !== 'write' && permission !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const config = getStorageConfig(storageContext) + + if (!QUOTA_EXEMPT_STORAGE_CONTEXTS.has(context as StorageContext)) { + const { checkStorageQuota } = await import('@/lib/billing/storage') + const quotaCheck = await checkStorageQuota(userId, fileSize ?? 0) + if (!quotaCheck.allowed) { + return NextResponse.json( + { error: quotaCheck.error || 'Storage limit exceeded' }, + { status: 413 } + ) + } + } + + let customKey: string | undefined + if (context === 'workspace' || context === 'mothership') { + const { MAX_WORKSPACE_FILE_SIZE } = await import('@/lib/uploads/shared/types') + if (typeof fileSize === 'number' && fileSize > MAX_WORKSPACE_FILE_SIZE) { + return NextResponse.json( + { error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` }, + { status: 413 } + ) + } + + const { generateWorkspaceFileKey } = await import( + '@/lib/uploads/contexts/workspace/workspace-file-manager' + ) + customKey = generateWorkspaceFileKey(workspaceId, fileName) + } else if (context === 'execution') { + const workflowId = (data as { workflowId?: unknown }).workflowId + const executionId = (data as { executionId?: unknown }).executionId + if (typeof workflowId !== 'string' || !workflowId.trim()) { + return NextResponse.json( + { error: 'workflowId is required for execution uploads' }, + { status: 400 } + ) + } + if (typeof executionId !== 'string' || !executionId.trim()) { + return NextResponse.json( + { error: 'executionId is required for execution uploads' }, + { status: 400 } + ) + } + const { generateExecutionFileKey } = await import( + '@/lib/uploads/contexts/execution/utils' + ) + customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName) + } + + let uploadId: string + let key: string if (storageProvider === 's3') { const { initiateS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') - const result = await initiateS3MultipartUpload({ fileName, contentType, fileSize, + customConfig: buildS3CustomConfig(config), + customKey, + purpose: context, }) - - logger.info( - `Initiated S3 multipart upload for ${fileName} (context: ${context}): ${result.uploadId}` - ) - - return NextResponse.json({ - uploadId: result.uploadId, - key: result.key, - }) - } - if (storageProvider === 'blob') { + uploadId = result.uploadId + key = result.key + } else if (storageProvider === 'blob') { const { initiateMultipartUpload } = await import('@/lib/uploads/providers/blob/client') - const result = await initiateMultipartUpload({ fileName, contentType, fileSize, - customConfig: { - containerName: config.containerName!, - accountName: config.accountName!, - accountKey: config.accountKey, - connectionString: config.connectionString, - }, + customConfig: buildBlobCustomConfig(config), + customKey, }) - - logger.info( - `Initiated Azure multipart upload for ${fileName} (context: ${context}): ${result.uploadId}` + uploadId = result.uploadId + key = result.key + } else { + return NextResponse.json( + { error: `Unsupported storage provider: ${storageProvider}` }, + { status: 400 } ) - - return NextResponse.json({ - uploadId: result.uploadId, - key: result.key, - }) } - return NextResponse.json( - { error: `Unsupported storage provider: ${storageProvider}` }, - { status: 400 } + const uploadToken = signUploadToken({ + uploadId, + key, + userId, + workspaceId, + context: storageContext, + }) + + logger.info( + `Initiated ${storageProvider} multipart upload for ${fileName} (context: ${storageContext}, workspace: ${workspaceId}): ${uploadId}` ) + + return NextResponse.json({ uploadId, key, uploadToken }) } case 'get-part-urls': { - const data: GetPartUrlsRequest = await request.json() - const { uploadId, key, partNumbers, context = 'knowledge-base' } = data + const parsed = await parseRequest( + getMultipartPartUrlsContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response + + const data = parsed.data.body + const { partNumbers } = data + + const tokenPayload = verifyTokenForUser(data.uploadToken, userId) + if (!tokenPayload) { + return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) + } + const { uploadId, key, context } = tokenPayload const config = getStorageConfig(context) if (storageProvider === 's3') { const { getS3MultipartPartUrls } = await import('@/lib/uploads/providers/s3/client') - - const presignedUrls = await getS3MultipartPartUrls(key, uploadId, partNumbers) - + const presignedUrls = await getS3MultipartPartUrls( + key, + uploadId, + partNumbers, + buildS3CustomConfig(config) + ) return NextResponse.json({ presignedUrls }) } if (storageProvider === 'blob') { const { getMultipartPartUrls } = await import('@/lib/uploads/providers/blob/client') - - const presignedUrls = await getMultipartPartUrls(key, partNumbers, { - containerName: config.containerName!, - accountName: config.accountName!, - accountKey: config.accountKey, - connectionString: config.connectionString, - }) - + const presignedUrls = await getMultipartPartUrls( + key, + partNumbers, + buildBlobCustomConfig(config) + ) return NextResponse.json({ presignedUrls }) } @@ -132,124 +279,146 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } case 'complete': { - const data = await request.json() - const context: StorageContext = data.context || 'knowledge-base' + const parsed = await parseRequest( + completeMultipartUploadContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response - const config = getStorageConfig(context) + const data: CompleteMultipartBody = parsed.data.body - if ('uploads' in data) { - const results = await Promise.all( - data.uploads.map(async (upload: any) => { - const { uploadId, key } = upload - - if (storageProvider === 's3') { - const { completeS3MultipartUpload } = await import( - '@/lib/uploads/providers/s3/client' - ) - const parts = upload.parts // S3 format: { ETag, PartNumber } - - const result = await completeS3MultipartUpload(key, uploadId, parts) - - return { - success: true, - location: result.location, - path: result.path, - key: result.key, - } - } - if (storageProvider === 'blob') { - const { completeMultipartUpload } = await import( - '@/lib/uploads/providers/blob/client' - ) - const parts = upload.parts // Azure format: { blockId, partNumber } - - const result = await completeMultipartUpload(key, parts, { - containerName: config.containerName!, - accountName: config.accountName!, - accountKey: config.accountKey, - connectionString: config.connectionString, - }) - - return { - success: true, - location: result.location, - path: result.path, - key: result.key, - } - } + const s3Module = + storageProvider === 's3' ? await import('@/lib/uploads/providers/s3/client') : null + const blobModule = + storageProvider === 'blob' ? await import('@/lib/uploads/providers/blob/client') : null - throw new Error(`Unsupported storage provider: ${storageProvider}`) - }) - ) + const completeOne = async (payload: UploadTokenPayload, parts: ClientCompletedPart[]) => { + const { uploadId, key, context } = payload + const config = getStorageConfig(context) - logger.info(`Completed ${data.uploads.length} multipart uploads (context: ${context})`) - return NextResponse.json({ results }) + if (storageProvider === 's3' && s3Module) { + const { completeS3MultipartUpload } = s3Module + const s3Parts = parts.map((p) => { + if (!p.etag) { + throw new Error(`Missing etag for S3 part ${p.partNumber}`) + } + return { ETag: p.etag, PartNumber: p.partNumber } + }) + const result = await completeS3MultipartUpload( + key, + uploadId, + s3Parts, + buildS3CustomConfig(config) + ) + return { + success: true as const, + location: result.location, + path: result.path, + key: result.key, + } + } + + if (storageProvider === 'blob' && blobModule) { + const { completeMultipartUpload, deriveBlobBlockId } = blobModule + const blobParts = parts.map((p) => ({ + partNumber: p.partNumber, + blockId: deriveBlobBlockId(p.partNumber), + })) + const result = await completeMultipartUpload( + key, + blobParts, + buildBlobCustomConfig(config) + ) + return { + success: true as const, + location: result.location, + path: result.path, + key: result.key, + } + } + + throw new Error(`Unsupported storage provider: ${storageProvider}`) } - const { uploadId, key, parts } = data + if ('uploads' in data && Array.isArray(data.uploads)) { + const verified: Array<{ payload: UploadTokenPayload; parts: ClientCompletedPart[] }> = [] + for (const upload of data.uploads) { + const payload = verifyTokenForUser(upload.uploadToken, userId) + if (!payload) { + return NextResponse.json( + { error: 'Invalid or expired upload token' }, + { status: 403 } + ) + } + if (!isClientCompletedParts(upload.parts)) { + return NextResponse.json( + { error: 'Invalid parts payload: expected [{ partNumber, etag? }]' }, + { status: 400 } + ) + } + verified.push({ payload, parts: upload.parts }) + } - if (storageProvider === 's3') { - const { completeS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') - - const result = await completeS3MultipartUpload(key, uploadId, parts) - - logger.info(`Completed S3 multipart upload for key ${key} (context: ${context})`) + const results = await Promise.all( + verified.map(({ payload, parts }) => completeOne(payload, parts)) + ) - return NextResponse.json({ - success: true, - location: result.location, - path: result.path, - key: result.key, - }) + logger.info(`Completed ${verified.length} multipart uploads`) + return NextResponse.json({ results }) } - if (storageProvider === 'blob') { - const { completeMultipartUpload } = await import('@/lib/uploads/providers/blob/client') - - const result = await completeMultipartUpload(key, parts, { - containerName: config.containerName!, - accountName: config.accountName!, - accountKey: config.accountKey, - connectionString: config.connectionString, - }) - - logger.info(`Completed Azure multipart upload for key ${key} (context: ${context})`) - return NextResponse.json({ - success: true, - location: result.location, - path: result.path, - key: result.key, - }) + const single = data + const tokenPayload = verifyTokenForUser(single.uploadToken, userId) + if (!tokenPayload) { + return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) + } + if (!isClientCompletedParts(single.parts)) { + return NextResponse.json( + { error: 'Invalid parts payload: expected [{ partNumber, etag? }]' }, + { status: 400 } + ) } - return NextResponse.json( - { error: `Unsupported storage provider: ${storageProvider}` }, - { status: 400 } + const result = await completeOne(tokenPayload, single.parts) + logger.info( + `Completed ${storageProvider} multipart upload for key ${tokenPayload.key} (context: ${tokenPayload.context})` ) + return NextResponse.json(result) } case 'abort': { - const data = await request.json() - const { uploadId, key, context = 'knowledge-base' } = data + const parsed = await parseRequest( + abortMultipartUploadContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response - const config = getStorageConfig(context as StorageContext) + const data = parsed.data.body + const tokenPayload = verifyTokenForUser(data.uploadToken, userId) + if (!tokenPayload) { + return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) + } + + const { uploadId, key, context } = tokenPayload + const config = getStorageConfig(context) if (storageProvider === 's3') { const { abortS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') - - await abortS3MultipartUpload(key, uploadId) - + await abortS3MultipartUpload(key, uploadId, buildS3CustomConfig(config)) logger.info(`Aborted S3 multipart upload for key ${key} (context: ${context})`) } else if (storageProvider === 'blob') { const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/client') - - await abortMultipartUpload(key, { - containerName: config.containerName!, - accountName: config.accountName!, - accountKey: config.accountKey, - connectionString: config.connectionString, - }) - + await abortMultipartUpload(key, buildBlobCustomConfig(config)) logger.info(`Aborted Azure multipart upload for key ${key} (context: ${context})`) } else { return NextResponse.json( @@ -270,7 +439,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error('Multipart upload error:', error) return NextResponse.json( - { error: error instanceof Error ? error.message : 'Multipart upload failed' }, + { error: getErrorMessage(error, 'Multipart upload failed') }, { status: 500 } ) } diff --git a/apps/sim/app/api/files/parse/route.test.ts b/apps/sim/app/api/files/parse/route.test.ts index 8a2c06f19ff..b2ab510c9f8 100644 --- a/apps/sim/app/api/files/parse/route.test.ts +++ b/apps/sim/app/api/files/parse/route.test.ts @@ -8,8 +8,11 @@ import { createMockRequest, hybridAuthMockFns, inputValidationMock, + inputValidationMockFns, permissionsMock, permissionsMockFns, + storageServiceMock, + storageServiceMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -22,14 +25,13 @@ const { mockIsSupportedFileType, mockParseFile, mockParseBuffer, - mockDownloadFile, - mockHasCloudStorage, mockFsAccess, mockFsStat, mockFsReadFile, mockFsWriteFile, mockJoin, actualPath, + mockUploadWorkspaceFile, } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports const actualPath = require('path') as typeof import('path') @@ -47,10 +49,8 @@ const { content: 'parsed buffer content', metadata: { pageCount: 1 }, }), - mockDownloadFile: vi.fn(), - mockHasCloudStorage: vi.fn().mockReturnValue(true), mockFsAccess: vi.fn().mockResolvedValue(undefined), - mockFsStat: vi.fn().mockImplementation(() => ({ isFile: () => true })), + mockFsStat: vi.fn().mockImplementation(() => ({ isFile: () => true, size: 17 })), mockFsReadFile: vi.fn().mockResolvedValue(Buffer.from('test file content')), mockFsWriteFile: vi.fn().mockResolvedValue(undefined), mockJoin: vi.fn((...args: string[]): string => { @@ -60,6 +60,19 @@ const { return actualPath.join(...args) }), actualPath, + mockUploadWorkspaceFile: vi + .fn() + .mockImplementation( + async (workspaceId: string, _userId: string, _buffer: Buffer, fileName: string) => ({ + id: 'wf_test', + name: fileName, + size: 0, + type: 'application/octet-stream', + url: `/api/files/serve/${workspaceId}/${fileName}`, + key: `${workspaceId}/${fileName}`, + context: 'workspace', + }) + ), } }) @@ -71,6 +84,7 @@ vi.mock('@/app/api/files/authorization', () => ({ vi.mock('@/lib/uploads', () => ({ getStorageProvider: mockGetStorageProvider, isUsingCloudStorage: mockIsUsingCloudStorage, + StorageService: storageServiceMock, })) vi.mock('@/lib/file-parsers', () => ({ @@ -79,10 +93,7 @@ vi.mock('@/lib/file-parsers', () => ({ parseBuffer: mockParseBuffer, })) -vi.mock('@/lib/uploads/core/storage-service', () => ({ - downloadFile: mockDownloadFile, - hasCloudStorage: mockHasCloudStorage, -})) +vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) vi.mock('path', () => ({ default: actualPath, @@ -107,6 +118,10 @@ vi.mock('@/lib/uploads/contexts/execution', () => ({ uploadExecutionFile: vi.fn(), })) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + uploadWorkspaceFile: mockUploadWorkspaceFile, +})) + vi.mock('@/lib/uploads/server/metadata', () => ({ getFileMetadataByKey: vi.fn(), })) @@ -176,7 +191,12 @@ describe('File Parse API Route', () => { }) permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue({ canView: true }) + storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true) + storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('test file content')) + mockFsStat.mockResolvedValue({ isFile: () => true, size: 17 }) + mockFsReadFile.mockResolvedValue(Buffer.from('test file content')) mockIsSupportedFileType.mockReturnValue(true) + mockUploadWorkspaceFile.mockClear() mockParseFile.mockResolvedValue({ content: 'parsed content', metadata: { pageCount: 1 }, @@ -249,6 +269,48 @@ describe('File Parse API Route', () => { } }) + it('should keep known binary extensions as binary even when the bytes are valid UTF-8', async () => { + setupFileApiMocks({ + cloudEnabled: true, + storageProvider: 's3', + authenticated: true, + }) + mockIsSupportedFileType.mockReturnValue(false) + storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('valid utf8 bytes')) + + const req = createMockRequest('POST', { + filePath: '/api/files/serve/execution/workspace-1/workflow-1/execution-1/image.png', + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.output.content).toBe('[Binary PNG file - 16 bytes]') + }) + + it('should parse unknown extensions as text when the bytes look like UTF-8 text', async () => { + setupFileApiMocks({ + cloudEnabled: true, + storageProvider: 's3', + authenticated: true, + }) + mockIsSupportedFileType.mockReturnValue(false) + storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('plain text content')) + + const req = createMockRequest('POST', { + filePath: '/api/files/serve/execution/workspace-1/workflow-1/execution-1/readme.customtext', + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.output.content).toBe('plain text content') + }) + it('should handle multiple files', async () => { setupFileApiMocks({ cloudEnabled: false, @@ -270,6 +332,265 @@ describe('File Parse API Route', () => { expect(data.results).toHaveLength(2) }) + it('should keep the multi-file download cap independent from the remaining parsed-output cap', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP + .mockResolvedValueOnce( + new Response('file content', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }) + ) + .mockResolvedValueOnce( + new Response('second file content', { + status: 200, + headers: { + 'content-length': String(20 * 1024 * 1024), + 'content-type': 'text/plain', + }, + }) + ) + + const fourMbContent = 'a'.repeat(4 * 1024 * 1024) + mockParseBuffer + .mockResolvedValueOnce({ + content: fourMbContent, + metadata: { pageCount: 1 }, + }) + .mockResolvedValueOnce({ + content: 'second file', + metadata: { pageCount: 1 }, + }) + + const req = createMockRequest('POST', { + filePath: ['https://example.com/file1.txt', 'https://example.com/file2.txt'], + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.results).toHaveLength(2) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenNthCalledWith( + 1, + 'https://example.com/file1.txt', + '203.0.113.10', + expect.objectContaining({ maxResponseBytes: 100 * 1024 * 1024 }) + ) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenNthCalledWith( + 2, + 'https://example.com/file2.txt', + '203.0.113.10', + expect.objectContaining({ maxResponseBytes: 100 * 1024 * 1024 }) + ) + }) + + it('should never dedup external URL fetches by path filename — two URLs sharing image.png both download', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP + .mockResolvedValueOnce( + new Response('first image bytes', { + status: 200, + headers: { 'content-type': 'image/png' }, + }) + ) + .mockResolvedValueOnce( + new Response('second image bytes — different content', { + status: 200, + headers: { 'content-type': 'image/png' }, + }) + ) + mockIsSupportedFileType.mockReturnValue(false) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + + const req = createMockRequest('POST', { + filePath: [ + 'https://files.slack.com/files-pri/T07-FAAA/download/image.png', + 'https://files.slack.com/files-pri/T07-FBBB/download/image.png', + ], + workspaceId: 'workspace-id', + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.results).toHaveLength(2) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(2) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenNthCalledWith( + 1, + 'https://files.slack.com/files-pri/T07-FAAA/download/image.png', + '203.0.113.10', + expect.any(Object) + ) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenNthCalledWith( + 2, + 'https://files.slack.com/files-pri/T07-FBBB/download/image.png', + '203.0.113.10', + expect.any(Object) + ) + expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(2) + expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled() + }) + + it('should stop multi-file parsing once the combined parsed output is too large', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + new Response('file content', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }) + ) + + mockParseBuffer.mockResolvedValueOnce({ + content: 'a'.repeat(5 * 1024 * 1024 + 1), + metadata: { pageCount: 1 }, + }) + + const req = createMockRequest('POST', { + filePath: ['https://example.com/file1.txt', 'https://example.com/file2.txt'], + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(413) + expect(data.success).toBe(false) + expect(data.error).toContain('too large') + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(1) + }) + + it('should include successful multi-file parse results when a later file exceeds the cap', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + new Response('file content', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }) + ) + + mockParseBuffer + .mockResolvedValueOnce({ + content: 'first file', + metadata: { pageCount: 1 }, + }) + .mockResolvedValueOnce({ + content: 'a'.repeat(5 * 1024 * 1024), + metadata: { pageCount: 1 }, + }) + + const req = createMockRequest('POST', { + filePath: ['https://example.com/file1.txt', 'https://example.com/file2.txt'], + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.error).toContain('too large') + expect(data.results).toHaveLength(1) + expect(data.results[0].output.content).toBe('first file') + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(2) + }) + + it('should pass custom headers when fetching external URLs', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + new Response('private file content', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }) + ) + + const headers = { Authorization: 'Bearer xoxb-test-token' } + const req = createMockRequest('POST', { + filePath: 'https://files.slack.com/files-pri/T000-F000/download/report.txt', + headers, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith( + 'https://files.slack.com/files-pri/T000-F000/download/report.txt', + '203.0.113.10', + expect.objectContaining({ + timeout: 30000, + headers, + }) + ) + }) + + it('should reject oversized external downloads before reading the body', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + new Response('oversized', { + status: 200, + headers: { 'content-length': '104857601', 'content-type': 'text/plain' }, + }) + ) + + const req = createMockRequest('POST', { + filePath: 'https://example.com/large.txt', + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(false) + expect(data.error).toContain('too large') + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith( + 'https://example.com/large.txt', + '203.0.113.10', + expect.objectContaining({ + maxResponseBytes: 104857600, + }) + ) + }) + + it('should reject oversized local files before materializing them', async () => { + setupFileApiMocks({ + cloudEnabled: false, + storageProvider: 'local', + authenticated: true, + }) + mockFsStat.mockResolvedValue({ isFile: () => true, size: 104857601 }) + + const req = createMockRequest('POST', { + filePath: 'workspace/large.txt', + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(false) + expect(data.error).toContain('too large') + expect(mockFsReadFile).not.toHaveBeenCalled() + }) + it('should process execution file URLs with context query param', async () => { setupFileApiMocks({ cloudEnabled: true, @@ -325,8 +646,8 @@ describe('File Parse API Route', () => { authenticated: true, }) - mockDownloadFile.mockRejectedValue(new Error('Access denied')) - mockHasCloudStorage.mockReturnValue(true) + storageServiceMockFns.mockDownloadFile.mockRejectedValue(new Error('Access denied')) + storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true) const req = new NextRequest('http://localhost:3000/api/files/parse', { method: 'POST', diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index 74d70ca2e83..ea4f493dd80 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -1,19 +1,24 @@ -import { Buffer } from 'buffer' +import { Buffer, isUtf8 } from 'buffer' import { createHash } from 'crypto' -import fsPromises, { readFile } from 'fs/promises' +import fsPromises from 'fs/promises' import path from 'path' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' import binaryExtensionsList from 'binary-extensions' import { type NextRequest, NextResponse } from 'next/server' +import { fileParseContract } from '@/lib/api/contracts/storage-transfer' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { - secureFetchWithPinnedIP, - validateUrlWithDNS, -} from '@/lib/core/security/input-validation.server' import { sanitizeUrlForLog } from '@/lib/core/utils/logging' +import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { isSupportedFileType, parseFile } from '@/lib/file-parsers' import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads' import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' +import { + ExternalUrlValidationError, + fetchExternalUrlToWorkspace, +} from '@/lib/uploads/contexts/workspace' import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' import { @@ -25,7 +30,6 @@ import { inferContextFromKey, isInternalFileUrl, } from '@/lib/uploads/utils/file-utils' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { verifyFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' import '@/lib/uploads/core/setup.server' @@ -37,6 +41,13 @@ const logger = createLogger('FilesParseAPI') const MAX_DOWNLOAD_SIZE_BYTES = 100 * 1024 * 1024 // 100 MB const DOWNLOAD_TIMEOUT_MS = 30000 // 30 seconds +const MAX_FILE_REFERENCE_LENGTH = 4096 +const MAX_MULTI_FILE_PARSE_OUTPUT_BYTES = 5 * 1024 * 1024 +const BINARY_EXTENSIONS = new Set(binaryExtensionsList) + +function isLikelyTextBuffer(fileBuffer: Buffer): boolean { + return isUtf8(fileBuffer) && !fileBuffer.includes(0) +} interface ExecutionContext { workspaceId: string @@ -60,6 +71,10 @@ interface ParseResult { } } +function getContentBytes(content: unknown): number { + return typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : 0 +} + /** * Main API route handler */ @@ -84,8 +99,28 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = authResult.userId - const requestData = await request.json() - const { filePath, fileType, workspaceId, workflowId, executionId } = requestData + + const parsed = await parseRequest( + fileParseContract, + request, + {}, + { + validationErrorResponse: (error) => { + const message = getValidationErrorMessage(error, 'Invalid request data') + return NextResponse.json( + { + success: false, + error: message, + filePath: '', + }, + { status: message.includes('At most 10 files') ? 413 : 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + + const { filePath, fileType, headers, workspaceId, workflowId, executionId } = parsed.data.body if (!filePath || (typeof filePath === 'string' && filePath.trim() === '')) { return NextResponse.json({ success: false, error: 'No file path provided' }, { status: 400 }) @@ -103,10 +138,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workspaceId, userId, hasExecutionContext: !!executionContext, + hasHeaders: Boolean(headers && Object.keys(headers).length > 0), }) if (Array.isArray(filePath)) { const results = [] + let totalOutputBytes = 0 + for (const singlePath of filePath) { if (!singlePath || (typeof singlePath === 'string' && singlePath.trim() === '')) { results.push({ @@ -117,18 +155,32 @@ export const POST = withRouteHandler(async (request: NextRequest) => { continue } + const remainingOutputBytes = MAX_MULTI_FILE_PARSE_OUTPUT_BYTES - totalOutputBytes + if (remainingOutputBytes <= 0) { + return parsedOutputTooLargeResponse(results) + } + const result = await parseFileSingle( singlePath, fileType, workspaceId, userId, - executionContext + executionContext, + headers, + request.signal, + MAX_DOWNLOAD_SIZE_BYTES, + remainingOutputBytes ) if (result.metadata) { result.metadata.processingTime = Date.now() - startTime } if (result.success) { + totalOutputBytes += getContentBytes(result.content) + if (totalOutputBytes > MAX_MULTI_FILE_PARSE_OUTPUT_BYTES) { + return parsedOutputTooLargeResponse(results) + } + const displayName = result.originalName || extractCleanFilename(result.filePath) || 'unknown' results.push({ @@ -144,9 +196,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { filePath: result.filePath, viewerUrl: result.viewerUrl, }) - } else { - results.push(result) + continue } + + if (result.error?.startsWith('Parsed file output is too large')) { + return parsedOutputTooLargeResponse(results) + } + + results.push(result) } return NextResponse.json({ @@ -155,7 +212,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } - const result = await parseFileSingle(filePath, fileType, workspaceId, userId, executionContext) + const result = await parseFileSingle( + filePath, + fileType, + workspaceId, + userId, + executionContext, + headers, + request.signal + ) if (result.metadata) { result.metadata.processingTime = Date.now() - startTime @@ -184,7 +249,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), filePath: '', }, { status: 500 } @@ -200,7 +265,11 @@ async function parseFileSingle( fileType: string, workspaceId: string, userId: string, - executionContext?: ExecutionContext + executionContext?: ExecutionContext, + headers?: Record, + signal?: AbortSignal, + maxDownloadBytes = MAX_DOWNLOAD_SIZE_BYTES, + maxParsedOutputBytes?: number ): Promise { logger.info('Parsing file:', filePath) @@ -212,6 +281,15 @@ async function parseFileSingle( } } + const referenceValidation = validateFileReferenceShape(filePath) + if (!referenceValidation.isValid) { + return { + success: false, + error: referenceValidation.error || 'Invalid file reference', + filePath, + } + } + const pathValidation = validateFilePath(filePath) if (!pathValidation.isValid) { return { @@ -222,18 +300,122 @@ async function parseFileSingle( } if (isInternalFileUrl(filePath)) { - return handleCloudFile(filePath, fileType, undefined, userId, executionContext) + return handleCloudFile( + filePath, + fileType, + undefined, + userId, + executionContext, + maxDownloadBytes, + maxParsedOutputBytes + ) } if (filePath.startsWith('http://') || filePath.startsWith('https://')) { - return handleExternalUrl(filePath, fileType, workspaceId, userId, executionContext) + return handleExternalUrl( + filePath, + fileType, + workspaceId, + userId, + executionContext, + headers, + signal, + maxDownloadBytes, + maxParsedOutputBytes + ) } if (isUsingCloudStorage()) { - return handleCloudFile(filePath, fileType, undefined, userId, executionContext) + return handleCloudFile( + filePath, + fileType, + undefined, + userId, + executionContext, + maxDownloadBytes, + maxParsedOutputBytes + ) + } + + return handleLocalFile( + filePath, + fileType, + userId, + executionContext, + maxDownloadBytes, + maxParsedOutputBytes + ) +} + +function validateFileReferenceShape(filePath: string): { isValid: boolean; error?: string } { + const trimmed = filePath.trim() + if ( + trimmed.startsWith('http://') || + trimmed.startsWith('https://') || + isInternalFileUrl(trimmed) + ) { + return { isValid: true } + } + + if (trimmed.startsWith('data:')) { + return { + isValid: false, + error: 'File input must be a URL or uploaded file reference, not inline file content', + } + } + + if (filePath.length > MAX_FILE_REFERENCE_LENGTH) { + return { + isValid: false, + error: 'File reference is too long; provide a file URL or upload the file instead', + } + } + + if (/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(filePath)) { + return { + isValid: false, + error: + 'File reference contains binary content; provide a file URL or upload the file instead', + } + } + + const newlineCount = filePath.match(/\r\n|\r|\n/g)?.length ?? 0 + if (newlineCount > 2) { + return { + isValid: false, + error: + 'File reference looks like inline file content; provide a file URL or upload the file instead', + } } - return handleLocalFile(filePath, fileType, userId, executionContext) + return { isValid: true } +} + +function parsedOutputTooLargeResponse(results?: unknown[]): NextResponse { + const hasPartialResults = Boolean(results && results.length > 0) + return NextResponse.json( + { + success: hasPartialResults, + error: `Parsed file output is too large to return safely. Maximum combined parsed output is ${prettySize( + MAX_MULTI_FILE_PARSE_OUTPUT_BYTES + )}.`, + ...(results && results.length > 0 ? { results } : {}), + }, + { status: hasPartialResults ? 200 : 413 } + ) +} + +function getParsedOutputTooLargeMessage(maxBytes: number): string { + return `Parsed file output is too large to return safely. Maximum parsed output is ${prettySize( + maxBytes + )}.` +} + +function assertParsedContentWithinLimit(content: string, maxBytes?: number): string { + if (maxBytes !== undefined) { + assertKnownSizeWithinLimit(Buffer.byteLength(content, 'utf8'), maxBytes, 'parsed file output') + } + return content } /** @@ -264,36 +446,30 @@ function validateFilePath(filePath: string): { isValid: boolean; error?: string } /** - * Handle external URL - * If workspaceId is provided, checks if file already exists and saves to workspace if not - * If executionContext is provided, also stores the file in execution storage and returns UserFile + * Handle external URL. + * + * Always fetches the URL fresh — there is no filename-based dedup. Distinct URLs + * commonly share a path tail (e.g. every Slack clipboard paste is `image.png`), + * so keying a cache by filename returns stale bytes. `fetchExternalUrlToWorkspace` + * delegates to `uploadWorkspaceFile`, which suffix-disambiguates collisions on save. + * + * Workspace save is skipped when the URL already points at our execution-files + * bucket (re-uploading our own bytes is wasteful and would generate `image (1).png` + * style aliases for files we already own). */ async function handleExternalUrl( url: string, fileType: string, workspaceId: string, userId: string, - executionContext?: ExecutionContext + executionContext?: ExecutionContext, + headers?: Record, + signal?: AbortSignal, + maxDownloadBytes = MAX_DOWNLOAD_SIZE_BYTES, + maxParsedOutputBytes?: number ): Promise { try { logger.info('Fetching external URL:', url) - logger.info('WorkspaceId for URL save:', workspaceId) - - const urlValidation = await validateUrlWithDNS(url, 'fileUrl') - if (!urlValidation.isValid) { - logger.warn(`Blocked external URL request: ${urlValidation.error}`) - return { - success: false, - error: urlValidation.error || 'Invalid external URL', - filePath: url, - } - } - - const urlPath = new URL(url).pathname - const filename = urlPath.split('/').pop() || 'download' - const extension = path.extname(filename).toLowerCase().substring(1) - - logger.info(`Extracted filename: ${filename}, workspaceId: ${workspaceId}`) const { S3_EXECUTION_FILES_CONFIG, @@ -318,105 +494,46 @@ async function handleExternalUrl( isExecutionFile = false } - // Only apply workspace deduplication if: - // 1. WorkspaceId is provided - // 2. URL is NOT from execution files bucket/container - const shouldCheckWorkspace = workspaceId && !isExecutionFile - - if (shouldCheckWorkspace) { - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission === null) { - logger.warn('User does not have workspace access for file parse', { - userId, - workspaceId, - filename, - }) - return { - success: false, - error: 'File not found', - filePath: url, - } - } - - const { fileExistsInWorkspace, listWorkspaceFiles } = await import( - '@/lib/uploads/contexts/workspace' - ) - const exists = await fileExistsInWorkspace(workspaceId, filename) - - if (exists) { - logger.info(`File ${filename} already exists in workspace, using existing file`) - const workspaceFiles = await listWorkspaceFiles(workspaceId) - const existingFile = workspaceFiles.find((f) => f.name === filename) - - if (existingFile) { - const storageFilePath = `/api/files/serve/${existingFile.key}` - return handleCloudFile(storageFilePath, fileType, 'workspace', userId, executionContext) - } - } - } - - const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { - timeout: DOWNLOAD_TIMEOUT_MS, + const { filename, buffer, mimeType } = await fetchExternalUrlToWorkspace({ + url, + userId, + workspaceId: workspaceId || undefined, + saveToWorkspace: Boolean(workspaceId) && !isExecutionFile, + headers, + signal, + maxDownloadBytes, + timeoutMs: DOWNLOAD_TIMEOUT_MS, }) - if (!response.ok) { - throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`) - } - - const contentLength = response.headers.get('content-length') - if (contentLength && Number.parseInt(contentLength) > MAX_DOWNLOAD_SIZE_BYTES) { - throw new Error(`File too large: ${contentLength} bytes (max: ${MAX_DOWNLOAD_SIZE_BYTES})`) - } - - const buffer = Buffer.from(await response.arrayBuffer()) - - if (buffer.length > MAX_DOWNLOAD_SIZE_BYTES) { - throw new Error(`File too large: ${buffer.length} bytes (max: ${MAX_DOWNLOAD_SIZE_BYTES})`) - } + const extension = path.extname(filename).toLowerCase().substring(1) logger.info(`Downloaded file from URL: ${url}, size: ${buffer.length} bytes`) let userFile: UserFile | undefined - const mimeType = response.headers.get('content-type') || getMimeTypeFromExtension(extension) - if (executionContext) { try { userFile = await uploadExecutionFile(executionContext, buffer, filename, mimeType, userId) logger.info(`Stored file in execution storage: ${filename}`, { key: userFile.key }) } catch (uploadError) { - logger.warn(`Failed to store file in execution storage:`, uploadError) - // Continue without userFile - parsing can still work - } - } - - if (shouldCheckWorkspace) { - try { - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - logger.warn('User does not have write permission for workspace file save', { - userId, - workspaceId, - filename, - permission, - }) - } else { - const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace') - await uploadWorkspaceFile(workspaceId, userId, buffer, filename, mimeType) - logger.info(`Saved URL file to workspace storage: ${filename}`) - } - } catch (saveError) { - logger.warn(`Failed to save URL file to workspace:`, saveError) + logger.warn('Failed to store file in execution storage:', uploadError) } } let parseResult: ParseResult if (extension === 'pdf') { - parseResult = await handlePdfBuffer(buffer, filename, fileType, url) + parseResult = await handlePdfBuffer(buffer, filename, fileType, url, maxParsedOutputBytes) } else if (extension === 'csv') { - parseResult = await handleCsvBuffer(buffer, filename, fileType, url) + parseResult = await handleCsvBuffer(buffer, filename, fileType, url, maxParsedOutputBytes) } else if (isSupportedFileType(extension)) { - parseResult = await handleGenericTextBuffer(buffer, filename, extension, fileType, url) + parseResult = await handleGenericTextBuffer( + buffer, + filename, + extension, + fileType, + url, + maxParsedOutputBytes + ) } else { - parseResult = handleGenericBuffer(buffer, filename, extension, fileType) + parseResult = handleGenericBuffer(buffer, filename, extension, fileType, maxParsedOutputBytes) } // Attach userFile to the result @@ -427,6 +544,34 @@ async function handleExternalUrl( return parseResult } catch (error) { logger.error(`Error handling external URL ${sanitizeUrlForLog(url)}:`, error) + if (isPayloadSizeLimitError(error)) { + logger.warn('Rejected oversized external file parse payload', { + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + label: error.label, + url: sanitizeUrlForLog(url), + }) + return { + success: false, + error: + error.label === 'parsed file output' + ? getParsedOutputTooLargeMessage(error.maxBytes) + : `File is too large to parse safely. Maximum supported download size is ${prettySize( + error.maxBytes + )}.`, + filePath: url, + } + } + + if (error instanceof ExternalUrlValidationError) { + logger.warn(`Blocked external URL request: ${error.message}`) + return { + success: false, + error: error.message, + filePath: url, + } + } + return { success: false, error: `Error fetching URL: ${(error as Error).message}`, @@ -445,7 +590,9 @@ async function handleCloudFile( fileType: string, explicitContext: string | undefined, userId: string, - executionContext?: ExecutionContext + executionContext?: ExecutionContext, + maxDownloadBytes = MAX_DOWNLOAD_SIZE_BYTES, + maxParsedOutputBytes?: number ): Promise { try { const cloudKey = extractStorageKey(filePath) @@ -485,7 +632,11 @@ async function handleCloudFile( } } - const fileBuffer = await StorageService.downloadFile({ key: cloudKey, context }) + const fileBuffer = await StorageService.downloadFile({ + key: cloudKey, + context, + maxBytes: maxDownloadBytes, + }) logger.info( `Downloaded file from ${context} storage (${explicitContext ? 'explicit' : 'inferred'}): ${cloudKey}, size: ${fileBuffer.length} bytes` ) @@ -515,7 +666,7 @@ async function handleCloudFile( // If file is already from execution context, create UserFile reference without re-uploading if (context === 'execution') { userFile = { - id: `file_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + id: `file_${Date.now()}_${generateShortId(7)}`, name: filename, url: normalizedFilePath, size: fileBuffer.length, @@ -543,19 +694,38 @@ async function handleCloudFile( let parseResult: ParseResult if (extension === 'pdf') { - parseResult = await handlePdfBuffer(fileBuffer, filename, fileType, normalizedFilePath) + parseResult = await handlePdfBuffer( + fileBuffer, + filename, + fileType, + normalizedFilePath, + maxParsedOutputBytes + ) } else if (extension === 'csv') { - parseResult = await handleCsvBuffer(fileBuffer, filename, fileType, normalizedFilePath) + parseResult = await handleCsvBuffer( + fileBuffer, + filename, + fileType, + normalizedFilePath, + maxParsedOutputBytes + ) } else if (isSupportedFileType(extension)) { parseResult = await handleGenericTextBuffer( fileBuffer, filename, extension, fileType, - normalizedFilePath + normalizedFilePath, + maxParsedOutputBytes ) } else { - parseResult = handleGenericBuffer(fileBuffer, filename, extension, fileType) + parseResult = handleGenericBuffer( + fileBuffer, + filename, + extension, + fileType, + maxParsedOutputBytes + ) parseResult.filePath = normalizedFilePath } @@ -575,6 +745,25 @@ async function handleCloudFile( logger.error(`Error handling cloud file ${filePath}:`, error) const errorMessage = (error as Error).message + if (isPayloadSizeLimitError(error)) { + logger.warn('Rejected oversized cloud file parse payload', { + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + label: error.label, + filePath, + }) + return { + success: false, + error: + error.label === 'parsed file output' + ? getParsedOutputTooLargeMessage(error.maxBytes) + : `File is too large to parse safely. Maximum supported download size is ${prettySize( + error.maxBytes + )}.`, + filePath, + } + } + if (errorMessage.includes('Access denied') || errorMessage.includes('Forbidden')) { throw new Error(`Error accessing file from cloud storage: ${errorMessage}`) } @@ -594,14 +783,17 @@ async function handleLocalFile( filePath: string, fileType: string, userId: string, - executionContext?: ExecutionContext + executionContext?: ExecutionContext, + maxDownloadBytes = MAX_DOWNLOAD_SIZE_BYTES, + maxParsedOutputBytes?: number ): Promise { try { - const filename = filePath.split('/').pop() || filePath + const storageKey = isInternalFileUrl(filePath) ? extractStorageKey(filePath) : filePath + const filename = storageKey.split('/').pop() || storageKey - const context = inferContextFromKey(filename) + const context = inferContextFromKey(storageKey) const hasAccess = await verifyFileAccess( - filename, + storageKey, userId, undefined, // customConfig context, // context @@ -617,7 +809,7 @@ async function handleLocalFile( } } - const fullPath = path.join(UPLOAD_DIR_SERVER, filename) + const fullPath = path.join(UPLOAD_DIR_SERVER, storageKey) logger.info('Processing local file:', fullPath) @@ -627,10 +819,12 @@ async function handleLocalFile( throw new Error(`File not found: ${filename}`) } - const result = await parseFile(fullPath) - const stats = await fsPromises.stat(fullPath) - const fileBuffer = await readFile(fullPath) + assertKnownSizeWithinLimit(stats.size, maxDownloadBytes, 'local file') + + const result = await parseFile(fullPath) + const content = assertParsedContentWithinLimit(result.content, maxParsedOutputBytes) + const fileBuffer = await fsPromises.readFile(fullPath) const hash = createHash('md5').update(fileBuffer).digest('hex') const extension = path.extname(filename).toLowerCase().substring(1) @@ -655,7 +849,7 @@ async function handleLocalFile( return { success: true, - content: result.content, + content, filePath, userFile, metadata: { @@ -667,6 +861,25 @@ async function handleLocalFile( } } catch (error) { logger.error(`Error handling local file ${filePath}:`, error) + if (isPayloadSizeLimitError(error)) { + logger.warn('Rejected oversized local file parse payload', { + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + label: error.label, + filePath, + }) + return { + success: false, + error: + error.label === 'parsed file output' + ? getParsedOutputTooLargeMessage(error.maxBytes) + : `File is too large to parse safely. Maximum supported local file size is ${prettySize( + error.maxBytes + )}.`, + filePath, + } + } + return { success: false, error: `Error processing local file: ${(error as Error).message}`, @@ -682,7 +895,8 @@ async function handlePdfBuffer( fileBuffer: Buffer, filename: string, fileType?: string, - originalPath?: string + originalPath?: string, + maxParsedOutputBytes?: number ): Promise { try { logger.info(`Parsing PDF in memory: ${filename}`) @@ -692,10 +906,11 @@ async function handlePdfBuffer( const content = result.content || createPdfFallbackMessage(result.metadata?.pageCount || 0, fileBuffer.length, originalPath) + const limitedContent = assertParsedContentWithinLimit(content, maxParsedOutputBytes) return { success: true, - content, + content: limitedContent, filePath: originalPath || filename, metadata: { fileType: fileType || 'application/pdf', @@ -705,6 +920,8 @@ async function handlePdfBuffer( }, } } catch (error) { + if (isPayloadSizeLimitError(error)) throw error + logger.error('Failed to parse PDF in memory:', error) const content = createPdfFailureMessage( @@ -735,7 +952,8 @@ async function handleCsvBuffer( fileBuffer: Buffer, filename: string, fileType?: string, - originalPath?: string + originalPath?: string, + maxParsedOutputBytes?: number ): Promise { try { logger.info(`Parsing CSV in memory: ${filename}`) @@ -745,7 +963,7 @@ async function handleCsvBuffer( return { success: true, - content: result.content, + content: assertParsedContentWithinLimit(result.content, maxParsedOutputBytes), filePath: originalPath || filename, metadata: { fileType: fileType || 'text/csv', @@ -755,6 +973,8 @@ async function handleCsvBuffer( }, } } catch (error) { + if (isPayloadSizeLimitError(error)) throw error + logger.error('Failed to parse CSV in memory:', error) return { success: false, @@ -778,7 +998,8 @@ async function handleGenericTextBuffer( filename: string, extension: string, fileType?: string, - originalPath?: string + originalPath?: string, + maxParsedOutputBytes?: number ): Promise { try { logger.info(`Parsing text file in memory: ${filename}`) @@ -791,7 +1012,7 @@ async function handleGenericTextBuffer( return { success: true, - content: result.content, + content: assertParsedContentWithinLimit(result.content, maxParsedOutputBytes), filePath: originalPath || filename, metadata: { fileType: fileType || getMimeTypeFromExtension(extension), @@ -802,14 +1023,17 @@ async function handleGenericTextBuffer( } } } catch (parserError) { + if (isPayloadSizeLimitError(parserError)) throw parserError + logger.warn('Specialized parser failed, falling back to generic parsing:', parserError) } const content = fileBuffer.toString('utf-8') + const limitedContent = assertParsedContentWithinLimit(content, maxParsedOutputBytes) return { success: true, - content, + content: limitedContent, filePath: originalPath || filename, metadata: { fileType: fileType || getMimeTypeFromExtension(extension), @@ -819,6 +1043,8 @@ async function handleGenericTextBuffer( }, } } catch (error) { + if (isPayloadSizeLimitError(error)) throw error + logger.error('Failed to parse text file in memory:', error) return { success: false, @@ -841,12 +1067,14 @@ function handleGenericBuffer( fileBuffer: Buffer, filename: string, extension: string, - fileType?: string + fileType?: string, + maxParsedOutputBytes?: number ): ParseResult { - const isBinary = binaryExtensionsList.includes(extension) - const content = isBinary - ? `[Binary ${extension.toUpperCase()} file - ${fileBuffer.length} bytes]` - : fileBuffer.toString('utf-8') + const normalizedExtension = extension.toLowerCase() + const content = + !BINARY_EXTENSIONS.has(normalizedExtension) && isLikelyTextBuffer(fileBuffer) + ? assertParsedContentWithinLimit(fileBuffer.toString('utf-8'), maxParsedOutputBytes) + : `[Binary ${normalizedExtension.toUpperCase()} file - ${fileBuffer.length} bytes]` return { success: true, diff --git a/apps/sim/app/api/files/presigned/batch/route.ts b/apps/sim/app/api/files/presigned/batch/route.ts index ba96146b85c..ac5015c9a7d 100644 --- a/apps/sim/app/api/files/presigned/batch/route.ts +++ b/apps/sim/app/api/files/presigned/batch/route.ts @@ -1,5 +1,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { + batchPresignedUploadBodyContract, + uploadTypeSchema, +} from '@/lib/api/contracts/storage-transfer' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { StorageContext } from '@/lib/uploads/config' @@ -13,15 +18,7 @@ import { createErrorResponse } from '@/app/api/files/utils' const logger = createLogger('BatchPresignedUploadAPI') -interface BatchFileRequest { - fileName: string - contentType: string - fileSize: number -} - -interface BatchPresignedUrlRequest { - files: BatchFileRequest[] -} +const VALID_UPLOAD_TYPES = ['knowledge-base', 'chat', 'copilot', 'profile-pictures'] as const export const POST = withRouteHandler(async (request: NextRequest) => { try { @@ -30,69 +27,41 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - let data: BatchPresignedUrlRequest - try { - data = await request.json() - } catch { - return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) - } - - const { files } = data - - if (!files || !Array.isArray(files) || files.length === 0) { - return NextResponse.json( - { error: 'files array is required and cannot be empty' }, - { status: 400 } - ) - } + const parsed = await parseRequest( + batchPresignedUploadBodyContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { error: getValidationErrorMessage(error, 'Invalid request data') }, + { status: 400 } + ), + invalidJsonResponse: () => + NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response - if (files.length > 100) { - return NextResponse.json( - { error: 'Cannot process more than 100 files at once' }, - { status: 400 } - ) - } + const { files } = parsed.data.body const uploadTypeParam = request.nextUrl.searchParams.get('type') if (!uploadTypeParam) { return NextResponse.json({ error: 'type query parameter is required' }, { status: 400 }) } - const 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 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 }) - } - if (!file.contentType?.trim()) { - return NextResponse.json( - { error: 'contentType is required for all files' }, - { status: 400 } - ) - } - if (!file.fileSize || file.fileSize <= 0) { - return NextResponse.json( - { error: 'fileSize must be positive for all files' }, - { status: 400 } - ) - } - if (file.fileSize > MAX_FILE_SIZE) { - return NextResponse.json( - { error: `File ${file.fileName} exceeds maximum size of ${MAX_FILE_SIZE} bytes` }, - { status: 400 } - ) - } + const uploadType = uploadTypeResult.data as StorageContext - if (uploadType === 'knowledge-base') { + if (uploadType === 'knowledge-base') { + for (const file of files) { const fileValidationError = validateFileType(file.fileName, file.contentType) if (fileValidationError) { return NextResponse.json( @@ -162,16 +131,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 = files[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, @@ -186,17 +156,3 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } }) - -export const OPTIONS = withRouteHandler(async () => { - return NextResponse.json( - {}, - { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - }, - } - ) -}) diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts index f6641c07d9f..9abfa5be2d4 100644 --- a/apps/sim/app/api/files/presigned/route.test.ts +++ b/apps/sim/app/api/files/presigned/route.test.ts @@ -4,7 +4,7 @@ * @vitest-environment node */ -import { authMockFns } from '@sim/testing' +import { authMockFns, storageServiceMock, storageServiceMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -16,14 +16,16 @@ const { mockGetStorageConfig, mockIsUsingCloudStorage, mockGetStorageProvider, - mockHasCloudStorage, - mockGeneratePresignedUploadUrl, - mockGeneratePresignedDownloadUrl, mockValidateFileType, + mockValidateAttachmentFileType, mockGenerateCopilotUploadUrl, mockIsImageFileType, mockGetStorageProviderUploads, mockIsUsingCloudStorageUploads, + mockGetUserEntityPermissions, + mockGenerateWorkspaceFileKey, + mockGenerateExecutionFileKey, + mockInsertFileMetadata, } = vi.hoisted(() => ({ mockVerifyFileAccess: vi.fn().mockResolvedValue(true), mockVerifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true), @@ -32,10 +34,8 @@ const { mockGetStorageConfig: vi.fn(), mockIsUsingCloudStorage: vi.fn(), mockGetStorageProvider: vi.fn(), - mockHasCloudStorage: vi.fn(), - mockGeneratePresignedUploadUrl: vi.fn(), - mockGeneratePresignedDownloadUrl: vi.fn().mockResolvedValue('https://example.com/presigned-url'), mockValidateFileType: vi.fn().mockReturnValue(null), + mockValidateAttachmentFileType: vi.fn().mockReturnValue(null), mockGenerateCopilotUploadUrl: vi.fn().mockResolvedValue({ url: 'https://example.com/presigned-url', key: 'copilot/test-key.txt', @@ -43,6 +43,15 @@ const { mockIsImageFileType: vi.fn().mockReturnValue(true), mockGetStorageProviderUploads: vi.fn(), mockIsUsingCloudStorageUploads: vi.fn(), + mockGetUserEntityPermissions: vi.fn().mockResolvedValue('admin'), + mockGenerateWorkspaceFileKey: vi.fn( + (workspaceId: string, fileName: string) => `workspace/${workspaceId}/${fileName}` + ), + mockGenerateExecutionFileKey: vi.fn( + (ctx: { workspaceId: string; workflowId: string; executionId: string }, fileName: string) => + `execution/${ctx.workspaceId}/${ctx.workflowId}/${ctx.executionId}/${fileName}` + ), + mockInsertFileMetadata: vi.fn().mockResolvedValue({ id: 'wf_test' }), })) vi.mock('@/app/api/files/authorization', () => ({ @@ -63,14 +72,27 @@ vi.mock('@/lib/uploads/config', () => ({ getStorageProvider: mockGetStorageProvider, })) -vi.mock('@/lib/uploads/core/storage-service', () => ({ - hasCloudStorage: mockHasCloudStorage, - generatePresignedUploadUrl: mockGeneratePresignedUploadUrl, - generatePresignedDownloadUrl: mockGeneratePresignedDownloadUrl, -})) +vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) vi.mock('@/lib/uploads/utils/validation', () => ({ validateFileType: mockValidateFileType, + validateAttachmentFileType: mockValidateAttachmentFileType, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + generateWorkspaceFileKey: mockGenerateWorkspaceFileKey, +})) + +vi.mock('@/lib/uploads/contexts/execution/utils', () => ({ + generateExecutionFileKey: mockGenerateExecutionFileKey, +})) + +vi.mock('@/lib/uploads/server/metadata', () => ({ + insertFileMetadata: mockInsertFileMetadata, })) vi.mock('@/lib/uploads/utils/file-utils', () => ({ @@ -85,7 +107,7 @@ vi.mock('@/lib/uploads', () => ({ isUsingCloudStorage: mockIsUsingCloudStorageUploads, })) -import { OPTIONS, POST } from '@/app/api/files/presigned/route' +import { POST } from '@/app/api/files/presigned/route' const defaultMockUser = { id: 'test-user-id', @@ -132,8 +154,8 @@ function setupFileApiMocks( storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local' ) - mockHasCloudStorage.mockReturnValue(cloudEnabled) - mockGeneratePresignedUploadUrl.mockImplementation( + storageServiceMockFns.mockHasCloudStorage.mockReturnValue(cloudEnabled) + storageServiceMockFns.mockGeneratePresignedUploadUrl.mockImplementation( async (opts: { fileName: string; context: string }) => { const timestamp = Date.now() const safeFileName = opts.fileName.replace(/[^a-zA-Z0-9.-]/g, '_') @@ -144,9 +166,13 @@ function setupFileApiMocks( } } ) - mockGeneratePresignedDownloadUrl.mockResolvedValue('https://example.com/presigned-url') + storageServiceMockFns.mockGeneratePresignedDownloadUrl.mockResolvedValue( + 'https://example.com/presigned-url' + ) mockValidateFileType.mockReturnValue(null) + mockValidateAttachmentFileType.mockReturnValue(null) + mockGetUserEntityPermissions.mockResolvedValue('admin') mockGetStorageProviderUploads.mockReturnValue( storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local' @@ -431,7 +457,7 @@ describe('/api/files/presigned', () => { storageProvider: 's3', }) - mockGeneratePresignedUploadUrl.mockRejectedValue( + storageServiceMockFns.mockGeneratePresignedUploadUrl.mockRejectedValue( new Error('Unknown storage provider: unknown') ) @@ -458,7 +484,9 @@ describe('/api/files/presigned', () => { storageProvider: 's3', }) - mockGeneratePresignedUploadUrl.mockRejectedValue(new Error('S3 service unavailable')) + storageServiceMockFns.mockGeneratePresignedUploadUrl.mockRejectedValue( + new Error('S3 service unavailable') + ) const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { method: 'POST', @@ -483,7 +511,9 @@ describe('/api/files/presigned', () => { storageProvider: 'blob', }) - mockGeneratePresignedUploadUrl.mockRejectedValue(new Error('Azure service unavailable')) + storageServiceMockFns.mockGeneratePresignedUploadUrl.mockRejectedValue( + new Error('Azure service unavailable') + ) const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { method: 'POST', @@ -522,15 +552,279 @@ describe('/api/files/presigned', () => { }) }) - describe('OPTIONS', () => { - it('should handle CORS preflight requests', async () => { - const response = await OPTIONS() + describe('mothership uploads', () => { + it('uses validateAttachmentFileType (not validateFileType) — accepts images', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'screenshot.png', + contentType: 'image/png', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + expect(response.status).toBe(200) + expect(mockValidateAttachmentFileType).toHaveBeenCalledWith('screenshot.png') + expect(mockValidateFileType).not.toHaveBeenCalled() + }) + + it('rejects unsupported types when validator returns an error', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + mockValidateAttachmentFileType.mockReturnValue({ + code: 'UNSUPPORTED_FILE_TYPE', + message: 'Unsupported file type: exe.', + supportedTypes: [], + }) + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'virus.exe', + contentType: 'application/octet-stream', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + const data = await response.json() + expect(response.status).toBe(400) + expect(data.code).toBe('VALIDATION_ERROR') + expect(data.error).toContain('exe') + }) + + it('returns 403 when user lacks workspace write permission', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + mockGetUserEntityPermissions.mockResolvedValue('read') + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'doc.pdf', + contentType: 'application/pdf', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + expect(response.status).toBe(403) + }) + + it('inserts a workspaceFiles row with context=mothership so previews authorize', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'screenshot.png', + contentType: 'image/png', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(mockInsertFileMetadata).toHaveBeenCalledTimes(1) + expect(mockInsertFileMetadata).toHaveBeenCalledWith({ + key: data.fileInfo.key, + userId: 'test-user-id', + workspaceId: 'ws-1', + context: 'mothership', + originalName: 'screenshot.png', + contentType: 'image/png', + size: 4096, + }) + }) + + it('returns 500 when insertFileMetadata fails so callers do not get an unauthorizable URL', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + mockInsertFileMetadata.mockRejectedValueOnce(new Error('DB connection lost')) + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'screenshot.png', + contentType: 'image/png', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + expect(response.status).toBe(500) + }) + }) + + describe('execution uploads', () => { + it('uses validateAttachmentFileType — accepts video', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'output.mp4', + contentType: 'video/mp4', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + expect(response.status).toBe(200) + expect(mockValidateAttachmentFileType).toHaveBeenCalledWith('output.mp4') + expect(mockValidateFileType).not.toHaveBeenCalled() + }) + + it('rejects when validator returns an error', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + mockValidateAttachmentFileType.mockReturnValue({ + code: 'UNSUPPORTED_FILE_TYPE', + message: 'Unsupported file type: bin.', + supportedTypes: [], + }) + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'blob.bin', + contentType: 'application/octet-stream', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + const data = await response.json() + expect(response.status).toBe(400) + expect(data.code).toBe('VALIDATION_ERROR') + }) + + it('returns 400 when missing workflowId/executionId', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'output.mp4', + contentType: 'video/mp4', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + expect(response.status).toBe(400) + }) + + it('inserts a workspaceFiles row with context=execution so previews authorize', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'output.mp4', + contentType: 'video/mp4', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(mockInsertFileMetadata).toHaveBeenCalledTimes(1) + expect(mockInsertFileMetadata).toHaveBeenCalledWith({ + key: data.fileInfo.key, + userId: 'test-user-id', + workspaceId: 'ws-1', + context: 'execution', + originalName: 'output.mp4', + contentType: 'video/mp4', + size: 4096, + }) + }) + }) + + describe('workspace-logos uploads', () => { + it('inserts a workspaceFiles row with context=workspace-logos so logos authorize', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=workspace-logos&workspaceId=ws-1', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'logo.png', + contentType: 'image/png', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + const data = await response.json() expect(response.status).toBe(200) - expect(response.headers.get('Access-Control-Allow-Methods')).toBe('POST, OPTIONS') - expect(response.headers.get('Access-Control-Allow-Headers')).toBe( - 'Content-Type, Authorization' + expect(mockInsertFileMetadata).toHaveBeenCalledTimes(1) + expect(mockInsertFileMetadata).toHaveBeenCalledWith({ + key: data.fileInfo.key, + userId: 'test-user-id', + workspaceId: 'ws-1', + context: 'workspace-logos', + originalName: 'logo.png', + contentType: 'image/png', + size: 4096, + }) + }) + }) + + describe('knowledge-base uploads', () => { + it('uses validateFileType (docs-only), not validateAttachmentFileType', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=knowledge-base', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'doc.pdf', + contentType: 'application/pdf', + fileSize: 4096, + }), + } ) + + const response = await POST(request) + expect(response.status).toBe(200) + expect(mockValidateFileType).toHaveBeenCalledWith('doc.pdf', 'application/pdf') + expect(mockValidateAttachmentFileType).not.toHaveBeenCalled() }) }) }) diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index 7003aa900ae..3312434f04d 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -1,24 +1,33 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { presignedUploadBodyContract, uploadTypeSchema } from '@/lib/api/contracts/storage-transfer' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CopilotFiles } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' import { USE_BLOB_STORAGE } from '@/lib/uploads/config' +import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils' +import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service' +import { insertFileMetadata } from '@/lib/uploads/server/metadata' import { isImageFileType } from '@/lib/uploads/utils/file-utils' -import { validateFileType } from '@/lib/uploads/utils/validation' +import { validateAttachmentFileType, validateFileType } from '@/lib/uploads/utils/validation' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { createErrorResponse } from '@/app/api/files/utils' const logger = createLogger('PresignedUploadAPI') -interface PresignedUrlRequest { - fileName: string - contentType: string - fileSize: number - userId?: string - chatId?: string -} +const VALID_UPLOAD_TYPES = [ + 'knowledge-base', + 'chat', + 'copilot', + 'profile-pictures', + 'mothership', + 'workspace-logos', + 'execution', +] as const class PresignedUrlError extends Error { constructor( @@ -44,43 +53,36 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - let data: PresignedUrlRequest - try { - data = await request.json() - } catch { - throw new ValidationError('Invalid JSON in request body') - } - - const { fileName, contentType, fileSize } = data + const parsed = await parseRequest( + presignedUploadBodyContract, + request, + {}, + { + validationErrorResponse: (error) => { + throw new ValidationError(getValidationErrorMessage(error, 'Invalid request data')) + }, + invalidJsonResponse: () => { + throw new ValidationError('Invalid JSON in request body') + }, + } + ) + if (!parsed.success) return parsed.response - if (!fileName?.trim()) { - throw new ValidationError('fileName is required and cannot be empty') - } - if (!contentType?.trim()) { - throw new ValidationError('contentType is required and cannot be empty') - } - if (!fileSize || fileSize <= 0) { - 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)` - ) - } + const { fileName, contentType, fileSize } = parsed.data.body const uploadTypeParam = request.nextUrl.searchParams.get('type') if (!uploadTypeParam) { throw new ValidationError('type query parameter is required') } - const 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) @@ -123,10 +125,133 @@ export const POST = withRouteHandler(async (request: NextRequest) => { expirationSeconds: 3600, }) } catch (error) { + throw new ValidationError(getErrorMessage(error, 'Copilot validation failed')) + } + } else if (uploadType === 'mothership') { + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workspaceId?.trim()) { + throw new ValidationError('workspaceId query parameter is required for mothership uploads') + } + + const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) + if (permission !== 'write' && permission !== 'admin') { + return NextResponse.json( + { error: 'Write or Admin access required for mothership uploads' }, + { status: 403 } + ) + } + + const fileValidationError = validateAttachmentFileType(fileName) + if (fileValidationError) { + throw new ValidationError(fileValidationError.message) + } + + const customKey = generateWorkspaceFileKey(workspaceId, fileName) + presignedUrlResponse = await generatePresignedUploadUrl({ + fileName, + contentType, + fileSize, + context: 'mothership', + userId: sessionUserId, + customKey, + expirationSeconds: 3600, + metadata: { workspaceId }, + }) + + await insertFileMetadata({ + key: presignedUrlResponse.key, + userId: sessionUserId, + workspaceId, + context: 'mothership', + originalName: fileName, + contentType, + size: fileSize, + }) + } else if (uploadType === 'execution') { + const workflowId = request.nextUrl.searchParams.get('workflowId') + const executionId = request.nextUrl.searchParams.get('executionId') + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workflowId?.trim() || !executionId?.trim() || !workspaceId?.trim()) { throw new ValidationError( - error instanceof Error ? error.message : 'Copilot validation failed' + 'workflowId, executionId, and workspaceId query parameters are required for execution uploads' ) } + + const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) + if (permission !== 'write' && permission !== 'admin') { + return NextResponse.json( + { error: 'Write or Admin access required for execution uploads' }, + { status: 403 } + ) + } + + const fileValidationError = validateAttachmentFileType(fileName) + if (fileValidationError) { + throw new ValidationError(fileValidationError.message) + } + + const customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName) + presignedUrlResponse = await generatePresignedUploadUrl({ + fileName, + contentType, + fileSize, + context: 'execution', + userId: sessionUserId, + customKey, + expirationSeconds: 3600, + metadata: { workspaceId, workflowId, executionId }, + }) + + await insertFileMetadata({ + key: presignedUrlResponse.key, + userId: sessionUserId, + workspaceId, + context: 'execution', + originalName: fileName, + contentType, + size: fileSize, + }) + } else if (uploadType === 'workspace-logos') { + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workspaceId?.trim()) { + throw new ValidationError( + 'workspaceId query parameter is required for workspace-logos uploads' + ) + } + + const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) + if (permission !== 'admin') { + return NextResponse.json( + { error: 'Admin access required for workspace logo uploads' }, + { status: 403 } + ) + } + + if (!isImageFileType(contentType)) { + throw new ValidationError( + 'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for workspace logo uploads' + ) + } + + presignedUrlResponse = await generatePresignedUploadUrl({ + fileName, + contentType, + fileSize, + context: 'workspace-logos', + userId: sessionUserId, + expirationSeconds: 3600, + metadata: { workspaceId }, + }) + + await insertFileMetadata({ + key: presignedUrlResponse.key, + userId: sessionUserId, + workspaceId, + context: 'workspace-logos', + originalName: fileName, + contentType, + size: fileSize, + }) } else { if (uploadType === 'profile-pictures') { if (!sessionUserId?.trim()) { @@ -185,17 +310,3 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } }) - -export const OPTIONS = withRouteHandler(async () => { - return NextResponse.json( - {}, - { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - }, - } - ) -}) diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index 17b7a8d2fda..f0e7738c8d0 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { hybridAuthMockFns } from '@sim/testing' +import { hybridAuthMockFns, storageServiceMock, storageServiceMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -11,7 +11,6 @@ const { mockVerifyFileAccess, mockReadFile, mockIsUsingCloudStorage, - mockDownloadFile, mockDownloadCopilotFile, mockInferContextFromKey, mockGetContentType, @@ -30,7 +29,6 @@ const { mockVerifyFileAccess: vi.fn(), mockReadFile: vi.fn(), mockIsUsingCloudStorage: vi.fn(), - mockDownloadFile: vi.fn(), mockDownloadCopilotFile: vi.fn(), mockInferContextFromKey: vi.fn(), mockGetContentType: vi.fn(), @@ -58,10 +56,7 @@ vi.mock('@/lib/uploads', () => ({ isUsingCloudStorage: mockIsUsingCloudStorage, })) -vi.mock('@/lib/uploads/core/storage-service', () => ({ - downloadFile: mockDownloadFile, - hasCloudStorage: vi.fn().mockReturnValue(true), -})) +vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) vi.mock('@/lib/uploads/utils/file-utils', () => ({ inferContextFromKey: mockInferContextFromKey, @@ -104,6 +99,7 @@ describe('File Serve API Route', () => { mockVerifyFileAccess.mockResolvedValue(true) mockReadFile.mockResolvedValue(Buffer.from('test content')) mockIsUsingCloudStorage.mockReturnValue(false) + storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true) mockInferContextFromKey.mockReturnValue('workspace') mockGetContentType.mockReturnValue('text/plain') mockFindLocalFile.mockReturnValue('/test/uploads/test-file.txt') @@ -161,7 +157,7 @@ describe('File Serve API Route', () => { it('should serve cloud file by downloading and proxying', async () => { mockIsUsingCloudStorage.mockReturnValue(true) - mockDownloadFile.mockResolvedValue(Buffer.from('test cloud file content')) + storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('test cloud file content')) mockGetContentType.mockReturnValue('image/png') const req = new NextRequest( @@ -174,7 +170,7 @@ describe('File Serve API Route', () => { expect(response.status).toBe(200) expect(response.headers.get('Content-Type')).toBe('image/png') - expect(mockDownloadFile).toHaveBeenCalledWith({ + expect(storageServiceMockFns.mockDownloadFile).toHaveBeenCalledWith({ key: 'workspace/test-workspace-id/1234567890-image.png', context: 'workspace', }) 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.test.ts b/apps/sim/app/api/files/upload/route.test.ts index 8e9ff1dbe8b..356d993d610 100644 --- a/apps/sim/app/api/files/upload/route.test.ts +++ b/apps/sim/app/api/files/upload/route.test.ts @@ -3,7 +3,14 @@ * * @vitest-environment node */ -import { authMockFns, hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' +import { + authMockFns, + hybridAuthMockFns, + permissionsMock, + permissionsMockFns, + storageServiceMock, + storageServiceMockFns, +} from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -16,8 +23,6 @@ const mocks = vi.hoisted(() => { const mockGetStorageProvider = vi.fn() const mockIsUsingCloudStorage = vi.fn() const mockUploadFile = vi.fn() - const mockHasCloudStorage = vi.fn() - const mockStorageUploadFile = vi.fn() return { mockVerifyFileAccess, @@ -28,8 +33,6 @@ const mocks = vi.hoisted(() => { mockGetStorageProvider, mockIsUsingCloudStorage, mockUploadFile, - mockHasCloudStorage, - mockStorageUploadFile, } }) @@ -85,17 +88,22 @@ vi.mock('@/lib/uploads', () => ({ uploadFile: mocks.mockUploadFile, })) -vi.mock('@/lib/uploads/core/storage-service', () => ({ - uploadFile: mocks.mockStorageUploadFile, - hasCloudStorage: mocks.mockHasCloudStorage, -})) +vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) + +vi.mock('@/lib/uploads/shared/types', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + MAX_WORKSPACE_FORMDATA_FILE_SIZE: 1024, + } +}) vi.mock('@/lib/uploads/setup.server', () => ({ UPLOAD_DIR_SERVER: '/tmp/test-uploads', })) import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { OPTIONS, POST } from '@/app/api/files/upload/route' +import { POST } from '@/app/api/files/upload/route' /** * Configure mocks for authenticated file upload tests @@ -153,8 +161,8 @@ function setupFileApiMocks( type: 'text/plain', }) - mocks.mockHasCloudStorage.mockReturnValue(cloudEnabled) - mocks.mockStorageUploadFile.mockResolvedValue({ + storageServiceMockFns.mockHasCloudStorage.mockReturnValue(cloudEnabled) + storageServiceMockFns.mockUploadFile.mockResolvedValue({ key: 'test-key', path: '/test/path', }) @@ -179,6 +187,13 @@ describe('File Upload API Route', () => { return new File([content], name, { type }) } + const createUploadRequest = (formData: FormData): NextRequest => + new NextRequest('http://localhost:3000/api/files/upload', { + method: 'POST', + headers: { 'content-length': '1024' }, + body: formData, + }) + beforeEach(() => { vi.clearAllMocks() }) @@ -196,10 +211,7 @@ describe('File Upload API Route', () => { const mockFile = createMockFile() const formData = createMockFormData([mockFile]) - const req = new NextRequest('http://localhost:3000/api/files/upload', { - method: 'POST', - body: formData, - }) + const req = createUploadRequest(formData) const response = await POST(req) const data = await response.json() @@ -215,6 +227,26 @@ describe('File Upload API Route', () => { expect(uploadWorkspaceFile).toHaveBeenCalled() }) + it('should accept chunked multipart uploads without a content-length header', async () => { + setupFileApiMocks({ + cloudEnabled: false, + storageProvider: 'local', + }) + + const formData = createMockFormData([createMockFile()]) + const req = new NextRequest('http://localhost:3000/api/files/upload', { + method: 'POST', + body: formData, + }) + + expect(req.headers.get('content-length')).toBeNull() + + const response = await POST(req) + + expect(response.status).toBe(200) + expect(uploadWorkspaceFile).toHaveBeenCalled() + }) + it('should upload a file to S3 when in S3 mode', async () => { setupFileApiMocks({ cloudEnabled: true, @@ -224,10 +256,7 @@ describe('File Upload API Route', () => { const mockFile = createMockFile() const formData = createMockFormData([mockFile]) - const req = new NextRequest('http://localhost:3000/api/files/upload', { - method: 'POST', - body: formData, - }) + const req = createUploadRequest(formData) const response = await POST(req) const data = await response.json() @@ -253,10 +282,7 @@ describe('File Upload API Route', () => { const mockFile2 = createMockFile('file2.txt', 'text/plain') const formData = createMockFormData([mockFile1, mockFile2]) - const req = new NextRequest('http://localhost:3000/api/files/upload', { - method: 'POST', - body: formData, - }) + const req = createUploadRequest(formData) const response = await POST(req) const data = await response.json() @@ -266,15 +292,44 @@ describe('File Upload API Route', () => { expect(data).toBeDefined() }) + it('rejects oversized workspace uploads before materializing file contents', async () => { + setupFileApiMocks({ + cloudEnabled: false, + storageProvider: 'local', + }) + + const mockFile = createMockFile('large.txt', 'text/plain', 'x'.repeat(1025)) + const arrayBufferSpy = vi.spyOn(mockFile, 'arrayBuffer') + const formData = { + getAll: (name: string) => (name === 'file' ? [mockFile] : []), + get: (name: string) => { + if (name === 'context') return 'workspace' + if (name === 'workspaceId') return 'test-workspace-id' + return null + }, + } as unknown as FormData + + const req = { + formData: async () => formData, + } as unknown as NextRequest + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(413) + expect(data.error).toBe('PayloadSizeLimitError') + expect(data.message).toContain('File exceeds the server upload limit') + expect(data.message).toContain('Use direct upload for larger workspace files') + expect(arrayBufferSpy).not.toHaveBeenCalled() + expect(uploadWorkspaceFile).not.toHaveBeenCalled() + }) + it('should handle missing files', async () => { setupFileApiMocks() const formData = new FormData() - const req = new NextRequest('http://localhost:3000/api/files/upload', { - method: 'POST', - body: formData, - }) + const req = createUploadRequest(formData) const response = await POST(req) const data = await response.json() @@ -295,10 +350,7 @@ describe('File Upload API Route', () => { const mockFile = createMockFile() const formData = createMockFormData([mockFile]) - const req = new NextRequest('http://localhost:3000/api/files/upload', { - method: 'POST', - body: formData, - }) + const req = createUploadRequest(formData) const response = await POST(req) const data = await response.json() @@ -307,14 +359,6 @@ describe('File Upload API Route', () => { expect(data).toHaveProperty('error') expect(typeof data.error).toBe('string') }) - - it('should handle CORS preflight requests', async () => { - const response = await OPTIONS() - - expect(response.status).toBe(204) - expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, DELETE, OPTIONS') - expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type') - }) }) describe('File Upload Security Tests', () => { @@ -325,8 +369,8 @@ describe('File Upload Security Tests', () => { user: { id: 'test-user-id' }, }) - mocks.mockHasCloudStorage.mockReturnValue(false) - mocks.mockStorageUploadFile.mockResolvedValue({ + storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false) + storageServiceMockFns.mockUploadFile.mockResolvedValue({ key: 'test-key', path: '/test/path', }) @@ -370,6 +414,7 @@ describe('File Upload Security Tests', () => { const req = new Request('http://localhost/api/files/upload', { method: 'POST', + headers: { 'content-length': '1024' }, body: formData, }) @@ -389,6 +434,7 @@ describe('File Upload Security Tests', () => { const req = new Request('http://localhost/api/files/upload', { method: 'POST', + headers: { 'content-length': '1024' }, body: formData, }) @@ -408,6 +454,7 @@ describe('File Upload Security Tests', () => { const req = new Request('http://localhost/api/files/upload', { method: 'POST', + headers: { 'content-length': '1024' }, body: formData, }) @@ -426,6 +473,7 @@ describe('File Upload Security Tests', () => { const req = new Request('http://localhost/api/files/upload', { method: 'POST', + headers: { 'content-length': '1024' }, body: formData, }) @@ -445,6 +493,7 @@ describe('File Upload Security Tests', () => { const req = new Request('http://localhost/api/files/upload', { method: 'POST', + headers: { 'content-length': '1024' }, body: formData, }) @@ -470,6 +519,7 @@ describe('File Upload Security Tests', () => { const req = new Request('http://localhost/api/files/upload', { method: 'POST', + headers: { 'content-length': '1024' }, body: formData, }) @@ -491,6 +541,7 @@ describe('File Upload Security Tests', () => { const req = new Request('http://localhost/api/files/upload', { method: 'POST', + headers: { 'content-length': '1024' }, body: formData, }) diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index 705ea2d8b17..52e6f3a5116 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -1,37 +1,37 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { sanitizeFileName } from '@/executor/constants' import '@/lib/uploads/core/setup.server' import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { + uploadFilesFormFieldsSchema, + uploadFilesFormFilesSchema, +} from '@/lib/api/contracts/storage-transfer' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { + assertKnownSizeWithinLimit, + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import type { StorageContext } from '@/lib/uploads/config' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { isImageFileType } from '@/lib/uploads/utils/file-utils' +import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types' +import { isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils' import { - SUPPORTED_AUDIO_EXTENSIONS, - SUPPORTED_CODE_EXTENSIONS, - SUPPORTED_DOCUMENT_EXTENSIONS, - SUPPORTED_VIDEO_EXTENSIONS, + SUPPORTED_ATTACHMENT_EXTENSIONS, + SUPPORTED_IMAGE_EXTENSIONS, validateFileType, } from '@/lib/uploads/utils/validation' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { - createErrorResponse, - createOptionsResponse, - InvalidRequestError, -} from '@/app/api/files/utils' +import { createErrorResponse, InvalidRequestError } from '@/app/api/files/utils' -const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'] as const - -const ALLOWED_EXTENSIONS = new Set([ - ...SUPPORTED_DOCUMENT_EXTENSIONS, - ...SUPPORTED_CODE_EXTENSIONS, - ...IMAGE_EXTENSIONS, - ...SUPPORTED_AUDIO_EXTENSIONS, - ...SUPPORTED_VIDEO_EXTENSIONS, -]) +const ALLOWED_EXTENSIONS = new Set(SUPPORTED_ATTACHMENT_EXTENSIONS) +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 function validateFileExtension(filename: string): boolean { const extension = filename.split('.').pop()?.toLowerCase() @@ -50,19 +50,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const formData = await request.formData() + const formData = await readFormDataWithLimit(request, { + maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'multipart upload body', + }) const rawFiles = formData.getAll('file') - const 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 totalFileSize = files.reduce((total, file) => total + file.size, 0) + assertKnownSizeWithinLimit(totalFileSize, MAX_WORKSPACE_FORMDATA_FILE_SIZE, 'uploaded files') + + const formFieldsResult = uploadFilesFormFieldsSchema.safeParse({ + workflowId: formData.get('workflowId'), + executionId: formData.get('executionId'), + workspaceId: formData.get('workspaceId'), + context: formData.get('context'), + }) + if (!formFieldsResult.success) { + throw new InvalidRequestError( + getValidationErrorMessage(formFieldsResult.error, 'Invalid upload form data') + ) + } + const formFields = formFieldsResult.data + const { workflowId, executionId, workspaceId, context: contextParam } = formFields // Context must be explicitly provided if (!contextParam) { @@ -89,8 +103,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const bytes = await file.arrayBuffer() - const buffer = Buffer.from(bytes) + const buffer = await readFileToBufferWithLimit(file, { + maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE, + label: 'uploaded file', + }) // Handle execution context if (context === 'execution') { @@ -214,8 +230,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { uploadResults.push(userFile) continue } catch (workspaceError) { - const errorMessage = - workspaceError instanceof Error ? workspaceError.message : 'Upload failed' + const errorMessage = getErrorMessage(workspaceError, 'Upload failed') const isDuplicate = errorMessage.includes('already exists') const isStorageLimitError = errorMessage.includes('Storage limit exceeded') || @@ -291,10 +306,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { context === 'profile-pictures' || context === 'workspace-logos' ) { - if (context !== 'copilot' && !isImageFileType(file.type)) { - throw new InvalidRequestError( - `Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for ${context} uploads` + if (context !== 'copilot') { + const mimeType = file.type + const isGenericMime = !mimeType || mimeType === 'application/octet-stream' + const extension = originalName.split('.').pop()?.toLowerCase() ?? '' + const extensionIsImage = (SUPPORTED_IMAGE_EXTENSIONS as readonly string[]).includes( + extension ) + const isImage = isGenericMime ? extensionIsImage : isImageFileType(mimeType) + if (!isImage) { + throw new InvalidRequestError(`Only image files are allowed for ${context} uploads`) + } } if (context === 'workspace-logos') { @@ -330,6 +352,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info(`Uploading ${context} file: ${originalName}`) + const resolvedContentType = resolveFileType({ type: file.type, name: originalName }) + const timestamp = Date.now() const safeFileName = sanitizeFileName(originalName) const storageKey = `${context}/${timestamp}-${safeFileName}` @@ -348,7 +372,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const fileInfo = await storageService.uploadFile({ file: buffer, fileName: storageKey, - contentType: file.type, + contentType: resolvedContentType, context, preserveKey: true, customKey: storageKey, @@ -365,7 +389,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { key: fileInfo.key, name: originalName, size: buffer.length, - type: file.type, + type: resolvedContentType, }, directUploadSupported: false, } @@ -386,7 +410,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { fileName: originalName, fileKey: fileInfo.key, fileSize: buffer.length, - fileType: file.type, + fileType: resolvedContentType, }, request, }) @@ -414,10 +438,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ files: uploadResults }) } catch (error) { logger.error('Error in file upload:', error) + if (isPayloadSizeLimitError(error)) { + return NextResponse.json( + { + error: 'PayloadSizeLimitError', + message: `File exceeds the server upload limit of ${Math.round(error.maxBytes / (1024 * 1024))}MB. Use direct upload for larger workspace files.`, + }, + { status: 413 } + ) + } return createErrorResponse(error instanceof Error ? error : new Error('File upload failed')) } }) - -export const OPTIONS = withRouteHandler(async () => { - return createOptionsResponse() -}) diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index d290288f2da..2bdf7663825 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -9,7 +9,7 @@ export interface ApiSuccessResponse { [key: string]: any } -export interface ApiErrorResponse { +interface ApiErrorResponse { error: string message?: string } @@ -191,7 +191,7 @@ function getSecureFileHeaders(filename: string, originalContentType: string) { } } -function encodeFilenameForHeader(storageKey: string): string { +export function encodeFilenameForHeader(storageKey: string): string { const filename = storageKey.split('/').pop() || storageKey const hasNonAscii = /[^\x00-\x7F]/.test(filename) @@ -238,13 +238,3 @@ export function createErrorResponse(error: Error, status = 500): NextResponse { export function createSuccessResponse(data: ApiSuccessResponse): NextResponse { return NextResponse.json(data) } - -export function createOptionsResponse(): NextResponse { - return new NextResponse(null, { - status: 204, - headers: { - 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - }, - }) -} diff --git a/apps/sim/app/api/files/view/[id]/route.ts b/apps/sim/app/api/files/view/[id]/route.ts new file mode 100644 index 00000000000..681d00f4430 --- /dev/null +++ b/apps/sim/app/api/files/view/[id]/route.ts @@ -0,0 +1,44 @@ +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { fileViewContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { USE_BLOB_STORAGE } from '@/lib/uploads/config' +import { getFileMetadataById } from '@/lib/uploads/server/metadata' +import { verifyFileAccess } from '@/app/api/files/authorization' + +const logger = createLogger('FilesViewAPI') + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const parsed = await parseRequest(fileViewContract, request, context) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const record = await getFileMetadataById(id) + if (!record) { + logger.warn('File not found by ID', { id }) + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const hasAccess = await verifyFileAccess(record.key, authResult.userId) + if (!hasAccess) { + logger.warn('Unauthorized file view attempt', { id, userId: authResult.userId }) + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3' + const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}` + logger.info('Redirecting file view to serve path', { id, servePath }) + + return NextResponse.redirect(new URL(servePath, request.url), { status: 302 }) + } +) diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 9b7811a822f..c7f339fe3ba 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -3,29 +3,24 @@ import { db } from '@sim/db' import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { FolderLockedError } from '@sim/workflow-authz' import { and, eq, isNull, min } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { duplicateFolderContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { DbOrTx } from '@/lib/db/types' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' 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 }> }) => { - const { id: sourceFolderId } = await params + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const { id: sourceFolderId } = await context.params const requestId = generateRequestId() const startTime = Date.now() @@ -36,21 +31,16 @@ export const POST = withRouteHandler( } try { - const body = await req.json() - const { - name, - workspaceId, - parentId, - color, - newId: clientNewId, - } = DuplicateRequestSchema.parse(body) + const parsed = await parseRequest(duplicateFolderContract, req, context) + if (!parsed.success) return parsed.response + const { name, workspaceId, parentId, color, newId: clientNewId } = parsed.data.body logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`) const sourceFolder = await db .select() .from(workflowFolder) - .where(eq(workflowFolder.id, sourceFolderId)) + .where(and(eq(workflowFolder.id, sourceFolderId), isNull(workflowFolder.archivedAt))) .then((rows) => rows[0]) if (!sourceFolder) { @@ -68,11 +58,15 @@ export const POST = withRouteHandler( } const targetWorkspaceId = workspaceId || sourceFolder.workspaceId + if (targetWorkspaceId !== sourceFolder.workspaceId) { + throw new Error('Cross-workspace folder duplication is not supported') + } - const { newFolderId, folderMapping } = await db.transaction(async (tx) => { + const { newFolderId, folderMapping, workflowStats } = await db.transaction(async (tx) => { const newFolderId = clientNewId || generateId() const now = new Date() const targetParentId = parentId ?? sourceFolder.parentId + await assertTargetParentFolderMutable(tx, targetParentId, targetWorkspaceId, sourceFolderId) const folderParentCondition = targetParentId ? eq(workflowFolder.parentId, targetParentId) @@ -100,16 +94,23 @@ export const POST = withRouteHandler( return Math.min(currentMin, candidate) }, null) const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0 + const deduplicatedName = await deduplicateFolderName( + tx, + targetWorkspaceId, + targetParentId, + name + ) await tx.insert(workflowFolder).values({ id: newFolderId, userId: session.user.id, workspaceId: targetWorkspaceId, - name, + name: deduplicatedName, color: color || sourceFolder.color, parentId: targetParentId, sortOrder, isExpanded: false, + locked: false, createdAt: now, updatedAt: now, }) @@ -126,16 +127,17 @@ export const POST = withRouteHandler( folderMapping ) - return { newFolderId, folderMapping } - }) + const workflowStats = await duplicateWorkflowsInFolderTree( + tx, + sourceFolder.workspaceId, + targetWorkspaceId, + folderMapping, + session.user.id, + requestId + ) - const workflowStats = await duplicateWorkflowsInFolderTree( - sourceFolder.workspaceId, - targetWorkspaceId, - folderMapping, - session.user.id, - requestId - ) + return { newFolderId, folderMapping, workflowStats } + }) const elapsed = Date.now() - startTime logger.info( @@ -144,7 +146,6 @@ export const POST = withRouteHandler( foldersCount: folderMapping.size, workflowsCount: workflowStats.total, workflowsSucceeded: workflowStats.succeeded, - workflowsFailed: workflowStats.failed, } ) @@ -165,20 +166,19 @@ 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 instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + if (error.message === 'Source folder not found') { logger.warn(`[${requestId}] Source folder ${sourceFolderId} not found`) return NextResponse.json({ error: 'Source folder not found' }, { status: 404 }) @@ -190,14 +190,20 @@ export const POST = withRouteHandler( ) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - } - 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 } - ) + if (error.message === 'Cross-workspace folder duplication is not supported') { + logger.warn( + `[${requestId}] User ${session.user.id} attempted cross-workspace folder duplication for ${sourceFolderId}` + ) + return NextResponse.json({ error: error.message }, { status: 400 }) + } + + if ( + error.message === 'Target parent folder not found' || + error.message === 'Cannot duplicate folder into itself or one of its descendants' + ) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } } const elapsed = Date.now() - startTime @@ -210,8 +216,76 @@ export const POST = withRouteHandler( } ) +async function assertTargetParentFolderMutable( + tx: DbOrTx, + parentId: string | null, + targetWorkspaceId: string, + sourceFolderId: string +): Promise { + let currentFolderId = parentId + const visited = new Set() + + while (currentFolderId && !visited.has(currentFolderId)) { + visited.add(currentFolderId) + const [folder] = await tx + .select({ + id: workflowFolder.id, + parentId: workflowFolder.parentId, + workspaceId: workflowFolder.workspaceId, + locked: workflowFolder.locked, + archivedAt: workflowFolder.archivedAt, + }) + .from(workflowFolder) + .where(eq(workflowFolder.id, currentFolderId)) + .limit(1) + + if (!folder || folder.workspaceId !== targetWorkspaceId || folder.archivedAt) { + throw new Error('Target parent folder not found') + } + if (folder.id === sourceFolderId) { + throw new Error('Cannot duplicate folder into itself or one of its descendants') + } + if (folder.locked) { + throw new FolderLockedError() + } + + currentFolderId = folder.parentId + } +} + +async function deduplicateFolderName( + tx: DbOrTx, + workspaceId: string, + parentId: string | null, + requestedName: string +): Promise { + const parentCondition = parentId + ? eq(workflowFolder.parentId, parentId) + : isNull(workflowFolder.parentId) + const siblingRows = await tx + .select({ name: workflowFolder.name }) + .from(workflowFolder) + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + parentCondition, + isNull(workflowFolder.archivedAt) + ) + ) + const siblingNames = new Set(siblingRows.map((row) => row.name)) + if (!siblingNames.has(requestedName)) return requestedName + + let suffix = 1 + let candidate = `${requestedName} (${suffix})` + while (siblingNames.has(candidate)) { + suffix += 1 + candidate = `${requestedName} (${suffix})` + } + return candidate +} + async function duplicateFolderStructure( - tx: any, + tx: DbOrTx, sourceFolderId: string, newParentFolderId: string, sourceWorkspaceId: string, @@ -226,7 +300,8 @@ async function duplicateFolderStructure( .where( and( eq(workflowFolder.parentId, sourceFolderId), - eq(workflowFolder.workspaceId, sourceWorkspaceId) + eq(workflowFolder.workspaceId, sourceWorkspaceId), + isNull(workflowFolder.archivedAt) ) ) @@ -243,6 +318,7 @@ async function duplicateFolderStructure( parentId: newParentFolderId, sortOrder: childFolder.sortOrder, isExpanded: false, + locked: false, createdAt: timestamp, updatedAt: timestamp, }) @@ -261,40 +337,53 @@ async function duplicateFolderStructure( } async function duplicateWorkflowsInFolderTree( + tx: DbOrTx, sourceWorkspaceId: string, targetWorkspaceId: string, folderMapping: Map, userId: string, requestId: string -): Promise<{ total: number; succeeded: number; failed: number }> { - const stats = { total: 0, succeeded: 0, failed: 0 } +): Promise<{ total: number; succeeded: number }> { + const stats = { total: 0, succeeded: 0 } + const workflowsByNewFolder = new Map>() + const workflowIdMap = new Map() for (const [oldFolderId, newFolderId] of folderMapping.entries()) { - const workflowsInFolder = await db + const workflowsInFolder = await tx .select() .from(workflow) - .where(and(eq(workflow.folderId, oldFolderId), eq(workflow.workspaceId, sourceWorkspaceId))) + .where( + and( + eq(workflow.folderId, oldFolderId), + eq(workflow.workspaceId, sourceWorkspaceId), + isNull(workflow.archivedAt) + ) + ) stats.total += workflowsInFolder.length + workflowsByNewFolder.set(newFolderId, workflowsInFolder) + for (const sourceWorkflow of workflowsInFolder) { + workflowIdMap.set(sourceWorkflow.id, generateId()) + } + } + for (const [newFolderId, workflowsInFolder] of workflowsByNewFolder.entries()) { for (const sourceWorkflow of workflowsInFolder) { - try { - await duplicateWorkflow({ - sourceWorkflowId: sourceWorkflow.id, - userId, - name: sourceWorkflow.name, - description: sourceWorkflow.description || undefined, - color: sourceWorkflow.color, - workspaceId: targetWorkspaceId, - folderId: newFolderId, - requestId, - }) + await duplicateWorkflow({ + sourceWorkflowId: sourceWorkflow.id, + userId, + name: sourceWorkflow.name, + description: sourceWorkflow.description || undefined, + color: sourceWorkflow.color, + workspaceId: targetWorkspaceId, + folderId: newFolderId, + requestId, + tx, + newWorkflowId: workflowIdMap.get(sourceWorkflow.id), + workflowIdMap, + }) - stats.succeeded++ - } catch (error) { - stats.failed++ - logger.error(`[${requestId}] Error duplicating workflow ${sourceWorkflow.id}:`, error) - } + stats.succeeded++ } } diff --git a/apps/sim/app/api/folders/[id]/restore/route.ts b/apps/sim/app/api/folders/[id]/restore/route.ts index 5717c0be22a..5ad28b90b28 100644 --- a/apps/sim/app/api/folders/[id]/restore/route.ts +++ b/apps/sim/app/api/folders/[id]/restore/route.ts @@ -1,5 +1,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { restoreFolderContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' @@ -8,54 +11,50 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreFolderAPI') -export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id: folderId } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json().catch(() => ({})) - const workspaceId = body.workspaceId as string | undefined - - if (!workspaceId) { - return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) - } - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const result = await performRestoreFolder({ - folderId, - workspaceId, - userId: session.user.id, - }) - - if (!result.success) { - return NextResponse.json({ error: result.error }, { status: 400 }) - } - - logger.info(`Restored folder ${folderId}`, { restoredItems: result.restoredItems }) - - captureServerEvent( - session.user.id, - 'folder_restored', - { folder_id: folderId, workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - - return NextResponse.json({ success: true, restoredItems: result.restoredItems }) - } catch (error) { - logger.error(`Error restoring folder ${folderId}`, error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Internal server error' }, - { status: 500 } - ) +type RouteContext = { params: Promise<{ id: string }> } + +export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(restoreFolderContract, request, context) + if (!parsed.success) return parsed.response + const { id: folderId } = parsed.data.params + const { workspaceId } = parsed.data.body + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } + + const result = await performRestoreFolder({ + folderId, + workspaceId, + userId: session.user.id, + }) + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }) + } + + logger.info(`Restored folder ${folderId}`, { restoredItems: result.restoredItems }) + + captureServerEvent( + session.user.id, + 'folder_restored', + { folder_id: folderId, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + + return NextResponse.json({ success: true, restoredItems: result.restoredItems }) + } catch (error) { + logger.error('Error restoring folder', error) + return NextResponse.json( + { error: getErrorMessage(error, 'Internal server error') }, + { status: 500 } + ) } -) +}) diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index 477ada12fce..a60e552dc06 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -34,6 +34,7 @@ const { mockLogger, mockDbRef } = vi.hoisted(() => { }) const mockPerformDeleteFolder = workflowsOrchestrationMockFns.mockPerformDeleteFolder +const mockPerformUpdateFolder = workflowsOrchestrationMockFns.mockPerformUpdateFolder const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions @@ -54,16 +55,6 @@ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) import { DELETE, PUT } from '@/app/api/folders/[id]/route' -/** Type for captured folder values in tests */ -interface CapturedFolderValues { - name?: string - color?: string - parentId?: string | null - isExpanded?: boolean - sortOrder?: number - updatedAt?: Date -} - interface FolderDbMockOptions { folderLookupResult?: any updateResult?: any[] @@ -160,6 +151,41 @@ describe('Individual Folder API Route', () => { success: true, deletedItems: { folders: 1, workflows: 0 }, }) + mockPerformUpdateFolder.mockImplementation(async (params) => { + if (params.parentId && params.parentId === params.folderId) { + return { + success: false, + error: 'Folder cannot be its own parent', + errorCode: 'validation', + } + } + if ( + params.parentId && + (await workflowsUtilsMockFns.mockCheckForCircularReference( + params.folderId, + params.parentId + )) + ) { + return { + success: false, + error: 'Cannot create circular folder reference', + errorCode: 'validation', + } + } + return { + success: true, + folder: { + ...mockFolder, + id: params.folderId, + name: params.name !== undefined ? params.name.trim() : 'Updated Folder', + color: params.color ?? mockFolder.color, + parentId: params.parentId ?? mockFolder.parentId, + isExpanded: params.isExpanded, + sortOrder: params.sortOrder ?? mockFolder.sortOrder, + updatedAt: new Date(), + }, + } + }) workflowsUtilsMockFns.mockCheckForCircularReference.mockResolvedValue(false) }) @@ -180,7 +206,7 @@ describe('Individual Folder API Route', () => { const data = await response.json() expect(data).toHaveProperty('folder') expect(data.folder).toMatchObject({ - name: 'Updated Folder', + name: 'Updated Folder Name', }) }) @@ -285,44 +311,15 @@ describe('Individual Folder API Route', () => { it('should trim folder name when updating', async () => { mockAuthenticatedUser() - let capturedUpdates: CapturedFolderValues | null = null - - const mockSelect = vi.fn().mockImplementation(() => ({ - from: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockImplementation(() => ({ - then: vi.fn().mockImplementation((callback) => { - return Promise.resolve(callback([mockFolder])) - }), - })), - })), - })) - - const mockUpdate = vi.fn().mockImplementation(() => ({ - set: vi.fn().mockImplementation((updates) => { - capturedUpdates = updates - return { - where: vi.fn().mockImplementation(() => ({ - returning: vi.fn().mockReturnValue([{ ...mockFolder, name: 'Folder With Spaces' }]), - })), - } - }), - })) - - mockDbRef.current = { - select: mockSelect, - update: mockUpdate, - delete: vi.fn(), - } - const req = createMockRequest('PUT', { name: ' Folder With Spaces ', }) const params = Promise.resolve({ id: 'folder-1' }) - await PUT(req, { params }) + const response = await PUT(req, { params }) + const data = await response.json() - expect(capturedUpdates).not.toBeNull() - expect(capturedUpdates!.name).toBe('Folder With Spaces') + expect(data.folder.name).toBe('Folder With Spaces') }) it('should handle database errors gracefully', async () => { diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index e7966299977..bc622793bc4 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -1,50 +1,44 @@ import { db } from '@sim/db' import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { assertFolderMutable, FolderLockedError } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateFolderContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { performDeleteFolder } from '@/lib/workflows/orchestration' -import { checkForCircularReference } from '@/lib/workflows/utils' +import { performDeleteFolder, performUpdateFolder } from '@/lib/workflows/orchestration' 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 }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { try { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id } = await params - const body = await request.json() - - const validationResult = updateFolderSchema.safeParse(body) - if (!validationResult.success) { - logger.error('Folder update validation failed:', { - errors: validationResult.error.errors, - }) - const errorMessages = validationResult.error.errors - .map((err) => `${err.path.join('.')}: ${err.message}`) - .join(', ') - return NextResponse.json({ error: `Validation failed: ${errorMessages}` }, { status: 400 }) - } + const parsed = await parseRequest(updateFolderContract, request, context, { + validationErrorResponse: (error) => { + logger.error('Folder update validation failed:', { errors: error.issues }) + const errorMessages = error.issues + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join(', ') + return NextResponse.json( + { error: `Validation failed: ${errorMessages}` }, + { status: 400 } + ) + }, + }) + if (!parsed.success) return parsed.response - const { name, color, isExpanded, parentId, sortOrder } = validationResult.data + const { id } = parsed.data.params + const { name, color, isExpanded, locked, parentId, sortOrder } = parsed.data.body // Verify the folder exists const existingFolder = await db @@ -71,39 +65,47 @@ export const PUT = withRouteHandler( ) } - // Prevent setting a folder as its own parent or creating circular references - if (parentId && parentId === id) { - return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 }) + if (locked !== undefined && workspacePermission !== 'admin') { + return NextResponse.json( + { error: 'Admin access required to lock folders' }, + { status: 403 } + ) } - // Check for circular references if parentId is provided - if (parentId) { - const wouldCreateCycle = await checkForCircularReference(id, parentId) - if (wouldCreateCycle) { - return NextResponse.json( - { error: 'Cannot create circular folder reference' }, - { status: 400 } - ) - } + const hasNonLockUpdate = Object.keys(parsed.data.body).some((key) => key !== 'locked') + if (hasNonLockUpdate) { + await assertFolderMutable(id) + } + if (parentId !== undefined) { + await assertFolderMutable(parentId) } - const updates: Record = { updatedAt: new Date() } - if (name !== undefined) updates.name = name.trim() - if (color !== undefined) updates.color = color - if (isExpanded !== undefined) updates.isExpanded = isExpanded - if (parentId !== undefined) updates.parentId = parentId || null - if (sortOrder !== undefined) updates.sortOrder = sortOrder + const result = await performUpdateFolder({ + folderId: id, + workspaceId: existingFolder.workspaceId, + userId: session.user.id, + name, + color, + isExpanded, + locked, + parentId, + sortOrder, + }) - const [updatedFolder] = await db - .update(workflowFolder) - .set(updates) - .where(eq(workflowFolder.id, id)) - .returning() + if (!result.success || !result.folder) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + return NextResponse.json({ error: result.error }, { status }) + } - logger.info('Updated folder:', { id, updates }) + logger.info('Updated folder:', { id, updates: parsed.data.body }) - return NextResponse.json({ folder: updatedFolder }) + return NextResponse.json({ folder: result.folder }) } catch (error) { + if (error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error('Error updating folder:', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } @@ -145,6 +147,8 @@ export const DELETE = withRouteHandler( ) } + await assertFolderMutable(id) + const result = await performDeleteFolder({ folderId: id, workspaceId: existingFolder.workspaceId, @@ -170,6 +174,10 @@ export const DELETE = withRouteHandler( deletedItems: result.deletedItems, }) } catch (error) { + if (error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error('Error deleting folder:', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index 1cc59aa77f9..87222b75b8d 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -1,9 +1,11 @@ import { db } from '@sim/db' import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { assertFolderMutable, FolderLockedError } from '@sim/workflow-authz' import { eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { reorderFoldersContract } from '@/lib/api/contracts' +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' @@ -11,17 +13,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() @@ -32,8 +23,9 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { } try { - const body = await req.json() - const { workspaceId, updates } = ReorderSchema.parse(body) + const parsed = await parseRequest(reorderFoldersContract, req, {}) + if (!parsed.success) return parsed.response + const { workspaceId, updates } = parsed.data.body const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) if (!permission || permission === 'read') { @@ -59,6 +51,13 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 }) } + for (const update of validUpdates) { + await assertFolderMutable(update.id) + if (update.parentId !== undefined) { + await assertFolderMutable(update.parentId) + } + } + await db.transaction(async (tx) => { for (const update of validUpdates) { const updateData: Record = { @@ -78,12 +77,8 @@ 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 } - ) + if (error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) } logger.error(`[${requestId}] Error reordering folders`, error) diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index e5040507b92..a2e145bf1e3 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -150,7 +150,11 @@ describe('Folders API Route', () => { mockSelect.mockReturnValue({ from: mockFrom }) mockFrom.mockReturnValue({ where: mockWhere }) - mockWhere.mockReturnValue({ orderBy: mockOrderBy }) + const defaultWhereResult = [] as Array> & { + orderBy: typeof mockOrderBy + } + defaultWhereResult.orderBy = mockOrderBy + mockWhere.mockReturnValue(defaultWhereResult) mockOrderBy.mockReturnValue(mockFolders) mockInsert.mockReturnValue({ values: mockValues }) @@ -164,10 +168,12 @@ describe('Folders API Route', () => { it('should return folders for a valid workspace', async () => { mockAuthenticatedUser() - const mockRequest = createMockRequest('GET') - Object.defineProperty(mockRequest, 'url', { - value: 'http://localhost:3000/api/folders?workspaceId=workspace-123', - }) + const mockRequest = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/folders?workspaceId=workspace-123' + ) const response = await GET(mockRequest) @@ -186,10 +192,12 @@ describe('Folders API Route', () => { it('should return 401 for unauthenticated requests', async () => { mockUnauthenticated() - const mockRequest = createMockRequest('GET') - Object.defineProperty(mockRequest, 'url', { - value: 'http://localhost:3000/api/folders?workspaceId=workspace-123', - }) + const mockRequest = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/folders?workspaceId=workspace-123' + ) const response = await GET(mockRequest) @@ -202,27 +210,32 @@ describe('Folders API Route', () => { it('should return 400 when workspaceId is missing', async () => { mockAuthenticatedUser() - const mockRequest = createMockRequest('GET') - Object.defineProperty(mockRequest, 'url', { - value: 'http://localhost:3000/api/folders', - }) + const mockRequest = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/folders' + ) const response = await GET(mockRequest) expect(response.status).toBe(400) const data = await response.json() - expect(data).toHaveProperty('error', 'Workspace ID is required') + expect(data).toHaveProperty('error', 'Validation error') + expect(data.details?.[0]?.message).toBe('Workspace ID is required') }) it('should return 403 when user has no workspace permissions', async () => { mockAuthenticatedUser() mockGetUserEntityPermissions.mockResolvedValue(null) - const mockRequest = createMockRequest('GET') - Object.defineProperty(mockRequest, 'url', { - value: 'http://localhost:3000/api/folders?workspaceId=workspace-123', - }) + const mockRequest = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/folders?workspaceId=workspace-123' + ) const response = await GET(mockRequest) @@ -236,10 +249,12 @@ describe('Folders API Route', () => { mockAuthenticatedUser() mockGetUserEntityPermissions.mockResolvedValue('read') - const mockRequest = createMockRequest('GET') - Object.defineProperty(mockRequest, 'url', { - value: 'http://localhost:3000/api/folders?workspaceId=workspace-123', - }) + const mockRequest = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/folders?workspaceId=workspace-123' + ) const response = await GET(mockRequest) @@ -256,10 +271,12 @@ describe('Folders API Route', () => { throw new Error('Database connection failed') }) - const mockRequest = createMockRequest('GET') - Object.defineProperty(mockRequest, 'url', { - value: 'http://localhost:3000/api/folders?workspaceId=workspace-123', - }) + const mockRequest = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/folders?workspaceId=workspace-123' + ) const response = await GET(mockRequest) @@ -315,6 +332,14 @@ describe('Folders API Route', () => { }, }) ) + mockWhere + .mockReturnValueOnce([{ minSortOrder: 5 }]) + .mockReturnValueOnce([{ minSortOrder: 2 }]) + mockValues.mockImplementationOnce((values: CapturedFolderValues) => { + capturedValues = values + return { returning: mockReturning } + }) + mockReturning.mockReturnValueOnce([{ ...mockFolders[0], sortOrder: 1 }]) const req = createMockRequest('POST', { name: 'New Test Folder', @@ -342,6 +367,7 @@ describe('Folders API Route', () => { insertResult: [{ ...mockFolders[1] }], }) ) + mockReturning.mockReturnValueOnce([{ ...mockFolders[1] }]) const req = createMockRequest('POST', { name: 'Subfolder', @@ -458,15 +484,15 @@ describe('Folders API Route', () => { expect(response.status).toBe(400) const data = await response.json() - expect(data).toHaveProperty('error', 'Invalid request data') + expect(data).toHaveProperty('error', 'Validation error') } }) it('should handle database errors gracefully', async () => { mockAuthenticatedUser() - mockTransaction.mockImplementationOnce(() => { - throw new Error('Database transaction failed') + mockInsert.mockImplementationOnce(() => { + throw new Error('Database insert failed') }) const req = createMockRequest('POST', { @@ -480,7 +506,7 @@ describe('Folders API Route', () => { const data = await response.json() expect(data).toHaveProperty('error', 'Internal server error') - expect(mockLogger.error).toHaveBeenCalledWith('Error creating folder:', { + expect(mockLogger.error).toHaveBeenCalledWith('Failed to create workflow folder', { error: expect.any(Error), }) }) @@ -499,6 +525,10 @@ describe('Folders API Route', () => { }, }) ) + mockValues.mockImplementationOnce((values: CapturedFolderValues) => { + capturedValues = values + return { returning: mockReturning } + }) const req = createMockRequest('POST', { name: ' Test Folder With Spaces ', @@ -525,6 +555,10 @@ describe('Folders API Route', () => { }, }) ) + mockValues.mockImplementationOnce((values: CapturedFolderValues) => { + capturedValues = values + return { returning: mockReturning } + }) const req = createMockRequest('POST', { name: 'Test Folder', diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index 14117e2b171..404ebe0873c 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -1,26 +1,24 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, asc, eq, isNotNull, isNull, min } from 'drizzle-orm' +import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { createFolderContract, listFoldersContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' +import { performCreateFolder } from '@/lib/workflows/orchestration' 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(), -}) +function folderMutationStatus(errorCode: string | undefined): number { + if (errorCode === 'validation') return 400 + if (errorCode === 'conflict') return 409 + if (errorCode === 'not_found') return 404 + return 500 +} // GET - Fetch folders for a workspace export const GET = withRouteHandler(async (request: NextRequest) => { @@ -30,12 +28,9 @@ 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') - - if (!workspaceId) { - return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) - } + const parsed = await parseRequest(listFoldersContract, request, {}) + if (!parsed.success) return parsed.response + const { workspaceId, scope } = parsed.data.query // Check if user has workspace permissions const workspacePermission = await getUserEntityPermissions( @@ -48,7 +43,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) @@ -75,7 +69,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() + const parsed = await parseRequest(createFolderContract, request, {}) + if (!parsed.success) return parsed.response const { id: clientId, name, @@ -83,7 +78,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { parentId, color, sortOrder: providedSortOrder, - } = CreateFolderSchema.parse(body) + } = parsed.data.body const workspacePermission = await getUserEntityPermissions( session.user.id, @@ -98,59 +93,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const id = clientId || generateId() - - const newFolder = await db.transaction(async (tx) => { - let sortOrder: number - if (providedSortOrder !== undefined) { - sortOrder = providedSortOrder - } else { - const folderParentCondition = parentId - ? eq(workflowFolder.parentId, parentId) - : isNull(workflowFolder.parentId) - const workflowParentCondition = parentId - ? eq(workflow.folderId, parentId) - : isNull(workflow.folderId) - - const [[folderResult], [workflowResult]] = await Promise.all([ - tx - .select({ minSortOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)), - tx - .select({ minSortOrder: min(workflow.sortOrder) }) - .from(workflow) - .where(and(eq(workflow.workspaceId, workspaceId), workflowParentCondition)), - ]) - - const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce< - number | null - >((currentMin, candidate) => { - if (candidate == null) return currentMin - if (currentMin == null) return candidate - return Math.min(currentMin, candidate) - }, null) - - sortOrder = minSortOrder != null ? minSortOrder - 1 : 0 - } - - const [folder] = await tx - .insert(workflowFolder) - .values({ - id, - name: name.trim(), - userId: session.user.id, - workspaceId, - parentId: parentId || null, - color: color || '#6B7280', - sortOrder, - }) - .returning() - - return folder + const result = await performCreateFolder({ + id: clientId, + userId: session.user.id, + workspaceId, + name, + parentId, + color, + sortOrder: providedSortOrder, }) - logger.info('Created new folder:', { id, name, workspaceId, parentId }) + if (!result.success || !result.folder) { + return NextResponse.json( + { error: result.error }, + { status: folderMutationStatus(result.errorCode) } + ) + } + + const newFolder = result.folder + + logger.info('Created new folder:', { id: newFolder.id, name, workspaceId, parentId }) captureServerEvent( session.user.id, @@ -159,36 +121,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { groups: { workspace: workspaceId } } ) - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FOLDER_CREATED, - resourceType: AuditResourceType.FOLDER, - resourceId: id, - resourceName: name.trim(), - description: `Created folder "${name.trim()}"`, - metadata: { - name: name.trim(), - workspaceId, - parentId: parentId || undefined, - color: color || '#6B7280', - sortOrder: newFolder.sortOrder, - }, - request, - }) - 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]/otp/route.test.ts b/apps/sim/app/api/form/[identifier]/otp/route.test.ts new file mode 100644 index 00000000000..5a0a9eb1033 --- /dev/null +++ b/apps/sim/app/api/form/[identifier]/otp/route.test.ts @@ -0,0 +1,691 @@ +/** + * Tests for form OTP API route + * + * @vitest-environment node + */ +import { + redisConfigMock, + redisConfigMockFns, + requestUtilsMockFns, + workflowsApiUtilsMock, + workflowsApiUtilsMockFns, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockRedisSet, + mockRedisGet, + mockRedisDel, + mockRedisTtl, + mockRedisEval, + mockRedisClient, + mockDbSelect, + mockDbInsert, + mockDbDelete, + mockDbUpdate, + mockSendEmail, + mockRenderOTPEmail, + mockSetFormAuthCookie, + mockGetStorageMethod, + mockZodParse, + mockGetEnv, +} = vi.hoisted(() => { + const mockRedisSet = vi.fn() + const mockRedisGet = vi.fn() + const mockRedisDel = vi.fn() + const mockRedisTtl = vi.fn() + const mockRedisEval = vi.fn() + const mockRedisClient = { + set: mockRedisSet, + get: mockRedisGet, + del: mockRedisDel, + ttl: mockRedisTtl, + eval: mockRedisEval, + } + return { + mockRedisSet, + mockRedisGet, + mockRedisDel, + mockRedisTtl, + mockRedisEval, + mockRedisClient, + mockDbSelect: vi.fn(), + mockDbInsert: vi.fn(), + mockDbDelete: vi.fn(), + mockDbUpdate: vi.fn(), + mockSendEmail: vi.fn(), + mockRenderOTPEmail: vi.fn(), + mockSetFormAuthCookie: vi.fn(), + mockGetStorageMethod: vi.fn(), + mockZodParse: vi.fn(), + mockGetEnv: vi.fn(), + } +}) + +const mockGetRedisClient = redisConfigMockFns.mockGetRedisClient +const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse +const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse + +vi.mock('@/lib/core/config/redis', () => redisConfigMock) + +vi.mock('@sim/db', () => ({ + db: { + select: mockDbSelect, + insert: mockDbInsert, + delete: mockDbDelete, + update: mockDbUpdate, + transaction: vi.fn(async (callback: (tx: Record) => unknown) => { + return callback({ + select: mockDbSelect, + insert: mockDbInsert, + delete: mockDbDelete, + update: mockDbUpdate, + }) + }), + }, +})) + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((field: string, value: string) => ({ field, value, type: 'eq' })), + and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), + gt: vi.fn((field: string, value: string) => ({ field, value, type: 'gt' })), + lt: vi.fn((field: string, value: string) => ({ field, value, type: 'lt' })), + isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), +})) + +vi.mock('@/lib/core/storage', () => ({ + getStorageMethod: mockGetStorageMethod, +})) + +const { mockCheckRateLimitDirect } = vi.hoisted(() => ({ + mockCheckRateLimitDirect: vi.fn(), +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mockCheckRateLimitDirect + }, +})) + +vi.mock('@/lib/messaging/email/mailer', () => ({ + sendEmail: mockSendEmail, +})) + +vi.mock('@/components/emails', () => ({ + renderOTPEmail: mockRenderOTPEmail, +})) + +vi.mock('@/lib/core/security/deployment', () => ({ + isEmailAllowed: (email: string, allowedEmails: string[]) => { + if (allowedEmails.includes(email)) return true + const atIndex = email.indexOf('@') + if (atIndex > 0) { + const domain = email.substring(atIndex + 1) + if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) return true + } + return false + }, +})) + +vi.mock('@/app/api/form/utils', () => ({ + setFormAuthCookie: mockSetFormAuthCookie, +})) + +vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) + +vi.mock('@/lib/core/config/env', () => ({ + env: { + NEXT_PUBLIC_APP_URL: 'http://localhost:3000', + NODE_ENV: 'test', + }, + getEnv: mockGetEnv, + isTruthy: vi.fn().mockReturnValue(false), + isFalsy: vi.fn().mockReturnValue(true), +})) + +vi.mock('zod', () => { + class ZodError extends Error { + errors: Array<{ message: string }> + constructor(issues: Array<{ message: string }>) { + super('ZodError') + this.errors = issues + } + } + 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 + }, + } + ), + } +}) + +import { POST, PUT } from './route' + +describe('Form OTP API Route', () => { + const mockEmail = 'user@example.com' + const mockFormId = 'form-123' + const mockIdentifier = 'test-form' + const mockOTP = '123456' + + const deploymentRow = { + id: mockFormId, + authType: 'email', + allowedEmails: [mockEmail], + title: 'Test Form', + isActive: true, + } + + const verifyDeploymentRow = { + id: mockFormId, + authType: 'email', + password: null, + allowedEmails: [mockEmail], + isActive: true, + } + + const selectOnce = (rows: unknown[]) => + mockDbSelect.mockImplementationOnce(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(rows), + }), + }), + })) + + beforeEach(() => { + vi.clearAllMocks() + + vi.spyOn(Math, 'random').mockReturnValue(0.123456) + vi.spyOn(Date, 'now').mockReturnValue(1640995200000) + + vi.stubGlobal('crypto', { + ...crypto, + randomUUID: vi.fn().mockReturnValue('test-uuid-1234'), + }) + + mockGetRedisClient.mockReturnValue(mockRedisClient) + mockRedisSet.mockResolvedValue('OK') + mockRedisGet.mockResolvedValue(null) + mockRedisDel.mockResolvedValue(1) + mockRedisTtl.mockResolvedValue(600) + + mockDbSelect.mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + })) + mockDbInsert.mockImplementation(() => ({ values: vi.fn().mockResolvedValue(undefined) })) + mockDbDelete.mockImplementation(() => ({ where: vi.fn().mockResolvedValue(undefined) })) + mockDbUpdate.mockImplementation(() => ({ + set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), + })) + + mockGetStorageMethod.mockReturnValue('redis') + + mockSendEmail.mockResolvedValue({ success: true }) + mockRenderOTPEmail.mockResolvedValue('OTP Email') + + mockCreateSuccessResponse.mockImplementation((data: unknown) => ({ + json: () => Promise.resolve(data), + status: 200, + })) + mockCreateErrorResponse.mockImplementation((message: string, status: number) => ({ + json: () => Promise.resolve({ error: message }), + status, + })) + + requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('req-123') + requestUtilsMockFns.mockGetClientIp.mockReturnValue('1.2.3.4') + + mockCheckRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 10, + resetAt: new Date(Date.now() + 60_000), + }) + + mockZodParse.mockImplementation((data: unknown) => data) + mockGetEnv.mockReturnValue('http://localhost:3000') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('POST /otp - request code', () => { + it('stores OTP in Redis when storage is redis and sends email', async () => { + selectOnce([deploymentRow]) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockRedisSet).toHaveBeenCalledWith( + `form-otp:${mockEmail}:${mockFormId}`, + expect.stringMatching(/^\d{6}:0$/), + 'EX', + 900 + ) + expect(mockSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ to: mockEmail, subject: expect.stringContaining('Test Form') }) + ) + expect(mockDbInsert).not.toHaveBeenCalled() + }) + + it('stores OTP in database when storage is database', async () => { + mockGetStorageMethod.mockReturnValue('database') + mockGetRedisClient.mockReturnValue(null) + selectOnce([deploymentRow]) + const insertValues = vi.fn().mockResolvedValue(undefined) + mockDbInsert.mockImplementationOnce(() => ({ values: insertValues })) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ + identifier: `form-otp:${mockFormId}:${mockEmail}`, + value: expect.stringMatching(/^\d{6}:0$/), + }) + ) + expect(mockRedisSet).not.toHaveBeenCalled() + }) + + it('returns 404 when form is not found', async () => { + selectOnce([]) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockCreateErrorResponse).toHaveBeenCalledWith('Form not found', 404) + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('returns 403 when form is inactive', async () => { + selectOnce([{ ...deploymentRow, isActive: false }]) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockCreateErrorResponse).toHaveBeenCalledWith( + 'This form is currently unavailable', + 403 + ) + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('returns 400 when form authType is not email', async () => { + selectOnce([{ ...deploymentRow, authType: 'public' }]) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockCreateErrorResponse).toHaveBeenCalledWith( + 'This form does not use email authentication', + 400 + ) + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('returns 403 when email is not in allowedEmails', async () => { + selectOnce([{ ...deploymentRow, allowedEmails: ['other@example.com'] }]) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockCreateErrorResponse).toHaveBeenCalledWith( + 'Email not authorized for this form', + 403 + ) + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('authorizes by domain match in allowedEmails', async () => { + selectOnce([{ ...deploymentRow, allowedEmails: ['@example.com'] }]) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockSendEmail).toHaveBeenCalled() + }) + + it('returns 429 with Retry-After when IP rate limit is exceeded', async () => { + mockCheckRateLimitDirect.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date(Date.now() + 900_000), + retryAfterMs: 900_000, + }) + const headerSet = vi.fn() + mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({ + json: () => Promise.resolve({ error: message }), + status, + headers: { set: headerSet }, + })) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + const response = await POST(request, { + params: Promise.resolve({ identifier: mockIdentifier }), + }) + + expect(response.status).toBe(429) + expect(headerSet).toHaveBeenCalledWith('Retry-After', '900') + expect(mockSendEmail).not.toHaveBeenCalled() + expect(mockDbSelect).not.toHaveBeenCalled() + }) + + it('returns 429 with Retry-After when email rate limit is exceeded', async () => { + mockCheckRateLimitDirect + .mockResolvedValueOnce({ + allowed: true, + remaining: 9, + resetAt: new Date(Date.now() + 60_000), + }) + .mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date(Date.now() + 900_000), + retryAfterMs: 900_000, + }) + const headerSet = vi.fn() + mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({ + json: () => Promise.resolve({ error: message }), + status, + headers: { set: headerSet }, + })) + selectOnce([deploymentRow]) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + const response = await POST(request, { + params: Promise.resolve({ identifier: mockIdentifier }), + }) + + expect(response.status).toBe(429) + expect(headerSet).toHaveBeenCalledWith('Retry-After', '900') + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('rate-limits the IP bucket before reading the deployment row', async () => { + mockCheckRateLimitDirect.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date(Date.now() + 900_000), + retryAfterMs: 900_000, + }) + mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({ + json: () => Promise.resolve({ error: message }), + status, + headers: { set: vi.fn() }, + })) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockDbSelect).not.toHaveBeenCalled() + }) + + it('returns 500 when email send fails', async () => { + selectOnce([deploymentRow]) + mockSendEmail.mockResolvedValueOnce({ success: false, message: 'smtp down' }) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockCreateErrorResponse).toHaveBeenCalledWith('Failed to send verification email', 500) + }) + }) + + describe('PUT /otp - verify code', () => { + it('verifies OTP, deletes it, and sets the form auth cookie on success', async () => { + selectOnce([verifyDeploymentRow]) + mockRedisGet.mockResolvedValue(`${mockOTP}:0`) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'PUT', + body: JSON.stringify({ email: mockEmail, otp: mockOTP }), + }) + + await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockRedisGet).toHaveBeenCalledWith(`form-otp:${mockEmail}:${mockFormId}`) + expect(mockRedisDel).toHaveBeenCalledWith(`form-otp:${mockEmail}:${mockFormId}`) + expect(mockSetFormAuthCookie).toHaveBeenCalledWith( + expect.any(Object), + mockFormId, + 'email', + null + ) + expect(mockCreateSuccessResponse).toHaveBeenCalledWith({ authenticated: true }) + }) + + it('returns 404 when form is not found', async () => { + selectOnce([]) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'PUT', + body: JSON.stringify({ email: mockEmail, otp: mockOTP }), + }) + + await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockCreateErrorResponse).toHaveBeenCalledWith('Form not found', 404) + expect(mockSetFormAuthCookie).not.toHaveBeenCalled() + }) + + it('returns 403 when form is inactive at verify time', async () => { + selectOnce([{ ...verifyDeploymentRow, isActive: false }]) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'PUT', + body: JSON.stringify({ email: mockEmail, otp: mockOTP }), + }) + + await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockCreateErrorResponse).toHaveBeenCalledWith( + 'This form is currently unavailable', + 403 + ) + expect(mockSetFormAuthCookie).not.toHaveBeenCalled() + }) + + it('returns 403 when email is no longer in allowedEmails at verify time', async () => { + selectOnce([{ ...verifyDeploymentRow, allowedEmails: ['other@example.com'] }]) + mockRedisGet.mockResolvedValue(`${mockOTP}:0`) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'PUT', + body: JSON.stringify({ email: mockEmail, otp: mockOTP }), + }) + + await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockCreateErrorResponse).toHaveBeenCalledWith( + 'Email not authorized for this form', + 403 + ) + expect(mockSetFormAuthCookie).not.toHaveBeenCalled() + }) + + it('returns 400 when no OTP is stored', async () => { + selectOnce([verifyDeploymentRow]) + mockRedisGet.mockResolvedValue(null) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'PUT', + body: JSON.stringify({ email: mockEmail, otp: mockOTP }), + }) + + await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockCreateErrorResponse).toHaveBeenCalledWith( + 'No verification code found, request a new one', + 400 + ) + expect(mockSetFormAuthCookie).not.toHaveBeenCalled() + }) + + it('atomically increments attempts on wrong OTP and returns 400', async () => { + selectOnce([verifyDeploymentRow]) + mockRedisGet.mockResolvedValue('654321:0') + mockRedisEval.mockResolvedValue('654321:1') + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'PUT', + body: JSON.stringify({ email: mockEmail, otp: 'wrong1' }), + }) + + await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockRedisEval).toHaveBeenCalledWith( + expect.any(String), + 1, + `form-otp:${mockEmail}:${mockFormId}`, + 5 + ) + expect(mockCreateErrorResponse).toHaveBeenCalledWith('Invalid verification code', 400) + expect(mockSetFormAuthCookie).not.toHaveBeenCalled() + }) + + it('invalidates OTP and returns 429 after max failed attempts', async () => { + selectOnce([verifyDeploymentRow]) + mockRedisGet.mockResolvedValue('654321:4') + mockRedisEval.mockResolvedValue('LOCKED') + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'PUT', + body: JSON.stringify({ email: mockEmail, otp: 'wrong5' }), + }) + + await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockCreateErrorResponse).toHaveBeenCalledWith( + 'Too many failed attempts. Please request a new code.', + 429 + ) + expect(mockSetFormAuthCookie).not.toHaveBeenCalled() + }) + + it('rejects when stored OTP is already at max attempts', async () => { + selectOnce([verifyDeploymentRow]) + mockRedisGet.mockResolvedValue(`${mockOTP}:5`) + const deleteWhere = vi.fn().mockResolvedValue(undefined) + mockDbDelete.mockImplementation(() => ({ where: deleteWhere })) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'PUT', + body: JSON.stringify({ email: mockEmail, otp: mockOTP }), + }) + + await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockCreateErrorResponse).toHaveBeenCalledWith( + 'Too many failed attempts. Please request a new code.', + 429 + ) + expect(mockSetFormAuthCookie).not.toHaveBeenCalled() + }) + + it('uses database storage path when configured', async () => { + mockGetStorageMethod.mockReturnValue('database') + mockGetRedisClient.mockReturnValue(null) + let selectCallCount = 0 + mockDbSelect.mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockImplementation(() => { + selectCallCount++ + if (selectCallCount === 1) return Promise.resolve([verifyDeploymentRow]) + return Promise.resolve([ + { + value: `${mockOTP}:0`, + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + }, + ]) + }), + }), + }), + })) + const deleteWhere = vi.fn().mockResolvedValue(undefined) + mockDbDelete.mockImplementation(() => ({ where: deleteWhere })) + + const request = new NextRequest('http://localhost:3000/api/form/test/otp', { + method: 'PUT', + body: JSON.stringify({ email: mockEmail, otp: mockOTP }), + }) + + await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockDbDelete).toHaveBeenCalled() + expect(mockRedisDel).not.toHaveBeenCalled() + expect(mockSetFormAuthCookie).toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/app/api/form/[identifier]/otp/route.ts b/apps/sim/app/api/form/[identifier]/otp/route.ts new file mode 100644 index 00000000000..55f3f493ca0 --- /dev/null +++ b/apps/sim/app/api/form/[identifier]/otp/route.ts @@ -0,0 +1,225 @@ +import { db } from '@sim/db' +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 { renderOTPEmail } from '@/components/emails' +import { requestFormEmailOtpContract, verifyFormEmailOtpContract } from '@/lib/api/contracts/forms' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { RateLimiter } from '@/lib/core/rate-limiter' +import { isEmailAllowed } from '@/lib/core/security/deployment' +import { + decodeOTPValue, + deleteOTP, + generateOTP, + getOTP, + incrementOTPAttempts, + MAX_OTP_ATTEMPTS, + OTP_EMAIL_RATE_LIMIT, + OTP_IP_RATE_LIMIT, + storeOTP, +} from '@/lib/core/security/otp' +import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { sendEmail } from '@/lib/messaging/email/mailer' +import { setFormAuthCookie } from '@/app/api/form/utils' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' + +const logger = createLogger('FormOtpAPI') + +const rateLimiter = new RateLimiter() + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => { + const { identifier } = await context.params + const requestId = generateRequestId() + + try { + const ip = getClientIp(request) + const ipRateLimit = await rateLimiter.checkRateLimitDirect( + `form-otp:ip:${identifier}:${ip}`, + OTP_IP_RATE_LIMIT + ) + if (!ipRateLimit.allowed) { + logger.warn(`[${requestId}] OTP IP rate limit exceeded for ${identifier} from ${ip}`) + const retryAfter = Math.ceil( + (ipRateLimit.retryAfterMs ?? OTP_IP_RATE_LIMIT.refillIntervalMs) / 1000 + ) + const response = createErrorResponse('Too many requests. Please try again later.', 429) + response.headers.set('Retry-After', String(retryAfter)) + return response + } + + const parsed = await parseRequest(requestFormEmailOtpContract, request, context, { + validationErrorResponse: (error) => + createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400), + }) + if (!parsed.success) return parsed.response + const { email } = parsed.data.body + + const deploymentResult = await db + .select({ + id: form.id, + authType: form.authType, + allowedEmails: form.allowedEmails, + title: form.title, + isActive: form.isActive, + }) + .from(form) + .where(and(eq(form.identifier, identifier), isNull(form.archivedAt))) + .limit(1) + + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`) + return createErrorResponse('Form not found', 404) + } + + const deployment = deploymentResult[0] + + if (!deployment.isActive) { + return createErrorResponse('This form is currently unavailable', 403) + } + + if (deployment.authType !== 'email') { + return createErrorResponse('This form does not use email authentication', 400) + } + + const allowedEmails: string[] = Array.isArray(deployment.allowedEmails) + ? (deployment.allowedEmails as string[]) + : [] + + if (!isEmailAllowed(email, allowedEmails)) { + return createErrorResponse('Email not authorized for this form', 403) + } + + const emailRateLimit = await rateLimiter.checkRateLimitDirect( + `form-otp:email:${deployment.id}:${email.toLowerCase()}`, + OTP_EMAIL_RATE_LIMIT + ) + if (!emailRateLimit.allowed) { + logger.warn( + `[${requestId}] OTP email rate limit exceeded for ${email} on form ${deployment.id}` + ) + const retryAfter = Math.ceil( + (emailRateLimit.retryAfterMs ?? OTP_EMAIL_RATE_LIMIT.refillIntervalMs) / 1000 + ) + const response = createErrorResponse( + 'Too many verification code requests. Please try again later.', + 429 + ) + response.headers.set('Retry-After', String(retryAfter)) + return response + } + + const otp = generateOTP() + await storeOTP('form', deployment.id, email, otp) + + const emailHtml = await renderOTPEmail( + otp, + email, + 'email-verification', + deployment.title || 'Form' + ) + + const emailResult = await sendEmail({ + to: email, + subject: `Verification code for ${deployment.title || 'Form'}`, + html: emailHtml, + }) + + if (!emailResult.success) { + logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message) + return createErrorResponse('Failed to send verification email', 500) + } + + logger.info(`[${requestId}] OTP sent to ${email} for form ${deployment.id}`) + return createSuccessResponse({ message: 'Verification code sent' }) + } catch (error) { + logger.error(`[${requestId}] Error processing OTP request:`, error) + return createErrorResponse('Failed to process request', 500) + } + } +) + +export const PUT = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => { + const { identifier } = await context.params + const requestId = generateRequestId() + + try { + const parsed = await parseRequest(verifyFormEmailOtpContract, request, context, { + validationErrorResponse: (error) => + createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400), + }) + if (!parsed.success) return parsed.response + const { email, otp } = parsed.data.body + + const deploymentResult = await db + .select({ + id: form.id, + authType: form.authType, + password: form.password, + allowedEmails: form.allowedEmails, + isActive: form.isActive, + }) + .from(form) + .where(and(eq(form.identifier, identifier), isNull(form.archivedAt))) + .limit(1) + + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`) + return createErrorResponse('Form not found', 404) + } + + const deployment = deploymentResult[0] + + if (!deployment.isActive) { + return createErrorResponse('This form is currently unavailable', 403) + } + + if (deployment.authType !== 'email') { + return createErrorResponse('This form does not use email authentication', 400) + } + + const allowedEmails: string[] = Array.isArray(deployment.allowedEmails) + ? (deployment.allowedEmails as string[]) + : [] + + if (!isEmailAllowed(email, allowedEmails)) { + return createErrorResponse('Email not authorized for this form', 403) + } + + const storedValue = await getOTP('form', deployment.id, email) + if (!storedValue) { + return createErrorResponse('No verification code found, request a new one', 400) + } + + const { otp: storedOTP, attempts } = decodeOTPValue(storedValue) + + if (attempts >= MAX_OTP_ATTEMPTS) { + await deleteOTP('form', deployment.id, email) + logger.warn(`[${requestId}] OTP already locked out for ${email}`) + return createErrorResponse('Too many failed attempts. Please request a new code.', 429) + } + + if (storedOTP !== otp) { + const result = await incrementOTPAttempts('form', deployment.id, email, storedValue) + if (result === 'locked') { + logger.warn(`[${requestId}] OTP invalidated after max failed attempts for ${email}`) + return createErrorResponse('Too many failed attempts. Please request a new code.', 429) + } + return createErrorResponse('Invalid verification code', 400) + } + + await deleteOTP('form', deployment.id, email) + + const response = createSuccessResponse({ authenticated: true }) + setFormAuthCookie(response, deployment.id, deployment.authType, deployment.password) + + return response + } catch (error) { + logger.error(`[${requestId}] Error verifying OTP:`, error) + return createErrorResponse('Failed to process request', 500) + } + } +) diff --git a/apps/sim/app/api/form/[identifier]/route.ts b/apps/sim/app/api/form/[identifier]/route.ts index 62b2faa8bb2..46b0f1e068f 100644 --- a/apps/sim/app/api/form/[identifier]/route.ts +++ b/apps/sim/app/api/form/[identifier]/route.ts @@ -4,8 +4,9 @@ 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 { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment' +import { formSubmitBodySchema } from '@/lib/api/contracts/forms' +import { parseJsonBody } from '@/lib/api/server' +import { validateAuthToken } from '@/lib/core/security/deployment' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { preprocessExecution } from '@/lib/execution/preprocessing' @@ -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,22 @@ 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 - ) - } + const parsedJson = await parseJsonBody(request) + if (!parsedJson.success) { + return createErrorResponse('Invalid request body', 400) + } - parsedBody = validation.data - } catch (_error) { - 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 createErrorResponse(`Invalid request body: ${errorMessage}`, 400) } + const parsedBody = bodyValidation.data + const deploymentResult = await db .select({ id: form.id, @@ -96,7 +86,7 @@ export const POST = withRouteHandler( if (deploymentResult.length === 0) { logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Form not found', 404), request) + return createErrorResponse('Form not found', 404) } const deployment = deploymentResult[0] @@ -115,10 +105,7 @@ export const POST = withRouteHandler( logger.warn( `[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace` ) - return addCorsHeaders( - createErrorResponse('This form is currently unavailable', 403), - request - ) + return createErrorResponse('This form is currently unavailable', 403) } const executionId = generateId() @@ -143,31 +130,25 @@ export const POST = withRouteHandler( traceSpans: [], }) - return addCorsHeaders( - createErrorResponse('This form is currently unavailable', 403), - request - ) + return createErrorResponse('This form is currently unavailable', 403) } const authResult = await validateFormAuth(requestId, deployment, request, parsedBody) if (!authResult.authorized) { - return addCorsHeaders( - createErrorResponse(authResult.error || 'Authentication required', 401), - request - ) + return createErrorResponse(authResult.error || 'Authentication required', 401) } const { formData, password, email } = parsedBody // If only authentication credentials provided (no form data), just return authenticated if ((password || email) && !formData) { - const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request) + const response = createSuccessResponse({ authenticated: true }) setFormAuthCookie(response, deployment.id, deployment.authType, deployment.password) return response } if (!formData || Object.keys(formData).length === 0) { - return addCorsHeaders(createErrorResponse('No form data provided', 400), request) + return createErrorResponse('No form data provided', 400) } const executionId = generateId() @@ -191,12 +172,9 @@ export const POST = withRouteHandler( if (!preprocessResult.success) { logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`) - return addCorsHeaders( - createErrorResponse( - preprocessResult.error?.message || 'Failed to process request', - preprocessResult.error?.statusCode || 500 - ), - request + return createErrorResponse( + preprocessResult.error?.message || 'Failed to process request', + preprocessResult.error?.statusCode || 500 ) } @@ -205,10 +183,7 @@ export const POST = withRouteHandler( const workspaceId = workflowRecord?.workspaceId if (!workspaceId) { logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`) - return addCorsHeaders( - createErrorResponse('Workflow has no associated workspace', 500), - request - ) + return createErrorResponse('Workflow has no associated workspace', 500) } try { @@ -234,6 +209,9 @@ export const POST = withRouteHandler( workflowTriggerType: 'api', }, executionId, + workspaceId, + workflowId: deployment.workflowId, + userId: workspaceOwnerId, executeFn: async ({ onStream, onBlockComplete, abortSignal }) => executeWorkflow( workflowForExecution, @@ -268,29 +246,20 @@ export const POST = withRouteHandler( // Return success with customizations for thank you screen const customizations = deployment.customizations as Record | null - return addCorsHeaders( - createSuccessResponse({ - success: true, - executionId, - thankYouTitle: customizations?.thankYouTitle || 'Thank you!', - thankYouMessage: - customizations?.thankYouMessage || 'Your response has been submitted successfully.', - }), - request - ) + return createSuccessResponse({ + success: true, + executionId, + thankYouTitle: customizations?.thankYouTitle || 'Thank you!', + thankYouMessage: + customizations?.thankYouMessage || 'Your response has been submitted successfully.', + }) } catch (error: any) { logger.error(`[${requestId}] Error processing form submission:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to process form submission', 500), - request - ) + return createErrorResponse(error.message || 'Failed to process form submission', 500) } } catch (error: any) { logger.error(`[${requestId}] Error processing form submission:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to process form submission', 500), - request - ) + return createErrorResponse(error.message || 'Failed to process form submission', 500) } } ) @@ -320,17 +289,14 @@ export const GET = withRouteHandler( if (deploymentResult.length === 0) { logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Form not found', 404), request) + return createErrorResponse('Form not found', 404) } const deployment = deploymentResult[0] if (!deployment.isActive) { logger.warn(`[${requestId}] Form is not active: ${identifier}`) - return addCorsHeaders( - createErrorResponse('This form is currently unavailable', 403), - request - ) + return createErrorResponse('This form is currently unavailable', 403) } // Get the workflow's input schema @@ -345,18 +311,15 @@ export const GET = withRouteHandler( authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password) ) { - return addCorsHeaders( - createSuccessResponse({ - id: deployment.id, - title: deployment.title, - description: deployment.description, - customizations: deployment.customizations, - authType: deployment.authType, - showBranding: deployment.showBranding, - inputSchema, - }), - request - ) + return createSuccessResponse({ + id: deployment.id, + title: deployment.title, + description: deployment.description, + customizations: deployment.customizations, + authType: deployment.authType, + showBranding: deployment.showBranding, + inputSchema, + }) } // Check authentication requirement @@ -366,46 +329,33 @@ export const GET = withRouteHandler( logger.info( `[${requestId}] Authentication required for form: ${identifier}, type: ${deployment.authType}` ) - return addCorsHeaders( - NextResponse.json( - { - success: false, - error: authResult.error || 'Authentication required', - authType: deployment.authType, - title: deployment.title, - customizations: { - primaryColor: (deployment.customizations as any)?.primaryColor, - logoUrl: (deployment.customizations as any)?.logoUrl, - }, + return NextResponse.json( + { + success: false, + error: authResult.error || 'Authentication required', + authType: deployment.authType, + title: deployment.title, + customizations: { + primaryColor: (deployment.customizations as any)?.primaryColor, + logoUrl: (deployment.customizations as any)?.logoUrl, }, - { status: 401 } - ), - request + }, + { status: 401 } ) } - return addCorsHeaders( - createSuccessResponse({ - id: deployment.id, - title: deployment.title, - description: deployment.description, - customizations: deployment.customizations, - authType: deployment.authType, - showBranding: deployment.showBranding, - inputSchema, - }), - request - ) + return createSuccessResponse({ + id: deployment.id, + title: deployment.title, + description: deployment.description, + customizations: deployment.customizations, + authType: deployment.authType, + showBranding: deployment.showBranding, + inputSchema, + }) } catch (error: any) { logger.error(`[${requestId}] Error fetching form info:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to fetch form information', 500), - request - ) + return createErrorResponse(error.message || 'Failed to fetch form information', 500) } } ) - -export const OPTIONS = withRouteHandler(async (request: NextRequest) => { - return addCorsHeaders(new NextResponse(null, { status: 204 }), request) -}) diff --git a/apps/sim/app/api/form/manage/[id]/route.ts b/apps/sim/app/api/form/manage/[id]/route.ts index 501f3edbf1a..982a8b36bda 100644 --- a/apps/sim/app/api/form/manage/[id]/route.ts +++ b/apps/sim/app/api/form/manage/[id]/route.ts @@ -2,9 +2,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { form } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { z } from 'zod' +import { formIdParamsSchema, updateFormContract } from '@/lib/api/contracts/forms' +import { getValidationErrorMessage, parseRequest } 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,57 +15,6 @@ 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(), -}) - export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { try { @@ -73,7 +24,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,15 +40,15 @@ 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) } } ) export const PATCH = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { try { const session = await getSession() @@ -105,7 +56,24 @@ export const PATCH = withRouteHandler( return createErrorResponse('Unauthorized', 401) } - const { id } = await params + const parsed = await parseRequest(updateFormContract, request, context, { + validationErrorResponse: (error) => + createErrorResponse(getValidationErrorMessage(error), 400, 'VALIDATION_ERROR'), + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { + identifier, + title, + description, + customizations, + authType, + password, + allowedEmails, + showBranding, + isActive, + } = parsed.data.body const { hasAccess, @@ -117,114 +85,90 @@ export const PATCH = withRouteHandler( return createErrorResponse('Form not found or access denied', 404) } - const body = await request.json() - - try { - const validatedData = updateFormSchema.parse(body) - - const { - identifier, - title, - description, - customizations, - authType, - password, - allowedEmails, - showBranding, - isActive, - } = validatedData - - if (identifier && identifier !== formRecord.identifier) { - const existingIdentifier = await db - .select() - .from(form) - .where(and(eq(form.identifier, identifier), isNull(form.archivedAt))) - .limit(1) - - if (existingIdentifier.length > 0) { - return createErrorResponse('Identifier already in use', 400) - } - } + if (identifier && identifier !== formRecord.identifier) { + const existingIdentifier = await db + .select() + .from(form) + .where(and(eq(form.identifier, identifier), isNull(form.archivedAt))) + .limit(1) - if (authType === 'password' && !password && !formRecord.password) { - return createErrorResponse('Password is required when using password protection', 400) + if (existingIdentifier.length > 0) { + return createErrorResponse('Identifier already in use', 400) } + } - if ( - authType === 'email' && - (!allowedEmails || allowedEmails.length === 0) && - (!formRecord.allowedEmails || (formRecord.allowedEmails as string[]).length === 0) - ) { - return createErrorResponse( - 'At least one email or domain is required when using email access control', - 400 - ) - } + if (authType === 'password' && !password && !formRecord.password) { + return createErrorResponse('Password is required when using password protection', 400) + } - const updateData: Record = { - updatedAt: new Date(), - } + if ( + authType === 'email' && + (!allowedEmails || allowedEmails.length === 0) && + (!formRecord.allowedEmails || (formRecord.allowedEmails as string[]).length === 0) + ) { + return createErrorResponse( + 'At least one email or domain is required when using email access control', + 400 + ) + } - if (identifier !== undefined) updateData.identifier = identifier - if (title !== undefined) updateData.title = title - if (description !== undefined) updateData.description = description - if (showBranding !== undefined) updateData.showBranding = showBranding - if (isActive !== undefined) updateData.isActive = isActive - if (authType !== undefined) updateData.authType = authType - if (allowedEmails !== undefined) updateData.allowedEmails = allowedEmails - - if (customizations !== undefined) { - const existingCustomizations = (formRecord.customizations as Record) || {} - updateData.customizations = { - ...DEFAULT_FORM_CUSTOMIZATIONS, - ...existingCustomizations, - ...customizations, - } - } + const updateData: Record = { + updatedAt: new Date(), + } - if (password) { - const { encrypted } = await encryptSecret(password) - updateData.password = encrypted - } else if (authType && authType !== 'password') { - updateData.password = null + if (identifier !== undefined) updateData.identifier = identifier + if (title !== undefined) updateData.title = title + if (description !== undefined) updateData.description = description + if (showBranding !== undefined) updateData.showBranding = showBranding + if (isActive !== undefined) updateData.isActive = isActive + if (authType !== undefined) updateData.authType = authType + if (allowedEmails !== undefined) updateData.allowedEmails = allowedEmails + + if (customizations !== undefined) { + const existingCustomizations = (formRecord.customizations as Record) || {} + updateData.customizations = { + ...DEFAULT_FORM_CUSTOMIZATIONS, + ...existingCustomizations, + ...customizations, } + } - await db.update(form).set(updateData).where(eq(form.id, id)) - - logger.info(`Form ${id} updated successfully`) - - recordAudit({ - workspaceId: formWorkspaceId ?? null, - actorId: session.user.id, - action: AuditAction.FORM_UPDATED, - resourceType: AuditResourceType.FORM, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: (title || formRecord.title) ?? undefined, - description: `Updated form "${title || formRecord.title}"`, - metadata: { - identifier: identifier || formRecord.identifier, - workflowId: formRecord.workflowId, - authType: authType || formRecord.authType, - updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), - }, - request, - }) - - return createSuccessResponse({ - 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') - } - throw validationError + if (password) { + const { encrypted } = await encryptSecret(password) + updateData.password = encrypted + } else if (authType && authType !== 'password') { + updateData.password = null } - } catch (error: any) { + + await db.update(form).set(updateData).where(eq(form.id, id)) + + logger.info(`Form ${id} updated successfully`) + + recordAudit({ + workspaceId: formWorkspaceId ?? null, + actorId: session.user.id, + action: AuditAction.FORM_UPDATED, + resourceType: AuditResourceType.FORM, + resourceId: id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: (title || formRecord.title) ?? undefined, + description: `Updated form "${title || formRecord.title}"`, + metadata: { + identifier: identifier || formRecord.identifier, + workflowId: formRecord.workflowId, + authType: authType || formRecord.authType, + updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), + }, + request, + }) + + return createSuccessResponse({ + message: 'Form updated successfully', + }) + } 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 +182,7 @@ export const DELETE = withRouteHandler( return createErrorResponse('Unauthorized', 401) } - const { id } = await params + const { id } = formIdParamsSchema.parse(await params) const { hasAccess, @@ -250,9 +194,12 @@ export const DELETE = withRouteHandler( return createErrorResponse('Form not found or access denied', 404) } - await db.delete(form).where(eq(form.id, id)) + await db + .update(form) + .set({ archivedAt: new Date(), isActive: false, updatedAt: new Date() }) + .where(eq(form.id, id)) - logger.info(`Form ${id} deleted (soft delete)`) + logger.info(`Form ${id} soft deleted`) recordAudit({ workspaceId: formWorkspaceId ?? null, @@ -271,9 +218,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.test.ts b/apps/sim/app/api/form/route.test.ts new file mode 100644 index 00000000000..4be49a941eb --- /dev/null +++ b/apps/sim/app/api/form/route.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ +import { + authMockFns, + dbChainMock, + dbChainMockFns, + resetDbChainMock, + workflowsApiUtilsMock, + workflowsApiUtilsMockFns, + workflowsOrchestrationMock, + workflowsOrchestrationMockFns, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckWorkflowAccessForFormCreation } = vi.hoisted(() => ({ + mockCheckWorkflowAccessForFormCreation: vi.fn(), +})) + +const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse +const mockPerformFullDeploy = workflowsOrchestrationMockFns.mockPerformFullDeploy + +vi.mock('@sim/db', () => dbChainMock) + +vi.mock('@sim/utils/id', () => ({ + generateId: vi.fn(() => 'form-123'), +})) + +vi.mock('@/app/api/form/utils', () => ({ + checkWorkflowAccessForFormCreation: mockCheckWorkflowAccessForFormCreation, + DEFAULT_FORM_CUSTOMIZATIONS: {}, +})) + +vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isDev: true, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getEmailDomain: vi.fn(() => 'localhost:3000'), +})) + +vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) + +import { POST } from '@/app/api/form/route' + +describe('Form API Route', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + + authMockFns.mockGetSession.mockResolvedValue({ + user: { + id: 'user-123', + email: 'user@example.com', + name: 'Test User', + }, + }) + mockCreateErrorResponse.mockImplementation((message, status = 500) => { + return new Response(JSON.stringify({ error: message }), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + }) + mockCheckWorkflowAccessForFormCreation.mockResolvedValue({ + hasAccess: true, + workflow: { + id: 'workflow-123', + isDeployed: false, + workspaceId: 'workspace-123', + }, + }) + dbChainMockFns.limit.mockResolvedValue([]) + }) + + it('cleans up inserted form when deploy throws', async () => { + mockPerformFullDeploy.mockRejectedValue(new Error('Deploy exploded')) + + const request = new NextRequest('http://localhost:3000/api/form', { + method: 'POST', + body: JSON.stringify({ + workflowId: 'workflow-123', + identifier: 'test-form', + title: 'Test Form', + }), + }) + + const response = await POST(request) + + expect(response.status).toBe(500) + expect(dbChainMockFns.insert).toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalled() + expect(mockCreateErrorResponse).toHaveBeenCalledWith('Deploy exploded', 500) + }) +}) diff --git a/apps/sim/app/api/form/route.ts b/apps/sim/app/api/form/route.ts index 5336d324502..2b232c9132f 100644 --- a/apps/sim/app/api/form/route.ts +++ b/apps/sim/app/api/form/route.ts @@ -2,17 +2,18 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { form } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { z } from 'zod' +import { createFormContract } from '@/lib/api/contracts/forms' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { isDev } from '@/lib/core/config/feature-flags' import { encryptSecret } from '@/lib/core/security/encryption' import { getEmailDomain } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { notifySocketDeploymentChanged } from '@/lib/workflows/orchestration' -import { deployWorkflow } from '@/lib/workflows/persistence/utils' +import { performFullDeploy } from '@/lib/workflows/orchestration' import { checkWorkflowAccessForFormCreation, DEFAULT_FORM_CUSTOMIZATIONS, @@ -20,52 +21,15 @@ import { import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('FormAPI') +export const maxDuration = 120 -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), -}) +async function cleanupFormAfterDeployFailure(formId: string) { + try { + await db.delete(form).where(eq(form.id, formId)) + } catch (cleanupError) { + logger.error('Failed to clean up form after deploy failure:', cleanupError) + } +} export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -81,9 +45,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) } }) @@ -95,141 +59,148 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return createErrorResponse('Unauthorized', 401) } - const body = await request.json() - - try { - const validatedData = formSchema.parse(body) - - const { - workflowId, - identifier, - title, - description = '', - customizations, - authType = 'public', - password, - allowedEmails = [], - showBranding = true, - } = validatedData - - if (authType === 'password' && !password) { - return createErrorResponse('Password is required when using password protection', 400) - } - - if (authType === 'email' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) { - return createErrorResponse( - 'At least one email or domain is required when using email access control', - 400 - ) - } - - // Check identifier availability and workflow access in parallel - const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([ - db - .select() - .from(form) - .where(and(eq(form.identifier, identifier), isNull(form.archivedAt))) - .limit(1), - checkWorkflowAccessForFormCreation(workflowId, session.user.id), - ]) - - if (existingIdentifier.length > 0) { - return createErrorResponse('Identifier already in use', 400) - } - - if (!hasAccess || !workflowRecord) { - return createErrorResponse('Workflow not found or access denied', 404) + const parsed = await parseRequest( + createFormContract, + request, + {}, + { + validationErrorResponse: (error) => + createErrorResponse(getValidationErrorMessage(error), 400, 'VALIDATION_ERROR'), } + ) + if (!parsed.success) return parsed.response + + const { + workflowId, + identifier, + title, + description = '', + customizations, + authType = 'public', + password, + allowedEmails = [], + showBranding = true, + } = parsed.data.body + + if (authType === 'password' && !password) { + return createErrorResponse('Password is required when using password protection', 400) + } - const result = await deployWorkflow({ - workflowId, - deployedBy: session.user.id, - }) + if (authType === 'email' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) { + return createErrorResponse( + 'At least one email or domain is required when using email access control', + 400 + ) + } - if (!result.success) { - return createErrorResponse(result.error || 'Failed to deploy workflow', 500) - } + // Check identifier availability and workflow access in parallel + const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([ + db + .select() + .from(form) + .where(and(eq(form.identifier, identifier), isNull(form.archivedAt))) + .limit(1), + checkWorkflowAccessForFormCreation(workflowId, session.user.id), + ]) + + if (existingIdentifier.length > 0) { + return createErrorResponse('Identifier already in use', 400) + } - logger.info( - `${workflowRecord.isDeployed ? 'Redeployed' : 'Auto-deployed'} workflow ${workflowId} for form (v${result.version})` - ) + if (!hasAccess || !workflowRecord) { + return createErrorResponse('Workflow not found or access denied', 404) + } - await notifySocketDeploymentChanged(workflowId) + let encryptedPassword = null + if (authType === 'password' && password) { + const { encrypted } = await encryptSecret(password) + encryptedPassword = encrypted + } - let encryptedPassword = null - if (authType === 'password' && password) { - const { encrypted } = await encryptSecret(password) - encryptedPassword = encrypted - } + const id = generateId() - const id = generateId() + logger.info('Creating form deployment with values:', { + workflowId, + identifier, + title, + authType, + hasPassword: !!encryptedPassword, + emailCount: allowedEmails?.length || 0, + showBranding, + }) - logger.info('Creating form deployment with values:', { - workflowId, - identifier, - title, - authType, - hasPassword: !!encryptedPassword, - emailCount: allowedEmails?.length || 0, - showBranding, - }) + const mergedCustomizations = { + ...DEFAULT_FORM_CUSTOMIZATIONS, + ...(customizations || {}), + } - const mergedCustomizations = { - ...DEFAULT_FORM_CUSTOMIZATIONS, - ...(customizations || {}), - } + await db.insert(form).values({ + id, + workflowId, + userId: session.user.id, + identifier, + title, + description: description || null, + customizations: mergedCustomizations, + isActive: true, + authType, + password: encryptedPassword, + allowedEmails: authType === 'email' ? allowedEmails : [], + showBranding, + createdAt: new Date(), + updatedAt: new Date(), + }) - await db.insert(form).values({ - id, + let result: Awaited> + try { + result = await performFullDeploy({ workflowId, userId: session.user.id, - identifier, - title, - description: description || null, - customizations: mergedCustomizations, - isActive: true, - authType, - password: encryptedPassword, - allowedEmails: authType === 'email' ? allowedEmails : [], - showBranding, - createdAt: new Date(), - updatedAt: new Date(), - }) - - const baseDomain = getEmailDomain() - const protocol = isDev ? 'http' : 'https' - const formUrl = `${protocol}://${baseDomain}/form/${identifier}` - - logger.info(`Form "${title}" deployed successfully at ${formUrl}`) - - recordAudit({ - workspaceId: workflowRecord.workspaceId ?? null, - actorId: session.user.id, - action: AuditAction.FORM_CREATED, - resourceType: AuditResourceType.FORM, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: title, - description: `Created form "${title}" for workflow ${workflowId}`, - metadata: { identifier, workflowId, authType, formUrl, showBranding }, request, }) + } catch (error) { + await cleanupFormAfterDeployFailure(id) + throw error + } - return createSuccessResponse({ - id, - formUrl, - 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') - } - throw validationError + if (!result.success) { + await cleanupFormAfterDeployFailure(id) + const status = + result.errorCode === 'validation' ? 400 : result.errorCode === 'not_found' ? 404 : 500 + return createErrorResponse(result.error || 'Failed to deploy workflow', status) } - } catch (error: any) { + + logger.info( + `${workflowRecord.isDeployed ? 'Redeployed' : 'Auto-deployed'} workflow ${workflowId} for form (v${result.version})` + ) + + const baseDomain = getEmailDomain() + const protocol = isDev ? 'http' : 'https' + const formUrl = `${protocol}://${baseDomain}/form/${identifier}` + + logger.info(`Form "${title}" deployed successfully at ${formUrl}`) + + recordAudit({ + workspaceId: workflowRecord.workspaceId ?? null, + actorId: session.user.id, + action: AuditAction.FORM_CREATED, + resourceType: AuditResourceType.FORM, + resourceId: id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: title, + description: `Created form "${title}" for workflow ${workflowId}`, + metadata: { identifier, workflowId, authType, formUrl, showBranding }, + request, + }) + + return createSuccessResponse({ + id, + formUrl, + message: 'Form deployment created successfully', + }) + } 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/utils.test.ts b/apps/sim/app/api/form/utils.test.ts index 9c36ccc6e92..d6b51c2d778 100644 --- a/apps/sim/app/api/form/utils.test.ts +++ b/apps/sim/app/api/form/utils.test.ts @@ -7,17 +7,13 @@ import { encryptionMock, encryptionMockFns, workflowsUtilsMock } from '@sim/test import type { NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockValidateAuthToken, - mockSetDeploymentAuthCookie, - mockAddCorsHeaders, - mockIsEmailAllowed, -} = vi.hoisted(() => ({ - mockValidateAuthToken: vi.fn().mockReturnValue(false), - mockSetDeploymentAuthCookie: vi.fn(), - mockAddCorsHeaders: vi.fn((response: unknown) => response), - mockIsEmailAllowed: vi.fn(), -})) +const { mockValidateAuthToken, mockSetDeploymentAuthCookie, mockIsEmailAllowed } = vi.hoisted( + () => ({ + mockValidateAuthToken: vi.fn().mockReturnValue(false), + mockSetDeploymentAuthCookie: vi.fn(), + mockIsEmailAllowed: vi.fn(), + }) +) const mockDecryptSecret = encryptionMockFns.mockDecryptSecret @@ -26,7 +22,6 @@ vi.mock('@/lib/core/security/encryption', () => encryptionMock) vi.mock('@/lib/core/security/deployment', () => ({ validateAuthToken: mockValidateAuthToken, setDeploymentAuthCookie: mockSetDeploymentAuthCookie, - addCorsHeaders: mockAddCorsHeaders, isEmailAllowed: mockIsEmailAllowed, })) @@ -239,18 +234,20 @@ describe('Form API Utils', () => { }, } as any - // Exact email match should authorize + // Exact email match should require OTP verification, not authorize directly mockIsEmailAllowed.mockReturnValue(true) const result1 = await validateFormAuth('request-id', deployment, mockRequest, { email: 'user@example.com', }) - expect(result1.authorized).toBe(true) + expect(result1.authorized).toBe(false) + expect(result1.error).toBe('otp_required') - // Domain match should authorize + // Domain match should also require OTP verification const result2 = await validateFormAuth('request-id', deployment, mockRequest, { email: 'other@company.com', }) - expect(result2.authorized).toBe(true) + expect(result2.authorized).toBe(false) + expect(result2.error).toBe('otp_required') // Unknown email should not authorize mockIsEmailAllowed.mockReturnValue(false) diff --git a/apps/sim/app/api/form/utils.ts b/apps/sim/app/api/form/utils.ts index 55bbe65e17f..7b1f1df54dc 100644 --- a/apps/sim/app/api/form/utils.ts +++ b/apps/sim/app/api/form/utils.ts @@ -159,7 +159,7 @@ export async function validateFormAuth( const allowedEmails: string[] = deployment.allowedEmails || [] if (isEmailAllowed(email, allowedEmails)) { - return { authorized: true } + return { authorized: false, error: 'otp_required' } } return { authorized: false, error: 'Email not authorized for this form' } diff --git a/apps/sim/app/api/form/validate/route.ts b/apps/sim/app/api/form/validate/route.ts index 9af0542314f..0db7f37ee96 100644 --- a/apps/sim/app/api/form/validate/route.ts +++ b/apps/sim/app/api/form/validate/route.ts @@ -1,23 +1,17 @@ import { db } from '@sim/db' import { form } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { 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 +24,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)) { @@ -65,7 +59,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { error: isAvailable ? null : 'This identifier is already in use', }) } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Failed to validate identifier' + const message = getErrorMessage(error, 'Failed to validate identifier') logger.error('Error validating form identifier:', error) return createErrorResponse(message, 500) } diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 1176523c1c5..3b191146c48 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -12,9 +12,10 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockExecuteInE2B, mockExecuteInIsolatedVM } = vi.hoisted(() => ({ +const { mockExecuteInE2B, mockExecuteInIsolatedVM, mockUploadFile } = vi.hoisted(() => ({ mockExecuteInE2B: vi.fn(), mockExecuteInIsolatedVM: vi.fn(), + mockUploadFile: vi.fn(), })) vi.mock('@/lib/execution/isolated-vm', () => ({ @@ -42,100 +43,26 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ uploadWorkspaceFile: vi.fn(), })) +vi.mock('@/lib/uploads', () => ({ + StorageService: { + uploadFile: mockUploadFile, + }, +})) + vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) import { validateProxyUrl } from '@/lib/core/security/input-validation' +import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache' +import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata' +import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' import { POST } from '@/app/api/function/execute/route' -/** - * Creates a fake isolated-vm execution result by evaluating code - * in a sandboxed context, mimicking the real executeInIsolatedVM behavior. - */ -function createIsolatedVmImplementation() { - return async (req: { - code: string - params: Record - envVars: Record - contextVariables: Record - }) => { - const { code, params, envVars, contextVariables } = req - const stdoutChunks: string[] = [] - - const mockConsole = { - log: (...args: unknown[]) => { - stdoutChunks.push( - `${args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ')}\n` - ) - }, - error: (...args: unknown[]) => { - stdoutChunks.push( - 'ERROR: ' + - args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') + - '\n' - ) - }, - warn: (...args: unknown[]) => mockConsole.log('WARN:', ...args), - info: (...args: unknown[]) => mockConsole.log(...args), - } - - try { - const escapePattern = /this\.constructor\.constructor|\.constructor\s*\(/ - if (escapePattern.test(code)) { - return { result: undefined, stdout: '' } - } - - const context: Record = { - console: mockConsole, - params, - environmentVariables: envVars, - ...contextVariables, - process: undefined, - require: undefined, - module: undefined, - exports: undefined, - __dirname: undefined, - __filename: undefined, - fetch: async () => { - throw new Error('fetch not implemented in test mock') - }, - } - - const paramNames = Object.keys(context) - const paramValues = Object.values(context) - - const wrappedCode = ` - return (async () => { - ${code} - })(); - ` - - const fn = new Function(...paramNames, wrappedCode) - const result = await fn(...paramValues) - - return { - result, - stdout: stdoutChunks.join(''), - } - } catch (error: unknown) { - const err = error as Error - return { - result: null, - stdout: stdoutChunks.join(''), - error: { - message: err.message || String(error), - name: err.name || 'Error', - stack: err.stack, - }, - } - } - } -} - describe('Function Execute API Route', () => { beforeEach(() => { vi.clearAllMocks() + featureFlagsMock.isE2bEnabled = false hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ success: true, @@ -143,7 +70,9 @@ describe('Function Execute API Route', () => { authType: 'internal_jwt', }) - mockExecuteInIsolatedVM.mockImplementation(createIsolatedVmImplementation()) + mockExecuteInIsolatedVM.mockResolvedValue({ result: 'test', stdout: '' }) + mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey })) + clearLargeValueCacheForTests() mockExecuteInE2B.mockResolvedValue({ result: 'e2b success', @@ -183,7 +112,9 @@ describe('Function Execute API Route', () => { expect(data.output.result).toBe('test') }) - it.concurrent('should prevent VM escape via constructor chain', async () => { + it('should prevent VM escape via constructor chain', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: undefined, stdout: '' }) + const req = createMockRequest('POST', { code: 'return this.constructor.constructor("return process")().env', }) @@ -191,7 +122,7 @@ describe('Function Execute API Route', () => { const response = await POST(req) const data = await response.json() - if (response.status === 500) { + if (response.status === 422 || response.status === 500) { expect(data.success).toBe(false) } else { const result = data.output?.result @@ -219,7 +150,9 @@ describe('Function Execute API Route', () => { } }) - it.concurrent('should not expose process object', async () => { + it('should not expose process object', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'undefined', stdout: '' }) + const req = createMockRequest('POST', { code: 'return typeof process', }) @@ -231,7 +164,9 @@ describe('Function Execute API Route', () => { expect(data.output.result).toBe('undefined') }) - it.concurrent('should not expose require function', async () => { + it('should not expose require function', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'undefined', stdout: '' }) + const req = createMockRequest('POST', { code: 'return typeof require', }) @@ -279,7 +214,63 @@ describe('Function Execute API Route', () => { expect(data.output).toHaveProperty('executionTime') }) - it.concurrent('should return computed result for multi-line code', async () => { + it('compacts large array result fields to manifests when execution context is durable', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ + result: { + rows: Array.from({ length: 120_000 }, (_, index) => ({ + key: `SIM-${index}`, + payload: 'x'.repeat(100), + })), + }, + stdout: '', + }) + + const req = createMockRequest('POST', { + code: 'return rows', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(isLargeArrayManifest(data.output.result.rows)).toBe(true) + expect(data.output.result.rows).toMatchObject({ + __simLargeArrayManifest: true, + kind: 'array', + totalCount: 120_000, + }) + }) + + it('keeps large string result fields as generic large value refs', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ + result: { + text: 'x'.repeat(9 * 1024 * 1024), + }, + stdout: '', + }) + + const req = createMockRequest('POST', { + code: 'return text', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(isLargeValueRef(data.output.result.text)).toBe(true) + }) + + it('should return computed result for multi-line code', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 10, stdout: '' }) + const req = createMockRequest('POST', { code: 'const a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;\nreturn a + b + c + d;', timeout: 5000, @@ -301,8 +292,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') }) @@ -317,6 +307,73 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(200) expect(data.success).toBe(true) }) + + it('rejects large refs in runtimes without ref-native helpers', async () => { + featureFlagsMock.isE2bEnabled = true + const req = createMockRequest('POST', { + code: 'echo "$__blockRef_0"', + language: 'shell', + contextVariables: { + __blockRef_0: { + __simLargeValueRef: true, + version: 1, + id: 'lv_ABCDEFGHIJKL', + kind: 'array', + size: 12 * 1024 * 1024, + executionId: 'execution-1', + }, + }, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data.success).toBe(false) + expect(data.error).toContain( + 'Large execution values require the JavaScript isolated-vm runtime' + ) + }) + + it('registers manifest array read broker for isolated-vm execution', async () => { + const req = createMockRequest('POST', { + code: 'return await sim.values.readArray(__blockRef_0)', + language: 'javascript', + contextVariables: { + __blockRef_0: { + __simLargeArrayManifest: true, + version: 2, + kind: 'array', + totalCount: 1, + chunkCount: 1, + byteSize: 16, + chunks: [ + { + ref: { + __simLargeValueRef: true, + version: 1, + id: 'lv_ABCDEFGHIJKL', + kind: 'array', + size: 16, + executionId: 'execution-1', + }, + count: 1, + byteSize: 16, + }, + ], + preview: [{ id: 1 }], + }, + }, + }) + + const response = await POST(req) + const data = await response.json() + const [, options] = mockExecuteInIsolatedVM.mock.calls.at(-1) ?? [] + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(options?.brokers).toHaveProperty('sim.values.readArray') + }) }) describe('Template Variable Resolution', () => { @@ -466,7 +523,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 () => { @@ -496,6 +553,12 @@ describe('Function Execute API Route', () => { describe('Enhanced Error Handling', () => { it('should provide detailed syntax error with line content', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ + result: null, + stdout: '', + error: { message: 'Unexpected end of input', name: 'SyntaxError' }, + }) + const req = createMockRequest('POST', { code: 'const obj = {\n name: "test",\n description: "This has a missing closing quote\n};\nreturn obj;', timeout: 5000, @@ -504,12 +567,21 @@ describe('Function Execute API Route', () => { const response = await POST(req) const data = await response.json() - expect(response.status).toBe(500) + expect(response.status).toBe(422) expect(data.success).toBe(false) expect(data.error).toBeTruthy() }) it('should provide detailed runtime error with line and column', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ + result: null, + stdout: '', + error: { + message: "Cannot read properties of null (reading 'someMethod')", + name: 'TypeError', + }, + }) + const req = createMockRequest('POST', { code: 'const obj = null;\nreturn obj.someMethod();', timeout: 5000, @@ -518,13 +590,19 @@ describe('Function Execute API Route', () => { const response = await POST(req) const data = await response.json() - expect(response.status).toBe(500) + expect(response.status).toBe(422) expect(data.success).toBe(false) expect(data.error).toContain('Type Error') expect(data.error).toContain('Cannot read properties of null') }) it('should handle ReferenceError with enhanced details', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ + result: null, + stdout: '', + error: { message: 'undefinedVariable is not defined', name: 'ReferenceError' }, + }) + const req = createMockRequest('POST', { code: 'const x = 42;\nreturn undefinedVariable + x;', timeout: 5000, @@ -533,13 +611,49 @@ describe('Function Execute API Route', () => { const response = await POST(req) const data = await response.json() - expect(response.status).toBe(500) + expect(response.status).toBe(422) expect(data.success).toBe(false) expect(data.error).toContain('Reference Error') expect(data.error).toContain('undefinedVariable is not defined') }) + it('should show original source code when resolved block references cause syntax errors', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ + result: null, + stdout: '', + error: { + message: 'Unexpected identifier "globalThis"', + name: 'SyntaxError', + line: 1, + column: 7, + lineContent: 'retur globalThis["__blockRef_0"]', + }, + }) + + const req = createMockRequest('POST', { + code: 'retur globalThis["__blockRef_0"]', + sourceCode: 'retur ', + contextVariables: { __blockRef_0: 'value' }, + timeout: 5000, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(422) + expect(data.success).toBe(false) + expect(data.error).toContain('Line 1: `retur `') + expect(data.error).not.toContain('globalThis') + expect(data.debug.lineContent).toBe('retur ') + }) + it('should handle thrown errors gracefully', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ + result: null, + stdout: '', + error: { message: 'Custom error message', name: 'Error' }, + }) + const req = createMockRequest('POST', { code: 'throw new Error("Custom error message");', timeout: 5000, @@ -548,12 +662,18 @@ describe('Function Execute API Route', () => { const response = await POST(req) const data = await response.json() - expect(response.status).toBe(500) + expect(response.status).toBe(422) expect(data.success).toBe(false) expect(data.error).toContain('Custom error message') }) - it.concurrent('should provide helpful suggestions for common syntax errors', async () => { + it('should provide helpful suggestions for common syntax errors', async () => { + mockExecuteInIsolatedVM.mockResolvedValueOnce({ + result: null, + stdout: '', + error: { message: 'Unexpected end of input', name: 'SyntaxError' }, + }) + const req = createMockRequest('POST', { code: 'const obj = {\n name: "test"\n// Missing closing brace', timeout: 5000, @@ -562,7 +682,7 @@ describe('Function Execute API Route', () => { const response = await POST(req) const data = await response.json() - expect(response.status).toBe(500) + expect(response.status).toBe(422) expect(data.success).toBe(false) expect(data.error).toBeTruthy() }) diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 63dfbff136b..92dbaa87ef2 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, @@ -10,8 +12,23 @@ import { isE2bEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { executeInE2B, executeShellInE2B } from '@/lib/execution/e2b' -import { executeInIsolatedVM } from '@/lib/execution/isolated-vm' +import { executeInIsolatedVM, type IsolatedVMBrokerHandler } from '@/lib/execution/isolated-vm' import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' +import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys' +import { + isLargeArrayManifest, + materializeLargeArrayManifest, +} from '@/lib/execution/payloads/large-array-manifest' +import { containsLargeValueRef, isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' +import { + MAX_FUNCTION_INLINE_BYTES, + MAX_INLINE_MATERIALIZATION_BYTES, + readUserFileContent, + unavailableLargeValueError, +} from '@/lib/execution/payloads/materialization.server' +import { compactExecutionPayload } from '@/lib/execution/payloads/serializer' +import { materializeLargeValueRef } from '@/lib/execution/payloads/store' +import { isExecutionResourceLimitError } from '@/lib/execution/resource-errors' import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getWorkflowById } from '@/lib/workflows/utils' import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' @@ -25,8 +42,6 @@ import { export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -export const MAX_DURATION = 210 - const logger = createLogger('FunctionExecuteAPI') const TAG_PATTERN = createReferencePattern() @@ -34,6 +49,59 @@ const TAG_PATTERN = createReferencePattern() const E2B_JS_WRAPPER_LINES = 3 const E2B_PYTHON_WRAPPER_LINES = 1 +/** Matches valid JS identifier names (letters, digits, underscore; no leading digit). */ +const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/ + +/** ES2023 reserved words — using these as `const` variable names produces a SyntaxError. */ +const JS_RESERVED_WORDS = new Set([ + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'debugger', + 'default', + 'delete', + 'do', + 'else', + 'export', + 'extends', + 'false', + 'finally', + 'for', + 'function', + 'if', + 'import', + 'in', + 'instanceof', + 'let', + 'new', + 'null', + 'return', + 'static', + 'super', + 'switch', + 'this', + 'throw', + 'true', + 'try', + 'typeof', + 'var', + 'void', + 'while', + 'with', + 'yield', + 'enum', + 'await', + 'implements', + 'interface', + 'package', + 'private', + 'protected', + 'public', +]) + type TypeScriptModule = typeof import('typescript') let typescriptModulePromise: Promise | null = null @@ -357,6 +425,30 @@ function createUserFriendlyErrorMessage( return errorMessage } +function getErrorDisplayCode(sourceCode: string | undefined, resolvedCode: string): string { + return sourceCode && sourceCode.length > 0 ? sourceCode : resolvedCode +} + +function getLineContent(code: string, line: number | undefined): string | undefined { + if (line === undefined || line < 1) { + return undefined + } + + return code.split('\n')[line - 1]?.trim() +} + +function getErrorDisplayMessage( + message: string, + sourceCode: string | undefined, + resolvedCode: string +): string { + if (!sourceCode || sourceCode === resolvedCode || !resolvedCode.includes('__blockRef_')) { + return message + } + + return message.replace(/\s+["']globalThis["']/g, '') +} + function resolveWorkflowVariables( code: string, workflowVariables: Record, @@ -587,6 +679,184 @@ function cleanStdout(stdout: string): string { return stdout } +/** + * Serializes a value for use as a shell environment variable. Strings pass through + * unchanged; primitives are coerced via `String`; objects, arrays, and other complex + * values are JSON-stringified so that referencing them via `$VAR` yields a useful + * representation instead of `[object Object]`. `null`/`undefined` become an empty + * string to match POSIX env semantics. + */ +function serializeForShellEnv(value: unknown, nullValue = ''): string { + if (value === null || value === undefined) return nullValue + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return String(value) + } + try { + return JSON.stringify(value) ?? '' + } catch { + return String(value) + } +} + +interface FunctionRouteExecutionContext { + workflowId?: string + workspaceId?: string + executionId?: string + largeValueExecutionIds?: string[] + largeValueKeys?: string[] + fileKeys?: string[] + allowLargeValueWorkflowScope?: boolean + userId?: string + requestId: string +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + +function getPositiveNumber(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return undefined + } + return value +} + +function clampInlineBytes(value: unknown, limit = MAX_FUNCTION_INLINE_BYTES): number { + const requested = getPositiveNumber(value) + return Math.min(requested ?? limit, limit) +} + +function getBrokerFileArgs(args: unknown): { + file: unknown + maxBytes: number + offset?: number + length?: number +} { + const record = asRecord(args) + const options = asRecord(record.options) + return { + file: record.file, + maxBytes: clampInlineBytes(options.maxBytes), + offset: getPositiveNumber(options.offset), + length: getPositiveNumber(options.length), + } +} + +function createFunctionRuntimeBrokers( + context: FunctionRouteExecutionContext +): Record { + context.largeValueKeys ??= [] + context.fileKeys ??= [] + const largeValueKeys = context.largeValueKeys + const fileKeys = context.fileKeys + const base = { + requestId: context.requestId, + workflowId: context.workflowId, + workspaceId: context.workspaceId, + executionId: context.executionId, + largeValueExecutionIds: context.largeValueExecutionIds, + largeValueKeys, + fileKeys, + allowLargeValueWorkflowScope: context.allowLargeValueWorkflowScope, + userId: context.userId, + logger, + } + + const recordMaterializedKeys = (value: unknown) => + recordMaterializedAccessKeys({ largeValueKeys, fileKeys }, value) + + const readFile = async (args: unknown, encoding: 'base64' | 'text', chunked = false) => { + const fileArgs = getBrokerFileArgs(args) + return readUserFileContent(fileArgs.file, { + ...base, + encoding, + maxBytes: fileArgs.maxBytes, + chunked, + offset: chunked ? fileArgs.offset : undefined, + length: chunked ? fileArgs.length : undefined, + }) + } + + return { + 'sim.files.readBase64': (args) => readFile(args, 'base64'), + 'sim.files.readText': (args) => readFile(args, 'text'), + 'sim.files.readBase64Chunk': (args) => readFile(args, 'base64', true), + 'sim.files.readTextChunk': (args) => readFile(args, 'text', true), + 'sim.values.read': async (args) => { + const record = asRecord(args) + const options = asRecord(record.options) + const ref = record.ref + if (!isLargeValueRef(ref)) { + throw new Error('Expected a large execution value reference.') + } + if (!context.executionId) { + throw new Error('Large execution values require an execution context.') + } + const value = await materializeLargeValueRef(ref, { + ...base, + maxBytes: clampInlineBytes(options.maxBytes, MAX_INLINE_MATERIALIZATION_BYTES), + }) + if (value === undefined) { + throw unavailableLargeValueError(ref) + } + recordMaterializedKeys(value) + return value + }, + 'sim.values.readArray': async (args) => { + const record = asRecord(args) + const options = asRecord(record.options) + const manifest = record.ref + if (!isLargeArrayManifest(manifest)) { + throw new Error('Expected a large array manifest.') + } + if (!context.executionId) { + throw new Error('Large array manifests require an execution context.') + } + const value = await materializeLargeArrayManifest(manifest, { + ...base, + maxBytes: clampInlineBytes(options.maxBytes, MAX_INLINE_MATERIALIZATION_BYTES), + }) + recordMaterializedKeys(value) + return value + }, + } +} + +async function compactFunctionRouteBody( + body: T, + context: FunctionRouteExecutionContext +): Promise { + return compactExecutionPayload(body, { + workflowId: context.workflowId, + workspaceId: context.workspaceId, + executionId: context.executionId, + userId: context.userId, + preserveRoot: true, + requireDurable: Boolean(context.workspaceId && context.workflowId && context.executionId), + }) +} + +async function functionJsonResponse( + body: T, + context: FunctionRouteExecutionContext, + init?: ResponseInit +) { + return NextResponse.json( + await compactFunctionRouteBody( + { + ...body, + largeValueKeys: context.largeValueKeys, + fileKeys: context.fileKeys, + }, + context + ), + init + ) +} + async function maybeExportSandboxFileToWorkspace(args: { authUserId: string workflowId?: string @@ -694,6 +964,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { let stdout = '' let userCodeStartLine = 3 // Default value for error reporting let resolvedCode = '' // Store resolved code for error reporting + let sourceCodeForErrors: string | undefined + let routeContext: FunctionRouteExecutionContext | undefined try { const auth = await checkInternalAuth(req) @@ -702,12 +974,15 @@ 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') const { code, + sourceCode, params = {}, timeout = DEFAULT_EXECUTION_TIMEOUT_MS, language = DEFAULT_CODE_LANGUAGE, @@ -720,11 +995,18 @@ export const POST = withRouteHandler(async (req: NextRequest) => { blockNameMapping = {}, blockOutputSchemas = {}, workflowVariables = {}, + contextVariables: preResolvedContextVariables = {}, workflowId, + executionId, + largeValueExecutionIds, + largeValueKeys, + fileKeys, + allowLargeValueWorkflowScope = false, workspaceId, isCustomTool = false, _sandboxFiles, } = body + sourceCodeForErrors = sourceCode const executionParams = { ...params } executionParams._context = undefined @@ -734,9 +1016,22 @@ export const POST = withRouteHandler(async (req: NextRequest) => { paramsCount: Object.keys(executionParams).length, timeout, workflowId, + executionId, isCustomTool, }) + routeContext = { + workflowId, + workspaceId, + executionId, + largeValueExecutionIds, + largeValueKeys, + fileKeys, + allowLargeValueWorkflowScope, + userId: auth.userId, + requestId, + } + const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE let contextVariables: Record = {} @@ -744,6 +1039,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => { // For shell, env vars are injected as OS env vars via shellEnvs. // Replace {{VAR}} placeholders with $VAR so the shell can access them natively. resolvedCode = code.replace(/\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g, '$$$1') + // Carry pre-resolved block output variables (e.g. __blockRef_N) so they can be + // injected as shell env vars below. The executor replaces block references in the + // code with these names, so the values must be present at runtime. + contextVariables = { ...preResolvedContextVariables } } else { const codeResolution = resolveCodeVariables( code, @@ -756,7 +1055,16 @@ export const POST = withRouteHandler(async (req: NextRequest) => { lang ) resolvedCode = codeResolution.resolvedCode - contextVariables = codeResolution.contextVariables + // Merge pre-resolved block output variables from the executor. These take precedence + // because they were produced by the resolver using full execution-state context + // (including loop/parallel scope) and should not be overwritten. + contextVariables = { ...codeResolution.contextVariables, ...preResolvedContextVariables } + } + + if (lang === CodeLanguage.Shell && containsLargeValueRef(contextVariables)) { + throw new Error( + 'Large execution values require the JavaScript isolated-vm runtime. Select a nested field or read the value in a JavaScript function.' + ) } let jsImports = '' @@ -781,10 +1089,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const shellEnvs: Record = {} for (const [k, v] of Object.entries(envVars)) { - shellEnvs[k] = String(v) + shellEnvs[k] = serializeForShellEnv(v) } for (const [k, v] of Object.entries(contextVariables)) { - shellEnvs[k] = String(v) + shellEnvs[k] = serializeForShellEnv(v, 'null') } logger.info(`[${requestId}] E2B shell execution`, { @@ -817,13 +1125,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) if (shellError) { - return NextResponse.json( + return functionJsonResponse( { success: false, error: shellError, output: { result: null, stdout: cleanStdout(shellStdout), executionTime }, }, - { status: 500 } + routeContext, + { status: 422 } ) } @@ -843,10 +1152,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (fileExportResponse) return fileExportResponse } - return NextResponse.json({ - success: true, - output: { result: shellResult ?? null, stdout: cleanStdout(shellStdout), executionTime }, - }) + return functionJsonResponse( + { + success: true, + output: { result: shellResult ?? null, stdout: cleanStdout(shellStdout), executionTime }, + }, + routeContext + ) } if (lang === CodeLanguage.Python && !isE2bEnabled) { @@ -866,6 +1178,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { !isCustomTool && (lang === CodeLanguage.Python || (lang === CodeLanguage.JavaScript && hasImports)) + if (useE2B && containsLargeValueRef(contextVariables)) { + throw new Error( + 'Large execution values require the JavaScript isolated-vm runtime. Remove imports, select a nested field, or read the value in a JavaScript function without E2B.' + ) + } + if (useE2B) { logger.info(`[${requestId}] E2B status`, { enabled: isE2bEnabled, @@ -891,7 +1209,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n` prologueLineCount++ for (const [k, v] of Object.entries(contextVariables)) { - prologue += `const ${k} = ${formatLiteralForCode(v, 'javascript')};\n` + prologue += `globalThis[${JSON.stringify(k)}] = ${formatLiteralForCode(v, 'javascript')};\n` + prologue += `const ${k} = globalThis[${JSON.stringify(k)}];\n` + prologueLineCount++ prologueLineCount++ } @@ -934,20 +1254,22 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) if (e2bError) { + const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode) const { formattedError, cleanedOutput } = formatE2BError( - e2bError, + getErrorDisplayMessage(e2bError, sourceCodeForErrors, resolvedCode), e2bStdout, lang, - resolvedCode, + errorDisplayCode, prologueLineCount + importLineCount ) - return NextResponse.json( + return functionJsonResponse( { success: false, error: formattedError, output: { result: null, stdout: cleanedOutput, executionTime }, }, - { status: 500 } + routeContext, + { status: 422 } ) } @@ -967,10 +1289,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (fileExportResponse) return fileExportResponse } - return NextResponse.json({ - success: true, - output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime }, - }) + return functionJsonResponse( + { + success: true, + output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime }, + }, + routeContext + ) } let prologueLineCount = 0 @@ -1016,20 +1341,22 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) if (e2bError) { + const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode) const { formattedError, cleanedOutput } = formatE2BError( - e2bError, + getErrorDisplayMessage(e2bError, sourceCodeForErrors, resolvedCode), e2bStdout, lang, - resolvedCode, + errorDisplayCode, prologueLineCount ) - return NextResponse.json( + return functionJsonResponse( { success: false, error: formattedError, output: { result: null, stdout: cleanedOutput, executionTime }, }, - { status: 500 } + routeContext, + { status: 422 } ) } @@ -1049,18 +1376,27 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (fileExportResponse) return fileExportResponse } - return NextResponse.json({ - success: true, - output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime }, - }) + return functionJsonResponse( + { + success: true, + output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime }, + }, + routeContext + ) } const executionMethod = 'isolated-vm' + const isSafeParamKey = (key: string) => SAFE_IDENTIFIER.test(key) && !JS_RESERVED_WORDS.has(key) + const wrapperLines = ['(async () => {', ' try {'] if (isCustomTool) { Object.keys(executionParams).forEach((key) => { - wrapperLines.push(` const ${key} = params.${key};`) + if (isSafeParamKey(key)) { + wrapperLines.push(` const ${key} = params.${key};`) + } else { + logger.warn('Skipping param key — not a safe JS identifier', { key, requestId }) + } }) } userCodeStartLine = wrapperLines.length + 1 @@ -1068,29 +1404,35 @@ export const POST = withRouteHandler(async (req: NextRequest) => { let codeToExecute = resolvedCode let prependedLineCount = 0 if (isCustomTool) { - const paramKeys = Object.keys(executionParams) + const paramKeys = Object.keys(executionParams).filter(isSafeParamKey) const paramDestructuring = paramKeys.map((key) => `const ${key} = params.${key};`).join('\n') codeToExecute = `${paramDestructuring}\n${resolvedCode}` prependedLineCount = paramKeys.length } - const isolatedResult = await executeInIsolatedVM({ - code: codeToExecute, - params: executionParams, - envVars, - contextVariables, - timeoutMs: timeout, - requestId, - ownerKey: `user:${auth.userId}`, - ownerWeight: 1, - }) + const isolatedResult = await executeInIsolatedVM( + { + code: codeToExecute, + params: executionParams, + envVars, + contextVariables, + timeoutMs: timeout, + requestId, + ownerKey: `user:${auth.userId}`, + ownerWeight: 1, + }, + { brokers: createFunctionRuntimeBrokers(routeContext) } + ) const executionTime = Date.now() - startTime if (isolatedResult.error) { - logger.error(`[${requestId}] Function execution failed in isolated-vm`, { + const isSystemError = isolatedResult.error.isSystemError === true + const logFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger) + logFn(`[${requestId}] Function execution failed in isolated-vm`, { error: isolatedResult.error, executionTime, + isSystemError, }) const ivmError = isolatedResult.error @@ -1098,13 +1440,16 @@ export const POST = withRouteHandler(async (req: NextRequest) => { let adjustedLineContent = ivmError.lineContent if (prependedLineCount > 0 && ivmError.line !== undefined) { adjustedLine = Math.max(1, ivmError.line - prependedLineCount) - const codeLines = resolvedCode.split('\n') - if (adjustedLine <= codeLines.length) { - adjustedLineContent = codeLines[adjustedLine - 1]?.trim() - } } + const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode) + const displayMessage = getErrorDisplayMessage( + ivmError.message, + sourceCodeForErrors, + resolvedCode + ) + adjustedLineContent = getLineContent(errorDisplayCode, adjustedLine) ?? adjustedLineContent const enhancedError: EnhancedError = { - message: ivmError.message, + message: displayMessage, name: ivmError.name, stack: ivmError.stack, originalError: ivmError, @@ -1116,10 +1461,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const userFriendlyErrorMessage = createUserFriendlyErrorMessage( enhancedError, requestId, - resolvedCode + errorDisplayCode ) - logger.error(`[${requestId}] Enhanced error details`, { + const detailLogFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger) + detailLogFn(`[${requestId}] Enhanced error details`, { originalMessage: ivmError.message, enhancedMessage: userFriendlyErrorMessage, line: enhancedError.line, @@ -1128,7 +1474,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { errorType: enhancedError.name, }) - return NextResponse.json( + return functionJsonResponse( { success: false, error: userFriendlyErrorMessage, @@ -1145,7 +1491,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { stack: enhancedError.stack, }, }, - { status: 500 } + routeContext, + { status: isSystemError ? 500 : 422 } ) } @@ -1154,23 +1501,63 @@ export const POST = withRouteHandler(async (req: NextRequest) => { executionTime, }) - return NextResponse.json({ - success: true, - output: { result: isolatedResult.result, stdout: cleanStdout(stdout), executionTime }, - }) + return functionJsonResponse( + { + success: true, + output: { result: isolatedResult.result, stdout: cleanStdout(stdout), executionTime }, + }, + routeContext + ) } catch (error: any) { const executionTime = Date.now() - startTime + if (isExecutionResourceLimitError(error)) { + logger.warn(`[${requestId}] Function execution exceeded resource limits`, { + resource: error.resource, + attemptedBytes: error.attemptedBytes, + limitBytes: error.limitBytes, + executionTime, + }) + if (routeContext) { + return functionJsonResponse( + { + success: false, + error: error.message, + output: { + result: null, + stdout: cleanStdout(stdout), + executionTime, + }, + }, + routeContext, + { status: error.statusCode } + ) + } + return NextResponse.json( + { + success: false, + error: error.message, + output: { + result: null, + stdout: cleanStdout(stdout), + executionTime, + }, + }, + { status: error.statusCode } + ) + } + logger.error(`[${requestId}] Function execution failed`, { error: error.message || 'Unknown error', stack: error.stack, executionTime, }) - const enhancedError = extractEnhancedError(error, userCodeStartLine, resolvedCode) + const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode) + const enhancedError = extractEnhancedError(error, userCodeStartLine, errorDisplayCode) const userFriendlyErrorMessage = createUserFriendlyErrorMessage( enhancedError, requestId, - resolvedCode + errorDisplayCode ) logger.error(`[${requestId}] Enhanced error details`, { @@ -1200,6 +1587,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }, } + if (routeContext) { + return functionJsonResponse(errorResponse, routeContext, { status: 500 }) + } + return NextResponse.json(errorResponse, { status: 500 }) } }) 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/help/integration-request/route.ts b/apps/sim/app/api/help/integration-request/route.ts index cf3c33e0499..6a8faf682b6 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 { integrationRequestContract } from '@/lib/api/contracts/common' +import { parseRequest, 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' @@ -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() @@ -57,20 +44,17 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - const body = await req.json() - - const validationResult = integrationRequestSchema.safeParse(body) - if (!validationResult.success) { - logger.warn(`[${requestId}] Invalid integration request data`, { - errors: validationResult.error.format(), - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationResult.error.format() }, - { status: 400 } - ) - } + const parsed = await parseRequest( + integrationRequestContract, + req, + {}, + { + validationErrorResponse: (err) => validationErrorResponse(err, 'Invalid request data'), + } + ) + if (!parsed.success) return parsed.response - const { integrationName, email, useCase } = validationResult.data + const { integrationName, email, useCase } = parsed.data.body logger.info(`[${requestId}] Processing integration request`, { integrationName, diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index e396d30aadc..7e21fb2ee62 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 { validationErrorResponse } 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 = helpFormBodySchema.safeParse({ 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 validationErrorResponse(validationResult.error) } 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..147e297898d 100644 --- a/apps/sim/app/api/invitations/[id]/accept/route.ts +++ b/apps/sim/app/api/invitations/[id]/accept/route.ts @@ -1,35 +1,32 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { acceptInvitationContract } from '@/lib/api/contracts/invitations' +import { parseRequest } 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 + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const session = await getSession() if (!session?.user?.id || !session.user.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json().catch(() => ({})) - const parsed = bodySchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) - } + const parsed = await parseRequest(acceptInvitationContract, request, context) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params const result = await acceptInvitation({ userId: session.user.id, userEmail: session.user.email, invitationId: id, - token: parsed.data.token ?? null, + token: parsed.data.body.token ?? null, }) if (!result.success) { diff --git a/apps/sim/app/api/invitations/[id]/reject/route.ts b/apps/sim/app/api/invitations/[id]/reject/route.ts index 7f9c311b4ca..1eedf0632b3 100644 --- a/apps/sim/app/api/invitations/[id]/reject/route.ts +++ b/apps/sim/app/api/invitations/[id]/reject/route.ts @@ -1,35 +1,32 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { rejectInvitationContract } from '@/lib/api/contracts/invitations' +import { parseRequest } 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 + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const session = await getSession() if (!session?.user?.id || !session.user.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json().catch(() => ({})) - const parsed = bodySchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) - } + const parsed = await parseRequest(rejectInvitationContract, request, context) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params const result = await rejectInvitation({ userId: session.user.id, userEmail: session.user.email, invitationId: id, - token: parsed.data.token ?? null, + token: parsed.data.body.token ?? null, }) if (!result.success) { diff --git a/apps/sim/app/api/invitations/[id]/resend/route.ts b/apps/sim/app/api/invitations/[id]/resend/route.ts index 1841f93118a..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) { @@ -146,6 +155,7 @@ export const POST = withRouteHandler( targetEmail: inv.email, targetRole: inv.role, kind: inv.kind, + membershipIntent: inv.membershipIntent, }, request, }) diff --git a/apps/sim/app/api/invitations/[id]/route.ts b/apps/sim/app/api/invitations/[id]/route.ts index 8e08cfc89dc..4bcdca5a1f6 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 { + getInvitationContract, + invitationParamsSchema, + updateInvitationContract, +} from '@/lib/api/contracts/invitations' +import { getValidationErrorMessage, parseRequest } 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' @@ -14,21 +19,25 @@ import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InvitationsAPI') export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id } = await params + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const parsed = await parseRequest(getInvitationContract, request, context) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { token } = parsed.data.query + try { const inv = await getInvitationById(id) if (!inv) { return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) } - const token = request.nextUrl.searchParams.get('token') const isInvitee = normalizeEmail(session.user.email || '') === normalizeEmail(inv.email) const tokenMatches = !!token && token === inv.token @@ -54,6 +63,7 @@ export const GET = withRouteHandler( email: inv.email, organizationId: inv.organizationId, organizationName: inv.organizationName, + membershipIntent: inv.membershipIntent, role: inv.role, status: inv.status, expiresAt: inv.expiresAt, @@ -74,31 +84,20 @@ 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 + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const parsed = await parseRequest(updateInvitationContract, request, context) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { role, grants } = parsed.data.body + try { const inv = await getInvitationById(id) if (!inv) { @@ -109,18 +108,13 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: 'Can only modify pending invitations' }, { status: 400 }) } - const body = await request.json().catch(() => ({})) - const parsed = patchSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.errors[0]?.message || 'Invalid request body' }, - { status: 400 } - ) - } - - const { role, grants } = parsed.data - if (role !== undefined) { + if (inv.membershipIntent === 'external') { + return NextResponse.json( + { error: 'Role updates are not valid on external workspace invitations' }, + { status: 400 } + ) + } if (!inv.organizationId) { return NextResponse.json( { error: 'Role updates are only valid on organization-scoped invitations' }, @@ -187,6 +181,7 @@ export const PATCH = withRouteHandler( invitationId: id, targetEmail: inv.email, kind: inv.kind, + membershipIntent: inv.membershipIntent, roleUpdate: role ?? null, grantUpdates: grantsToApply, }, @@ -203,7 +198,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.test.ts b/apps/sim/app/api/jobs/[jobId]/route.test.ts index dac212544a0..0dceacd56eb 100644 --- a/apps/sim/app/api/jobs/[jobId]/route.test.ts +++ b/apps/sim/app/api/jobs/[jobId]/route.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { hybridAuthMockFns, workflowsUtilsMock, workflowsUtilsMockFns } from '@sim/testing' -import type { NextRequest } from 'next/server' +import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetJobQueue, mockAuthorizeWorkflow, mockGetJob } = vi.hoisted(() => ({ @@ -24,11 +24,7 @@ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) import { GET } from './route' function createMockRequest(): NextRequest { - return { - headers: { - get: () => null, - }, - } as NextRequest + return new NextRequest(new URL('http://localhost:3000/api/jobs/test')) } describe('GET /api/jobs/[jobId]', () => { diff --git a/apps/sim/app/api/jobs/[jobId]/route.ts b/apps/sim/app/api/jobs/[jobId]/route.ts index 927c33e24dc..adba3ec4d5d 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 { getJobStatusContract } from '@/lib/api/contracts/common' +import { parseRequest } from '@/lib/api/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { getJobQueue } from '@/lib/core/async-jobs' import { generateRequestId } from '@/lib/core/utils/request' @@ -10,8 +12,10 @@ import { createErrorResponse } from '@/app/api/workflows/utils' const logger = createLogger('TaskStatusAPI') export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ jobId: string }> }) => { - const { jobId: taskId } = await params + async (request: NextRequest, context: { params: Promise<{ jobId: string }> }) => { + const parsed = await parseRequest(getJobStatusContract, request, context) + if (!parsed.success) return parsed.response + const { jobId: taskId } = parsed.data.params 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..9eebf944c29 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 { patchKnowledgeConnectorDocumentsContract } from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -116,18 +117,13 @@ 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. */ -export const PATCH = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteParams) => { const requestId = generateRequestId() - const { id: knowledgeBaseId, connectorId } = await params + const { id: knowledgeBaseId, connectorId } = await context.params try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -158,16 +154,10 @@ 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 parsed = await parseRequest(patchKnowledgeConnectorDocumentsContract, request, context) + if (!parsed.success) return parsed.response - const { operation, documentIds } = parsed.data + const { operation, documentIds } = parsed.data.body if (operation === 'restore') { const updated = await db diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts index 9f78afd961a..94e41e01ba9 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts @@ -162,7 +162,7 @@ describe('Knowledge Connector By ID API Route', () => { const data = await response.json() expect(response.status).toBe(400) - expect(data.error).toBe('Invalid request') + expect(data.error).toBe('Validation error') }) it('returns 404 when connector not found during sourceConfig validation', async () => { 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..1e13574c61d 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 { updateKnowledgeConnectorContract } from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { decryptApiKey } from '@/lib/api-key/crypto' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { 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 */ @@ -93,9 +88,9 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou /** * PATCH /api/knowledge/[id]/connectors/[connectorId] - Update a connector */ -export const PATCH = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteParams) => { const requestId = generateRequestId() - const { id: knowledgeBaseId, connectorId } = await params + const { id: knowledgeBaseId, connectorId } = await context.params try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -109,19 +104,14 @@ 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 parsed = await parseRequest(updateKnowledgeConnectorContract, request, context) + if (!parsed.success) return parsed.response + const body = parsed.data.body if ( - parsed.data.syncIntervalMinutes !== undefined && - parsed.data.syncIntervalMinutes > 0 && - parsed.data.syncIntervalMinutes < 60 + body.syncIntervalMinutes !== undefined && + body.syncIntervalMinutes > 0 && + body.syncIntervalMinutes < 60 ) { const canUseLiveSync = await hasLiveSyncAccess(auth.userId) if (!canUseLiveSync) { @@ -132,7 +122,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R } } - if (parsed.data.sourceConfig !== undefined) { + if (body.sourceConfig !== undefined) { const existingRows = await db .select() .from(knowledgeConnector) @@ -200,7 +190,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R ) } - const validation = await connectorConfig.validateConfig(accessToken, parsed.data.sourceConfig) + const validation = await connectorConfig.validateConfig(accessToken, body.sourceConfig) if (!validation.valid) { return NextResponse.json( { error: validation.error || 'Invalid source configuration' }, @@ -210,20 +200,20 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R } const updates: Record = { updatedAt: new Date() } - if (parsed.data.sourceConfig !== undefined) { - updates.sourceConfig = parsed.data.sourceConfig + if (body.sourceConfig !== undefined) { + updates.sourceConfig = body.sourceConfig } - if (parsed.data.syncIntervalMinutes !== undefined) { - updates.syncIntervalMinutes = parsed.data.syncIntervalMinutes - if (parsed.data.syncIntervalMinutes > 0) { - updates.nextSyncAt = new Date(Date.now() + parsed.data.syncIntervalMinutes * 60 * 1000) + if (body.syncIntervalMinutes !== undefined) { + updates.syncIntervalMinutes = body.syncIntervalMinutes + if (body.syncIntervalMinutes > 0) { + updates.nextSyncAt = new Date(Date.now() + body.syncIntervalMinutes * 60 * 1000) } else { updates.nextSyncAt = null } } - if (parsed.data.status !== undefined) { - updates.status = parsed.data.status - if (parsed.data.status === 'active') { + if (body.status !== undefined) { + updates.status = body.status + if (body.status === 'active') { updates.consecutiveFailures = 0 updates.lastSyncError = null if (updates.nextSyncAt === undefined) { @@ -274,10 +264,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R knowledgeBaseName: writeCheck.knowledgeBase.name, connectorType: updatedData.connectorType, updatedFields: Object.keys(parsed.data), - ...(parsed.data.syncIntervalMinutes !== undefined && { - syncIntervalMinutes: parsed.data.syncIntervalMinutes, + ...(body.syncIntervalMinutes !== undefined && { + syncIntervalMinutes: body.syncIntervalMinutes, }), - ...(parsed.data.status !== undefined && { newStatus: parsed.data.status }), + ...(body.status !== undefined && { newStatus: body.status }), }, request, }) 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..8b270ef5472 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 { triggerKnowledgeConnectorSyncContract } from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,9 +20,11 @@ type RouteParams = { params: Promise<{ id: string; connectorId: string }> } /** * POST /api/knowledge/[id]/connectors/[connectorId]/sync - Trigger a manual sync */ -export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { +export const POST = withRouteHandler(async (request: NextRequest, context: RouteParams) => { const requestId = generateRequestId() - const { id: knowledgeBaseId, connectorId } = await params + const parsed = await parseRequest(triggerKnowledgeConnectorSyncContract, request, context) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId, connectorId } = parsed.data.params 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..3a9d6d61785 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 { createKnowledgeConnectorContract } from '@/lib/api/contracts/knowledge' +import { parseRequest } 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 */ @@ -79,9 +72,9 @@ export const GET = withRouteHandler( * POST /api/knowledge/[id]/connectors - Create a new connector */ export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id: knowledgeBaseId } = await params + const { id: knowledgeBaseId } = await context.params try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -98,16 +91,11 @@ 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 parsed = await parseRequest(createKnowledgeConnectorContract, request, context) + if (!parsed.success) return parsed.response - const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = parsed.data + const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = + parsed.data.body if (syncIntervalMinutes > 0 && syncIntervalMinutes < 60) { const canUseLiveSync = await hasLiveSyncAccess(auth.userId) @@ -157,10 +145,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..10594ff6635 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 { updateKnowledgeChunkContract } from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { 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, @@ -67,10 +63,10 @@ export const GET = withRouteHandler( export const PUT = withRouteHandler( async ( req: NextRequest, - { params }: { params: Promise<{ id: string; documentId: string; chunkId: string }> } + context: { params: Promise<{ id: string; documentId: string; chunkId: string }> } ) => { const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId, documentId, chunkId } = await params + const { id: knowledgeBaseId, documentId, chunkId } = await context.params try { const session = await getSession() @@ -109,38 +105,26 @@ export const PUT = withRouteHandler( ) } - const body = await req.json() + const parsed = await parseRequest(updateKnowledgeChunkContract, req, context) + if (!parsed.success) return parsed.response - try { - const validatedData = UpdateChunkSchema.parse(body) + const validatedData = parsed.data.body - const updatedChunk = await updateChunk( - chunkId, - validatedData, - requestId, - accessCheck.knowledgeBase?.workspaceId - ) + const updatedChunk = await updateChunk( + chunkId, + validatedData, + requestId, + accessCheck.knowledgeBase?.workspaceId + ) - logger.info( - `[${requestId}] Chunk updated: ${chunkId} in document ${documentId} in knowledge base ${knowledgeBaseId}` - ) + logger.info( + `[${requestId}] Chunk updated: ${chunkId} in document ${documentId} in knowledge base ${knowledgeBaseId}` + ) - 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 - } + 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..c977a8c5edf 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,13 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + bulkKnowledgeChunksContract, + createChunkBodySchema, + listKnowledgeChunksQuerySchema, +} from '@/lib/api/contracts/knowledge' +import { isZodError, 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,28 +17,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 +68,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 +76,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 +168,7 @@ export const POST = withRouteHandler( } try { - const validatedData = CreateChunkSchema.parse(searchParams) + const validatedData = createChunkBodySchema.parse(searchParams) const docTags = { // Text tags (7 slots) @@ -215,10 +205,15 @@ export const POST = withRouteHandler( let cost = null try { - cost = calculateCost('text-embedding-3-small', newChunk.tokenCount, 0, false) + cost = calculateCost( + accessCheck.knowledgeBase.embeddingModel, + newChunk.tokenCount, + 0, + false + ) } catch (error) { logger.warn(`[${requestId}] Failed to calculate cost for chunk upload`, { - error: error instanceof Error ? error.message : 'Unknown error', + error: getErrorMessage(error, 'Unknown error'), }) // Continue without cost information rather than failing the upload } @@ -240,7 +235,7 @@ export const POST = withRouteHandler( completion: 0, total: newChunk.tokenCount, }, - model: 'text-embedding-3-small', + model: accessCheck.knowledgeBase.embeddingModel, pricing: cost.pricing, }, } @@ -248,12 +243,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 } ) } @@ -304,36 +299,36 @@ export const PATCH = withRouteHandler( ) } - const body = await req.json() - - try { - const validatedData = BatchOperationSchema.parse(body) - const { operation, chunkIds } = validatedData - - const result = await batchChunkOperation(documentId, operation, chunkIds, requestId) - - return NextResponse.json({ - success: true, - data: { - operation, - successCount: result.processed, - errorCount: result.errors.length, - processed: result.processed, - errors: result.errors, + const parsed = await parseRequest( + bulkKnowledgeChunksContract, + req, + { params }, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid batch operation data`, { errors: error.issues }) + return NextResponse.json( + { error: 'Invalid request data', details: error.issues }, + { status: 400 } + ) }, - }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid batch operation data`, { - errors: validationError.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) } - throw validationError - } + ) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + const { operation, chunkIds } = validatedData + + const result = await batchChunkOperation(documentId, operation, chunkIds, requestId) + + return NextResponse.json({ + success: true, + data: { + operation, + successCount: result.processed, + errorCount: result.errors.length, + processed: result.processed, + errors: result.errors, + }, + }) } catch (error) { logger.error(`[${requestId}] Error in batch chunk operation`, error) return NextResponse.json({ error: 'Failed to perform batch operation' }, { status: 500 }) 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..65b9d940857 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 { updateKnowledgeDocumentContract } from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -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() @@ -120,122 +88,122 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await req.json() - - try { - const validatedData = UpdateDocumentSchema.parse(body) - - const updateData: any = {} - - if (validatedData.markFailedDueToTimeout) { - const doc = accessCheck.document - - if (doc.processingStatus !== 'processing') { + const parsed = await parseRequest( + updateKnowledgeDocumentContract, + req, + { params }, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid document update data`, { errors: error.issues }) return NextResponse.json( - { error: `Document is not in processing state (current: ${doc.processingStatus})` }, + { error: 'Invalid request data', details: error.issues }, { status: 400 } ) - } + }, + } + ) + if (!parsed.success) return parsed.response - if (!doc.processingStartedAt) { - return NextResponse.json( - { error: 'Document has no processing start time' }, - { status: 400 } - ) - } + const validatedData = parsed.data.body - try { - await markDocumentAsFailedTimeout(documentId, doc.processingStartedAt, requestId) - - return NextResponse.json({ - success: true, - data: { - documentId, - status: 'failed', - message: 'Document marked as failed due to timeout', - }, - }) - } catch (error) { - if (error instanceof Error) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - throw error - } - } else if (validatedData.retryProcessing) { - const doc = accessCheck.document + const updateData: any = {} - if (doc.processingStatus !== 'failed') { - return NextResponse.json({ error: 'Document is not in failed state' }, { status: 400 }) - } + if (validatedData.markFailedDueToTimeout) { + const doc = accessCheck.document - const docData = { - filename: doc.filename, - fileUrl: doc.fileUrl, - fileSize: doc.fileSize, - mimeType: doc.mimeType, - } + if (doc.processingStatus !== 'processing') { + return NextResponse.json( + { error: `Document is not in processing state (current: ${doc.processingStatus})` }, + { status: 400 } + ) + } - const result = await retryDocumentProcessing( - knowledgeBaseId, - documentId, - docData, - requestId + if (!doc.processingStartedAt) { + return NextResponse.json( + { error: 'Document has no processing start time' }, + { status: 400 } ) + } + + try { + await markDocumentAsFailedTimeout(documentId, doc.processingStartedAt, requestId) return NextResponse.json({ success: true, data: { documentId, - status: result.status, - message: result.message, + status: 'failed', + message: 'Document marked as failed due to timeout', }, }) - } else { - const updatedDocument = await updateDocument(documentId, validatedData, requestId) - - logger.info( - `[${requestId}] Document updated: ${documentId} in knowledge base ${knowledgeBaseId}` - ) + } catch (error) { + if (error instanceof Error) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + throw error + } + } else if (validatedData.retryProcessing) { + const doc = accessCheck.document - recordAudit({ - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.DOCUMENT_UPDATED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: documentId, - resourceName: validatedData.filename ?? accessCheck.document?.filename, - description: `Updated document "${validatedData.filename ?? accessCheck.document?.filename}" in knowledge base "${knowledgeBaseId}"`, - metadata: { - knowledgeBaseId, - knowledgeBaseName: accessCheck.knowledgeBase?.name, - fileName: validatedData.filename ?? accessCheck.document?.filename, - updatedFields: Object.keys(validatedData).filter( - (k) => validatedData[k as keyof typeof validatedData] !== undefined - ), - ...(validatedData.enabled !== undefined && { enabled: validatedData.enabled }), - }, - request: req, - }) + if (doc.processingStatus !== 'failed') { + return NextResponse.json({ error: 'Document is not in failed state' }, { status: 400 }) + } - return NextResponse.json({ - success: true, - data: updatedDocument, - }) + const docData = { + filename: doc.filename, + fileUrl: doc.fileUrl, + fileSize: doc.fileSize, + mimeType: doc.mimeType, } - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid document update data`, { - errors: validationError.errors, + + const result = await retryDocumentProcessing( + knowledgeBaseId, + documentId, + docData, + requestId + ) + + return NextResponse.json({ + success: true, + data: { documentId, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) - } - throw validationError + status: result.status, + message: result.message, + }, + }) + } else { + const updatedDocument = await updateDocument(documentId, validatedData, requestId) + + logger.info( + `[${requestId}] Document updated: ${documentId} in knowledge base ${knowledgeBaseId}` + ) + + recordAudit({ + workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, + actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + action: AuditAction.DOCUMENT_UPDATED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: documentId, + resourceName: validatedData.filename ?? accessCheck.document?.filename, + description: `Updated document "${validatedData.filename ?? accessCheck.document?.filename}" in knowledge base "${knowledgeBaseId}"`, + metadata: { + knowledgeBaseId, + knowledgeBaseName: accessCheck.knowledgeBase?.name, + fileName: validatedData.filename ?? accessCheck.document?.filename, + updatedFields: Object.keys(validatedData).filter( + (k) => validatedData[k as keyof typeof validatedData] !== undefined + ), + ...(validatedData.enabled !== undefined && { enabled: validatedData.enabled }), + }, + request: req, + }) + + return NextResponse.json({ + success: true, + data: updatedDocument, + }) } } catch (error) { logger.error(`[${requestId}] Error updating document ${documentId}`, error) 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..f7ffcea6415 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 { saveDocumentTagDefinitionsContract } from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants' @@ -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 }> }) => { @@ -76,9 +65,9 @@ export const GET = withRouteHandler( // POST /api/knowledge/[id]/documents/[documentId]/tag-definitions - Create/update tag definitions export const POST = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { + async (req: NextRequest, context: { params: Promise<{ id: string; documentId: string }> }) => { const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId, documentId } = await params + const { id: knowledgeBaseId, documentId } = await context.params try { logger.info(`[${requestId}] Creating/updating tag definitions for document ${documentId}`) @@ -107,24 +96,25 @@ 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 }) - } - - if (!body || typeof body !== 'object') { - logger.error(`[${requestId}] Invalid request body:`, body) - return NextResponse.json( - { error: 'Request body must be a valid JSON object' }, - { status: 400 } - ) + const parsed = await parseRequest(saveDocumentTagDefinitionsContract, req, context) + if (!parsed.success) return parsed.response + + const validatedData = parsed.data.body + + for (const def of validatedData.definitions) { + /** + * Defense-in-depth runtime check: the contract types `fieldType` as a plain + * string because tightening to the field-type enum cascades into UI form + * state types. Cast here to allow `includes` to accept the wider input. + */ + if (!(SUPPORTED_FIELD_TYPES as readonly string[]).includes(def.fieldType)) { + return NextResponse.json( + { error: 'Invalid request data', details: `Unsupported field type: ${def.fieldType}` }, + { status: 400 } + ) + } } - const validatedData = BulkTagDefinitionsSchema.parse(body) - const bulkData: BulkTagDefinitionsData = { definitions: validatedData.definitions.map((def) => ({ tagSlot: def.tagSlot, @@ -145,13 +135,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..128f70ae37c 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -1,9 +1,15 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + bulkKnowledgeDocumentsContract, + createKnowledgeDocumentsContract, + listKnowledgeDocumentsQuerySchema, +} from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -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 ) @@ -172,26 +99,40 @@ export const POST = withRouteHandler( const { id: knowledgeBaseId } = await params try { - const body = await req.json() - const { workflowId } = body + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId + + const parsed = await parseRequest( + createKnowledgeDocumentsContract, + req, + { params }, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid document creation request`, { + errors: error.issues, + }) + return NextResponse.json( + { error: 'Invalid request data', details: error.issues }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const body = parsed.data.body + const workflowId = body.workflowId logger.info(`[${requestId}] Knowledge base document creation request`, { knowledgeBaseId, workflowId, hasWorkflowId: !!workflowId, - bodyKeys: Object.keys(body), + bulk: body.bulk === true, }) - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`, { - workflowId, - hasWorkflowId: !!workflowId, - }) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - if (workflowId) { const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId, @@ -222,175 +163,146 @@ export const POST = withRouteHandler( const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId if (body.bulk === true) { - try { - const validatedData = BulkCreateDocumentsSchema.parse(body) + const createdDocuments = await createDocumentRecords( + body.documents, + knowledgeBaseId, + requestId + ) - const createdDocuments = await createDocumentRecords( - validatedData.documents, - knowledgeBaseId, - requestId - ) + logger.info( + `[${requestId}] Starting controlled async processing of ${createdDocuments.length} documents` + ) - logger.info( - `[${requestId}] Starting controlled async processing of ${createdDocuments.length} documents` - ) + try { + const { PlatformEvents } = await import('@/lib/core/telemetry') + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId, + documentsCount: createdDocuments.length, + uploadType: 'bulk', + recipe: body.processingOptions?.recipe, + }) + } catch (_e) { + // Silently fail + } - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.knowledgeBaseDocumentsUploaded({ - knowledgeBaseId, - documentsCount: createdDocuments.length, - uploadType: 'bulk', - recipe: validatedData.processingOptions?.recipe, - }) - } catch (_e) { - // Silently fail + captureServerEvent( + userId, + 'knowledge_base_document_uploaded', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: kbWorkspaceId ?? '', + document_count: createdDocuments.length, + upload_type: 'bulk', + }, + { + ...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}), + setOnce: { first_document_uploaded_at: new Date().toISOString() }, } + ) - captureServerEvent( - userId, - 'knowledge_base_document_uploaded', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: kbWorkspaceId ?? '', - document_count: createdDocuments.length, - upload_type: 'bulk', - }, - { - ...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}), - setOnce: { first_document_uploaded_at: new Date().toISOString() }, - } - ) - - processDocumentsWithQueue( - createdDocuments, - knowledgeBaseId, - validatedData.processingOptions ?? {}, - requestId - ).catch((error: unknown) => { - logger.error(`[${requestId}] Critical error in document processing pipeline:`, error) - }) + processDocumentsWithQueue( + createdDocuments, + knowledgeBaseId, + body.processingOptions ?? {}, + requestId + ).catch((error: unknown) => { + logger.error(`[${requestId}] Critical error in document processing pipeline:`, error) + }) - recordAudit({ - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.DOCUMENT_UPLOADED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: knowledgeBaseId, - resourceName: `${createdDocuments.length} document(s)`, - description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${knowledgeBaseId}"`, - metadata: { - knowledgeBaseName: accessCheck.knowledgeBase?.name, - fileCount: createdDocuments.length, - }, - request: req, - }) + recordAudit({ + workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, + actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: knowledgeBaseId, + resourceName: `${createdDocuments.length} document(s)`, + description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${knowledgeBaseId}"`, + metadata: { + knowledgeBaseName: accessCheck.knowledgeBase?.name, + fileCount: createdDocuments.length, + }, + request: req, + }) - return NextResponse.json({ - success: true, - data: { - total: createdDocuments.length, - documentsCreated: createdDocuments.map((doc) => ({ - documentId: doc.documentId, - filename: doc.filename, - status: 'pending', - })), - processingMethod: 'background', - processingConfig: { - maxConcurrentDocuments: getProcessingConfig().maxConcurrentDocuments, - batchSize: getProcessingConfig().batchSize, - totalBatches: Math.ceil(createdDocuments.length / getProcessingConfig().batchSize), - }, + return NextResponse.json({ + success: true, + data: { + total: createdDocuments.length, + documentsCreated: createdDocuments.map((doc) => ({ + documentId: doc.documentId, + filename: doc.filename, + status: 'pending', + })), + processingMethod: 'background', + processingConfig: { + maxConcurrentDocuments: getProcessingConfig().maxConcurrentDocuments, + batchSize: getProcessingConfig().batchSize, + totalBatches: Math.ceil(createdDocuments.length / getProcessingConfig().batchSize), }, - }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid bulk processing request data`, { - errors: validationError.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) - } - throw validationError - } - } else { - try { - const validatedData = CreateDocumentSchema.parse(body) - - const newDocument = await createSingleDocument(validatedData, knowledgeBaseId, requestId) - - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.knowledgeBaseDocumentsUploaded({ - knowledgeBaseId, - documentsCount: 1, - uploadType: 'single', - mimeType: validatedData.mimeType, - fileSize: validatedData.fileSize, - }) - } catch (_e) { - // Silently fail - } + }, + }) + } - captureServerEvent( - userId, - 'knowledge_base_document_uploaded', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: kbWorkspaceId ?? '', - document_count: 1, - upload_type: 'single', - }, - { - ...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}), - setOnce: { first_document_uploaded_at: new Date().toISOString() }, - } - ) + const { bulk: _bulk, workflowId: _workflowId, ...singleDocumentData } = body + const newDocument = await createSingleDocument(singleDocumentData, knowledgeBaseId, requestId) - recordAudit({ - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.DOCUMENT_UPLOADED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: knowledgeBaseId, - resourceName: validatedData.filename, - description: `Uploaded document "${validatedData.filename}" to knowledge base "${knowledgeBaseId}"`, - metadata: { - knowledgeBaseName: accessCheck.knowledgeBase?.name, - fileName: validatedData.filename, - fileType: validatedData.mimeType, - fileSize: validatedData.fileSize, - }, - request: req, - }) + try { + const { PlatformEvents } = await import('@/lib/core/telemetry') + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId, + documentsCount: 1, + uploadType: 'single', + mimeType: singleDocumentData.mimeType, + fileSize: singleDocumentData.fileSize, + }) + } catch (_e) { + // Silently fail + } - return NextResponse.json({ - success: true, - data: newDocument, - }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid document data`, { - errors: validationError.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) - } - throw validationError + captureServerEvent( + userId, + 'knowledge_base_document_uploaded', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: kbWorkspaceId ?? '', + document_count: 1, + upload_type: 'single', + }, + { + ...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}), + setOnce: { first_document_uploaded_at: new Date().toISOString() }, } - } + ) + + recordAudit({ + workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, + actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: knowledgeBaseId, + resourceName: singleDocumentData.filename, + description: `Uploaded document "${singleDocumentData.filename}" to knowledge base "${knowledgeBaseId}"`, + metadata: { + knowledgeBaseName: accessCheck.knowledgeBase?.name, + fileName: singleDocumentData.filename, + fileType: singleDocumentData.mimeType, + fileSize: singleDocumentData.fileSize, + }, + request: req, + }) + + return NextResponse.json({ + success: true, + data: newDocument, + }) } catch (error) { logger.error(`[${requestId}] Error creating document`, error) - const errorMessage = error instanceof Error ? error.message : 'Failed to create document' + const errorMessage = getErrorMessage(error, 'Failed to create document') const isStorageLimitError = errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit') const isMissingKnowledgeBase = errorMessage === 'Knowledge base not found' @@ -428,55 +340,52 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await req.json() - - try { - const validatedData = BulkUpdateDocumentsSchema.parse(body) - const { operation, documentIds, selectAll, enabledFilter } = validatedData - - try { - let result - if (selectAll) { - result = await bulkDocumentOperationByFilter( - knowledgeBaseId, - operation, - enabledFilter, - requestId - ) - } else if (documentIds && documentIds.length > 0) { - result = await bulkDocumentOperation(knowledgeBaseId, operation, documentIds, requestId) - } else { - return NextResponse.json({ error: 'No documents specified' }, { status: 400 }) - } - - return NextResponse.json({ - success: true, - data: { - operation, - successCount: result.successCount, - updatedDocuments: result.updatedDocuments, - }, - }) - } catch (error) { - if (error instanceof Error && error.message === 'No valid documents found to update') { + const parsed = await parseRequest( + bulkKnowledgeDocumentsContract, + req, + { params }, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid bulk operation data`, { errors: error.issues }) return NextResponse.json( - { error: 'No valid documents found to update' }, - { status: 404 } + { error: 'Invalid request data', details: error.issues }, + { status: 400 } ) - } - throw error + }, } - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid bulk operation data`, { - errors: validationError.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } + ) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + const { operation, documentIds, selectAll, enabledFilter } = validatedData + + try { + let result + if (selectAll) { + result = await bulkDocumentOperationByFilter( + knowledgeBaseId, + operation, + enabledFilter, + requestId ) + } else if (documentIds && documentIds.length > 0) { + result = await bulkDocumentOperation(knowledgeBaseId, operation, documentIds, requestId) + } else { + return NextResponse.json({ error: 'No documents specified' }, { status: 400 }) + } + + return NextResponse.json({ + success: true, + data: { + operation, + successCount: result.successCount, + updatedDocuments: result.updatedDocuments, + }, + }) + } catch (error) { + if (error instanceof Error && error.message === 'No valid documents found to update') { + return NextResponse.json({ error: 'No valid documents found to update' }, { status: 404 }) } - throw validationError + throw error } } catch (error) { logger.error(`[${requestId}] Error in bulk document operation`, error) diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts new file mode 100644 index 00000000000..d5f64cf306e --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts @@ -0,0 +1,111 @@ +/** + * Tests for knowledge base document upsert API route + * + * @vitest-environment node + */ +import { + auditMock, + createMockRequest, + hybridAuthMock, + hybridAuthMockFns, + knowledgeApiUtilsMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDbChain } = vi.hoisted(() => { + const chain = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), + } + return { mockDbChain: chain } +}) + +vi.mock('@sim/db', () => ({ db: mockDbChain })) +vi.mock('@/lib/auth/hybrid', () => hybridAuthMock) +vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) +vi.mock('@sim/audit', () => auditMock) + +vi.mock('@/lib/knowledge/documents/service', () => ({ + createDocumentRecords: vi.fn(), + deleteDocument: vi.fn(), + getProcessingConfig: vi.fn().mockReturnValue({ maxConcurrentDocuments: 1, batchSize: 1 }), + processDocumentsWithQueue: vi.fn(), +})) + +import { createDocumentRecords, processDocumentsWithQueue } from '@/lib/knowledge/documents/service' +import { POST } from '@/app/api/knowledge/[id]/documents/upsert/route' +import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' + +describe('POST /api/knowledge/[id]/documents/upsert', () => { + const params = Promise.resolve({ id: 'kb-123' }) + + beforeEach(() => { + vi.clearAllMocks() + mockDbChain.select.mockReturnThis() + mockDbChain.from.mockReturnThis() + mockDbChain.where.mockReturnThis() + mockDbChain.limit.mockResolvedValue([]) + + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + userName: 'Test User', + userEmail: 'test@example.com', + }) + + vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ + hasAccess: true, + knowledgeBase: { id: 'kb-123', userId: 'user-1', workspaceId: 'ws-1', name: 'KB' }, + } as any) + + vi.mocked(createDocumentRecords).mockResolvedValue([ + { documentId: 'doc-new', filename: 'note.txt' }, + ] as any) + vi.mocked(processDocumentsWithQueue).mockResolvedValue(undefined as any) + }) + + const baseBody = { + filename: 'note.txt', + fileSize: 11, + mimeType: 'text/plain', + } + + it('accepts a data: URI', async () => { + const req = createMockRequest('POST', { + ...baseBody, + fileUrl: 'data:text/plain;base64,SGVsbG8gd29ybGQ=', + }) + const res = await POST(req, { params }) + expect(res.status).toBe(200) + expect(createDocumentRecords).toHaveBeenCalled() + }) + + it('accepts an https URL', async () => { + const req = createMockRequest('POST', { + ...baseBody, + fileUrl: 'https://example.com/note.txt', + }) + const res = await POST(req, { params }) + expect(res.status).toBe(200) + expect(createDocumentRecords).toHaveBeenCalled() + }) + + it.each([ + ['absolute local path', '/etc/passwd'], + ['app config path', '/app/.env'], + ['file:// URL', 'file:///etc/passwd'], + ['relative serve path', '/api/files/serve/kb/foo.pdf'], + ['ftp URL', 'ftp://example.com/file.pdf'], + ['parent traversal', '../../etc/passwd'], + ['windows path', 'C:\\Windows\\System32\\config\\SAM'], + ])('rejects %s with 400 and never invokes the pipeline', async (_label, fileUrl) => { + const req = createMockRequest('POST', { ...baseBody, fileUrl }) + const res = await POST(req, { params }) + expect(res.status).toBe(400) + expect(createDocumentRecords).not.toHaveBeenCalled() + expect(processDocumentsWithQueue).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts index 8dcc1385b61..4bde63da800 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts @@ -2,11 +2,13 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { document } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 { upsertKnowledgeDocumentContract } from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -19,34 +21,20 @@ 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 }> }) => { + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await params + const { id: knowledgeBaseId } = await context.params try { - const body = await req.json() + const parsed = await parseRequest(upsertKnowledgeDocumentContract, req, context) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Knowledge base document upsert request`, { knowledgeBaseId, - hasDocumentId: !!body.documentId, - filename: body.filename, + hasDocumentId: !!validatedData.documentId, + filename: validatedData.filename, }) const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) @@ -56,8 +44,6 @@ export const POST = withRouteHandler( } const userId = auth.userId - const validatedData = UpsertDocumentSchema.parse(body) - if (validatedData.workflowId) { const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId: validatedData.workflowId, @@ -231,17 +217,9 @@ 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' + const errorMessage = getErrorMessage(error, 'Failed to upsert document') const isStorageLimitError = errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit') const isMissingKnowledgeBase = errorMessage === 'Knowledge base not found' 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..6e46b7bb18f 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,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' +import { nextAvailableSlotContract } from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getNextAvailableSlot, getTagDefinitions } from '@/lib/knowledge/tags/service' @@ -10,15 +12,14 @@ const logger = createLogger('NextAvailableSlotAPI') // GET /api/knowledge/[id]/next-available-slot - Get the next available tag slot for a knowledge base and field type export const GET = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (req: NextRequest, context: { 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') - - if (!fieldType) { + const parsed = await parseRequest(nextAvailableSlotContract, req, context) + if (!parsed.success) { return NextResponse.json({ error: 'fieldType parameter is required' }, { status: 400 }) } + const { id: knowledgeBaseId } = parsed.data.params + const { fieldType } = parsed.data.query try { logger.info( diff --git a/apps/sim/app/api/knowledge/[id]/restore/route.ts b/apps/sim/app/api/knowledge/[id]/restore/route.ts index ece42f9f5dc..5dee08582a6 100644 --- a/apps/sim/app/api/knowledge/[id]/restore/route.ts +++ b/apps/sim/app/api/knowledge/[id]/restore/route.ts @@ -1,21 +1,25 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { knowledgeBase } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { restoreKnowledgeBaseContract } from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { KnowledgeBaseConflictError, restoreKnowledgeBase } from '@/lib/knowledge/service' +import { + getRestorableKnowledgeBase, + performRestoreKnowledgeBase, +} from '@/lib/knowledge/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreKnowledgeBaseAPI') export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const parsed = await parseRequest(restoreKnowledgeBaseContract, request, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -23,16 +27,7 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const [kb] = await db - .select({ - id: knowledgeBase.id, - name: knowledgeBase.name, - workspaceId: knowledgeBase.workspaceId, - userId: knowledgeBase.userId, - }) - .from(knowledgeBase) - .where(eq(knowledgeBase.id, id)) - .limit(1) + const kb = await getRestorableKnowledgeBase(id) if (!kb) { return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) @@ -47,35 +42,24 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - await restoreKnowledgeBase(id, requestId) + const result = await performRestoreKnowledgeBase({ + knowledgeBaseId: id, + userId: auth.userId, + requestId, + }) + if (!result.success) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) + } logger.info(`[${requestId}] Restored knowledge base ${id}`) - recordAudit({ - workspaceId: kb.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.KNOWLEDGE_BASE_RESTORED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: kb.name, - description: `Restored knowledge base "${kb.name}"`, - metadata: { - knowledgeBaseName: kb.name, - }, - request, - }) - return NextResponse.json({ success: true }) } catch (error) { - if (error instanceof KnowledgeBaseConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } - logger.error(`[${requestId}] Error restoring knowledge base ${id}`, error) return NextResponse.json( - { error: error instanceof Error ? error.message : 'Internal server error' }, + { error: getErrorMessage(error, 'Internal server error') }, { status: 500 } ) } diff --git a/apps/sim/app/api/knowledge/[id]/route.test.ts b/apps/sim/app/api/knowledge/[id]/route.test.ts index ff58a3149e1..2d0dc1ce2e2 100644 --- a/apps/sim/app/api/knowledge/[id]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/route.test.ts @@ -31,6 +31,7 @@ vi.mock('@/lib/knowledge/service', async (importOriginal) => { getKnowledgeBaseById: vi.fn(), updateKnowledgeBase: vi.fn(), deleteKnowledgeBase: vi.fn(), + KnowledgeBasePermissionError: actual.KnowledgeBasePermissionError, } }) @@ -39,6 +40,7 @@ vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) import { deleteKnowledgeBase, getKnowledgeBaseById, + KnowledgeBasePermissionError, updateKnowledgeBase, } from '@/lib/knowledge/service' import { DELETE, GET, PUT } from '@/app/api/knowledge/[id]/route' @@ -229,10 +231,59 @@ describe('Knowledge Base By ID API Route', () => { workspaceId: undefined, chunkingConfig: undefined, }, - expect.any(String) + expect.any(String), + { actorUserId: 'user-123' } ) }) + it('returns 403 when service rejects a cross-workspace transfer', async () => { + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'attacker', email: 'a@example.com' }, + }) + + resetMocks() + + vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ + hasAccess: true, + knowledgeBase: { id: 'kb-123', userId: 'user-123', workspaceId: 'ws-current' }, + }) + + vi.mocked(updateKnowledgeBase).mockRejectedValueOnce( + new KnowledgeBasePermissionError('User does not have permission on the target workspace') + ) + + const req = createMockRequest('PUT', { workspaceId: 'ws-target' }) + const response = await PUT(req, { params: mockParams }) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toBe('User does not have permission on the target workspace') + }) + + it('returns 403 when service rejects clearing workspaceId', async () => { + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-123', email: 'test@example.com' }, + }) + + resetMocks() + + vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ + hasAccess: true, + knowledgeBase: { id: 'kb-123', userId: 'user-123', workspaceId: 'ws-current' }, + }) + + vi.mocked(updateKnowledgeBase).mockRejectedValueOnce( + new KnowledgeBasePermissionError('Knowledge base workspace cannot be cleared') + ) + + const req = createMockRequest('PUT', { workspaceId: null }) + const response = await PUT(req, { params: mockParams }) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toBe('Knowledge base workspace cannot be cleared') + }) + it('should return unauthorized for unauthenticated user', async () => { authMockFns.mockGetSession.mockResolvedValue(null) @@ -285,7 +336,7 @@ describe('Knowledge Base By ID API Route', () => { const data = await response.json() expect(response.status).toBe(400) - expect(data.error).toBe('Invalid request data') + expect(data.error).toBe('Validation error') expect(data.details).toBeDefined() }) diff --git a/apps/sim/app/api/knowledge/[id]/route.ts b/apps/sim/app/api/knowledge/[id]/route.ts index 6f97a2515c4..34a85d1a684 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 { updateKnowledgeBaseContract } from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' @@ -10,48 +11,13 @@ import { deleteKnowledgeBase, getKnowledgeBaseById, KnowledgeBaseConflictError, + KnowledgeBasePermissionError, updateKnowledgeBase, } from '@/lib/knowledge/service' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' 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() @@ -98,9 +64,9 @@ export const GET = withRouteHandler( ) export const PUT = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const { id } = await context.params try { const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) @@ -123,71 +89,64 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await req.json() + const parsed = await parseRequest(updateKnowledgeBaseContract, req, context) + if (!parsed.success) return parsed.response - try { - const validatedData = UpdateKnowledgeBaseSchema.parse(body) + const validatedData = parsed.data.body - 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, + { actorUserId: userId } + ) - 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 }) } + if (error instanceof KnowledgeBasePermissionError) { + logger.warn(`[${requestId}] Forbidden knowledge base update on ${id}: ${error.message}`) + return NextResponse.json({ error: error.message }, { status: 403 }) + } logger.error(`[${requestId}] Error updating knowledge base`, error) return NextResponse.json({ error: 'Failed to update knowledge base' }, { status: 500 }) 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..83b418b73f7 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 { deleteTagDefinitionContract } from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteTagDefinition } from '@/lib/knowledge/tags/service' @@ -12,9 +14,11 @@ const logger = createLogger('TagDefinitionAPI') // DELETE /api/knowledge/[id]/tag-definitions/[tagId] - Delete a tag definition export const DELETE = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string; tagId: string }> }) => { + async (req: NextRequest, context: { params: Promise<{ id: string; tagId: string }> }) => { const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId, tagId } = await params + const parsed = await parseRequest(deleteTagDefinitionContract, req, context) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId, tagId } = parsed.data.params 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..8d8b1cc41be 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 { createTagDefinitionContract } from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants' @@ -56,9 +57,9 @@ export const GET = withRouteHandler( // POST /api/knowledge/[id]/tag-definitions - Create a new tag definition export const POST = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await params + const { id: knowledgeBaseId } = await context.params try { logger.info(`[${requestId}] Creating tag definition for knowledge base ${knowledgeBaseId}`) @@ -79,27 +80,15 @@ export const POST = withRouteHandler( } } - const body = await req.json() - - 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' }), - }), - }) + const parsed = await parseRequest(createTagDefinitionContract, req, context) + if (!parsed.success) return parsed.response - 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 validatedData = parsed.data.body + if (!(SUPPORTED_FIELD_TYPES as readonly string[]).includes(validatedData.fieldType)) { + return NextResponse.json( + { error: 'Invalid request data', details: 'Invalid field type' }, + { status: 400 } + ) } const newTagDefinition = await createTagDefinition( 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..7412ccf307a 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 { getTagUsageContract } from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getTagUsage } from '@/lib/knowledge/tags/service' @@ -12,9 +14,11 @@ const logger = createLogger('TagUsageAPI') // GET /api/knowledge/[id]/tag-usage - Get usage statistics for all tag definitions export const GET = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await params + const parsed = await parseRequest(getTagUsageContract, req, context) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId } = parsed.data.params try { logger.info( diff --git a/apps/sim/app/api/knowledge/route.test.ts b/apps/sim/app/api/knowledge/route.test.ts index bc0ab08d755..4ad2aad2acf 100644 --- a/apps/sim/app/api/knowledge/route.test.ts +++ b/apps/sim/app/api/knowledge/route.test.ts @@ -155,6 +155,23 @@ describe('Knowledge Base API Route', () => { expect(data.details).toBeDefined() }) + it('returns 403 when user lacks permission on target workspace', async () => { + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'attacker', email: 'a@example.com' }, + }) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read') + + const req = createMockRequest('POST', validKnowledgeBaseData) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toBe( + 'User does not have permission to create knowledge bases in this workspace' + ) + expect(mockDbChain.insert).not.toHaveBeenCalled() + }) + it('should validate chunking config constraints', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' }, diff --git a/apps/sim/app/api/knowledge/route.ts b/apps/sim/app/api/knowledge/route.ts index 7f8b0c1309b..e14efd14656 100644 --- a/apps/sim/app/api/knowledge/route.ts +++ b/apps/sim/app/api/knowledge/route.ts @@ -1,79 +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 { + createKnowledgeBaseContract, + listKnowledgeBasesQuerySchema, +} from '@/lib/api/contracts/knowledge' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' import { createKnowledgeBase, getKnowledgeBases, KnowledgeBaseConflictError, + KnowledgeBasePermissionError, type KnowledgeBaseScope, } from '@/lib/knowledge/service' 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 +33,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, @@ -113,14 +71,32 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await req.json() + const parsed = await parseRequest( + createKnowledgeBaseContract, + req, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid knowledge base data`, { errors: error.issues }) + return NextResponse.json( + { error: 'Invalid request data', details: error.issues }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + + const validatedData = parsed.data.body try { - const validatedData = CreateKnowledgeBaseSchema.parse(body) + const embeddingModel = getConfiguredEmbeddingModel() const createData = { ...validatedData, userId: session.user.id, + embeddingModel, + embeddingDimension: EMBEDDING_DIMENSIONS, } const newKnowledgeBase = await createKnowledgeBase(createData, requestId) @@ -166,8 +142,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { metadata: { name: validatedData.name, description: validatedData.description, - embeddingModel: validatedData.embeddingModel, - embeddingDimension: validatedData.embeddingDimension, + embeddingModel, + embeddingDimension: EMBEDDING_DIMENSIONS, chunkingStrategy: validatedData.chunkingConfig.strategy, chunkMaxSize: validatedData.chunkingConfig.maxSize, chunkMinSize: validatedData.chunkingConfig.minSize, @@ -180,23 +156,17 @@ export const POST = withRouteHandler(async (req: NextRequest) => { success: true, data: newKnowledgeBase, }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid knowledge base data`, { - errors: validationError.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) + } catch (createError) { + if (createError instanceof KnowledgeBaseConflictError) { + return NextResponse.json({ error: createError.message }, { status: 409 }) } - throw validationError + if (createError instanceof KnowledgeBasePermissionError) { + logger.warn(`[${requestId}] Forbidden knowledge base creation: ${createError.message}`) + return NextResponse.json({ error: createError.message }, { status: 403 }) + } + throw createError } } catch (error) { - if (error instanceof KnowledgeBaseConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } - logger.error(`[${requestId}] Error creating knowledge base`, error) return NextResponse.json({ error: 'Failed to create knowledge base' }, { status: 500 }) } diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index 52c1fc47ccf..0b36497d0ad 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -24,7 +24,7 @@ const { mockHandleTagAndVectorSearch, mockGetQueryStrategy, mockGenerateSearchEmbedding, - mockGetDocumentNamesByIds, + mockGetDocumentMetadataByIds, } = vi.hoisted(() => ({ mockDbChain: { select: vi.fn().mockReturnThis(), @@ -43,7 +43,7 @@ const { mockHandleTagAndVectorSearch: vi.fn(), mockGetQueryStrategy: vi.fn(), mockGenerateSearchEmbedding: vi.fn(), - mockGetDocumentNamesByIds: vi.fn(), + mockGetDocumentMetadataByIds: vi.fn(), })) const mockCheckKnowledgeBaseAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess @@ -101,7 +101,7 @@ vi.mock('./utils', () => ({ handleTagAndVectorSearch: mockHandleTagAndVectorSearch, getQueryStrategy: mockGetQueryStrategy, generateSearchEmbedding: mockGenerateSearchEmbedding, - getDocumentNamesByIds: mockGetDocumentNamesByIds, + getDocumentMetadataByIds: mockGetDocumentMetadataByIds, APIError: class APIError extends Error { public status: number constructor(message: string, status: number) { @@ -159,9 +159,9 @@ describe('Knowledge Search API Route', () => { singleQueryOptimized: true, }) mockGenerateSearchEmbedding.mockClear().mockResolvedValue([0.1, 0.2, 0.3, 0.4, 0.5]) - mockGetDocumentNamesByIds.mockClear().mockResolvedValue({ - doc1: 'Document 1', - doc2: 'Document 2', + mockGetDocumentMetadataByIds.mockClear().mockResolvedValue({ + doc1: { filename: 'Document 1', sourceUrl: null }, + doc2: { filename: 'Document 2', sourceUrl: null }, }) mockGetDocumentTagDefinitions.mockClear() hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockClear().mockResolvedValue({ @@ -413,7 +413,7 @@ describe('Knowledge Search API Route', () => { const data = await response.json() expect(response.status).toBe(400) - expect(data.error).toBe('Invalid request data') + expect(data.error).toBe('Validation error') expect(data.details).toBeDefined() }) @@ -432,6 +432,7 @@ describe('Knowledge Search API Route', () => { userId: 'user-123', name: 'Test KB', deletedAt: null, + embeddingModel: 'text-embedding-3-small', }, }) @@ -524,6 +525,7 @@ describe('Knowledge Search API Route', () => { userId: 'user-123', name: 'Test KB', deletedAt: null, + embeddingModel: 'text-embedding-3-small', }, }) @@ -571,6 +573,7 @@ describe('Knowledge Search API Route', () => { userId: 'user-123', name: 'Test KB', deletedAt: null, + embeddingModel: 'text-embedding-3-small', }, }) @@ -625,6 +628,7 @@ describe('Knowledge Search API Route', () => { userId: 'user-123', name: 'Test KB', deletedAt: null, + embeddingModel: 'text-embedding-3-small', }, }) @@ -694,6 +698,7 @@ describe('Knowledge Search API Route', () => { userId: 'user-123', name: 'Test KB', deletedAt: null, + embeddingModel: 'text-embedding-3-small', }, }) @@ -739,6 +744,7 @@ describe('Knowledge Search API Route', () => { userId: 'user-123', name: 'Test KB', deletedAt: null, + embeddingModel: 'text-embedding-3-small', }, }) @@ -788,7 +794,7 @@ describe('Knowledge Search API Route', () => { const data = await response.json() expect(response.status).toBe(400) - expect(data.error).toBe('Invalid request data') + expect(data.error).toBe('Validation error') expect(data.details).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -812,7 +818,7 @@ describe('Knowledge Search API Route', () => { const data = await response.json() expect(response.status).toBe(400) - expect(data.error).toBe('Invalid request data') + expect(data.error).toBe('Validation error') }) it('should handle empty tag values gracefully', async () => { @@ -827,7 +833,7 @@ describe('Knowledge Search API Route', () => { const data = await response.json() expect(response.status).toBe(400) - expect(data.error).toBe('Invalid request data') + expect(data.error).toBe('Validation error') expect(data.details).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -851,7 +857,7 @@ describe('Knowledge Search API Route', () => { const data = await response.json() expect(response.status).toBe(400) - expect(data.error).toBe('Invalid request data') + expect(data.error).toBe('Validation error') expect(data.details).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -877,6 +883,7 @@ describe('Knowledge Search API Route', () => { userId: 'user-123', name: 'Test KB', deletedAt: null, + embeddingModel: 'text-embedding-3-small', }, }) @@ -921,11 +928,17 @@ describe('Knowledge Search API Route', () => { userId: 'user-123', name: 'Test KB', deletedAt: null, + embeddingModel: 'text-embedding-3-small', }, }) .mockResolvedValueOnce({ hasAccess: true, - knowledgeBase: { id: 'kb-456', userId: 'user-123', name: 'Test KB 2' }, + knowledgeBase: { + id: 'kb-456', + userId: 'user-123', + name: 'Test KB 2', + embeddingModel: 'text-embedding-3-small', + }, }) mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions) @@ -985,8 +998,11 @@ describe('Knowledge Search API Route', () => { }) mockGenerateSearchEmbedding.mockResolvedValue([0.1, 0.2, 0.3]) - mockGetDocumentNamesByIds.mockResolvedValue({ - 'doc-active': 'Active Document.pdf', + mockGetDocumentMetadataByIds.mockResolvedValue({ + 'doc-active': { + filename: 'Active Document.pdf', + sourceUrl: 'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345', + }, }) const mockTagDefs = { @@ -1010,6 +1026,9 @@ describe('Knowledge Search API Route', () => { expect(data.data.results).toHaveLength(1) expect(data.data.results[0].documentId).toBe('doc-active') expect(data.data.results[0].documentName).toBe('Active Document.pdf') + expect(data.data.results[0].sourceUrl).toBe( + 'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345' + ) }) it('should exclude results from deleted documents in tag search', async () => { @@ -1054,8 +1073,8 @@ describe('Knowledge Search API Route', () => { singleQueryOptimized: true, }) - mockGetDocumentNamesByIds.mockResolvedValue({ - 'doc-active-tagged': 'Active Tagged Document.pdf', + mockGetDocumentMetadataByIds.mockResolvedValue({ + 'doc-active-tagged': { filename: 'Active Tagged Document.pdf', sourceUrl: null }, }) const mockTagDefs = { @@ -1127,8 +1146,8 @@ describe('Knowledge Search API Route', () => { }) mockGenerateSearchEmbedding.mockResolvedValue([0.1, 0.2, 0.3]) - mockGetDocumentNamesByIds.mockResolvedValue({ - 'doc-active-combined': 'Active Combined Search.pdf', + mockGetDocumentMetadataByIds.mockResolvedValue({ + 'doc-active-combined': { filename: 'Active Combined Search.pdf', sourceUrl: null }, }) const mockTagDefs = { diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index 6c9db51ccc2..482ee61950b 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -1,81 +1,42 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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, validationErrorResponse } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' +import { getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models' +import { rerank } from '@/lib/knowledge/reranker' import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' import type { StructuredFilter } from '@/lib/knowledge/types' import { estimateTokenCount } from '@/lib/tokenization/estimators' import { generateSearchEmbedding, - getDocumentNamesByIds, + getDocumentMetadataByIds, getQueryStrategy, handleTagAndVectorSearch, handleTagOnlySearch, handleVectorOnlySearch, type SearchResult, } from '@/app/api/knowledge/search/utils' -import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' +import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' +import { getRerankModelPricing } from '@/providers/models' 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 +47,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (workflowId) { const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, + workflowId: workflowId as string, userId, action: 'read', }) @@ -98,354 +59,440 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } - try { - const validatedData = VectorSearchSchema.parse(searchParams) + const validation = knowledgeSearchBodySchema.safeParse(searchParams) + if (!validation.success) return validationErrorResponse(validation.error) + const validatedData = validation.data - const knowledgeBaseIds = Array.isArray(validatedData.knowledgeBaseIds) - ? validatedData.knowledgeBaseIds - : [validatedData.knowledgeBaseIds] + const 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 accessibleKbs = accessChecks + .filter((ac): ac is KnowledgeBaseAccessResult => Boolean(ac?.hasAccess)) + .map((ac) => ac.knowledgeBase) + const workspaceId = accessibleKbs[0]?.workspaceId + + const useReranker = validatedData.rerankerEnabled && Boolean(validatedData.query?.trim()) + const rerankerModel = useReranker ? validatedData.rerankerModel : null + + const hasQuery = validatedData.query && validatedData.query.trim().length > 0 + const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel))) + if (hasQuery && embeddingModels.length > 1) { + return NextResponse.json( + { + error: + 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.', + }, + { status: 400 } + ) + } + const queryEmbeddingModel = embeddingModels[0] - const 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) { + const queryEmbeddingPromise = hasQuery + ? generateSearchEmbedding(validatedData.query!, queryEmbeddingModel, workspaceId) + : Promise.resolve(null) + + 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 + /** Oversample vector results when reranking so the reranker has more to choose from. + * Cap at 100 to bound Cohere request cost (1 search unit = ≤100 docs). When the caller + * supplies `rerankerInputCount`, honor it but never let it drop below `topK` + * (which would defeat the purpose) or exceed 100 (which would split into >1 search units). */ + const rawInputCount = validatedData.rerankerInputCount + if (useReranker && rawInputCount !== undefined && rawInputCount < validatedData.topK) { + logger.warn( + `[${requestId}] rerankerInputCount (${rawInputCount}) is below topK (${validatedData.topK}); raising to topK` + ) + } + const candidateTopK = useReranker + ? rawInputCount !== undefined + ? Math.min(100, Math.max(validatedData.topK, rawInputCount)) + : Math.min(100, validatedData.topK * 4) + : validatedData.topK + + if (!hasQuery && hasFilters) { + results = await 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, candidateTopK) + const queryVector = JSON.stringify(await queryEmbeddingPromise) + + results = await handleTagAndVectorSearch({ + knowledgeBaseIds: accessibleKbIds, + topK: candidateTopK, + structuredFilters, + queryVector, + distanceThreshold: strategy.distanceThreshold, + }) + } else if (hasQuery && !hasFilters) { + const strategy = getQueryStrategy(accessibleKbIds.length, candidateTopK) + const queryVector = JSON.stringify(await queryEmbeddingPromise) + + results = await handleVectorOnlySearch({ + knowledgeBaseIds: accessibleKbIds, + topK: candidateTopK, + 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 + /** Optional Cohere rerank pass on top of vector results. + * `rerankBilled` = Cohere was successfully called (even with 0 results) and we owe the search unit. */ + const rerankedScores = new Map() + let rerankBilled = false + let rerankIsBYOK = false + if (useReranker && rerankerModel && results.length > 0) { + const candidateCount = results.length + try { + const { results: ranked, isBYOK } = await rerank( + validatedData.query!, + results.map((r) => ({ id: r.id, text: r.content })), + { + model: rerankerModel, + topN: validatedData.topK, + workspaceId, + apiKey: validatedData.rerankerApiKey, + } ) - 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, + rerankBilled = true + rerankIsBYOK = isBYOK + if (ranked.length === 0) { + logger.warn( + `[${requestId}] Reranker returned 0 results; falling back to vector ordering`, + { model: rerankerModel, candidateCount } + ) + results = results.slice(0, validatedData.topK) + } else { + const idToResult = new Map(results.map((r) => [r.id, r])) + results = ranked + .map((r) => idToResult.get(r.item.id)) + .filter((r): r is SearchResult => Boolean(r)) + for (const r of ranked) rerankedScores.set(r.item.id, r.relevanceScore) + logger.info(`[${requestId}] Reranked ${candidateCount} → ${results.length} results`, { + model: rerankerModel, + }) + } + } catch (error) { + logger.warn(`[${requestId}] Reranker failed; falling back to vector ordering`, { + error: getErrorMessage(error, 'Unknown error'), + model: rerankerModel, + candidateCount, + workspaceId, }) - } 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, + results = results.slice(0, validatedData.topK) + } + } else if (useReranker) { + results = results.slice(0, validatedData.topK) + } + + let cost = null + let tokenCount = null + if (hasQuery) { + try { + tokenCount = estimateTokenCount( + validatedData.query!, + getEmbeddingModelInfo(queryEmbeddingModel).tokenizerProvider + ) + cost = calculateCost(queryEmbeddingModel, tokenCount.count, 0, false) + } catch (error) { + logger.warn(`[${requestId}] Failed to calculate cost for search query`, { + error: getErrorMessage(error, 'Unknown error'), }) + } + } + + /** Add Cohere rerank cost (1 search unit per successful call, since we cap candidates ≤100). + * Bill on every successful API response — Cohere charges even when 0 results are returned. */ + let rerankerCost = 0 + if (rerankBilled && rerankerModel && !rerankIsBYOK) { + const pricing = getRerankModelPricing(rerankerModel) + if (pricing) { + rerankerCost = pricing.perSearchUnit + if (cost) { + cost = { + ...cost, + input: cost.input + rerankerCost, + total: cost.total + rerankerCost, + } + } else { + cost = { + input: rerankerCost, + output: 0, + total: rerankerCost, + pricing: { input: 0, output: 0, updatedAt: pricing.updatedAt }, + } + } } else { - // 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 } - ) + logger.warn(`[${requestId}] No pricing entry for rerank model ${rerankerModel}`) } + } - // 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 documentMetadataMap = await getDocumentMetadataByIds(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, + }) + + const rerankerScore = rerankedScores.get(result.id) + const docMeta = documentMetadataMap[result.documentId] + return { + documentId: result.documentId, + documentName: docMeta?.filename || undefined, + sourceUrl: docMeta?.sourceUrl ?? null, + content: result.content, + chunkIndex: result.chunkIndex, + metadata: tags, + similarity: hasQuery ? 1 - result.distance : 1, + ...(rerankerScore !== undefined && { rerankerScore }), + } + }), + query: validatedData.query || '', + knowledgeBaseIds: accessibleKbIds, + knowledgeBaseId: accessibleKbIds[0], + topK: validatedData.topK, + totalResults: results.length, + ...(cost + ? { + cost: { + input: cost.input, + output: cost.output, + total: cost.total, + tokens: { + prompt: tokenCount?.count ?? 0, + completion: 0, + total: tokenCount?.count ?? 0, }, - } - : {}), - }, - }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) - } - throw validationError - } + model: queryEmbeddingModel, + pricing: cost.pricing, + ...(rerankBilled && !rerankIsBYOK + ? { rerankerCost, rerankerModel, rerankerSearchUnits: 1 } + : {}), + }, + } + : {}), + }, + }) } catch (error) { return NextResponse.json( { error: 'Failed to perform vector search', - message: error instanceof Error ? error.message : 'Unknown error', + message: getErrorMessage(error, 'Unknown error'), }, { status: 500 } ) diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 9fd4fa34538..526fb12c73b 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -220,7 +220,7 @@ describe('Knowledge Search Utils', () => { Object.keys(env).forEach((key) => delete (env as any)[key]) }) - it('should use default API version when not provided in Azure config', async () => { + it('falls back to OpenAI when AZURE_OPENAI_API_VERSION is not set', async () => { const { env } = await import('@/lib/core/config/env') Object.keys(env).forEach((key) => delete (env as any)[key]) Object.assign(env, { @@ -240,7 +240,7 @@ describe('Knowledge Search Utils', () => { await generateSearchEmbedding('test query') expect(vi.mocked(fetch)).toHaveBeenCalledWith( - expect.stringContaining('api-version='), + 'https://api.openai.com/v1/embeddings', expect.any(Object) ) @@ -282,7 +282,7 @@ describe('Knowledge Search Utils', () => { Object.keys(env).forEach((key) => delete (env as any)[key]) await expect(generateSearchEmbedding('test query')).rejects.toThrow( - 'Either OPENAI_API_KEY or Azure OpenAI configuration (AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT) must be configured' + 'OPENAI_API_KEY is not configured' ) }) @@ -354,6 +354,7 @@ describe('Knowledge Search Utils', () => { body: JSON.stringify({ input: ['test query'], encoding_format: 'float', + dimensions: 1536, }), }) ) @@ -395,11 +396,11 @@ describe('Knowledge Search Utils', () => { }) }) - describe('getDocumentNamesByIds', () => { + describe('getDocumentMetadataByIds', () => { it('should handle empty input gracefully', async () => { - const { getDocumentNamesByIds } = await import('./utils') + const { getDocumentMetadataByIds } = await import('./utils') - const result = await getDocumentNamesByIds([]) + const result = await getDocumentMetadataByIds([]) expect(result).toEqual({}) }) diff --git a/apps/sim/app/api/knowledge/search/utils.ts b/apps/sim/app/api/knowledge/search/utils.ts index 8ca7e7c438a..afaff875b5b 100644 --- a/apps/sim/app/api/knowledge/search/utils.ts +++ b/apps/sim/app/api/knowledge/search/utils.ts @@ -3,9 +3,22 @@ import { document, embedding } from '@sim/db/schema' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' import type { StructuredFilter } from '@/lib/knowledge/types' -export async function getDocumentNamesByIds( +export interface DocumentMetadata { + filename: string + sourceUrl: string | null +} + +/** + * Batch-fetch display metadata for documents referenced by search results. + * Excludes documents that are user-excluded, archived, or soft-deleted — + * mirrors the visibility filters applied inside the search SQL itself, so + * the lookup will never surface metadata for a row a caller could not have + * legitimately matched. Returns a map keyed by document id; missing ids + * indicate the document is no longer visible and should be skipped. + */ +export async function getDocumentMetadataByIds( documentIds: string[] -): Promise> { +): Promise> { if (documentIds.length === 0) { return {} } @@ -15,6 +28,7 @@ export async function getDocumentNamesByIds( .select({ id: document.id, filename: document.filename, + sourceUrl: document.sourceUrl, }) .from(document) .where( @@ -26,12 +40,12 @@ export async function getDocumentNamesByIds( ) ) - const documentNameMap: Record = {} + const map: Record = {} documents.forEach((doc) => { - documentNameMap[doc.id] = doc.filename + map[doc.id] = { filename: doc.filename, sourceUrl: doc.sourceUrl ?? null } }) - return documentNameMap + return map } export interface SearchResult { diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index 650c7b1dc6b..326bbee660f 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -22,6 +22,10 @@ vi.mock('@/lib/knowledge/documents/utils', () => ({ retryWithExponentialBackoff: (fn: any) => fn(), })) +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBilledAccountUserId: vi.fn().mockResolvedValue('user1'), +})) + vi.mock('@/lib/knowledge/documents/document-processor', () => ({ processDocument: vi.fn().mockResolvedValue({ chunks: [ @@ -112,6 +116,32 @@ vi.mock('@sim/db', async () => { }, } }, + innerJoin() { + // document × knowledge_base context JOIN — return the first kb and + // doc row merged (covers processDocumentAsync's prefetch). + return { + leftJoin: () => ({ + where: () => ({ + limit: (n: number) => + Promise.resolve( + kbRows.length > 0 && docRows.length > 0 + ? [ + { ...kbRows[0], ...docRows[0], billedAccountUserId: 'billing-user-1' }, + ].slice(0, n) + : [] + ), + }), + }), + where: () => ({ + limit: (n: number) => + Promise.resolve( + kbRows.length > 0 && docRows.length > 0 + ? [{ ...kbRows[0], ...docRows[0] }].slice(0, n) + : [] + ), + }), + } + }, } }, } @@ -211,7 +241,8 @@ describe('Knowledge Utils', () => { kbRows.push({ id: 'kb1', userId: 'user1', - workspaceId: null, + workspaceId: 'workspace1', + embeddingModel: 'text-embedding-3-small', chunkingConfig: { maxSize: 1024, minSize: 1, overlap: 200 }, }) docRows.push({ id: 'doc1', knowledgeBaseId: 'kb1' }) @@ -370,7 +401,7 @@ describe('Knowledge Utils', () => { Object.keys(env).forEach((key) => delete (env as any)[key]) await expect(generateEmbeddings(['test text'])).rejects.toThrow( - 'Either OPENAI_API_KEY or Azure OpenAI configuration (AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT) must be configured' + 'OPENAI_API_KEY is not configured' ) }) }) diff --git a/apps/sim/app/api/knowledge/utils.ts b/apps/sim/app/api/knowledge/utils.ts index 60042ccccf1..9dac8ffdb8e 100644 --- a/apps/sim/app/api/knowledge/utils.ts +++ b/apps/sim/app/api/knowledge/utils.ts @@ -3,7 +3,7 @@ import { document, embedding, knowledgeBase } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -export interface KnowledgeBaseData { +interface KnowledgeBaseData { id: string userId: string workspaceId?: string | null @@ -18,7 +18,7 @@ export interface KnowledgeBaseData { updatedAt: Date } -export interface DocumentData { +interface DocumentData { id: string knowledgeBaseId: string filename: string @@ -62,7 +62,7 @@ export interface DocumentData { externalId?: string | null } -export interface EmbeddingData { +interface EmbeddingData { id: string knowledgeBaseId: string documentId: string @@ -103,10 +103,13 @@ export interface EmbeddingData { export interface KnowledgeBaseAccessResult { hasAccess: true - knowledgeBase: Pick + knowledgeBase: Pick< + KnowledgeBaseData, + 'id' | 'userId' | 'workspaceId' | 'name' | 'embeddingModel' + > } -export interface KnowledgeBaseAccessDenied { +interface KnowledgeBaseAccessDenied { hasAccess: false notFound?: boolean reason?: string @@ -114,13 +117,16 @@ export interface KnowledgeBaseAccessDenied { export type KnowledgeBaseAccessCheck = KnowledgeBaseAccessResult | KnowledgeBaseAccessDenied -export interface DocumentAccessResult { +interface DocumentAccessResult { hasAccess: true document: DocumentData - knowledgeBase: Pick + knowledgeBase: Pick< + KnowledgeBaseData, + 'id' | 'userId' | 'workspaceId' | 'name' | 'embeddingModel' + > } -export interface DocumentAccessDenied { +interface DocumentAccessDenied { hasAccess: false notFound?: boolean reason: string @@ -128,14 +134,17 @@ export interface DocumentAccessDenied { export type DocumentAccessCheck = DocumentAccessResult | DocumentAccessDenied -export interface ChunkAccessResult { +interface ChunkAccessResult { hasAccess: true chunk: EmbeddingData document: DocumentData - knowledgeBase: Pick + knowledgeBase: Pick< + KnowledgeBaseData, + 'id' | 'userId' | 'workspaceId' | 'name' | 'embeddingModel' + > } -export interface ChunkAccessDenied { +interface ChunkAccessDenied { hasAccess: false notFound?: boolean reason: string @@ -156,6 +165,7 @@ export async function checkKnowledgeBaseAccess( userId: knowledgeBase.userId, workspaceId: knowledgeBase.workspaceId, name: knowledgeBase.name, + embeddingModel: knowledgeBase.embeddingModel, }) .from(knowledgeBase) .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) @@ -200,6 +210,7 @@ export async function checkKnowledgeBaseWriteAccess( userId: knowledgeBase.userId, workspaceId: knowledgeBase.workspaceId, name: knowledgeBase.name, + embeddingModel: knowledgeBase.embeddingModel, }) .from(knowledgeBase) .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) diff --git a/apps/sim/app/api/logs/[id]/route.ts b/apps/sim/app/api/logs/[id]/route.ts index 639132abdd9..5c0acd33e08 100644 --- a/apps/sim/app/api/logs/[id]/route.ts +++ b/apps/sim/app/api/logs/[id]/route.ts @@ -1,182 +1,39 @@ -import { db } from '@sim/db' -import { - jobExecutionLogs, - permissions, - workflow, - workflowDeploymentVersion, - workflowExecutionLogs, -} from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' +import { getLogDetailContract } from '@/lib/api/contracts/logs' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { fetchLogDetail } from '@/lib/logs/fetch-log-detail' const logger = createLogger('LogDetailsByIdAPI') -export const revalidate = 0 - export const GET = withRouteHandler( - async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized log details access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - const { id } = await params - - const rows = await db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - executionId: workflowExecutionLogs.executionId, - stateSnapshotId: workflowExecutionLogs.stateSnapshotId, - deploymentVersionId: workflowExecutionLogs.deploymentVersionId, - level: workflowExecutionLogs.level, - status: workflowExecutionLogs.status, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - cost: workflowExecutionLogs.cost, - files: workflowExecutionLogs.files, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowColor: workflow.color, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - deploymentVersion: workflowDeploymentVersion.version, - deploymentVersionName: workflowDeploymentVersion.name, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .leftJoin( - workflowDeploymentVersion, - eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) - ) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(eq(workflowExecutionLogs.id, id)) - .limit(1) - - const log = rows[0] - - // Fallback: check job_execution_logs - if (!log) { - const jobRows = await db - .select({ - id: jobExecutionLogs.id, - executionId: jobExecutionLogs.executionId, - level: jobExecutionLogs.level, - status: jobExecutionLogs.status, - trigger: jobExecutionLogs.trigger, - startedAt: jobExecutionLogs.startedAt, - endedAt: jobExecutionLogs.endedAt, - totalDurationMs: jobExecutionLogs.totalDurationMs, - executionData: jobExecutionLogs.executionData, - cost: jobExecutionLogs.cost, - createdAt: jobExecutionLogs.createdAt, - }) - .from(jobExecutionLogs) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, jobExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(eq(jobExecutionLogs.id, id)) - .limit(1) - - const jobLog = jobRows[0] - if (!jobLog) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json( + { error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } - const execData = jobLog.executionData as Record | null - const response = { - id: jobLog.id, - workflowId: null, - executionId: jobLog.executionId, - deploymentVersionId: null, - deploymentVersion: null, - deploymentVersionName: null, - level: jobLog.level, - status: jobLog.status, - duration: jobLog.totalDurationMs ? `${jobLog.totalDurationMs}ms` : null, - trigger: jobLog.trigger, - createdAt: jobLog.startedAt.toISOString(), - workflow: null, - jobTitle: (execData?.trigger?.source as string) || null, - executionData: { - totalDuration: jobLog.totalDurationMs, - ...execData, - enhanced: true, - }, - cost: jobLog.cost as any, - } + const parsed = await parseRequest(getLogDetailContract, request, context) + if (!parsed.success) return parsed.response - return NextResponse.json({ data: response }) - } + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query - const workflowSummary = log.workflowId - ? { - id: log.workflowId, - name: log.workflowName, - description: log.workflowDescription, - color: log.workflowColor, - folderId: log.workflowFolderId, - userId: log.workflowUserId, - workspaceId: log.workflowWorkspaceId, - createdAt: log.workflowCreatedAt, - updatedAt: log.workflowUpdatedAt, - } - : null + const data = await fetchLogDetail({ + userId: authResult.userId, + workspaceId, + lookupColumn: 'id', + lookupValue: id, + }) - const response = { - id: log.id, - workflowId: log.workflowId, - executionId: log.executionId, - deploymentVersionId: log.deploymentVersionId, - deploymentVersion: log.deploymentVersion ?? null, - deploymentVersionName: log.deploymentVersionName ?? null, - level: log.level, - status: log.status, - duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, - trigger: log.trigger, - createdAt: log.startedAt.toISOString(), - files: log.files || undefined, - workflow: workflowSummary, - executionData: { - totalDuration: log.totalDurationMs, - ...(log.executionData as any), - enhanced: true, - }, - cost: log.cost as any, - } + if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - return NextResponse.json({ data: response }) - } catch (error: any) { - logger.error(`[${requestId}] log details fetch error`, error) - return NextResponse.json({ error: error.message }, { status: 500 }) - } + logger.debug('Fetched log detail', { id, workspaceId }) + return NextResponse.json({ data }) } ) diff --git a/apps/sim/app/api/logs/by-execution/[executionId]/route.ts b/apps/sim/app/api/logs/by-execution/[executionId]/route.ts new file mode 100644 index 00000000000..172a77506cc --- /dev/null +++ b/apps/sim/app/api/logs/by-execution/[executionId]/route.ts @@ -0,0 +1,36 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { getLogByExecutionIdContract } from '@/lib/api/contracts/logs' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { fetchLogDetail } from '@/lib/logs/fetch-log-detail' + +const logger = createLogger('LogDetailsByExecutionAPI') + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getLogByExecutionIdContract, request, context) + if (!parsed.success) return parsed.response + + const { executionId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const data = await fetchLogDetail({ + userId: session.user.id, + workspaceId, + lookupColumn: 'executionId', + lookupValue: executionId, + }) + + if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + logger.debug('Fetched log by execution id', { executionId, workspaceId }) + return NextResponse.json({ data }) + } +) 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/export/route.ts b/apps/sim/app/api/logs/export/route.ts index b814678caf6..2c817411b68 100644 --- a/apps/sim/app/api/logs/export/route.ts +++ b/apps/sim/app/api/logs/export/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' +import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' const logger = createLogger('LogsExportAPI') @@ -45,6 +46,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { workflowName: sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, } + if (params.folderIds) { + params.folderIds = await expandFolderIdsWithDescendants(params.workspaceId, params.folderIds) + } + const workspaceCondition = eq(workflowExecutionLogs.workspaceId, params.workspaceId) const filterConditions = buildFilterConditions(params) const conditions = filterConditions diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index 6c34126a9ec..89f52048b72 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -10,6 +10,7 @@ import { import { createLogger } from '@sim/logger' import { and, + asc, desc, eq, gt, @@ -24,587 +25,448 @@ import { type SQL, sql, } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { listLogsContract, type WorkflowLogSummary } from '@/lib/api/contracts/logs' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' +import { buildFilterConditions } from '@/lib/logs/filters' +import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' const logger = createLogger('LogsAPI') -export const revalidate = 0 +type SortBy = 'date' | 'duration' | 'cost' | 'status' +type SortOrder = 'asc' | 'desc' -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), -}) +interface CursorData { + v: string | number | null + id: string +} -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() +function encodeCursor(data: CursorData): string { + return Buffer.from(JSON.stringify(data)).toString('base64') +} +function decodeCursor(cursor: string): CursorData | null { try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized logs access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const parsed = JSON.parse(Buffer.from(cursor, 'base64').toString()) + if (typeof parsed?.id !== 'string') return null + return parsed as CursorData + } catch { + return null + } +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json( + { error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + const userId = authResult.userId + + const parsed = await parseRequest(listLogsContract, request, {}) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + const sortBy = params.sortBy as SortBy + const sortOrder = params.sortOrder as SortOrder + const cursor = params.cursor ? decodeCursor(params.cursor) : null + + const workflowSortExpr: SQL = (() => { + switch (sortBy) { + case 'duration': + return sql`${workflowExecutionLogs.totalDurationMs}` + case 'cost': + return sql`(${workflowExecutionLogs.cost}->>'total')::numeric` + case 'status': + return sql`${workflowExecutionLogs.status}` + default: + return sql`${workflowExecutionLogs.startedAt}` } + })() + + const jobSortExpr: SQL = (() => { + switch (sortBy) { + case 'duration': + return sql`${jobExecutionLogs.totalDurationMs}` + case 'cost': + return sql`(${jobExecutionLogs.cost}->>'total')::numeric` + case 'status': + return sql`${jobExecutionLogs.status}` + default: + return sql`${jobExecutionLogs.startedAt}` + } + })() + + const dir = sortOrder === 'asc' ? asc : desc + const nullsLast = sql`NULLS LAST` + const orderByClause = (expr: SQL): SQL => sql`${dir(expr)} ${nullsLast}` + + const buildCursorCondition = (sortExpr: unknown, idCol: unknown): SQL | undefined => { + if (!cursor) return undefined + const v = cursor.v + const id = cursor.id + const cmp = sortOrder === 'asc' ? sql`>` : sql`<` + if (v === null) { + return sql`(${sortExpr} IS NULL AND ${idCol} ${cmp} ${id})` + } + return sql`((${sortExpr} IS NOT NULL AND ${sortExpr} ${cmp} ${v}) OR (${sortExpr} = ${v} AND ${idCol} ${cmp} ${id}) OR ${sortExpr} IS NULL)` + } - const userId = session.user.id - - try { - const { searchParams } = new URL(request.url) - const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) - - const selectColumns = - params.details === 'full' - ? { - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - executionId: workflowExecutionLogs.executionId, - stateSnapshotId: workflowExecutionLogs.stateSnapshotId, - deploymentVersionId: workflowExecutionLogs.deploymentVersionId, - level: workflowExecutionLogs.level, - status: workflowExecutionLogs.status, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - cost: workflowExecutionLogs.cost, - files: workflowExecutionLogs.files, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowColor: workflow.color, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - pausedStatus: pausedExecutions.status, - pausedTotalPauseCount: pausedExecutions.totalPauseCount, - pausedResumedCount: pausedExecutions.resumedCount, - deploymentVersion: workflowDeploymentVersion.version, - deploymentVersionName: workflowDeploymentVersion.name, - } - : { - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - executionId: workflowExecutionLogs.executionId, - stateSnapshotId: workflowExecutionLogs.stateSnapshotId, - deploymentVersionId: workflowExecutionLogs.deploymentVersionId, - level: workflowExecutionLogs.level, - status: workflowExecutionLogs.status, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: sql`NULL`, - cost: workflowExecutionLogs.cost, - files: sql`NULL`, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowColor: workflow.color, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - pausedStatus: pausedExecutions.status, - pausedTotalPauseCount: pausedExecutions.totalPauseCount, - pausedResumedCount: pausedExecutions.resumedCount, - deploymentVersion: workflowDeploymentVersion.version, - deploymentVersionName: sql`NULL`, - } - - const workspaceFilter = eq(workflowExecutionLogs.workspaceId, params.workspaceId) - - const baseQuery = db - .select(selectColumns) - .from(workflowExecutionLogs) - .leftJoin( - pausedExecutions, - eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) - ) - .leftJoin( - workflowDeploymentVersion, - eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) - ) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) + const fetchSize = params.limit + 1 - let conditions: SQL | undefined + // Build workflow log conditions + const workflowConditions: SQL[] = [eq(workflowExecutionLogs.workspaceId, params.workspaceId)] - if (params.level && params.level !== 'all') { - const levels = params.level.split(',').filter(Boolean) - const levelConditions: SQL[] = [] + if (params.level && params.level !== 'all') { + const levels = params.level.split(',').filter(Boolean) + const levelConditions: SQL[] = [] - for (const level of levels) { - if (level === 'error') { - levelConditions.push(eq(workflowExecutionLogs.level, 'error')) - } else if (level === 'info') { - const condition = and( - eq(workflowExecutionLogs.level, 'info'), - isNotNull(workflowExecutionLogs.endedAt) - ) - if (condition) levelConditions.push(condition) - } else if (level === 'running') { - const condition = and( - eq(workflowExecutionLogs.level, 'info'), - isNull(workflowExecutionLogs.endedAt) - ) - if (condition) levelConditions.push(condition) - } else if (level === 'pending') { - const condition = and( - eq(workflowExecutionLogs.level, 'info'), - or( - sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`, - and( - isNotNull(pausedExecutions.status), - sql`${pausedExecutions.status} != 'fully_resumed'` - ) - ) + for (const level of levels) { + if (level === 'error') { + levelConditions.push(eq(workflowExecutionLogs.level, 'error')) + } else if (level === 'info') { + const c = and( + eq(workflowExecutionLogs.level, 'info'), + isNotNull(workflowExecutionLogs.endedAt) + ) + if (c) levelConditions.push(c) + } else if (level === 'running') { + const c = and( + eq(workflowExecutionLogs.level, 'info'), + isNull(workflowExecutionLogs.endedAt) + ) + if (c) levelConditions.push(c) + } else if (level === 'pending') { + const c = and( + eq(workflowExecutionLogs.level, 'info'), + or( + sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`, + and( + isNotNull(pausedExecutions.status), + sql`${pausedExecutions.status} != 'fully_resumed'` ) - if (condition) levelConditions.push(condition) - } - } - - if (levelConditions.length > 0) { - conditions = and( - conditions, - levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions) ) - } - } - - // Apply common filters (workflowIds, folderIds, triggers, dates, search, cost, duration) - // Level filtering is handled above with advanced running/pending state logic - const commonFilters = buildFilterConditions(params, { useSimpleLevelFilter: false }) - if (commonFilters) { - conditions = and(conditions, commonFilters) + ) + if (c) levelConditions.push(c) } + } - // Workflow-specific filters exclude job logs entirely - const hasWorkflowSpecificFilters = !!( - params.workflowIds || - params.folderIds || - params.workflowName || - params.folderName + if (levelConditions.length > 0) { + workflowConditions.push( + levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions)! ) - // If triggers filter is set and doesn't include 'mothership', skip job logs - const triggersList = params.triggers?.split(',').filter(Boolean) || [] - const triggersExcludeJobs = - triggersList.length > 0 && - !triggersList.includes('all') && - !triggersList.includes('mothership') - const includeJobLogs = !hasWorkflowSpecificFilters && !triggersExcludeJobs - - const fetchSize = params.limit + params.offset - - const workflowLogs = await baseQuery - .where(and(workspaceFilter, conditions)) - .orderBy(desc(workflowExecutionLogs.startedAt)) - .limit(fetchSize) - - const workflowCountQuery = db - .select({ count: sql`count(*)` }) - .from(workflowExecutionLogs) - .leftJoin( - pausedExecutions, - eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) - ) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(and(eq(workflowExecutionLogs.workspaceId, params.workspaceId), conditions)) - - // Build job log filters (subset of filters that apply to job logs) - let jobLogs: Array<{ - id: string - executionId: string - level: string - status: string - trigger: string - startedAt: Date - endedAt: Date | null - totalDurationMs: number | null - executionData: unknown - cost: unknown - createdAt: Date - jobTitle: string | null - }> = [] - let jobCount = 0 - - if (includeJobLogs) { - const jobConditions: SQL[] = [eq(jobExecutionLogs.workspaceId, params.workspaceId)] - - // Permission check - jobConditions.push( - sql`EXISTS (SELECT 1 FROM ${permissions} WHERE ${permissions.entityType} = 'workspace' AND ${permissions.entityId} = ${jobExecutionLogs.workspaceId} AND ${permissions.userId} = ${userId})` - ) + } + } - // Level filter - if (params.level && params.level !== 'all') { - const levels = params.level.split(',').filter(Boolean) - const jobLevelConditions: SQL[] = [] - for (const level of levels) { - if (level === 'error') { - jobLevelConditions.push(eq(jobExecutionLogs.level, 'error')) - } else if (level === 'info') { - const c = and(eq(jobExecutionLogs.level, 'info'), isNotNull(jobExecutionLogs.endedAt)) - if (c) jobLevelConditions.push(c) - } - // 'running' and 'pending' don't apply to job logs (they complete synchronously) - } - if (jobLevelConditions.length > 0) { - jobConditions.push( - jobLevelConditions.length === 1 ? jobLevelConditions[0] : or(...jobLevelConditions)! - ) - } - } + if (params.folderIds) { + params.folderIds = await expandFolderIdsWithDescendants(params.workspaceId, params.folderIds) + } - // Trigger filter - if (triggersList.length > 0 && !triggersList.includes('all')) { - jobConditions.push(inArray(jobExecutionLogs.trigger, triggersList)) + const commonFilters = buildFilterConditions(params, { useSimpleLevelFilter: false }) + if (commonFilters) workflowConditions.push(commonFilters) + + const workflowCursorCond = buildCursorCondition(workflowSortExpr, workflowExecutionLogs.id) + if (workflowCursorCond) workflowConditions.push(workflowCursorCond) + + // Decide whether to include job logs + const hasWorkflowSpecificFilters = !!( + params.workflowIds || + params.folderIds || + params.workflowName || + params.folderName + ) + const triggersList = params.triggers?.split(',').filter(Boolean) || [] + const triggersExcludeJobs = + triggersList.length > 0 && !triggersList.includes('all') && !triggersList.includes('mothership') + const levelList = + params.level && params.level !== 'all' ? params.level.split(',').filter(Boolean) : [] + const levelExcludesJobs = + levelList.length > 0 && !levelList.some((l) => l === 'error' || l === 'info') + const includeJobLogs = !hasWorkflowSpecificFilters && !triggersExcludeJobs && !levelExcludesJobs + + const workflowQuery = db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + deploymentVersionId: workflowExecutionLogs.deploymentVersionId, + level: workflowExecutionLogs.level, + status: workflowExecutionLogs.status, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + cost: workflowExecutionLogs.cost, + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowColor: workflow.color, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + pausedStatus: pausedExecutions.status, + pausedTotalPauseCount: pausedExecutions.totalPauseCount, + pausedResumedCount: pausedExecutions.resumedCount, + deploymentVersion: workflowDeploymentVersion.version, + deploymentVersionName: workflowDeploymentVersion.name, + sortValue: sql`${workflowSortExpr}`.as('sort_value'), + }) + .from(workflowExecutionLogs) + .leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)) + .leftJoin( + workflowDeploymentVersion, + eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) + ) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflowExecutionLogs.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(and(...workflowConditions)) + .orderBy(orderByClause(workflowSortExpr), dir(workflowExecutionLogs.id)) + .limit(fetchSize) + + const jobConditions: SQL[] = [eq(jobExecutionLogs.workspaceId, params.workspaceId)] + + if (includeJobLogs) { + jobConditions.push( + sql`EXISTS (SELECT 1 FROM ${permissions} WHERE ${permissions.entityType} = 'workspace' AND ${permissions.entityId} = ${jobExecutionLogs.workspaceId} AND ${permissions.userId} = ${userId})` + ) + + if (params.level && params.level !== 'all') { + const levels = params.level.split(',').filter(Boolean) + const jobLevelConditions: SQL[] = [] + for (const level of levels) { + if (level === 'error') { + jobLevelConditions.push(eq(jobExecutionLogs.level, 'error')) + } else if (level === 'info') { + const c = and(eq(jobExecutionLogs.level, 'info'), isNotNull(jobExecutionLogs.endedAt)) + if (c) jobLevelConditions.push(c) } + } + if (jobLevelConditions.length > 0) { + jobConditions.push( + jobLevelConditions.length === 1 ? jobLevelConditions[0] : or(...jobLevelConditions)! + ) + } + } - // Date filters - if (params.startDate) { - jobConditions.push(gte(jobExecutionLogs.startedAt, new Date(params.startDate))) - } - if (params.endDate) { - jobConditions.push(lte(jobExecutionLogs.startedAt, new Date(params.endDate))) - } + if (triggersList.length > 0 && !triggersList.includes('all')) { + jobConditions.push(inArray(jobExecutionLogs.trigger, triggersList)) + } - // Search by executionId - if (params.search) { - jobConditions.push(sql`${jobExecutionLogs.executionId} ILIKE ${`%${params.search}%`}`) - } - if (params.executionId) { - jobConditions.push(eq(jobExecutionLogs.executionId, params.executionId)) - } + if (params.startDate) { + jobConditions.push(gte(jobExecutionLogs.startedAt, new Date(params.startDate))) + } + if (params.endDate) { + jobConditions.push(lte(jobExecutionLogs.startedAt, new Date(params.endDate))) + } - // Cost filter - if (params.costOperator && params.costValue !== undefined) { - const costField = sql`(${jobExecutionLogs.cost}->>'total')::numeric` - const ops = { - '=': sql`=`, - '>': sql`>`, - '<': sql`<`, - '>=': sql`>=`, - '<=': sql`<=`, - '!=': sql`!=`, - } as const - jobConditions.push(sql`${costField} ${ops[params.costOperator]} ${params.costValue}`) - } + if (params.search) { + jobConditions.push(sql`${jobExecutionLogs.executionId} ILIKE ${`%${params.search}%`}`) + } + if (params.executionId) { + jobConditions.push(eq(jobExecutionLogs.executionId, params.executionId)) + } - // Duration filter - if (params.durationOperator && params.durationValue !== undefined) { - const durationOps: Record< - string, - (field: typeof jobExecutionLogs.totalDurationMs, val: number) => SQL | undefined - > = { - '=': (f, v) => eq(f, v), - '>': (f, v) => gt(f, v), - '<': (f, v) => lt(f, v), - '>=': (f, v) => gte(f, v), - '<=': (f, v) => lte(f, v), - '!=': (f, v) => ne(f, v), - } - const durationCond = durationOps[params.durationOperator]?.( - jobExecutionLogs.totalDurationMs, - params.durationValue - ) - if (durationCond) jobConditions.push(durationCond) - } + if (params.costOperator && params.costValue !== undefined) { + const costField = sql`(${jobExecutionLogs.cost}->>'total')::numeric` + const ops = { + '=': sql`=`, + '>': sql`>`, + '<': sql`<`, + '>=': sql`>=`, + '<=': sql`<=`, + '!=': sql`!=`, + } as const + jobConditions.push(sql`${costField} ${ops[params.costOperator]} ${params.costValue}`) + } - const jobWhere = and(...jobConditions) - - const [jobLogResults, jobCountResult] = await Promise.all([ - db - .select({ - id: jobExecutionLogs.id, - executionId: jobExecutionLogs.executionId, - level: jobExecutionLogs.level, - status: jobExecutionLogs.status, - trigger: jobExecutionLogs.trigger, - startedAt: jobExecutionLogs.startedAt, - endedAt: jobExecutionLogs.endedAt, - totalDurationMs: jobExecutionLogs.totalDurationMs, - executionData: - params.details === 'full' ? jobExecutionLogs.executionData : sql`NULL`, - cost: jobExecutionLogs.cost, - createdAt: jobExecutionLogs.createdAt, - jobTitle: sql`${jobExecutionLogs.executionData}->'trigger'->>'source'`, - }) - .from(jobExecutionLogs) - .where(jobWhere) - .orderBy(desc(jobExecutionLogs.startedAt)) - .limit(fetchSize), - db.select({ count: sql`count(*)` }).from(jobExecutionLogs).where(jobWhere), - ]) - - jobLogs = jobLogResults as typeof jobLogs - jobCount = Number(jobCountResult[0]?.count || 0) + if (params.durationOperator && params.durationValue !== undefined) { + const durationOps: Record< + string, + (field: typeof jobExecutionLogs.totalDurationMs, val: number) => SQL | undefined + > = { + '=': (f, v) => eq(f, v), + '>': (f, v) => gt(f, v), + '<': (f, v) => lt(f, v), + '>=': (f, v) => gte(f, v), + '<=': (f, v) => lte(f, v), + '!=': (f, v) => ne(f, v), } + const durationCond = durationOps[params.durationOperator]?.( + jobExecutionLogs.totalDurationMs, + params.durationValue + ) + if (durationCond) jobConditions.push(durationCond) + } - const workflowCountResult = await workflowCountQuery - const workflowCount = Number(workflowCountResult[0]?.count || 0) - const totalCount = workflowCount + jobCount - - // Transform workflow logs to the unified shape - const blockExecutionsByExecution: Record = {} - - const createTraceSpans = (blockExecutions: any[]) => { - return blockExecutions.map((block, index) => { - let output = block.outputData - if (block.status === 'error' && block.errorMessage) { - output = { - ...output, - error: block.errorMessage, - stackTrace: block.errorStackTrace, - } - } + const jobCursorCond = buildCursorCondition(jobSortExpr, jobExecutionLogs.id) + if (jobCursorCond) jobConditions.push(jobCursorCond) + } - return { - id: block.id, - name: `Block ${block.blockName || block.blockType} (${block.blockType})`, - type: block.blockType, - duration: block.durationMs, - startTime: block.startedAt, - endTime: block.endedAt, - status: block.status === 'success' ? 'success' : 'error', - blockId: block.blockId, - input: block.inputData, - output, - tokens: block.cost?.tokens?.total || 0, - relativeStartMs: index * 100, - children: [], - toolCalls: [], - } + const jobQuery = includeJobLogs + ? db + .select({ + id: jobExecutionLogs.id, + executionId: jobExecutionLogs.executionId, + level: jobExecutionLogs.level, + status: jobExecutionLogs.status, + trigger: jobExecutionLogs.trigger, + startedAt: jobExecutionLogs.startedAt, + endedAt: jobExecutionLogs.endedAt, + totalDurationMs: jobExecutionLogs.totalDurationMs, + cost: jobExecutionLogs.cost, + createdAt: jobExecutionLogs.createdAt, + jobTitle: sql`${jobExecutionLogs.executionData}->'trigger'->>'source'`, + sortValue: sql`${jobSortExpr}`.as('sort_value'), }) - } + .from(jobExecutionLogs) + .where(and(...jobConditions)) + .orderBy(orderByClause(jobSortExpr), dir(jobExecutionLogs.id)) + .limit(fetchSize) + : Promise.resolve([]) - const extractCostSummary = (blockExecutions: any[]) => { - let totalCost = 0 - let totalInputCost = 0 - let totalOutputCost = 0 - let totalTokens = 0 - let totalPromptTokens = 0 - let totalCompletionTokens = 0 - const models = new Map() - - blockExecutions.forEach((block) => { - if (block.cost) { - totalCost += Number(block.cost.total) || 0 - totalInputCost += Number(block.cost.input) || 0 - totalOutputCost += Number(block.cost.output) || 0 - totalTokens += block.cost.tokens?.total || 0 - totalPromptTokens += block.cost.tokens?.prompt || 0 - totalCompletionTokens += block.cost.tokens?.completion || 0 - - if (block.cost.model) { - if (!models.has(block.cost.model)) { - models.set(block.cost.model, { - input: 0, - output: 0, - total: 0, - tokens: { input: 0, output: 0, total: 0 }, - }) - } - const modelCost = models.get(block.cost.model) - modelCost.input += Number(block.cost.input) || 0 - modelCost.output += Number(block.cost.output) || 0 - modelCost.total += Number(block.cost.total) || 0 - modelCost.tokens.input += block.cost.tokens?.input || block.cost.tokens?.prompt || 0 - modelCost.tokens.output += - block.cost.tokens?.output || block.cost.tokens?.completion || 0 - modelCost.tokens.total += block.cost.tokens?.total || 0 - } - } - }) + const [workflowRows, jobRows] = await Promise.all([workflowQuery, jobQuery]) - return { - total: totalCost, - input: totalInputCost, - output: totalOutputCost, - tokens: { - total: totalTokens, - input: totalPromptTokens, - output: totalCompletionTokens, - }, - models: Object.fromEntries(models), - } - } + type RowWithSort = { + id: string + sortValue: unknown + summary: WorkflowLogSummary + } - const transformedWorkflowLogs = workflowLogs.map((log) => { - const blockExecutions = blockExecutionsByExecution[log.executionId] || [] - - let traceSpans = [] - let finalOutput: any - let costSummary = (log.cost as any) || { total: 0 } - - if (params.details === 'full' && log.executionData) { - const storedTraceSpans = (log.executionData as any)?.traceSpans - traceSpans = - storedTraceSpans && Array.isArray(storedTraceSpans) && storedTraceSpans.length > 0 - ? storedTraceSpans - : createTraceSpans(blockExecutions) - - costSummary = - log.cost && Object.keys(log.cost as any).length > 0 - ? (log.cost as any) - : extractCostSummary(blockExecutions) - - try { - const fo = (log.executionData as any)?.finalOutput - if (fo !== undefined) finalOutput = fo - } catch {} - } + const workflowMapped: RowWithSort[] = workflowRows.map((log) => { + const totalPauseCount = Number(log.pausedTotalPauseCount ?? 0) + const resumedCount = Number(log.pausedResumedCount ?? 0) + const hasPendingPause = + (totalPauseCount > 0 && resumedCount < totalPauseCount) || + (log.pausedStatus !== null && log.pausedStatus !== 'fully_resumed') + + const summary: WorkflowLogSummary = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + deploymentVersionId: log.deploymentVersionId, + deploymentVersion: log.deploymentVersion ?? null, + deploymentVersionName: log.deploymentVersionName ?? null, + level: log.level, + status: log.status, + duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, + trigger: log.trigger, + createdAt: log.startedAt.toISOString(), + workflow: log.workflowId + ? { + id: log.workflowId, + name: log.workflowName, + description: log.workflowDescription, + color: log.workflowColor, + folderId: log.workflowFolderId, + userId: log.workflowUserId, + workspaceId: log.workflowWorkspaceId, + createdAt: log.workflowCreatedAt?.toISOString() ?? null, + updatedAt: log.workflowUpdatedAt?.toISOString() ?? null, + } + : null, + jobTitle: null, + cost: (log.cost as WorkflowLogSummary['cost']) ?? null, + pauseSummary: { + status: log.pausedStatus ?? null, + total: totalPauseCount, + resumed: resumedCount, + }, + hasPendingPause, + } + return { id: log.id, sortValue: log.sortValue, summary } + }) + + const jobMapped: RowWithSort[] = (jobRows as Awaited).map((log) => { + const summary: WorkflowLogSummary = { + id: log.id, + workflowId: null, + executionId: log.executionId, + deploymentVersionId: null, + deploymentVersion: null, + deploymentVersionName: null, + level: log.level, + status: log.status, + duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, + trigger: log.trigger, + createdAt: log.startedAt.toISOString(), + workflow: null, + jobTitle: log.jobTitle ?? null, + cost: (log.cost as WorkflowLogSummary['cost']) ?? null, + pauseSummary: { status: null, total: 0, resumed: 0 }, + hasPendingPause: false, + } + return { id: log.id, sortValue: log.sortValue, summary } + }) + + const compareSortValues = (a: unknown, b: unknown): number => { + if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime() + if (typeof a === 'number' && typeof b === 'number') return a - b + const aStr = String(a) + const bStr = String(b) + if (sortBy === 'date') { + return new Date(aStr).getTime() - new Date(bStr).getTime() + } + const aNum = Number(aStr) + const bNum = Number(bStr) + if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) return aNum - bNum + return aStr.localeCompare(bStr) + } - const workflowSummary = log.workflowId - ? { - id: log.workflowId, - name: log.workflowName, - description: log.workflowDescription, - color: log.workflowColor, - folderId: log.workflowFolderId, - userId: log.workflowUserId, - workspaceId: log.workflowWorkspaceId, - createdAt: log.workflowCreatedAt, - updatedAt: log.workflowUpdatedAt, - } - : null - - return { - id: log.id, - workflowId: log.workflowId, - executionId: log.executionId, - deploymentVersionId: log.deploymentVersionId, - deploymentVersion: log.deploymentVersion ?? null, - deploymentVersionName: log.deploymentVersionName ?? null, - level: log.level, - status: log.status, - duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, - trigger: log.trigger, - createdAt: log.startedAt.toISOString(), - files: params.details === 'full' ? log.files || undefined : undefined, - workflow: workflowSummary, - pauseSummary: { - status: log.pausedStatus ?? null, - total: log.pausedTotalPauseCount ?? 0, - resumed: log.pausedResumedCount ?? 0, - }, - executionData: - params.details === 'full' - ? { - totalDuration: log.totalDurationMs, - traceSpans, - blockExecutions, - finalOutput, - enhanced: true, - } - : undefined, - cost: - params.details === 'full' - ? (costSummary as any) - : { total: (costSummary as any)?.total || 0 }, - hasPendingPause: - (Number(log.pausedTotalPauseCount ?? 0) > 0 && - Number(log.pausedResumedCount ?? 0) < Number(log.pausedTotalPauseCount ?? 0)) || - (log.pausedStatus && log.pausedStatus !== 'fully_resumed'), - } - }) - - // Transform job logs to the same shape - const transformedJobLogs = jobLogs.map((log) => { - const execData = log.executionData as any - const costSummary = (log.cost as any) || { total: 0 } - - return { - id: log.id, - workflowId: null as string | null, - executionId: log.executionId, - deploymentVersionId: null as string | null, - deploymentVersion: null as number | null, - deploymentVersionName: null as string | null, - level: log.level, - status: log.status, - duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, - trigger: log.trigger, - createdAt: log.startedAt.toISOString(), - files: undefined as any, - workflow: null as any, - jobTitle: log.jobTitle, - pauseSummary: { - status: null as string | null, - total: 0, - resumed: 0, - }, - executionData: - params.details === 'full' && execData - ? { - totalDuration: log.totalDurationMs, - traceSpans: execData.traceSpans || [], - blockExecutions: [], - finalOutput: execData.finalOutput, - enhanced: true, - trigger: execData.trigger, - } - : undefined, - cost: params.details === 'full' ? costSummary : { total: costSummary?.total || 0 }, - hasPendingPause: false, - } - }) - - // Merge, sort by createdAt (which is startedAt ISO string) desc, paginate - const allLogs = [...transformedWorkflowLogs, ...transformedJobLogs] - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - .slice(params.offset, params.offset + params.limit) - - return NextResponse.json( - { - data: allLogs, - total: totalCount, - page: Math.floor(params.offset / params.limit) + 1, - pageSize: params.limit, - totalPages: Math.ceil(totalCount / params.limit), - }, - { status: 200 } - ) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid logs request parameters`, { - errors: validationError.errors, - }) - return NextResponse.json( - { - error: 'Invalid request parameters', - details: validationError.errors, - }, - { status: 400 } - ) - } - throw validationError + const merged = [...workflowMapped, ...jobMapped].sort((a, b) => { + const aNull = a.sortValue === null || a.sortValue === undefined + const bNull = b.sortValue === null || b.sortValue === undefined + // Mirror SQL's NULLS LAST for both ASC and DESC so the cursor stays consistent. + if (aNull && !bNull) return 1 + if (!aNull && bNull) return -1 + if (!aNull && !bNull) { + const cmp = compareSortValues(a.sortValue, b.sortValue) + if (cmp !== 0) return sortOrder === 'asc' ? cmp : -cmp } - } catch (error: any) { - logger.error(`[${requestId}] logs fetch error`, error) - return NextResponse.json({ error: error.message }, { status: 500 }) + const idCmp = a.id.localeCompare(b.id) + return sortOrder === 'asc' ? idCmp : -idCmp + }) + + const page = merged.slice(0, params.limit) + const hasMore = merged.length > params.limit + let nextCursor: string | null = null + if (hasMore && page.length > 0) { + const last = page[page.length - 1] + const v = last.sortValue + const cursorV = + v instanceof Date + ? v.toISOString() + : typeof v === 'number' || typeof v === 'string' + ? v + : v == null + ? null + : String(v) + nextCursor = encodeCursor({ v: cursorV, id: last.id }) } + + logger.debug('Listed logs', { + workspaceId: params.workspaceId, + count: page.length, + hasMore, + sortBy, + sortOrder, + }) + + return NextResponse.json({ + data: page.map((row) => row.summary), + nextCursor, + }) }) diff --git a/apps/sim/app/api/logs/stats/route.ts b/apps/sim/app/api/logs/stats/route.ts index 776982855e6..930e2e36d39 100644 --- a/apps/sim/app/api/logs/stats/route.ts +++ b/apps/sim/app/api/logs/stats/route.ts @@ -3,49 +3,23 @@ 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' +import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' 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,10 +34,17 @@ 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) + if (params.folderIds) { + params.folderIds = await expandFolderIdsWithDescendants( + params.workspaceId, + params.folderIds + ) + } + const commonFilters = buildFilterConditions(params, { useSimpleLevelFilter: true }) const whereCondition = commonFilters ? and(workspaceFilter, commonFilters) : workspaceFilter @@ -277,14 +258,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..1ebe834b6f9 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, 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' @@ -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,45 @@ 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 = triggersQuerySchema.safeParse(searchParamsToObject(searchParams)) + if (!validation.success) { + logger.error(`[${requestId}] Invalid query parameters`, { error: validation.error }) + return validationErrorResponse(validation.error) + } - 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 6ae73c4126d..9cdb7de7301 100644 --- a/apps/sim/app/api/mcp/copilot/route.ts +++ b/apps/sim/app/api/mcp/copilot/route.ts @@ -1,5 +1,5 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' import { CallToolRequestSchema, type CallToolResult, @@ -18,6 +18,7 @@ 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 { validateOAuthAccessToken } from '@/lib/auth/oauth-token' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' @@ -26,6 +27,7 @@ import { createRequestId } from '@/lib/copilot/request/http' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { orchestrateSubagentStream } from '@/lib/copilot/request/subagent' import { ensureHandlersRegistered, executeTool } from '@/lib/copilot/tool-executor' +import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { DIRECT_TOOL_DEFS, SUBAGENT_TOOL_DEFS } from '@/lib/copilot/tools/mcp/definitions' import { env } from '@/lib/core/config/env' @@ -166,16 +168,6 @@ function createError(id: RequestId, code: ErrorCode | number, message: string): } } -function normalizeRequestHeaders(request: NextRequest): HeaderMap { - const headers: HeaderMap = {} - - request.headers.forEach((value, key) => { - headers[key.toLowerCase()] = value - }) - - return headers -} - function readHeader(headers: HeaderMap | undefined, name: string): string | undefined { if (!headers) return undefined const value = headers[name.toLowerCase()] @@ -185,190 +177,6 @@ function readHeader(headers: HeaderMap | undefined, name: string): string | unde return value } -class NextResponseCapture { - private _status = 200 - private _headers = new Headers() - private _controller: ReadableStreamDefaultController | null = null - private _pendingChunks: Uint8Array[] = [] - private _closeHandlers: Array<() => void> = [] - private _errorHandlers: Array<(error: Error) => void> = [] - private _headersWritten = false - private _ended = false - private _headersPromise: Promise - private _resolveHeaders: (() => void) | null = null - private _endedPromise: Promise - private _resolveEnded: (() => void) | null = null - readonly readable: ReadableStream - - constructor() { - this._headersPromise = new Promise((resolve) => { - this._resolveHeaders = resolve - }) - - this._endedPromise = new Promise((resolve) => { - this._resolveEnded = resolve - }) - - this.readable = new ReadableStream({ - start: (controller) => { - this._controller = controller - if (this._pendingChunks.length > 0) { - for (const chunk of this._pendingChunks) { - controller.enqueue(chunk) - } - this._pendingChunks = [] - } - }, - cancel: () => { - this._ended = true - this._resolveEnded?.() - this.triggerCloseHandlers() - }, - }) - } - - private markHeadersWritten(): void { - if (this._headersWritten) return - this._headersWritten = true - this._resolveHeaders?.() - } - - private triggerCloseHandlers(): void { - for (const handler of this._closeHandlers) { - try { - handler() - } catch (error) { - this.triggerErrorHandlers(toError(error)) - } - } - } - - private triggerErrorHandlers(error: Error): void { - for (const errorHandler of this._errorHandlers) { - errorHandler(error) - } - } - - private normalizeChunk(chunk: unknown): Uint8Array | null { - if (typeof chunk === 'string') { - return new TextEncoder().encode(chunk) - } - - if (chunk instanceof Uint8Array) { - return chunk - } - - if (chunk === undefined || chunk === null) { - return null - } - - return new TextEncoder().encode(String(chunk)) - } - - writeHead(status: number, headers?: Record): this { - this._status = status - - if (headers) { - Object.entries(headers).forEach(([key, value]) => { - if (Array.isArray(value)) { - this._headers.set(key, value.join(', ')) - } else { - this._headers.set(key, String(value)) - } - }) - } - - this.markHeadersWritten() - return this - } - - flushHeaders(): this { - this.markHeadersWritten() - return this - } - - write(chunk: unknown): boolean { - const normalized = this.normalizeChunk(chunk) - if (!normalized) return true - - this.markHeadersWritten() - - if (this._controller) { - try { - this._controller.enqueue(normalized) - } catch (error) { - this.triggerErrorHandlers(toError(error)) - } - } else { - this._pendingChunks.push(normalized) - } - - return true - } - - end(chunk?: unknown): this { - if (chunk !== undefined) this.write(chunk) - this.markHeadersWritten() - if (this._ended) return this - - this._ended = true - this._resolveEnded?.() - - if (this._controller) { - try { - this._controller.close() - } catch (error) { - this.triggerErrorHandlers(toError(error)) - } - } - - this.triggerCloseHandlers() - - return this - } - - async waitForHeaders(timeoutMs = 30000): Promise { - if (this._headersWritten) return - - await Promise.race([ - this._headersPromise, - new Promise((resolve) => { - setTimeout(resolve, timeoutMs) - }), - ]) - } - - async waitForEnd(timeoutMs = 30000): Promise { - if (this._ended) return - - await Promise.race([ - this._endedPromise, - new Promise((resolve) => { - setTimeout(resolve, timeoutMs) - }), - ]) - } - - on(event: 'close' | 'error', handler: (() => void) | ((error: Error) => void)): this { - if (event === 'close') { - this._closeHandlers.push(handler as () => void) - } - - if (event === 'error') { - this._errorHandlers.push(handler as (error: Error) => void) - } - - return this - } - - toNextResponse(): NextResponse { - return new NextResponse(this.readable, { - status: this._status, - headers: this._headers, - }) - } -} - function buildMcpServer(abortSignal?: AbortSignal): Server { const server = new Server( { @@ -476,12 +284,11 @@ function buildMcpServer(abortSignal?: AbortSignal): Server { } } - const params = request.params as - | { name?: string; arguments?: Record } - | undefined - if (!params?.name) { + const paramsValidation = mcpToolCallParamsSchema.safeParse(request.params) + if (!paramsValidation.success) { throw new McpError(ErrorCode.InvalidParams, 'Tool name required') } + const params = paramsValidation.data const result = await handleToolsCall( { @@ -503,29 +310,17 @@ function buildMcpServer(abortSignal?: AbortSignal): Server { async function handleMcpRequestWithSdk( request: NextRequest, parsedBody: unknown -): Promise { +): Promise { const server = buildMcpServer(request.signal) - const transport = new StreamableHTTPServerTransport({ + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, }) - const responseCapture = new NextResponseCapture() - const requestAdapter = { - method: request.method, - headers: normalizeRequestHeaders(request), - } - await server.connect(transport) try { - await transport.handleRequest(requestAdapter as any, responseCapture as any, parsedBody) - await responseCapture.waitForHeaders() - // Must exceed the longest possible tool execution. - // Using ORCHESTRATION_TIMEOUT_MS + 60 s buffer so the orchestrator can - // finish or time-out on its own before the transport is torn down. - await responseCapture.waitForEnd(ORCHESTRATION_TIMEOUT_MS + 60_000) - return responseCapture.toNextResponse() + return await transport.handleRequest(request, { parsedBody }) } finally { await server.close().catch(() => {}) await transport.close().catch(() => {}) @@ -565,8 +360,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } - return await handleMcpRequestWithSdk(request, parsedBody) + const bodyValidation = mcpRequestBodySchema.safeParse(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( + createError(0, ErrorCode.ConnectionClosed, 'Client cancelled request'), + { status: 499 } + ) + } + logger.error('Error handling MCP request', { error }) return NextResponse.json(createError(0, ErrorCode.InternalError, 'Internal error'), { status: 500, @@ -574,19 +386,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } }) -export const OPTIONS = withRouteHandler(async () => { - return new NextResponse(null, { - status: 204, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, DELETE', - 'Access-Control-Allow-Headers': - 'Content-Type, Authorization, X-API-Key, X-Requested-With, Accept', - 'Access-Control-Max-Age': '86400', - }, - }) -}) - export const DELETE = withRouteHandler(async (request: NextRequest) => { void request return NextResponse.json(createError(0, -32000, 'Method not allowed.'), { status: 405 }) @@ -634,10 +433,36 @@ async function handleDirectToolCall( userId: string ): Promise { try { + const rawWorkflowId = (args.workflowId as string) || '' + let resolvedWorkspaceId: string | undefined + if (rawWorkflowId) { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: rawWorkflowId, + userId, + action: 'read', + }) + if (!authorization.allowed) { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { success: false, error: 'Workflow not found or access denied' }, + null, + 2 + ), + }, + ], + isError: true, + } + } + resolvedWorkspaceId = authorization.workflow?.workspaceId || undefined + } const execContext = await prepareExecutionContext( userId, - (args.workflowId as string) || '', - (args.chatId as string) || undefined + rawWorkflowId, + (args.chatId as string) || undefined, + { workspaceId: resolvedWorkspaceId } ) const toolCall = { @@ -831,12 +656,46 @@ async function handleSubagentToolCall( context.plan = args.plan } + // Authorize user-supplied workflowId / workspaceId before forwarding downstream + const rawWorkflowId = args.workflowId as string | undefined + const rawWorkspaceId = args.workspaceId as string | undefined + let resolvedWorkflowId: string | undefined + let resolvedWorkspaceId: string | undefined + + if (rawWorkflowId) { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: rawWorkflowId, + userId, + action: 'read', + }) + if (!authorization.allowed) { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { success: false, error: 'Workflow not found or access denied' }, + null, + 2 + ), + }, + ], + isError: true, + } + } + resolvedWorkflowId = rawWorkflowId + resolvedWorkspaceId = authorization.workflow?.workspaceId || undefined + } else if (rawWorkspaceId) { + await ensureWorkspaceAccess(rawWorkspaceId, userId, 'read') + resolvedWorkspaceId = rawWorkspaceId + } + const result = await orchestrateSubagentStream( toolDef.agentId, { message: requestText, - workflowId: args.workflowId, - workspaceId: args.workspaceId, + workflowId: resolvedWorkflowId, + workspaceId: resolvedWorkspaceId, context, model: DEFAULT_COPILOT_MODEL, headless: true, @@ -844,8 +703,8 @@ async function handleSubagentToolCall( }, { userId, - workflowId: args.workflowId as string | undefined, - workspaceId: args.workspaceId as string | undefined, + workflowId: resolvedWorkflowId, + workspaceId: resolvedWorkspaceId, simRequestId, abortSignal, } 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..73a1aaa55c5 100644 --- a/apps/sim/app/api/mcp/events/route.ts +++ b/apps/sim/app/api/mcp/events/route.ts @@ -8,6 +8,8 @@ * Auth is handled via session cookies (EventSource sends cookies automatically). */ +import type { NextRequest } from 'next/server' +import { mcpEventsQuerySchema } from '@/lib/api/contracts/mcp' 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 +17,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 = mcpEventsQuerySchema.safeParse({ + 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/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts new file mode 100644 index 00000000000..08171fbc97c --- /dev/null +++ b/apps/sim/app/api/mcp/oauth/callback/route.ts @@ -0,0 +1,181 @@ +import { auth as mcpAuth } from '@modelcontextprotocol/sdk/client/auth.js' +import { db } from '@sim/db' +import { mcpServers } from '@sim/db/schema' +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 { NextResponse } from 'next/server' +import { mcpOauthCallbackContract } from '@/lib/api/contracts/mcp' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + assertSafeOauthServerUrl, + clearState, + clearVerifier, + loadOauthRowByState, + loadPreregisteredClient, + type McpOauthCallbackReason, + SimMcpOauthProvider, +} from '@/lib/mcp/oauth' +import { mcpService } from '@/lib/mcp/service' + +const logger = createLogger('McpOauthCallbackAPI') + +export const dynamic = 'force-dynamic' + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function jsonLiteral(value: string | undefined): string { + if (value === undefined) return 'undefined' + return JSON.stringify(value).replace(//g, '\\u003e') +} + +function htmlClose( + message: string, + ok: boolean, + reason: McpOauthCallbackReason, + serverId?: string +): NextResponse { + const safeMessage = escapeHtml(message) + const title = ok ? 'Connected' : 'Connection failed' + const body = `${title}

${safeMessage}

` + return new NextResponse(body, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }) +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const parsed = await parseRequest(mcpOauthCallbackContract, request, {}) + if (!parsed.success) { + return htmlClose('Malformed authorization callback.', false, 'missing_params') + } + const { state, code, error: errorParam } = parsed.data.query + + const initialRow = state ? await loadOauthRowByState(state).catch(() => null) : null + const stateRowServerId = initialRow?.mcpServerId + + if (errorParam) { + logger.warn(`MCP OAuth callback received error: ${errorParam}`) + if (initialRow) await clearState(initialRow.id).catch(() => {}) + return htmlClose( + `Authorization failed: ${errorParam}`, + false, + 'provider_error', + stateRowServerId + ) + } + if (!state || !code) { + return htmlClose( + 'Missing state or code in callback URL.', + false, + 'missing_params', + stateRowServerId + ) + } + + let serverId: string | undefined + try { + const session = await getSession() + if (!session?.user?.id) { + return htmlClose( + 'You must be signed in to complete authorization.', + false, + 'unauthenticated', + stateRowServerId + ) + } + + const row = initialRow + if (!row) { + return htmlClose('Invalid or expired authorization state.', false, 'invalid_state') + } + serverId = row.mcpServerId + + if (session.user.id !== row.userId) { + return htmlClose( + 'You must be signed in as the same user that initiated the flow.', + false, + 'user_mismatch', + serverId + ) + } + + const [server] = await db + .select({ id: mcpServers.id, url: mcpServers.url, workspaceId: mcpServers.workspaceId }) + .from(mcpServers) + .where(and(eq(mcpServers.id, row.mcpServerId), isNull(mcpServers.deletedAt))) + .limit(1) + if (!server || !server.url) { + return htmlClose('Server no longer exists.', false, 'server_gone', serverId) + } + if (server.workspaceId !== row.workspaceId) { + return htmlClose( + 'Workspace mismatch on authorization callback.', + false, + 'invalid_state', + serverId + ) + } + try { + assertSafeOauthServerUrl(server.url) + } catch { + return htmlClose( + 'MCP OAuth requires https (or http://localhost for development).', + false, + 'insecure_url', + serverId + ) + } + + // Burn state before token exchange so a replayed callback cannot reuse it. + await clearState(row.id) + + const preregistered = await loadPreregisteredClient(server.id) + const provider = new SimMcpOauthProvider({ row, preregistered }) + let result: Awaited> + try { + result = await mcpAuth(provider, { + serverUrl: server.url, + authorizationCode: code, + }) + } catch (e) { + logger.error('Token exchange failed during MCP OAuth callback', e) + return htmlClose( + 'Token exchange failed. Please try again.', + false, + 'token_exchange_failed', + server.id + ) + } finally { + await clearVerifier(row.id) + } + + if (result !== 'AUTHORIZED') { + return htmlClose('Authorization did not complete.', false, 'token_exchange_failed', server.id) + } + + try { + // forceRefresh: skip any stale cache from before re-auth. + await mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId, true) + } catch (e) { + logger.warn('Post-auth tools refresh failed', toError(e).message) + } + + return htmlClose('Connected. You can close this window.', true, 'authorized', server.id) + } catch (error) { + logger.error('MCP OAuth callback failed', error) + return htmlClose('Authorization failed. Please try again.', false, 'unknown', serverId) + } +}) diff --git a/apps/sim/app/api/mcp/oauth/start/route.test.ts b/apps/sim/app/api/mcp/oauth/start/route.test.ts new file mode 100644 index 00000000000..fa8ebf26dc7 --- /dev/null +++ b/apps/sim/app/api/mcp/oauth/start/route.test.ts @@ -0,0 +1,198 @@ +/** + * @vitest-environment node + */ +import { + dbChainMock, + dbChainMockFns, + hybridAuthMock, + hybridAuthMockFns, + McpOauthRedirectRequiredMock, + mcpOauthMock, + mcpOauthMockFns, + permissionsMock, + permissionsMockFns, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockMcpAuth } = vi.hoisted(() => ({ + mockMcpAuth: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) +vi.mock('@sim/db/schema', () => schemaMock) +vi.mock('drizzle-orm', () => ({ + and: vi.fn(), + eq: vi.fn(), + isNull: vi.fn(), +})) +vi.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({ + auth: mockMcpAuth, +})) +vi.mock('@/lib/auth/hybrid', () => hybridAuthMock) +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@/lib/mcp/oauth', () => mcpOauthMock) + +import { GET, surfaceOauthError } from './route' + +describe('MCP OAuth start route', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-2', + userName: 'User Two', + userEmail: 'user2@example.com', + authType: 'session', + }) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + dbChainMockFns.limit.mockResolvedValue([ + { + id: 'server-1', + name: 'Exa', + url: 'https://mcp.exa.ai/mcp', + workspaceId: 'workspace-1', + authType: 'oauth', + deletedAt: null, + }, + ]) + mcpOauthMockFns.mockGetOrCreateOauthRow.mockResolvedValue({ + id: 'oauth-row-1', + mcpServerId: 'server-1', + userId: 'user-1', + workspaceId: 'workspace-1', + clientInformation: null, + tokens: null, + codeVerifier: null, + state: null, + stateCreatedAt: null, + updatedAt: new Date(), + }) + mcpOauthMockFns.mockLoadPreregisteredClient.mockResolvedValue(undefined) + mockMcpAuth.mockRejectedValue(new McpOauthRedirectRequiredMock('https://mcp.exa.ai/authorize')) + }) + + it('requires workspace write permission via MCP auth middleware', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1' + ) + + await GET(request) + + expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledWith( + 'user-2', + 'workspace', + 'workspace-1' + ) + }) + + it('uses a workspace-scoped OAuth row and stamps the latest authorizing user', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1' + ) + + const response = await GET(request) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual({ + status: 'redirect', + authorizationUrl: 'https://mcp.exa.ai/authorize', + }) + expect(mcpOauthMockFns.mockGetOrCreateOauthRow).toHaveBeenCalledWith({ + mcpServerId: 'server-1', + userId: 'user-2', + workspaceId: 'workspace-1', + }) + expect(mcpOauthMockFns.mockSetOauthRowUser).toHaveBeenCalledWith('oauth-row-1', 'user-2') + }) + + it('rejects a second user starting OAuth while another authorization is active', async () => { + mcpOauthMockFns.mockGetOrCreateOauthRow.mockResolvedValueOnce({ + id: 'oauth-row-1', + mcpServerId: 'server-1', + userId: 'user-1', + workspaceId: 'workspace-1', + clientInformation: null, + tokens: null, + codeVerifier: null, + state: 'hashed-active-state', + stateCreatedAt: new Date(), + updatedAt: new Date(), + }) + const request = new NextRequest( + 'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1' + ) + + const response = await GET(request) + const body = await response.json() + + expect(response.status).toBe(409) + expect(body.error).toBe('OAuth authorization already in progress for this server') + expect(mockMcpAuth).not.toHaveBeenCalled() + }) + + it('does not leak non-OAuth internal error details to the client', async () => { + mcpOauthMockFns.mockGetOrCreateOauthRow.mockRejectedValueOnce( + new Error('connect ECONNREFUSED 10.0.0.5:5432 (internal-db-host)') + ) + const request = new NextRequest( + 'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1' + ) + + const response = await GET(request) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body.error).toBe('Failed to start OAuth flow') + expect(body.error).not.toContain('ECONNREFUSED') + expect(body.error).not.toContain('internal-db-host') + }) +}) + +describe('surfaceOauthError', () => { + it('uses typed OAuthError errorCode and message for spec-compliant errors', async () => { + const { InvalidGrantError } = await import('@modelcontextprotocol/sdk/server/auth/errors.js') + const err = new InvalidGrantError('Refresh token expired') + expect(surfaceOauthError(err)).toBe('invalid_grant: Refresh token expired') + }) + + it('parses Raw body envelope for ServerError fallbacks (non-spec vendors)', async () => { + const { ServerError } = await import('@modelcontextprotocol/sdk/server/auth/errors.js') + const err = new ServerError( + 'HTTP 400: Invalid OAuth error response: zod error. Raw body: {"code":400,"message":"redirect URI https://example.com/cb is not allowed","retryable":false}' + ) + expect(surfaceOauthError(err)).toBe( + 'Authorization server: redirect URI https://example.com/cb is not allowed' + ) + }) + + it('prefers error_description over message over error in fallback envelope', async () => { + const { ServerError } = await import('@modelcontextprotocol/sdk/server/auth/errors.js') + const err = new ServerError( + 'HTTP 400: Invalid OAuth error response: zod. Raw body: {"error":"invalid_grant","error_description":"the description","message":"the message"}' + ) + expect(surfaceOauthError(err)).toBe('Authorization server: the description') + }) + + it('returns first line of generic errors', () => { + const err = new Error('Network blip\n at fetch (...)') + expect(surfaceOauthError(err)).toBe('Network blip') + }) + + it('truncates messages longer than 250 chars with ellipsis', async () => { + const { InvalidGrantError } = await import('@modelcontextprotocol/sdk/server/auth/errors.js') + const longMessage = 'x'.repeat(300) + const result = surfaceOauthError(new InvalidGrantError(longMessage)) + expect(result.endsWith('…')).toBe(true) + expect(result.length).toBe(251) + }) + + it('returns generic fallback for non-Error values', () => { + expect(surfaceOauthError(null)).toBe('Failed to start OAuth flow') + expect(surfaceOauthError(undefined)).toBe('Failed to start OAuth flow') + }) +}) diff --git a/apps/sim/app/api/mcp/oauth/start/route.ts b/apps/sim/app/api/mcp/oauth/start/route.ts new file mode 100644 index 00000000000..c7619b9d555 --- /dev/null +++ b/apps/sim/app/api/mcp/oauth/start/route.ts @@ -0,0 +1,160 @@ +import { auth as mcpAuth } from '@modelcontextprotocol/sdk/client/auth.js' +import { OAuthError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors.js' +import { db } from '@sim/db' +import { mcpServers } from '@sim/db/schema' +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 { NextResponse } from 'next/server' +import { startMcpOauthContract } from '@/lib/api/contracts/mcp' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withMcpAuth } from '@/lib/mcp/middleware' +import { + assertSafeOauthServerUrl, + getOrCreateOauthRow, + loadPreregisteredClient, + McpOauthInsecureUrlError, + McpOauthRedirectRequired, + SimMcpOauthProvider, + setOauthRowUser, +} from '@/lib/mcp/oauth' +import { createMcpErrorResponse } from '@/lib/mcp/utils' + +const logger = createLogger('McpOauthStartAPI') +const OAUTH_START_TTL_MS = 10 * 60 * 1000 +const MAX_SURFACED_ERROR_LENGTH = 250 + +export function surfaceOauthError(error: unknown): string { + // Spec-compliant OAuth servers throw typed subclasses with clean RFC 6749 fields. + if (error instanceof OAuthError && !(error instanceof ServerError)) { + return truncate(`${error.errorCode}: ${error.message}`) + } + + // ServerError wraps non-spec response bodies as "HTTP N: Invalid OAuth error + // response: ... Raw body: {...}". Dig the vendor message out of the JSON tail. + if (error instanceof Error) { + const rawBodyMatch = error.message.match(/Raw body:\s*(\{[\s\S]*\})\s*$/) + if (rawBodyMatch) { + try { + const body = JSON.parse(rawBodyMatch[1]) as Record + const vendorMessage = + (typeof body.error_description === 'string' && body.error_description) || + (typeof body.message === 'string' && body.message) || + (typeof body.error === 'string' && body.error) || + null + if (vendorMessage) return truncate(`Authorization server: ${vendorMessage}`) + } catch {} + } + return truncate(error.message.split('\n')[0] || 'Failed to start OAuth flow') + } + return 'Failed to start OAuth flow' +} + +function truncate(message: string): string { + return message.length > MAX_SURFACED_ERROR_LENGTH + ? `${message.slice(0, MAX_SURFACED_ERROR_LENGTH)}…` + : message +} + +export const dynamic = 'force-dynamic' + +export const GET = withRouteHandler( + withMcpAuth('write')(async (request: NextRequest, { userId, workspaceId }) => { + try { + const parsed = await parseRequest(startMcpOauthContract, request, {}) + if (!parsed.success) return parsed.response + const { serverId } = parsed.data.query + + const [server] = await db + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } + if (server.authType !== 'oauth') { + return createMcpErrorResponse( + new Error(`Server authType is "${server.authType}", not oauth`), + 'Server is not configured for OAuth', + 400 + ) + } + if (!server.url) { + return createMcpErrorResponse(new Error('Server has no URL'), 'Missing server URL', 400) + } + try { + assertSafeOauthServerUrl(server.url) + } catch (e) { + if (e instanceof McpOauthInsecureUrlError) { + return createMcpErrorResponse( + e, + 'MCP OAuth requires https (or http://localhost for development)', + 400 + ) + } + throw e + } + + const row = await getOrCreateOauthRow({ + mcpServerId: server.id, + userId, + workspaceId, + }) + const hasActiveFlow = + !!row.state && + !!row.stateCreatedAt && + row.stateCreatedAt.getTime() > Date.now() - OAUTH_START_TTL_MS + if (hasActiveFlow && row.userId && row.userId !== userId) { + return createMcpErrorResponse( + new Error('OAuth authorization already in progress'), + 'OAuth authorization already in progress for this server', + 409 + ) + } + if (row.userId !== userId) { + await setOauthRowUser(row.id, userId) + row.userId = userId + } + const preregistered = await loadPreregisteredClient(server.id) + const provider = new SimMcpOauthProvider({ row, preregistered }) + + try { + const result = await mcpAuth(provider, { serverUrl: server.url }) + if (result === 'AUTHORIZED') { + return NextResponse.json({ status: 'already_authorized' }) + } + return createMcpErrorResponse( + new Error('Provider did not capture redirect URL'), + 'Failed to start OAuth flow', + 500 + ) + } catch (e) { + if (e instanceof McpOauthRedirectRequired) { + logger.info(`OAuth redirect for server ${serverId}`) + return NextResponse.json({ + status: 'redirect', + authorizationUrl: e.authorizationUrl, + }) + } + throw e + } + } catch (error) { + logger.error('Error starting MCP OAuth flow:', error) + // Only surface OAuth-flow errors verbatim; everything else (DB, decryption, + // network) gets a generic message to avoid leaking internal details. + const userMessage = + error instanceof OAuthError ? surfaceOauthError(error) : 'Failed to start OAuth flow' + return createMcpErrorResponse(toError(error), userMessage, 500) + } + }) +) diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index bd9b10d4d3d..cd9c5523231 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -197,4 +197,51 @@ describe('MCP Serve Route', () => { expect(headers['X-API-Key']).toBeUndefined() expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1') }) + + describe('initialize protocol version negotiation', () => { + async function callInitialize(protocolVersion?: string) { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + const params: Record = { + capabilities: {}, + clientInfo: { name: 'test', version: '1.0.0' }, + } + if (protocolVersion !== undefined) params.protocolVersion = protocolVersion + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params }), + }) + const res = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + return res.json() as Promise<{ result: { protocolVersion: string } }> + } + + it('echoes a supported client protocolVersion (2025-06-18)', async () => { + const body = await callInitialize('2025-06-18') + expect(body.result.protocolVersion).toBe('2025-06-18') + }) + + it('echoes a supported client protocolVersion (2024-11-05)', async () => { + const body = await callInitialize('2024-11-05') + expect(body.result.protocolVersion).toBe('2024-11-05') + }) + + it('falls back to SDK latest when client requests unknown version', async () => { + const { LATEST_PROTOCOL_VERSION } = await import('@modelcontextprotocol/sdk/types.js') + const body = await callInitialize('2099-01-01') + expect(body.result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION) + }) + + it('falls back to SDK latest when client omits protocolVersion', async () => { + const { LATEST_PROTOCOL_VERSION } = await import('@modelcontextprotocol/sdk/types.js') + const body = await callInitialize(undefined) + expect(body.result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION) + }) + }) }) diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 85c302de282..d876dcd0ef2 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -11,8 +11,10 @@ import { type JSONRPCError, type JSONRPCMessage, type JSONRPCResultResponse, + LATEST_PROTOCOL_VERSION, type ListToolsResult, type RequestId, + SUPPORTED_PROTOCOL_VERSIONS, type Tool, } from '@modelcontextprotocol/sdk/types.js' import { db } from '@sim/db' @@ -20,6 +22,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' @@ -30,6 +38,17 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkflowMcpServeAPI') +function negotiateProtocolVersion(rpcParams: unknown): string { + const requested = + rpcParams && typeof rpcParams === 'object' && 'protocolVersion' in rpcParams + ? (rpcParams as { protocolVersion?: unknown }).protocolVersion + : undefined + if (typeof requested === 'string' && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)) { + return requested + } + return LATEST_PROTOCOL_VERSION +} + export const dynamic = 'force-dynamic' interface RouteParams { @@ -83,9 +102,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 +144,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 +178,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,12 +212,22 @@ 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': { const result: InitializeResult = { - protocolVersion: '2024-11-05', + protocolVersion: negotiateProtocolVersion(rpcParams), capabilities: { tools: {} }, serverInfo: { name: server.name, version: '1.0.0' }, } @@ -196,15 +240,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 +425,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..9f216ebf959 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts @@ -2,8 +2,10 @@ import { db } from '@sim/db' import { mcpServers, workflow, workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { and, eq, isNull } from 'drizzle-orm' +import { and, eq, inArray, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { mcpServerIdParamsSchema } from '@/lib/api/contracts/mcp' +import { validationErrorResponse } 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' @@ -75,13 +77,11 @@ async function syncToolSchemasToWorkflows( subBlocks: workflowBlocks.subBlocks, }) .from(workflowBlocks) - .where(eq(workflowBlocks.type, 'agent')) + .where(and(eq(workflowBlocks.type, 'agent'), inArray(workflowBlocks.workflowId, workflowIds))) const updatedWorkflowIds = new Set() for (const block of agentBlocks) { - if (!workflowIds.includes(block.workflowId)) continue - const subBlocks = block.subBlocks as Record | null if (!subBlocks) continue @@ -158,9 +158,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 = mcpServerIdParamsSchema.safeParse(await params) + if (!paramsValidation.success) return validationErrorResponse(paramsValidation.error) + const { id: serverId } = paramsValidation.data logger.info(`[${requestId}] Refreshing MCP server: ${serverId}`) const [server] = await db @@ -196,7 +197,12 @@ export const POST = withRouteHandler( } try { - discoveredTools = await mcpService.discoverServerTools(userId, serverId, workspaceId) + discoveredTools = await mcpService.discoverServerTools( + userId, + serverId, + workspaceId, + true + ) connectionStatus = 'connected' toolCount = discoveredTools.length logger.info(`[${requestId}] Discovered ${toolCount} tools from server ${serverId}`) diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index 13005bb6433..4242fdef119 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -1,21 +1,15 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { mcpServers } from '@sim/db/schema' 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 { updateMcpServerBodySchema } from '@/lib/api/contracts/mcp' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - McpDnsResolutionError, - McpDomainNotAllowedError, - McpSsrfError, - validateMcpDomain, - validateMcpServerSsrf, -} from '@/lib/mcp/domain-check' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpService } from '@/lib/mcp/service' -import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +import { performUpdateMcpServer } from '@/lib/mcp/orchestration' +import { + createMcpErrorResponse, + createMcpSuccessResponse, + mcpOrchestrationStatus, +} from '@/lib/mcp/utils' const logger = createLogger('McpServerAPI') @@ -31,10 +25,17 @@ export const PATCH = withRouteHandler( { userId, userName, userEmail, workspaceId, requestId }, { params } ) => { - const { id: serverId } = await params - try { - const body = getParsedBody(request) || (await request.json()) + const { id: serverId } = await params + + const rawBody = getParsedBody(request) ?? (await request.json()) + const parsedBody = updateMcpServerBodySchema.safeParse(rawBody) + + 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}`, @@ -44,104 +45,42 @@ export const PATCH = withRouteHandler( } ) - // Remove workspaceId from body to prevent it from being updated - const { workspaceId: _, ...updateData } = body - - if (updateData.url) { - try { - validateMcpDomain(updateData.url) - } catch (e) { - if (e instanceof McpDomainNotAllowedError) { - return createMcpErrorResponse(e, e.message, 403) - } - throw e - } - - try { - await validateMcpServerSsrf(updateData.url) - } catch (e) { - if (e instanceof McpDnsResolutionError) { - return createMcpErrorResponse(e, e.message, 502) - } - if (e instanceof McpSsrfError) { - return createMcpErrorResponse(e, e.message, 403) - } - throw e - } - } - - // Get the current server to check if URL is changing - const [currentServer] = await db - .select({ url: mcpServers.url }) - .from(mcpServers) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) - ) - .limit(1) - - const [updatedServer] = await db - .update(mcpServers) - .set({ - ...updateData, - updatedAt: new Date(), - }) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) - ) - .returning() - - if (!updatedServer) { + const result = await performUpdateMcpServer({ + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + serverId, + name: body.name, + description: body.description, + transport: body.transport, + url: body.url, + headers: body.headers, + timeout: body.timeout, + retries: body.retries, + enabled: body.enabled, + authType: body.authType, + oauthClientId: body.oauthClientId || null, + oauthClientIdProvided: body.oauthClientId !== undefined, + oauthClientSecret: body.oauthClientSecret, + oauthClientSecretProvided: body.oauthClientSecret !== undefined, + request, + }) + if (!result.success || !result.server) { return createMcpErrorResponse( new Error('Server not found or access denied'), - 'Server not found', - 404 + result.error || 'Server not found', + mcpOrchestrationStatus(result.errorCode) ) } - - const shouldClearCache = - (body.url !== undefined && currentServer?.url !== body.url) || - body.enabled !== undefined || - body.headers !== undefined || - body.timeout !== undefined || - body.retries !== undefined - - if (shouldClearCache) { - await mcpService.clearCache(workspaceId) - logger.info(`[${requestId}] Cleared MCP cache after server lifecycle update`) - } + const updatedServer = result.server logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`) - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: updatedServer.name || serverId, - description: `Updated MCP server "${updatedServer.name || serverId}"`, - metadata: { - serverName: updatedServer.name, - transport: updatedServer.transport, - url: updatedServer.url, - updatedFields: Object.keys(updateData).filter( - (k) => k !== 'workspaceId' && k !== 'updatedAt' - ), - }, - request, + const { oauthClientSecret: _secret, ...rest } = updatedServer + return createMcpSuccessResponse({ + server: { ...rest, hasOauthClientSecret: !!_secret }, }) - - return createMcpSuccessResponse({ server: updatedServer }) } catch (error) { logger.error(`[${requestId}] Error updating MCP server:`, error) return createMcpErrorResponse(toError(error), 'Failed to update MCP server', 500) diff --git a/apps/sim/app/api/mcp/servers/route.test.ts b/apps/sim/app/api/mcp/servers/route.test.ts new file mode 100644 index 00000000000..e831c802e0a --- /dev/null +++ b/apps/sim/app/api/mcp/servers/route.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ +import type { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPerformDeleteMcpServer } = vi.hoisted(() => ({ + mockPerformDeleteMcpServer: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: vi.fn(), + }, +})) + +vi.mock('@/lib/mcp/middleware', () => ({ + getParsedBody: () => undefined, + withMcpAuth: + () => + ( + handler: ( + request: NextRequest, + context: { + userId: string + userName: string + userEmail: string + workspaceId: string + requestId: string + } + ) => Promise + ) => + (request: NextRequest) => + handler(request, { + userId: 'user-1', + userName: 'Test User', + userEmail: 'test@example.com', + workspaceId: 'workspace-1', + requestId: 'request-1', + }), +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateMcpServer: vi.fn(), + performDeleteMcpServer: mockPerformDeleteMcpServer, +})) + +import { DELETE } from '@/app/api/mcp/servers/route' + +function createDeleteRequest(serverId = 'server-1') { + return new Request( + `http://localhost:3000/api/mcp/servers?workspaceId=workspace-1&serverId=${serverId}`, + { method: 'DELETE' } + ) as NextRequest +} + +describe('MCP servers DELETE route', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns 404 when orchestration reports a missing server', async () => { + mockPerformDeleteMcpServer.mockResolvedValueOnce({ + success: false, + error: 'Server not found', + errorCode: 'not_found', + }) + + const response = await DELETE(createDeleteRequest()) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body).toEqual({ success: false, error: 'Server not found' }) + }) + + it('returns 500 when orchestration reports an internal delete failure', async () => { + mockPerformDeleteMcpServer.mockResolvedValueOnce({ + success: false, + error: 'Failed to delete MCP server', + errorCode: 'internal', + }) + + const response = await DELETE(createDeleteRequest()) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body).toEqual({ success: false, error: 'Failed to delete MCP server' }) + }) +}) diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index bab33a9b9cb..1d02caeef74 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -1,27 +1,19 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' 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 { validationErrorResponse } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - McpDnsResolutionError, - McpDomainNotAllowedError, - McpSsrfError, - validateMcpDomain, - validateMcpServerSsrf, -} from '@/lib/mcp/domain-check' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpService } from '@/lib/mcp/service' +import { performCreateMcpServer, performDeleteMcpServer } from '@/lib/mcp/orchestration' import { createMcpErrorResponse, createMcpSuccessResponse, - generateMcpServerId, + mcpOrchestrationStatus, } from '@/lib/mcp/utils' -import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('McpServersAPI') @@ -35,11 +27,16 @@ export const GET = withRouteHandler( try { logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) - const servers = await db + const rows = await db .select() .from(mcpServers) .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) + const servers = rows.map(({ oauthClientSecret: _secret, ...rest }) => ({ + ...rest, + hasOauthClientSecret: !!_secret, + })) + logger.info( `[${requestId}] Listed ${servers.length} MCP servers for workspace ${workspaceId}` ) @@ -53,19 +50,19 @@ export const GET = withRouteHandler( /** * POST - Register a new MCP server for the workspace (requires write permission) - * - * Uses deterministic server IDs based on URL hash to ensure that re-adding - * the same server produces the same ID. This prevents "server not found" errors - * when workflows reference the old server ID after delete/re-add cycles. - * - * If a server with the same ID already exists (same URL in same workspace), - * it will be updated instead of creating a duplicate. */ 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 = createMcpServerBodySchema.safeParse(rawBody) + + 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,150 +70,55 @@ export const POST = withRouteHandler( workspaceId, }) - if (!body.name || !body.transport) { + const sourceParam = body.source as string | undefined + const source = + sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined + if (!body.url) { return createMcpErrorResponse( - new Error('Missing required fields: name or transport'), - 'Missing required fields', + new Error('url is required'), + 'Missing required parameter', 400 ) } - - try { - validateMcpDomain(body.url) - } catch (e) { - if (e instanceof McpDomainNotAllowedError) { - return createMcpErrorResponse(e, e.message, 403) - } - throw e - } - - try { - await validateMcpServerSsrf(body.url) - } catch (e) { - if (e instanceof McpDnsResolutionError) { - return createMcpErrorResponse(e, e.message, 502) - } - if (e instanceof McpSsrfError) { - return createMcpErrorResponse(e, e.message, 403) - } - throw e - } - - const serverId = body.url ? generateMcpServerId(workspaceId, body.url) : generateId() - - const [existingServer] = await db - .select({ id: mcpServers.id, deletedAt: mcpServers.deletedAt }) - .from(mcpServers) - .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - .limit(1) - - if (existingServer) { - logger.info( - `[${requestId}] Server with ID ${serverId} already exists, updating instead of creating` - ) - - await db - .update(mcpServers) - .set({ - name: body.name, - description: body.description, - transport: body.transport, - url: body.url, - headers: body.headers || {}, - timeout: body.timeout || 30000, - retries: body.retries || 3, - enabled: body.enabled !== false, - connectionStatus: 'connected', - lastConnected: new Date(), - updatedAt: new Date(), - deletedAt: null, - }) - .where(eq(mcpServers.id, serverId)) - - await mcpService.clearCache(workspaceId) - - logger.info( - `[${requestId}] Successfully updated MCP server: ${body.name} (ID: ${serverId})` + const result = await performCreateMcpServer({ + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + name: body.name, + description: body.description, + transport: body.transport, + url: body.url, + headers: body.headers, + timeout: body.timeout, + retries: body.retries, + enabled: body.enabled, + source, + authType: body.authType, + oauthClientId: body.oauthClientId || null, + oauthClientIdProvided: body.oauthClientId !== undefined, + oauthClientSecret: body.oauthClientSecret, + oauthClientSecretProvided: body.oauthClientSecret !== undefined, + request, + }) + if (!result.success || !result.serverId) { + return createMcpErrorResponse( + new Error(result.error || 'Failed to register MCP server'), + result.error || 'Failed to register MCP server', + mcpOrchestrationStatus(result.errorCode) ) - - return createMcpSuccessResponse({ serverId, updated: true }, 200) } - await db - .insert(mcpServers) - .values({ - id: serverId, - workspaceId, - createdBy: userId, - name: body.name, - description: body.description, - transport: body.transport, - url: body.url, - headers: body.headers || {}, - timeout: body.timeout || 30000, - retries: body.retries || 3, - enabled: body.enabled !== false, - connectionStatus: 'connected', - lastConnected: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() - - await mcpService.clearCache(workspaceId) - logger.info( - `[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${serverId})` + `[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${result.serverId})` ) - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.mcpServerAdded({ - serverId, - serverName: body.name, - transport: body.transport, - workspaceId, - }) - } catch (_e) { - // Silently fail - } - - const sourceParam = body.source as string | undefined - const source = - sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined - - captureServerEvent( - userId, - 'mcp_server_connected', - { workspace_id: workspaceId, server_name: body.name, transport: body.transport, source }, - { - groups: { workspace: workspaceId }, - setOnce: { first_mcp_connected_at: new Date().toISOString() }, - } + return createMcpSuccessResponse( + result.updated + ? { serverId: result.serverId, updated: true, authType: result.authType } + : { serverId: result.serverId, authType: result.authType }, + result.updated ? 200 : 201 ) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_ADDED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: body.name, - description: `Added MCP server "${body.name}"`, - metadata: { - serverName: body.name, - transport: body.transport, - url: body.url, - timeout: body.timeout || 30000, - retries: body.retries || 3, - source: source, - }, - request, - }) - - return createMcpSuccessResponse({ serverId }, 201) } catch (error) { logger.error(`[${requestId}] Error registering MCP server:`, error) return createMcpErrorResponse(toError(error), 'Failed to register MCP server', 500) @@ -233,8 +135,13 @@ 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 = deleteMcpServerByQuerySchema.safeParse( + Object.fromEntries(searchParams) + ) + if (!queryValidation.success) return validationErrorResponse(queryValidation.error) + const query = queryValidation.data + const serverId = query.serverId + const sourceParam = query.source const source = sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined @@ -250,48 +157,24 @@ export const DELETE = withRouteHandler( `[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}` ) - const [deletedServer] = await db - .delete(mcpServers) - .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - .returning() - - if (!deletedServer) { - return createMcpErrorResponse( - new Error('Server not found or access denied'), - 'Server not found', - 404 - ) - } - - await mcpService.clearCache(workspaceId) - - logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) - - captureServerEvent( - userId, - 'mcp_server_disconnected', - { workspace_id: workspaceId, server_name: deletedServer.name, source }, - { groups: { workspace: workspaceId } } - ) - - recordAudit({ + const result = await performDeleteMcpServer({ workspaceId, - actorId: userId, + userId, actorName: userName, actorEmail: userEmail, - action: AuditAction.MCP_SERVER_REMOVED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId!, - resourceName: deletedServer.name, - description: `Removed MCP server "${deletedServer.name}"`, - metadata: { - serverName: deletedServer.name, - transport: deletedServer.transport, - url: deletedServer.url, - source, - }, + serverId, + source, request, }) + if (!result.success || !result.server) { + return createMcpErrorResponse( + new Error(result.error || 'Failed to delete MCP server'), + result.error || 'Failed to delete MCP server', + mcpOrchestrationStatus(result.errorCode) + ) + } + + logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) } catch (error) { 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..c017de7a34c 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 { @@ -11,8 +12,9 @@ import { validateMcpServerSsrf, } from '@/lib/mcp/domain-check' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { detectMcpAuthType } from '@/lib/mcp/oauth' import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config' -import type { McpTransport } from '@/lib/mcp/types' +import type { McpAuthType, McpTransport } from '@/lib/mcp/types' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' const logger = createLogger('McpServerTestAPI') @@ -27,18 +29,11 @@ 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 + authRequired?: boolean + authType?: McpAuthType serverInfo?: { name: string version: string @@ -69,7 +64,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 +80,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'), @@ -104,6 +98,9 @@ export const POST = withRouteHandler( } try { + // Initial pre-resolution check; the authoritative resolved IP is + // captured after env-var resolution below and used to pin the + // connection against DNS rebinding. await validateMcpServerSsrf(body.url) } catch (e) { if (e instanceof McpDnsResolutionError) { @@ -149,8 +146,9 @@ export const POST = withRouteHandler( throw e } + let resolvedIP: string | null try { - await validateMcpServerSsrf(testConfig.url) + resolvedIP = await validateMcpServerSsrf(testConfig.url) } catch (e) { if (e instanceof McpDnsResolutionError) { return createMcpErrorResponse(e, e.message, 502) @@ -168,10 +166,26 @@ export const POST = withRouteHandler( } const result: TestConnectionResult = { success: false } + + // Skip unauth connect when the server returns an RFC 9728 OAuth challenge. + if (testConfig.url) { + const detectedAuthType = await detectMcpAuthType(testConfig.url) + if (detectedAuthType === 'oauth') { + result.authRequired = true + result.authType = 'oauth' + return createMcpSuccessResponse(result, 200) + } + result.authType = detectedAuthType + } + let client: McpClient | null = null try { - client = new McpClient(testConfig, testSecurityPolicy) + client = new McpClient({ + config: testConfig, + securityPolicy: testSecurityPolicy, + resolvedIP: resolvedIP ?? undefined, + }) await client.connect() result.negotiatedVersion = client.getNegotiatedVersion() diff --git a/apps/sim/app/api/mcp/tools/discover/route.ts b/apps/sim/app/api/mcp/tools/discover/route.ts index b6a0f7c09a3..612788b4875 100644 --- a/apps/sim/app/api/mcp/tools/discover/route.ts +++ b/apps/sim/app/api/mcp/tools/discover/route.ts @@ -1,9 +1,12 @@ +import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' +import { mcpToolDiscoveryQuerySchema, refreshMcpToolsBodySchema } from '@/lib/api/contracts/mcp' +import { validationErrorResponse } 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' -import type { McpToolDiscoveryResponse } from '@/lib/mcp/types' +import { McpOauthAuthorizationRequiredError, type McpToolDiscoveryResponse } from '@/lib/mcp/types' import { categorizeError, createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' const logger = createLogger('McpToolDiscoveryAPI') @@ -14,13 +17,18 @@ 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 = mcpToolDiscoveryQuerySchema.safeParse( + Object.fromEntries(searchParams) + ) + if (!queryValidation.success) return validationErrorResponse(queryValidation.error) + const query = queryValidation.data + const serverId = query.serverId + const forceRefresh = query.refresh === 'true' logger.info(`[${requestId}] Discovering MCP tools`, { serverId, workspaceId, forceRefresh }) const tools = serverId - ? await mcpService.discoverServerTools(userId, serverId, workspaceId) + ? await mcpService.discoverServerTools(userId, serverId, workspaceId, forceRefresh) : await mcpService.discoverTools(userId, workspaceId, forceRefresh) const byServer: Record = {} @@ -39,6 +47,12 @@ export const GET = withRouteHandler( ) return createMcpSuccessResponse(responseData) } catch (error) { + if ( + error instanceof McpOauthAuthorizationRequiredError || + error instanceof UnauthorizedError + ) { + return createMcpErrorResponse(error, 'OAuth re-authorization required', 401) + } logger.error(`[${requestId}] Error discovering MCP tools:`, error) const { message, status } = categorizeError(error) return createMcpErrorResponse(new Error(message), 'Failed to discover MCP tools', status) @@ -49,22 +63,20 @@ 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 = refreshMcpToolsBodySchema.safeParse(rawBody) + + 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( serverIds.map(async (serverId: string) => { - const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) + const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId, true) return { serverId, toolCount: tools.length } }) ) @@ -95,6 +107,12 @@ export const POST = withRouteHandler( }, }) } catch (error) { + if ( + error instanceof McpOauthAuthorizationRequiredError || + error instanceof UnauthorizedError + ) { + return createMcpErrorResponse(error, 'OAuth re-authorization required', 401) + } logger.error(`[${requestId}] Error refreshing tool discovery:`, error) const { message, status } = categorizeError(error) return createMcpErrorResponse(new Error(message), 'Failed to refresh tool discovery', status) diff --git a/apps/sim/app/api/mcp/tools/execute/route.ts b/apps/sim/app/api/mcp/tools/execute/route.ts index c94dc7d0aba..8599a5fcadf 100644 --- a/apps/sim/app/api/mcp/tools/execute/route.ts +++ b/apps/sim/app/api/mcp/tools/execute/route.ts @@ -1,19 +1,23 @@ +import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { mcpToolExecutionBodySchema } from '@/lib/api/contracts/mcp' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { getExecutionTimeout } from '@/lib/core/execution-limits' import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { SIM_VIA_HEADER } from '@/lib/execution/call-chain' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { McpOauthRedirectRequired } from '@/lib/mcp/oauth' import { mcpService } from '@/lib/mcp/service' -import type { McpTool, McpToolCall, McpToolResult } from '@/lib/mcp/types' import { - categorizeError, - createMcpErrorResponse, - createMcpSuccessResponse, - validateStringParam, -} from '@/lib/mcp/utils' + McpOauthAuthorizationRequiredError, + type McpTool, + type McpToolCall, + type McpToolResult, +} from '@/lib/mcp/types' +import { categorizeError, createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' import { assertPermissionsAllowed, McpToolsNotAllowedError, @@ -24,7 +28,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 @@ -47,12 +51,19 @@ function hasType(prop: unknown): prop is SchemaProperty { */ export const POST = withRouteHandler( withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { + let serverId: string | undefined try { - const body = getParsedBody(request) || (await request.json()) + const rawBody = getParsedBody(request) ?? (await request.json()) + const parsedBody = mcpToolExecutionBodySchema.safeParse(rawBody) + + 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, @@ -61,21 +72,10 @@ export const POST = withRouteHandler( userId: userId, }) - const { serverId, toolName, arguments: rawArgs } = body + const { toolName, arguments: rawArgs } = body + serverId = body.serverId 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,32 +95,24 @@ 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) { for (const [paramName, paramSchema] of Object.entries(tool.inputSchema.properties)) { - const schema = paramSchema as any + const schema = hasType(paramSchema) ? paramSchema : null + if (!schema) continue const value = args[paramName] if (value === undefined || value === null) { @@ -170,7 +162,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 ) } @@ -204,12 +196,18 @@ export const POST = withRouteHandler( extraHeaders[SIM_VIA_HEADER] = simViaHeader } + let timeoutHandle: ReturnType | undefined const result = await Promise.race([ mcpService.executeTool(userId, serverId, toolCall, workspaceId, extraHeaders), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Tool execution timeout')), executionTimeout) - ), - ]) + new Promise((_, reject) => { + timeoutHandle = setTimeout( + () => reject(new Error('Tool execution timeout')), + executionTimeout + ) + }), + ]).finally(() => { + if (timeoutHandle !== undefined) clearTimeout(timeoutHandle) + }) const transformedResult = transformToolResult(result) @@ -237,6 +235,27 @@ export const POST = withRouteHandler( return createMcpSuccessResponse(transformedResult) } catch (error) { + if ( + error instanceof McpOauthAuthorizationRequiredError || + error instanceof McpOauthRedirectRequired || + error instanceof UnauthorizedError + ) { + const errorServerId = + error instanceof McpOauthAuthorizationRequiredError ? error.serverId : serverId + logger.warn(`[${requestId}] OAuth re-authorization required for MCP tool execution`, { + serverId: errorServerId, + }) + return NextResponse.json( + { + success: false, + error: 'OAuth re-authorization required', + code: 'reauth_required', + serverId: errorServerId, + }, + { status: 401 } + ) + } + logger.error(`[${requestId}] Error executing MCP tool:`, error) const { message, status } = categorizeError(error) @@ -273,6 +292,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 +319,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..3606e05115d 100644 --- a/apps/sim/app/api/mcp/tools/stored/route.ts +++ b/apps/sim/app/api/mcp/tools/stored/route.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { workflow, workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withMcpAuth } from '@/lib/mcp/middleware' @@ -33,13 +33,13 @@ export const GET = withRouteHandler( const agentBlocks = await db .select({ workflowId: workflowBlocks.workflowId, subBlocks: workflowBlocks.subBlocks }) .from(workflowBlocks) - .where(eq(workflowBlocks.type, 'agent')) + .where( + and(eq(workflowBlocks.type, 'agent'), inArray(workflowBlocks.workflowId, workflowIds)) + ) const storedTools: StoredMcpTool[] = [] for (const block of agentBlocks) { - if (!workflowMap.has(block.workflowId)) continue - const subBlocks = block.subBlocks as Record | null if (!subBlocks) continue 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..803e9879e70 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -1,14 +1,24 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' 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' -import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +import { + performDeleteWorkflowMcpServer, + performUpdateWorkflowMcpServer, +} from '@/lib/mcp/orchestration' +import { + createMcpErrorResponse, + createMcpSuccessResponse, + mcpOrchestrationStatus, +} from '@/lib/mcp/utils' const logger = createLogger('WorkflowMcpServerAPI') @@ -25,7 +35,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,66 +93,40 @@ 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) - logger.info(`[${requestId}] Updating workflow MCP server: ${serverId}`) - - const [existingServer] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) - ) - .limit(1) - - if (!existingServer) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } - - const updateData: Record = { - updatedAt: new Date(), + if (!parsedBody.success) { + return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) } - if (body.name !== undefined) { - updateData.name = body.name.trim() - } - if (body.description !== undefined) { - updateData.description = body.description?.trim() || null - } - if (body.isPublic !== undefined) { - updateData.isPublic = body.isPublic - } + const body = parsedBody.data - const [updatedServer] = await db - .update(workflowMcpServer) - .set(updateData) - .where(and(eq(workflowMcpServer.id, serverId), isNull(workflowMcpServer.deletedAt))) - .returning() - - logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`) + logger.info(`[${requestId}] Updating workflow MCP server: ${serverId}`) - recordAudit({ + const result = await performUpdateWorkflowMcpServer({ + serverId, workspaceId, - actorId: userId, + userId, actorName: userName, actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: updatedServer.name, - description: `Updated workflow MCP server "${updatedServer.name}"`, - metadata: { - serverName: updatedServer.name, - isPublic: updatedServer.isPublic, - updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), - }, - request, + name: body.name, + description: body.description, + isPublic: body.isPublic, }) + if (!result.success || !result.server) { + const status = mcpOrchestrationStatus(result.errorCode) + return createMcpErrorResponse( + new Error(result.error || 'Failed to update workflow MCP server'), + result.error || 'Failed to update workflow MCP server', + status + ) + } + + const updatedServer = result.server + + logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`) return createMcpSuccessResponse({ server: updatedServer }) } catch (error) { @@ -164,38 +148,27 @@ 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}`) - const [deletedServer] = await db - .delete(workflowMcpServer) - .where( - and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) - ) - .returning() - - if (!deletedServer) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } - - logger.info(`[${requestId}] Successfully deleted workflow MCP server: ${serverId}`) - - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - - recordAudit({ + const result = await performDeleteWorkflowMcpServer({ + serverId, workspaceId, - actorId: userId, + userId, actorName: userName, actorEmail: userEmail, - action: AuditAction.MCP_SERVER_REMOVED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: deletedServer.name, - description: `Unpublished workflow MCP server "${deletedServer.name}"`, - metadata: { serverName: deletedServer.name }, - request, }) + if (!result.success || !result.server) { + return createMcpErrorResponse( + new Error(result.error || 'Server not found'), + result.error || 'Server not found', + mcpOrchestrationStatus(result.errorCode) + ) + } + const deletedServer = result.server + + logger.info(`[${requestId}] Successfully deleted workflow MCP server: ${serverId}`) return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) } catch (error) { 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..95e54946ded 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 @@ -1,15 +1,17 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' 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' +import { performDeleteWorkflowMcpTool, performUpdateWorkflowMcpTool } from '@/lib/mcp/orchestration' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' const logger = createLogger('WorkflowMcpToolAPI') @@ -27,7 +29,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,84 +85,42 @@ 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) - logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`) - - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) - ) - .limit(1) - - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } - - const [existingTool] = await db - .select({ id: workflowMcpTool.id }) - .from(workflowMcpTool) - .where( - and( - eq(workflowMcpTool.id, toolId), - eq(workflowMcpTool.serverId, serverId), - isNull(workflowMcpTool.archivedAt) - ) - ) - .limit(1) - - if (!existingTool) { - return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) - } - - const updateData: Record = { - updatedAt: new Date(), - } - - if (body.toolName !== undefined) { - updateData.toolName = sanitizeToolName(body.toolName) - } - if (body.toolDescription !== undefined) { - updateData.toolDescription = body.toolDescription?.trim() || null - } - if (body.parameterSchema !== undefined) { - updateData.parameterSchema = body.parameterSchema + if (!parsedBody.success) { + return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) } - const [updatedTool] = await db - .update(workflowMcpTool) - .set(updateData) - .where(eq(workflowMcpTool.id, toolId)) - .returning() + const body = parsedBody.data - logger.info(`[${requestId}] Successfully updated tool ${toolId}`) - - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) + logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`) - recordAudit({ + const result = await performUpdateWorkflowMcpTool({ + serverId, + toolId, workspaceId, - actorId: userId, + userId, actorName: userName, actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - description: `Updated tool "${updatedTool.toolName}" in MCP server`, - metadata: { - toolId, - toolName: updatedTool.toolName, - workflowId: updatedTool.workflowId, - updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), - }, - request, + toolName: body.toolName, + toolDescription: body.toolDescription, + parameterSchema: body.parameterSchema, }) + if (!result.success || !result.tool) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + return createMcpErrorResponse( + new Error(result.error || 'Failed to update tool'), + result.error || 'Failed to update tool', + status + ) + } + + const updatedTool = result.tool + + logger.info(`[${requestId}] Successfully updated tool ${toolId}`) return createMcpSuccessResponse({ tool: updatedTool }) } catch (error) { @@ -182,51 +142,28 @@ 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}`) - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) - ) - .limit(1) - - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } - - const [deletedTool] = await db - .delete(workflowMcpTool) - .where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId))) - .returning() - - if (!deletedTool) { - return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) - } - - logger.info(`[${requestId}] Successfully deleted tool ${toolId}`) - - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - - recordAudit({ + const result = await performDeleteWorkflowMcpTool({ + serverId, + toolId, workspaceId, - actorId: userId, + userId, actorName: userName, actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - description: `Removed tool "${deletedTool.toolName}" from MCP server`, - metadata: { toolId, toolName: deletedTool.toolName, workflowId: deletedTool.workflowId }, - request, }) + if (!result.success || !result.tool) { + return createMcpErrorResponse( + new Error(result.error || 'Tool not found'), + result.error || 'Tool not found', + result.errorCode === 'not_found' ? 404 : 500 + ) + } + const deletedTool = result.tool + + logger.info(`[${requestId}] Successfully deleted tool ${toolId}`) return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` }) } catch (error) { 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..4d87728cc2e 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 @@ -1,18 +1,21 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' 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' -import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' -import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' -import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server' +import { performCreateWorkflowMcpTool } from '@/lib/mcp/orchestration' +import { + createMcpErrorResponse, + createMcpSuccessResponse, + mcpOrchestrationStatus, +} from '@/lib/mcp/utils' const logger = createLogger('WorkflowMcpToolsAPI') @@ -29,7 +32,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,149 +95,45 @@ 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) - 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 - ) + if (!parsedBody.success) { + return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) } - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) - ) - .limit(1) - - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } + const body = parsedBody.data - const [workflowRecord] = await db - .select({ - id: workflow.id, - name: workflow.name, - description: workflow.description, - isDeployed: workflow.isDeployed, - workspaceId: workflow.workspaceId, - }) - .from(workflow) - .where(and(eq(workflow.id, body.workflowId), isNull(workflow.archivedAt))) - .limit(1) - - if (!workflowRecord) { - return createMcpErrorResponse(new Error('Workflow not found'), 'Workflow not found', 404) - } - - if (workflowRecord.workspaceId !== workspaceId) { - return createMcpErrorResponse( - new Error('Workflow does not belong to this workspace'), - 'Access denied', - 403 - ) - } - - if (!workflowRecord.isDeployed) { - return createMcpErrorResponse( - new Error('Workflow must be deployed before adding as a tool'), - 'Workflow not deployed', - 400 - ) - } - - const hasStartBlock = await hasValidStartBlock(body.workflowId) - if (!hasStartBlock) { - return createMcpErrorResponse( - new Error('Workflow must have a Start block to be used as an MCP tool'), - 'No start block found', - 400 - ) - } - - const [existingTool] = await db - .select({ id: workflowMcpTool.id }) - .from(workflowMcpTool) - .where( - and( - eq(workflowMcpTool.serverId, serverId), - eq(workflowMcpTool.workflowId, body.workflowId), - isNull(workflowMcpTool.archivedAt) - ) - ) - .limit(1) + logger.info(`[${requestId}] Adding tool to workflow MCP server: ${serverId}`, { + workflowId: body.workflowId, + }) - if (existingTool) { + const result = await performCreateWorkflowMcpTool({ + serverId, + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + workflowId: body.workflowId, + toolName: body.toolName, + toolDescription: body.toolDescription, + parameterSchema: body.parameterSchema, + }) + if (!result.success || !result.tool) { return createMcpErrorResponse( - new Error('This workflow is already added as a tool to this server'), - 'Tool already exists', - 409 + new Error(result.error || 'Failed to add tool'), + result.error || 'Failed to add tool', + mcpOrchestrationStatus(result.errorCode) ) } - const toolName = sanitizeToolName(body.toolName?.trim() || workflowRecord.name) - const toolDescription = - body.toolDescription?.trim() || - workflowRecord.description || - `Execute ${workflowRecord.name} workflow` - - const parameterSchema = - body.parameterSchema && Object.keys(body.parameterSchema).length > 0 - ? body.parameterSchema - : await generateParameterSchemaForWorkflow(body.workflowId) - - const toolId = generateId() - const [tool] = await db - .insert(workflowMcpTool) - .values({ - id: toolId, - serverId, - workflowId: body.workflowId, - toolName, - toolDescription, - parameterSchema, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() + const tool = result.tool logger.info( - `[${requestId}] Successfully added tool ${toolName} (workflow: ${body.workflowId}) to server ${serverId}` + `[${requestId}] Successfully added tool ${tool.toolName} (workflow: ${body.workflowId}) to server ${serverId}` ) - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - description: `Added tool "${toolName}" to MCP server`, - metadata: { - toolId, - toolName, - toolDescription, - workflowId: body.workflowId, - workflowName: workflowRecord.name, - }, - request, - }) - return createMcpSuccessResponse({ tool }, 201) } catch (error) { logger.error(`[${requestId}] Error adding tool:`, error) diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index f5c9c838557..4356592e4c8 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -1,18 +1,18 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' +import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' 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' -import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' -import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' -import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server' +import { performCreateWorkflowMcpServer } from '@/lib/mcp/orchestration' +import { + createMcpErrorResponse, + createMcpSuccessResponse, + mcpOrchestrationStatus, +} from '@/lib/mcp/utils' const logger = createLogger('WorkflowMcpServersAPI') @@ -96,7 +96,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,119 +111,31 @@ export const POST = withRouteHandler( workflowIds: body.workflowIds, }) - if (!body.name) { + const result = await performCreateWorkflowMcpServer({ + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + name: body.name, + description: body.description, + isPublic: body.isPublic, + workflowIds: body.workflowIds, + }) + if (!result.success || !result.server) { return createMcpErrorResponse( - new Error('Missing required field: name'), - 'Missing required field', - 400 + new Error(result.error || 'Failed to create workflow MCP server'), + result.error || 'Failed to create workflow MCP server', + mcpOrchestrationStatus(result.errorCode) ) } - const serverId = generateId() - - const [server] = await db - .insert(workflowMcpServer) - .values({ - id: serverId, - workspaceId, - createdBy: userId, - name: body.name.trim(), - description: body.description?.trim() || null, - isPublic: body.isPublic ?? false, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() - - const workflowIds: string[] = body.workflowIds || [] - const addedTools: Array<{ workflowId: string; toolName: string }> = [] - - if (workflowIds.length > 0) { - const workflows = await db - .select({ - id: workflow.id, - name: workflow.name, - description: workflow.description, - isDeployed: workflow.isDeployed, - workspaceId: workflow.workspaceId, - }) - .from(workflow) - .where(and(inArray(workflow.id, workflowIds), isNull(workflow.archivedAt))) - - for (const workflowRecord of workflows) { - if (workflowRecord.workspaceId !== workspaceId) { - logger.warn( - `[${requestId}] Skipping workflow ${workflowRecord.id} - does not belong to workspace` - ) - continue - } - - if (!workflowRecord.isDeployed) { - logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - not deployed`) - continue - } - - const hasStartBlock = await hasValidStartBlock(workflowRecord.id) - if (!hasStartBlock) { - logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - no start block`) - continue - } - - const toolName = sanitizeToolName(workflowRecord.name) - const toolDescription = - workflowRecord.description || `Execute ${workflowRecord.name} workflow` - - const parameterSchema = await generateParameterSchemaForWorkflow(workflowRecord.id) - - const toolId = generateId() - await db.insert(workflowMcpTool).values({ - id: toolId, - serverId, - workflowId: workflowRecord.id, - toolName, - toolDescription, - parameterSchema, - createdAt: new Date(), - updatedAt: new Date(), - }) - - addedTools.push({ workflowId: workflowRecord.id, toolName }) - } - - logger.info( - `[${requestId}] Added ${addedTools.length} tools to server ${serverId}:`, - addedTools.map((t) => t.toolName) - ) - - if (addedTools.length > 0) { - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - } - } + const { server } = result + const addedTools = result.addedTools || [] logger.info( - `[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})` + `[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${server.id})` ) - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_ADDED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: body.name.trim(), - description: `Published workflow MCP server "${body.name.trim()}" with ${addedTools.length} tool(s)`, - metadata: { - serverName: body.name.trim(), - isPublic: body.isPublic ?? false, - toolCount: addedTools.length, - toolNames: addedTools.map((t) => t.toolName), - workflowIds: addedTools.map((t) => t.workflowId), - }, - request, - }) - return createMcpSuccessResponse({ server, addedTools }, 201) } catch (error) { logger.error(`[${requestId}] Error creating workflow MCP server:`, error) diff --git a/apps/sim/app/api/memory/[id]/route.ts b/apps/sim/app/api/memory/[id]/route.ts index d5a6216d1e0..9cc7b5ccc08 100644 --- a/apps/sim/app/api/memory/[id]/route.ts +++ b/apps/sim/app/api/memory/[id]/route.ts @@ -1,9 +1,15 @@ import { db } from '@sim/db' import { memory } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + agentMemoryDataSchemaContract, + deleteMemoryByIdContract, + getMemoryByIdContract, + updateMemoryByIdContract, +} from '@/lib/api/contracts/memory' +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,25 +17,13 @@ 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()) +interface MemoryRouteContext { + params: Promise<{ id: string }> +} -const memoryPutBodySchema = z.object({ - data: z.union([agentMemoryDataSchema, genericMemoryDataSchema], { - errorMap: () => ({ message: 'Invalid memory data structure' }), - }), - workspaceId: z.string().uuid('Invalid workspace ID format'), -}) +function memoryEnvelopeError(message: string, status: number) { + return NextResponse.json({ success: false, error: { message } }, { status }) +} async function validateMemoryAccess( request: NextRequest, @@ -40,31 +34,16 @@ async function validateMemoryAccess( const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized memory ${action} attempt`) - return { - error: NextResponse.json( - { success: false, error: { message: 'Authentication required' } }, - { status: 401 } - ), - } + return { error: memoryEnvelopeError('Authentication required', 401) } } const access = await checkWorkspaceAccess(workspaceId, authResult.userId) if (!access.exists || !access.hasAccess) { - return { - error: NextResponse.json( - { success: false, error: { message: 'Workspace not found' } }, - { status: 404 } - ), - } + return { error: memoryEnvelopeError('Workspace not found', 404) } } if (action === 'write' && !access.canWrite) { - return { - error: NextResponse.json( - { success: false, error: { message: 'Write access denied' } }, - { status: 403 } - ), - } + return { error: memoryEnvelopeError('Write access denied', 403) } } return { userId: authResult.userId } @@ -73,90 +52,71 @@ async function validateMemoryAccess( export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const { id } = await params +export const GET = withRouteHandler(async (request: NextRequest, context: MemoryRouteContext) => { + const requestId = generateRequestId() + const { id } = await context.params + + try { + const validation = await parseRequest(getMemoryByIdContract, request, context, { + validationErrorResponse: (error) => + memoryEnvelopeError( + error.issues.map((err) => `${err.path.join('.')}: ${err.message}`).join(', '), + 400 + ), + }) + if (!validation.success) return validation.response + const { workspaceId: validatedWorkspaceId } = validation.data.query + + const accessCheck = await validateMemoryAccess(request, validatedWorkspaceId, requestId, 'read') + if ('error' in accessCheck) { + return accessCheck.error + } - try { - const url = new URL(request.url) - const workspaceId = url.searchParams.get('workspaceId') - - const validation = memoryQuerySchema.safeParse({ workspaceId }) - if (!validation.success) { - const errorMessage = validation.error.errors - .map((err) => `${err.path.join('.')}: ${err.message}`) - .join(', ') - return NextResponse.json( - { success: false, error: { message: errorMessage } }, - { status: 400 } + const memories = await db + .select() + .from(memory) + .where( + and( + eq(memory.key, id), + eq(memory.workspaceId, validatedWorkspaceId), + isNull(memory.deletedAt) ) - } - - const { workspaceId: validatedWorkspaceId } = validation.data - - const accessCheck = await validateMemoryAccess( - request, - validatedWorkspaceId, - requestId, - 'read' ) - if ('error' in accessCheck) { - return accessCheck.error - } - - const memories = await db - .select() - .from(memory) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) - .orderBy(memory.createdAt) - .limit(1) + .orderBy(memory.createdAt) + .limit(1) - if (memories.length === 0) { - return NextResponse.json( - { success: false, error: { message: 'Memory not found' } }, - { status: 404 } - ) - } + if (memories.length === 0) { + return NextResponse.json({ success: true, data: null }, { status: 200 }) + } - const mem = memories[0] + const mem = memories[0] - logger.info(`[${requestId}] Memory retrieved: ${id} for workspace: ${validatedWorkspaceId}`) - return NextResponse.json( - { success: true, data: { conversationId: mem.key, data: mem.data } }, - { status: 200 } - ) - } catch (error: any) { - logger.error(`[${requestId}] Error retrieving memory`, { error }) - return NextResponse.json( - { success: false, error: { message: error.message || 'Failed to retrieve memory' } }, - { status: 500 } - ) - } + logger.info(`[${requestId}] Memory retrieved: ${id} for workspace: ${validatedWorkspaceId}`) + return NextResponse.json( + { success: true, data: { conversationId: mem.key, data: mem.data } }, + { status: 200 } + ) + } catch (error: any) { + logger.error(`[${requestId}] Error retrieving memory`, { error }) + return memoryEnvelopeError(error.message || 'Failed to retrieve memory', 500) } -) +}) export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: MemoryRouteContext) => { const requestId = generateRequestId() - const { id } = await params + const { id } = await context.params try { - const url = new URL(request.url) - const workspaceId = url.searchParams.get('workspaceId') - - const validation = memoryQuerySchema.safeParse({ workspaceId }) - if (!validation.success) { - const errorMessage = validation.error.errors - .map((err) => `${err.path.join('.')}: ${err.message}`) - .join(', ') - return NextResponse.json( - { success: false, error: { message: errorMessage } }, - { status: 400 } - ) - } - - const { workspaceId: validatedWorkspaceId } = validation.data + const validation = await parseRequest(deleteMemoryByIdContract, request, context, { + validationErrorResponse: (error) => + memoryEnvelopeError( + error.issues.map((err) => `${err.path.join('.')}: ${err.message}`).join(', '), + 400 + ), + }) + if (!validation.success) return validation.response + const { workspaceId: validatedWorkspaceId } = validation.data.query const accessCheck = await validateMemoryAccess( request, @@ -171,19 +131,28 @@ export const DELETE = withRouteHandler( const existingMemory = await db .select({ id: memory.id }) .from(memory) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) + .where( + and( + eq(memory.key, id), + eq(memory.workspaceId, validatedWorkspaceId), + isNull(memory.deletedAt) + ) + ) .limit(1) if (existingMemory.length === 0) { - return NextResponse.json( - { success: false, error: { message: 'Memory not found' } }, - { status: 404 } - ) + return memoryEnvelopeError('Memory not found', 404) } await db .delete(memory) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) + .where( + and( + eq(memory.key, id), + eq(memory.workspaceId, validatedWorkspaceId), + isNull(memory.deletedAt) + ) + ) logger.info(`[${requestId}] Memory deleted: ${id} for workspace: ${validatedWorkspaceId}`) return NextResponse.json( @@ -192,104 +161,94 @@ export const DELETE = withRouteHandler( ) } catch (error: any) { logger.error(`[${requestId}] Error deleting memory`, { error }) - return NextResponse.json( - { success: false, error: { message: error.message || 'Failed to delete memory' } }, - { status: 500 } - ) + return memoryEnvelopeError(error.message || 'Failed to delete memory', 500) } } ) -export const PUT = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const { id } = await params +export const PUT = withRouteHandler(async (request: NextRequest, context: MemoryRouteContext) => { + const requestId = generateRequestId() + const { id } = await context.params + + try { + const validation = await parseRequest(updateMemoryByIdContract, request, context, { + validationErrorResponse: (error) => + memoryEnvelopeError( + `Invalid request body: ${error.issues.map((err) => `${err.path.join('.')}: ${err.message}`).join(', ')}`, + 400 + ), + invalidJsonResponse: () => memoryEnvelopeError('Invalid JSON in request body', 400), + }) + if (!validation.success) return validation.response + const { data: validatedData, workspaceId: validatedWorkspaceId } = validation.data.body + + const accessCheck = await validateMemoryAccess( + request, + validatedWorkspaceId, + requestId, + 'write' + ) + if ('error' in accessCheck) { + return accessCheck.error + } - try { - let validatedData - let validatedWorkspaceId - try { - const body = await request.json() - const validation = memoryPutBodySchema.safeParse(body) - - if (!validation.success) { - const errorMessage = validation.error.errors - .map((err) => `${err.path.join('.')}: ${err.message}`) - .join(', ') - return NextResponse.json( - { success: false, error: { message: `Invalid request body: ${errorMessage}` } }, - { status: 400 } - ) - } - - validatedData = validation.data.data - validatedWorkspaceId = validation.data.workspaceId - } catch { - return NextResponse.json( - { success: false, error: { message: 'Invalid JSON in request body' } }, - { status: 400 } + const existingMemories = await db + .select() + .from(memory) + .where( + and( + eq(memory.key, id), + eq(memory.workspaceId, validatedWorkspaceId), + isNull(memory.deletedAt) ) - } - - const accessCheck = await validateMemoryAccess( - request, - validatedWorkspaceId, - requestId, - 'write' ) - if ('error' in accessCheck) { - return accessCheck.error - } + .limit(1) - const existingMemories = await db - .select() - .from(memory) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) - .limit(1) + if (existingMemories.length === 0) { + return memoryEnvelopeError('Memory not found', 404) + } - if (existingMemories.length === 0) { - return NextResponse.json( - { success: false, error: { message: 'Memory not found' } }, - { status: 404 } - ) - } + const agentValidation = agentMemoryDataSchemaContract.safeParse(validatedData) + if (!agentValidation.success) { + const errorMessage = agentValidation.error.issues + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join(', ') + return memoryEnvelopeError(`Invalid agent memory data: ${errorMessage}`, 400) + } - const agentValidation = agentMemoryDataSchema.safeParse(validatedData) - if (!agentValidation.success) { - const errorMessage = agentValidation.error.errors - .map((err) => `${err.path.join('.')}: ${err.message}`) - .join(', ') - return NextResponse.json( - { success: false, error: { message: `Invalid agent memory data: ${errorMessage}` } }, - { status: 400 } + const now = new Date() + await db + .update(memory) + .set({ data: validatedData, updatedAt: now }) + .where( + and( + eq(memory.key, id), + eq(memory.workspaceId, validatedWorkspaceId), + isNull(memory.deletedAt) ) - } - - const now = new Date() - await db - .update(memory) - .set({ data: validatedData, updatedAt: now }) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) - - const updatedMemories = await db - .select() - .from(memory) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) - .limit(1) - - const mem = updatedMemories[0] - - logger.info(`[${requestId}] Memory updated: ${id} for workspace: ${validatedWorkspaceId}`) - return NextResponse.json( - { success: true, data: { conversationId: mem.key, data: mem.data } }, - { status: 200 } ) - } catch (error: any) { - logger.error(`[${requestId}] Error updating memory`, { error }) - return NextResponse.json( - { success: false, error: { message: error.message || 'Failed to update memory' } }, - { status: 500 } + + const updatedMemories = await db + .select() + .from(memory) + .where( + and( + eq(memory.key, id), + eq(memory.workspaceId, validatedWorkspaceId), + isNull(memory.deletedAt) + ) ) - } + .limit(1) + + const mem = updatedMemories[0] + + logger.info(`[${requestId}] Memory updated: ${id} for workspace: ${validatedWorkspaceId}`) + return NextResponse.json( + { success: true, data: { conversationId: mem.key, data: mem.data } }, + { status: 200 } + ) + } catch (error: any) { + logger.error(`[${requestId}] Error updating memory`, { error }) + return memoryEnvelopeError(error.message || 'Failed to update memory', 500) } -) +}) diff --git a/apps/sim/app/api/memory/route.ts b/apps/sim/app/api/memory/route.ts index 8e05d527e8c..53b9340f3c6 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 { + createMemoryContract, + deleteMemoryByQueryContract, + listMemoriesContract, + memoryMessageSchema, +} from '@/lib/api/contracts/memory' +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' @@ -27,10 +34,9 @@ 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 validation = await parseRequest(listMemoriesContract, request, {}) + if (!validation.success) return validation.response + const { workspaceId, query: searchQuery, limit } = validation.data.query if (!workspaceId) { return NextResponse.json( @@ -100,8 +106,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const { key, data, workspaceId } = body + const validation = await parseRequest(createMemoryContract, request, {}) + if (!validation.success) return validation.response + const { key, data, workspaceId } = validation.data.body if (!key) { return NextResponse.json( @@ -148,16 +155,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 = memoryMessageSchema.safeParse(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 } ) } @@ -220,7 +233,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.error(`[${requestId}] Error creating memory`, { error }) return NextResponse.json( - { success: false, error: { message: error.message || 'Failed to create memory' } }, + { success: false, error: { message: 'Failed to create memory' } }, { status: 500 } ) } @@ -239,9 +252,9 @@ 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 validation = await parseRequest(deleteMemoryByQueryContract, request, {}) + if (!validation.success) return validation.response + const { workspaceId, conversationId } = validation.data.query if (!workspaceId) { return NextResponse.json( @@ -280,7 +293,13 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { const result = await db .delete(memory) - .where(and(eq(memory.key, conversationId), eq(memory.workspaceId, workspaceId))) + .where( + and( + eq(memory.key, conversationId), + eq(memory.workspaceId, workspaceId), + isNull(memory.deletedAt) + ) + ) .returning({ id: memory.id }) const deletedCount = result.length diff --git a/apps/sim/app/api/mothership/chat/abort/route.ts b/apps/sim/app/api/mothership/chat/abort/route.ts index 344d89bfb34..4b62fa4ebd3 100644 --- a/apps/sim/app/api/mothership/chat/abort/route.ts +++ b/apps/sim/app/api/mothership/chat/abort/route.ts @@ -1 +1,19 @@ -export { POST } from '@/app/api/copilot/chat/abort/route' +import type { NextRequest } from 'next/server' +import { mothershipChatAbortEnvelopeSchema } from '@/lib/api/contracts/mothership-tasks' +import { validationErrorResponse } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { POST as copilotAbortPost } from '@/app/api/copilot/chat/abort/route' + +export const POST = withRouteHandler(async (request: NextRequest) => { + // boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body + const body = await request + .clone() + .json() + .catch(() => undefined) + if (body !== undefined) { + const validation = mothershipChatAbortEnvelopeSchema.safeParse(body) + if (!validation.success) return validationErrorResponse(validation.error) + } + + 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..169f16e4148 100644 --- a/apps/sim/app/api/mothership/chat/resources/route.ts +++ b/apps/sim/app/api/mothership/chat/resources/route.ts @@ -1 +1,43 @@ -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 { validationErrorResponse } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + DELETE as copilotResourcesDelete, + PATCH as copilotResourcesPatch, + POST as copilotResourcesPost, +} from '@/app/api/copilot/chat/resources/route' + +async function validateResourceRequestEnvelope(request: NextRequest): Promise { + // boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body + const body = await request + .clone() + .json() + .catch(() => undefined) + if (body !== undefined) { + const validation = mothershipChatResourceEnvelopeSchema.safeParse(body) + if (!validation.success) return validationErrorResponse(validation.error) + } + return null +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const validationResponse = await validateResourceRequestEnvelope(request) + if (validationResponse) return validationResponse + + return copilotResourcesPost(request, undefined) +}) + +export const PATCH = withRouteHandler(async (request: NextRequest) => { + const validationResponse = await validateResourceRequestEnvelope(request) + if (validationResponse) return validationResponse + + return copilotResourcesPatch(request, undefined) +}) + +export const DELETE = withRouteHandler(async (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..f6167b7c3e0 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 { validationErrorResponse } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +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 = mothershipChatGetQuerySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!validation.success) return validationErrorResponse(validation.error) + + return copilotChatGet(request) +}) + +export const POST = withRouteHandler(async (request: NextRequest) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body + const body = await request + .clone() + .json() + .catch(() => undefined) + if (body !== undefined) { + const validation = mothershipChatPostEnvelopeSchema.safeParse(body) + if (!validation.success) return validationErrorResponse(validation.error) + } + + 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..5ffd63dd1fb 100644 --- a/apps/sim/app/api/mothership/chat/stop/route.ts +++ b/apps/sim/app/api/mothership/chat/stop/route.ts @@ -1,2 +1,19 @@ -// Unified stop route surface. -export { POST } from '@/app/api/copilot/chat/stop/route' +import type { NextRequest } from 'next/server' +import { mothershipChatStopEnvelopeSchema } from '@/lib/api/contracts/mothership-tasks' +import { validationErrorResponse } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { POST as copilotStopPost } from '@/app/api/copilot/chat/stop/route' + +export const POST = withRouteHandler(async (request: NextRequest) => { + // boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body + const body = await request + .clone() + .json() + .catch(() => undefined) + if (body !== undefined) { + const validation = mothershipChatStopEnvelopeSchema.safeParse(body) + if (!validation.success) return validationErrorResponse(validation.error) + } + + 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..4707d5dbb16 100644 --- a/apps/sim/app/api/mothership/chat/stream/route.ts +++ b/apps/sim/app/api/mothership/chat/stream/route.ts @@ -1 +1,15 @@ -export { GET, maxDuration } from '@/app/api/copilot/chat/stream/route' +import type { NextRequest } from 'next/server' +import { mothershipChatStreamQuerySchema } from '@/lib/api/contracts/mothership-tasks' +import { validationErrorResponse } 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 = mothershipChatStreamQuerySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!validation.success) return validationErrorResponse(validation.error) + + return copilotStreamGet(request, undefined) +} diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts new file mode 100644 index 00000000000..1509f68eb55 --- /dev/null +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts @@ -0,0 +1,164 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-tasks' +import { parseRequest } from '@/lib/api/server' +import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' +import { fetchGo } from '@/lib/copilot/request/go/fetch' +import { + authenticateCopilotRequestSessionOnly, + createBadRequestResponse, + createForbiddenResponse, + createInternalServerErrorResponse, + createNotFoundResponse, + createUnauthorizedResponse, +} from '@/lib/copilot/request/http' +import type { MothershipResource } from '@/lib/copilot/resources/types' +import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' +import { taskPubSub } from '@/lib/copilot/tasks' +import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { + assertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('ForkChatAPI') + +/** + * POST /api/mothership/chats/[chatId]/fork + * Creates a new chat branched from the given chat, keeping messages up to and + * including the specified message. Resources and copilot-side state are copied. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { + try { + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !userId) { + return createUnauthorizedResponse() + } + + const parsed = await parseRequest(forkMothershipChatContract, request, context, { + validationErrorResponse: () => createBadRequestResponse('upToMessageId is required'), + }) + if (!parsed.success) return parsed.response + const { chatId } = parsed.data.params + const { upToMessageId } = parsed.data.body + + // Load parent chat and verify ownership. + const [parent] = await db + .select() + .from(copilotChats) + .where(eq(copilotChats.id, chatId)) + .limit(1) + + if (!parent || parent.userId !== userId || parent.type !== 'mothership') { + return createNotFoundResponse('Chat not found') + } + + if (parent.workspaceId) { + await assertActiveWorkspaceAccess(parent.workspaceId, userId) + } + + // Find the fork point in the Sim-side messages array. + const messages = Array.isArray(parent.messages) ? (parent.messages as PersistedMessage[]) : [] + const forkIdx = messages.findIndex((m) => m.id === upToMessageId) + if (forkIdx < 0) { + return createBadRequestResponse('Message not found in chat') + } + const forkedMessages = messages.slice(0, forkIdx + 1) + + // Resources are stored as a jsonb array on the chat row — copy them directly. + const parentResources = Array.isArray(parent.resources) + ? (parent.resources as MothershipResource[]) + : [] + + const newId = generateId() + const baseTitle = (parent.title ?? 'New task').replace(/^Fork \| /, '') + const title = `Fork | ${baseTitle}` + const now = new Date() + + const [newChat] = await db + .insert(copilotChats) + .values({ + id: newId, + userId, + workspaceId: parent.workspaceId, + type: parent.type, + title, + model: parent.model, + messages: forkedMessages, + resources: parentResources, + previewYaml: parent.previewYaml, + planArtifact: parent.planArtifact, + config: parent.config, + conversationId: null, + updatedAt: now, + lastSeenAt: now, + }) + .returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId }) + + if (!newChat) { + return createInternalServerErrorResponse('Failed to create forked chat') + } + + // Clone copilot-service conversation state (messages, active_messages, memory files). + // Best-effort: if the copilot service doesn't have a row for the source chat yet, skip. + try { + const copilotHeaders: Record = { 'Content-Type': 'application/json' } + if (env.COPILOT_API_KEY) { + copilotHeaders['x-api-key'] = env.COPILOT_API_KEY + } + Object.assign(copilotHeaders, getMothershipSourceEnvHeaders()) + const mothershipBaseURL = await getMothershipBaseURL({ userId }) + const copilotRes = await fetchGo(`${mothershipBaseURL}/api/chats/fork`, { + method: 'POST', + headers: copilotHeaders, + body: JSON.stringify({ + sourceChatId: chatId, + newChatId: newId, + upToMessageId, + userId, + }), + spanName: 'sim → go /api/chats/fork', + operation: 'fork_chat', + }) + if (!copilotRes.ok) { + const text = await copilotRes.text().catch(() => '') + logger.warn('Copilot fork returned non-OK', { status: copilotRes.status, body: text }) + } + } catch (err) { + // The copilot service may not have a row for this chat if no messages + // have been sent yet, or if it's unreachable. Log and continue. + logger.warn('Failed to fork copilot-service conversation, skipping', { err }) + } + + if (newChat.workspaceId) { + taskPubSub?.publishStatusChanged({ + workspaceId: newChat.workspaceId, + chatId: newId, + type: 'created', + }) + } + + captureServerEvent( + userId, + 'task_forked', + { workspace_id: parent.workspaceId ?? '', source_chat_id: chatId }, + { groups: { workspace: parent.workspaceId ?? '' } } + ) + + return NextResponse.json({ success: true, id: newId }) + } catch (error) { + if (isWorkspaceAccessDeniedError(error)) { + return createForbiddenResponse('Workspace access denied') + } + logger.error('Error forking chat:', error) + return createInternalServerErrorResponse('Failed to fork chat') + } + } +) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts new file mode 100644 index 00000000000..7e96d476220 --- /dev/null +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts @@ -0,0 +1,246 @@ +/** + * @vitest-environment node + */ +import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetAccessibleCopilotChat, + mockReconcileChatStreamMarkers, + mockReadEvents, + mockReadFilePreviewSessions, + mockGetLatestRunForStream, +} = vi.hoisted(() => ({ + mockGetAccessibleCopilotChat: vi.fn(), + mockReconcileChatStreamMarkers: vi.fn(), + mockReadEvents: vi.fn(), + mockReadFilePreviewSessions: vi.fn(), + mockGetLatestRunForStream: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ db: {} })) + +vi.mock('@sim/db/schema', () => ({ + copilotChats: { + id: 'copilotChats.id', + userId: 'copilotChats.userId', + type: 'copilotChats.type', + updatedAt: 'copilotChats.updatedAt', + lastSeenAt: 'copilotChats.lastSeenAt', + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), + eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), + sql: Object.assign( + vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + type: 'sql', + strings, + values, + })), + { raw: vi.fn() } + ), +})) + +vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChat, + getAccessibleCopilotChatWithMessages: mockGetAccessibleCopilotChat, +})) + +vi.mock('@/lib/copilot/chat/stream-liveness', () => ({ + reconcileChatStreamMarkers: mockReconcileChatStreamMarkers, +})) + +vi.mock('@/lib/copilot/request/session/buffer', () => ({ + readEvents: mockReadEvents, +})) + +vi.mock('@/lib/copilot/request/session/file-preview-session', () => ({ + readFilePreviewSessions: mockReadFilePreviewSessions, +})) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + getLatestRunForStream: mockGetLatestRunForStream, +})) + +vi.mock('@/lib/copilot/request/session/types', () => ({ + toStreamBatchEvent: (e: unknown) => e, +})) + +vi.mock('@/lib/copilot/chat/effective-transcript', () => ({ + buildEffectiveChatTranscript: ({ messages }: { messages: unknown[] }) => messages, +})) + +vi.mock('@/lib/copilot/chat/persisted-message', () => ({ + normalizeMessage: (m: unknown) => m, +})) + +vi.mock('@/lib/copilot/tasks', () => ({ + taskPubSub: { publishStatusChanged: vi.fn() }, +})) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: vi.fn(), +})) + +import { GET } from '@/app/api/mothership/chats/[chatId]/route' + +function makeContext(chatId: string) { + return { params: Promise.resolve({ chatId }) } +} + +function createRequest(chatId: string) { + return new NextRequest(`http://localhost:3000/api/mothership/chats/${chatId}`, { + method: 'GET', + }) +} + +describe('GET /api/mothership/chats/[chatId]', () => { + beforeEach(() => { + vi.clearAllMocks() + copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ + userId: 'user-1', + isAuthenticated: true, + }) + mockReconcileChatStreamMarkers.mockImplementation( + async (candidates: Array<{ chatId: string; streamId: string | null }>) => + new Map( + candidates.map((candidate) => [ + candidate.chatId, + { + chatId: candidate.chatId, + streamId: candidate.streamId, + status: candidate.streamId ? 'active' : 'inactive', + }, + ]) + ) + ) + mockReadEvents.mockResolvedValue([]) + mockReadFilePreviewSessions.mockResolvedValue([]) + mockGetLatestRunForStream.mockResolvedValue(null) + }) + + it('clears activeStreamId when the redis lock has expired (stuck-yellow bug)', async () => { + mockGetAccessibleCopilotChat.mockResolvedValueOnce({ + id: 'chat-stuck', + type: 'mothership', + title: 'Stuck', + messages: [], + resources: [], + conversationId: 'stream-orphaned', + createdAt: new Date('2026-05-11T12:00:00Z'), + updatedAt: new Date('2026-05-11T12:00:00Z'), + }) + mockReconcileChatStreamMarkers.mockResolvedValueOnce( + new Map([['chat-stuck', { chatId: 'chat-stuck', streamId: null, status: 'inactive' }]]) + ) + + const response = await GET(createRequest('chat-stuck'), makeContext('chat-stuck')) + expect(response.status).toBe(200) + const body = await response.json() + + expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith( + [{ chatId: 'chat-stuck', streamId: 'stream-orphaned' }], + { repairVerifiedStaleMarkers: true } + ) + expect(body.success).toBe(true) + expect(body.chat.activeStreamId).toBeNull() + expect(body.chat.streamSnapshot).toBeUndefined() + expect(mockReadEvents).not.toHaveBeenCalled() + }) + + it('returns the live activeStreamId when redis confirms the lock', async () => { + mockGetAccessibleCopilotChat.mockResolvedValueOnce({ + id: 'chat-live', + type: 'mothership', + title: 'Live', + messages: [], + resources: [], + conversationId: 'stream-live', + createdAt: new Date('2026-05-11T12:00:00Z'), + updatedAt: new Date('2026-05-11T12:00:00Z'), + }) + mockGetLatestRunForStream.mockResolvedValueOnce({ status: 'active' }) + + const response = await GET(createRequest('chat-live'), makeContext('chat-live')) + expect(response.status).toBe(200) + const body = await response.json() + + expect(body.chat.activeStreamId).toBe('stream-live') + expect(mockReadEvents).toHaveBeenCalledWith('stream-live', '0') + expect(body.chat.streamSnapshot).toBeDefined() + expect(body.chat.streamSnapshot.status).toBe('active') + }) + + it('uses the Redis lock owner when it differs from a stale persisted streamId', async () => { + mockGetAccessibleCopilotChat.mockResolvedValueOnce({ + id: 'chat-mismatch', + type: 'mothership', + title: 'Mismatch', + messages: [], + resources: [], + conversationId: 'stream-stale', + createdAt: new Date('2026-05-11T12:00:00Z'), + updatedAt: new Date('2026-05-11T12:00:00Z'), + }) + mockReconcileChatStreamMarkers.mockResolvedValueOnce( + new Map([ + ['chat-mismatch', { chatId: 'chat-mismatch', streamId: 'stream-live', status: 'active' }], + ]) + ) + + const response = await GET(createRequest('chat-mismatch'), makeContext('chat-mismatch')) + expect(response.status).toBe(200) + const body = await response.json() + + expect(body.chat.activeStreamId).toBe('stream-live') + expect(mockReadEvents).toHaveBeenCalledWith('stream-live', '0') + }) + + it('returns null when the persisted stream marker is already null', async () => { + mockGetAccessibleCopilotChat.mockResolvedValueOnce({ + id: 'chat-idle', + type: 'mothership', + title: 'Idle', + messages: [], + resources: [], + conversationId: null, + createdAt: new Date('2026-05-11T12:00:00Z'), + updatedAt: new Date('2026-05-11T12:00:00Z'), + }) + + const response = await GET(createRequest('chat-idle'), makeContext('chat-idle')) + expect(response.status).toBe(200) + + expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith( + [{ chatId: 'chat-idle', streamId: null }], + { repairVerifiedStaleMarkers: true } + ) + const body = await response.json() + expect(body.chat.activeStreamId).toBeNull() + }) + + it('returns 404 when the chat does not exist', async () => { + mockGetAccessibleCopilotChat.mockResolvedValueOnce(null) + + const response = await GET(createRequest('chat-missing'), makeContext('chat-missing')) + expect(response.status).toBe(404) + expect(mockReconcileChatStreamMarkers).not.toHaveBeenCalled() + }) + + it('returns 401 when unauthenticated', async () => { + copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ + userId: null, + isAuthenticated: false, + }) + + const response = await GET(createRequest('chat-x'), makeContext('chat-x')) + expect(response.status).toBe(401) + expect(mockGetAccessibleCopilotChat).not.toHaveBeenCalled() + expect(mockReconcileChatStreamMarkers).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index 3b3324f733c..2d7c20c1b1f 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -4,14 +4,22 @@ 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 { + deleteMothershipChatContract, + getMothershipChatContract, + updateMothershipChatContract, +} from '@/lib/api/contracts/mothership-tasks' +import { parseRequest } 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' +import { + getAccessibleCopilotChatAuth, + getAccessibleCopilotChatWithMessages, +} from '@/lib/copilot/chat/lifecycle' import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' +import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' import { authenticateCopilotRequestSessionOnly, - createBadRequestResponse, createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' @@ -25,29 +33,19 @@ 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 }> }) => { + async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { try { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } - const { chatId } = await params - if (!chatId) { - return createBadRequestResponse('chatId is required') - } + const paramsResult = await parseRequest(getMothershipChatContract, request, context) + if (!paramsResult.success) return paramsResult.response + const { chatId } = paramsResult.data.params - const chat = await getAccessibleCopilotChat(chatId, userId) + const chat = await getAccessibleCopilotChatWithMessages(chatId, userId) if (!chat || chat.type !== 'mothership') { return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) } @@ -58,23 +56,29 @@ export const GET = withRouteHandler( status: string } | null = null - if (chat.conversationId) { + const reconciledMarkers = await reconcileChatStreamMarkers( + [{ chatId: chat.id, streamId: chat.conversationId }], + { repairVerifiedStaleMarkers: true } + ) + const liveStreamId = reconciledMarkers.get(chat.id)?.streamId ?? null + + if (liveStreamId) { try { const [events, previewSessions] = await Promise.all([ - readEvents(chat.conversationId, '0'), - readFilePreviewSessions(chat.conversationId).catch((error) => { + readEvents(liveStreamId, '0'), + readFilePreviewSessions(liveStreamId).catch((error) => { logger.warn('Failed to read preview sessions for mothership chat', { chatId, - conversationId: chat.conversationId, + streamId: liveStreamId, error: toError(error).message, }) return [] }), ]) - const run = await getLatestRunForStream(chat.conversationId, userId).catch((error) => { + const run = await getLatestRunForStream(liveStreamId, userId).catch((error) => { logger.warn('Failed to fetch latest run for mothership chat snapshot', { chatId, - conversationId: chat.conversationId, + streamId: liveStreamId, error: toError(error).message, }) return null @@ -93,7 +97,7 @@ export const GET = withRouteHandler( } catch (error) { logger.warn('Failed to read stream snapshot for mothership chat', { chatId, - conversationId: chat.conversationId, + streamId: liveStreamId, error: toError(error).message, }) } @@ -106,7 +110,7 @@ export const GET = withRouteHandler( : [] const effectiveMessages = buildEffectiveChatTranscript({ messages: normalizedMessages, - activeStreamId: chat.conversationId || null, + activeStreamId: liveStreamId, ...(streamSnapshot ? { streamSnapshot } : {}), }) @@ -116,7 +120,7 @@ export const GET = withRouteHandler( id: chat.id, title: chat.title, messages: effectiveMessages, - conversationId: chat.conversationId || null, + activeStreamId: liveStreamId, resources: Array.isArray(chat.resources) ? chat.resources : [], createdAt: chat.createdAt, updatedAt: chat.updatedAt, @@ -131,20 +135,17 @@ export const GET = withRouteHandler( ) export const PATCH = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ chatId: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { try { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } - const { chatId } = await params - if (!chatId) { - return createBadRequestResponse('chatId is required') - } - - const body = await request.json() - const { title, isUnread } = UpdateChatSchema.parse(body) + const parsed = await parseRequest(updateMothershipChatContract, request, context) + if (!parsed.success) return parsed.response + const { chatId } = parsed.data.params + const { title, isUnread, pinned } = parsed.data.body const updates: Record = {} @@ -159,6 +160,9 @@ export const PATCH = withRouteHandler( if (isUnread !== undefined) { updates.lastSeenAt = isUnread ? null : sql`GREATEST(${copilotChats.updatedAt}, NOW())` } + if (pinned !== undefined) { + updates.pinned = pinned + } const [updatedChat] = await db .update(copilotChats) @@ -205,13 +209,20 @@ export const PATCH = withRouteHandler( } ) } + if (pinned !== undefined) { + captureServerEvent( + userId, + pinned ? 'task_pinned' : 'task_unpinned', + { workspace_id: updatedChat.workspaceId }, + { + groups: { workspace: updatedChat.workspaceId }, + } + ) + } } 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') } @@ -219,19 +230,18 @@ export const PATCH = withRouteHandler( ) export const DELETE = withRouteHandler( - async (_request: NextRequest, { params }: { params: Promise<{ chatId: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { try { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } - const { chatId } = await params - if (!chatId) { - return createBadRequestResponse('chatId is required') - } + const parsed = await parseRequest(deleteMothershipChatContract, request, context) + if (!parsed.success) return parsed.response + const { chatId } = parsed.data.params - const chat = await getAccessibleCopilotChat(chatId, userId) + const chat = await getAccessibleCopilotChatAuth(chatId, userId) if (!chat || chat.type !== 'mothership') { return NextResponse.json({ success: true }) } diff --git a/apps/sim/app/api/mothership/chats/read/route.ts b/apps/sim/app/api/mothership/chats/read/route.ts index af06ceaff8c..2973d1bbe89 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 { markMothershipChatReadContract } from '@/lib/api/contracts/mothership-tasks' +import { parseRequest } 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 parsed = await parseRequest(markMothershipChatReadContract, request, {}) + if (!parsed.success) return parsed.response + const { chatId } = parsed.data.body 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.test.ts b/apps/sim/app/api/mothership/chats/route.test.ts new file mode 100644 index 00000000000..5851d1b45df --- /dev/null +++ b/apps/sim/app/api/mothership/chats/route.test.ts @@ -0,0 +1,225 @@ +/** + * @vitest-environment node + */ +import { copilotHttpMock, copilotHttpMockFns, permissionsMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSelect, mockFrom, mockWhere, mockOrderBy, mockReconcileChatStreamMarkers } = vi.hoisted( + () => ({ + mockSelect: vi.fn(), + mockFrom: vi.fn(), + mockWhere: vi.fn(), + mockOrderBy: vi.fn(), + mockReconcileChatStreamMarkers: vi.fn(), + }) +) + +vi.mock('@sim/db', () => ({ + db: { + select: mockSelect, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + copilotChats: { + id: 'copilotChats.id', + title: 'copilotChats.title', + userId: 'copilotChats.userId', + workspaceId: 'copilotChats.workspaceId', + type: 'copilotChats.type', + updatedAt: 'copilotChats.updatedAt', + conversationId: 'copilotChats.conversationId', + lastSeenAt: 'copilotChats.lastSeenAt', + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), + desc: vi.fn((field: unknown) => ({ type: 'desc', field })), + eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), +})) + +vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) + +vi.mock('@/lib/copilot/chat/stream-liveness', () => ({ + reconcileChatStreamMarkers: mockReconcileChatStreamMarkers, +})) + +vi.mock('@/lib/copilot/tasks', () => ({ + taskPubSub: { publishStatusChanged: vi.fn() }, +})) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: vi.fn(), +})) + +import { GET } from '@/app/api/mothership/chats/route' + +function createRequest(workspaceId: string) { + return new NextRequest(`http://localhost:3000/api/mothership/chats?workspaceId=${workspaceId}`, { + method: 'GET', + }) +} + +describe('GET /api/mothership/chats', () => { + beforeEach(() => { + vi.clearAllMocks() + + copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ + userId: 'user-1', + isAuthenticated: true, + }) + + mockOrderBy.mockResolvedValue([]) + mockWhere.mockReturnValue({ orderBy: mockOrderBy }) + mockFrom.mockReturnValue({ where: mockWhere }) + mockSelect.mockReturnValue({ from: mockFrom }) + + mockReconcileChatStreamMarkers.mockImplementation( + async (candidates: Array<{ chatId: string; streamId: string | null }>) => + new Map( + candidates.map((candidate) => [ + candidate.chatId, + { + chatId: candidate.chatId, + streamId: candidate.streamId, + status: candidate.streamId ? 'active' : 'inactive', + }, + ]) + ) + ) + }) + + it('clears activeStreamId on chats whose redis lock has expired (stuck-yellow bug)', async () => { + const now = new Date('2026-05-11T12:00:00Z') + mockOrderBy.mockResolvedValueOnce([ + { + id: 'chat-stuck', + title: 'Stuck chat', + updatedAt: now, + activeStreamId: 'stream-orphaned', + lastSeenAt: null, + }, + { + id: 'chat-live', + title: 'Live chat', + updatedAt: now, + activeStreamId: 'stream-live', + lastSeenAt: null, + }, + { + id: 'chat-idle', + title: 'Idle chat', + updatedAt: now, + activeStreamId: null, + lastSeenAt: null, + }, + ]) + mockReconcileChatStreamMarkers.mockResolvedValueOnce( + new Map([ + ['chat-stuck', { chatId: 'chat-stuck', streamId: null, status: 'inactive' }], + ['chat-live', { chatId: 'chat-live', streamId: 'stream-live', status: 'active' }], + ['chat-idle', { chatId: 'chat-idle', streamId: null, status: 'inactive' }], + ]) + ) + + const response = await GET(createRequest('ws-1')) + expect(response.status).toBe(200) + const body = await response.json() + + expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith( + [ + { chatId: 'chat-stuck', streamId: 'stream-orphaned' }, + { chatId: 'chat-live', streamId: 'stream-live' }, + { chatId: 'chat-idle', streamId: null }, + ], + { repairVerifiedStaleMarkers: true } + ) + expect(body.success).toBe(true) + expect(body.data).toEqual([ + expect.objectContaining({ id: 'chat-stuck', activeStreamId: null }), + expect.objectContaining({ id: 'chat-live', activeStreamId: 'stream-live' }), + expect.objectContaining({ id: 'chat-idle', activeStreamId: null }), + ]) + }) + + it('preserves chats when no chat has a stream marker set', async () => { + const now = new Date('2026-05-11T12:00:00Z') + mockOrderBy.mockResolvedValueOnce([ + { id: 'chat-1', title: null, updatedAt: now, activeStreamId: null, lastSeenAt: null }, + { id: 'chat-2', title: null, updatedAt: now, activeStreamId: null, lastSeenAt: null }, + ]) + + const response = await GET(createRequest('ws-1')) + expect(response.status).toBe(200) + + expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith( + [ + { chatId: 'chat-1', streamId: null }, + { chatId: 'chat-2', streamId: null }, + ], + { repairVerifiedStaleMarkers: true } + ) + const body = await response.json() + expect(body.data).toEqual([ + expect.objectContaining({ id: 'chat-1', activeStreamId: null }), + expect.objectContaining({ id: 'chat-2', activeStreamId: null }), + ]) + }) + + it('leaves activeStreamId untouched when redis confirms every lock is live', async () => { + const now = new Date('2026-05-11T12:00:00Z') + mockOrderBy.mockResolvedValueOnce([ + { id: 'chat-a', title: null, updatedAt: now, activeStreamId: 'stream-a', lastSeenAt: null }, + { id: 'chat-b', title: null, updatedAt: now, activeStreamId: 'stream-b', lastSeenAt: null }, + ]) + + const response = await GET(createRequest('ws-1')) + const body = await response.json() + + expect(body.data).toEqual([ + expect.objectContaining({ id: 'chat-a', activeStreamId: 'stream-a' }), + expect.objectContaining({ id: 'chat-b', activeStreamId: 'stream-b' }), + ]) + }) + + it('uses Redis lock owner when it differs from a stale activeStreamId', async () => { + const now = new Date('2026-05-11T12:00:00Z') + mockOrderBy.mockResolvedValueOnce([ + { + id: 'chat-mismatch', + title: null, + updatedAt: now, + activeStreamId: 'stream-stale', + lastSeenAt: null, + }, + ]) + mockReconcileChatStreamMarkers.mockResolvedValueOnce( + new Map([ + ['chat-mismatch', { chatId: 'chat-mismatch', streamId: 'stream-live', status: 'active' }], + ]) + ) + + const response = await GET(createRequest('ws-1')) + expect(response.status).toBe(200) + const body = await response.json() + + expect(body.data).toEqual([ + expect.objectContaining({ id: 'chat-mismatch', activeStreamId: 'stream-live' }), + ]) + }) + + it('returns 401 when unauthenticated', async () => { + copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ + userId: null, + isAuthenticated: false, + }) + + const response = await GET(createRequest('ws-1')) + expect(response.status).toBe(401) + expect(mockSelect).not.toHaveBeenCalled() + expect(mockReconcileChatStreamMarkers).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index d704451643d..1b7157fdde5 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -3,17 +3,25 @@ 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 { + createMothershipChatContract, + listMothershipChatsContract, +} from '@/lib/api/contracts/mothership-tasks' +import { parseRequest } from '@/lib/api/server' +import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' import { authenticateCopilotRequestSessionOnly, - createBadRequestResponse, + createForbiddenResponse, createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' import { taskPubSub } from '@/lib/copilot/tasks' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { + assertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('MothershipChatsAPI') @@ -28,10 +36,9 @@ 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 = await parseRequest(listMothershipChatsContract, request, {}) + if (!queryResult.success) return queryResult.response + const { workspaceId } = queryResult.data.query await assertActiveWorkspaceAccess(workspaceId, userId) @@ -42,6 +49,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { updatedAt: copilotChats.updatedAt, activeStreamId: copilotChats.conversationId, lastSeenAt: copilotChats.lastSeenAt, + pinned: copilotChats.pinned, }) .from(copilotChats) .where( @@ -51,19 +59,27 @@ export const GET = withRouteHandler(async (request: NextRequest) => { eq(copilotChats.type, 'mothership') ) ) - .orderBy(desc(copilotChats.updatedAt)) + .orderBy(desc(copilotChats.pinned), desc(copilotChats.updatedAt)) + + const streamMarkers = await reconcileChatStreamMarkers( + chats.map((c) => ({ chatId: c.id, streamId: c.activeStreamId })), + { repairVerifiedStaleMarkers: true } + ) + const reconciled = chats.map((c) => { + const activeStreamId = streamMarkers.get(c.id)?.streamId ?? null + return activeStreamId === c.activeStreamId ? c : { ...c, activeStreamId } + }) - return NextResponse.json({ success: true, data: chats }) + return NextResponse.json({ success: true, data: reconciled }) } catch (error) { + if (isWorkspaceAccessDeniedError(error)) { + return createForbiddenResponse('Workspace access denied') + } logger.error('Error fetching mothership chats:', error) return createInternalServerErrorResponse('Failed to fetch chats') } }) -const CreateChatSchema = z.object({ - workspaceId: z.string().min(1), -}) - /** * POST /api/mothership/chats * Creates an empty mothership chat and returns its ID. @@ -75,8 +91,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return createUnauthorizedResponse() } - const body = await request.json() - const { workspaceId } = CreateChatSchema.parse(body) + const validation = await parseRequest(createMothershipChatContract, request, {}) + if (!validation.success) return validation.response + const { workspaceId } = validation.data.body await assertActiveWorkspaceAccess(workspaceId, userId) @@ -108,8 +125,8 @@ 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') + if (isWorkspaceAccessDeniedError(error)) { + return createForbiddenResponse('Workspace access denied') } 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..bb3e1f278c8 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 { validationErrorResponse } 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, + ...(event.streamId ? { streamId: event.streamId } : {}), + timestamp: Date.now(), }) - }, + }) }, - ], - }) -) + }, + ], +}) + +export const GET = withRouteHandler((request: NextRequest) => { + const validation = mothershipEventsQuerySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!validation.success) return validationErrorResponse(validation.error) + 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..a3550718b92 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -1,50 +1,84 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' +import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mothershipExecuteContract } from '@/lib/api/contracts/mothership-tasks' +import { parseRequest } 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' +import { + MothershipStreamV1EventType, + MothershipStreamV1TextChannel, +} from '@/lib/copilot/generated/mothership-stream-v1' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' +import type { StreamEvent } from '@/lib/copilot/request/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { buildMothershipToolsForRequest } from '@/lib/mothership/settings/runtime' import { assertActiveWorkspaceAccess, getUserEntityPermissions, + isWorkspaceAccessDeniedError, } from '@/lib/workspaces/permissions/utils' 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(), -}) +const MOTHERSHIP_EXECUTE_STREAM_HEADER = 'x-mothership-execute-stream' +const MOTHERSHIP_EXECUTE_STREAM_VALUE = 'ndjson' +const MOTHERSHIP_EXECUTE_STREAM_CONTENT_TYPE = 'application/x-ndjson' +const MOTHERSHIP_EXECUTE_HEARTBEAT_INTERVAL_MS = 15_000 +const ndjsonEncoder = new TextEncoder() function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === 'AbortError' } +function wantsStreamedExecuteResponse(req: NextRequest): boolean { + return ( + req.headers.get(MOTHERSHIP_EXECUTE_STREAM_HEADER) === MOTHERSHIP_EXECUTE_STREAM_VALUE || + req.headers.get('accept')?.includes(MOTHERSHIP_EXECUTE_STREAM_CONTENT_TYPE) === true + ) +} + +function encodeNdjson(value: unknown): Uint8Array { + return ndjsonEncoder.encode(`${JSON.stringify(value)}\n`) +} + +function buildExecuteResponsePayload( + result: Awaited>, + effectiveChatId: string, + integrationTools: Array<{ name: string }> +) { + const clientToolNames = new Set(integrationTools.map((t) => t.name)) + const clientToolCalls = (result.toolCalls || []).filter( + (tc: { name: string }) => clientToolNames.has(tc.name) || tc.name.startsWith('mcp-') + ) + + return { + content: result.content, + model: 'mothership', + conversationId: effectiveChatId, + tokens: result.usage + ? { + prompt: result.usage.prompt, + completion: result.usage.completion, + total: (result.usage.prompt || 0) + (result.usage.completion || 0), + } + : {}, + cost: result.cost || undefined, + toolCalls: clientToolCalls, + } +} + /** * POST /api/mothership/execute * - * Non-streaming endpoint for Mothership block execution within workflows. - * Called by the executor via internal JWT auth, not by the browser directly. - * Consumes the Go SSE stream internally and returns a single JSON response. + * Endpoint for Mothership block execution within workflows. Called by the + * executor via internal JWT auth, not by the browser directly. JSON callers get + * a single final response; NDJSON callers get heartbeats followed by a final + * event so long-running headless requests do not look idle to HTTP stacks. */ export const POST = withRouteHandler(async (req: NextRequest) => { let messageId: string | undefined @@ -56,7 +90,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - const body = await req.json() + const validation = await parseRequest(mothershipExecuteContract, req, {}) + if (!validation.success) return validation.response const { messages, responseFormat, @@ -65,9 +100,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => { chatId, messageId: providedMessageId, requestId: providedRequestId, + fileAttachments, workflowId, executionId, - } = ExecuteRequestSchema.parse(body) + } = validation.data.body await assertActiveWorkspaceAccess(workspaceId, userId) @@ -80,11 +116,19 @@ export const POST = withRouteHandler(async (req: NextRequest) => { workflowId, executionId, }) - const [workspaceContext, integrationTools, userPermission] = await Promise.all([ - generateWorkspaceContext(workspaceId, userId), - buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), - getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null), - ]) + const [workspaceContext, integrationTools, mothershipToolRuntime, userPermission] = + await Promise.all([ + generateWorkspaceContext(workspaceId, userId), + buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), + buildMothershipToolsForRequest({ workspaceId, userId }), + getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null), + ]) + const workspaceContextWithMothershipTools = [ + workspaceContext, + mothershipToolRuntime.catalogContext, + ] + .filter(Boolean) + .join('\n\n') const requestPayload: Record = { messages, @@ -94,14 +138,19 @@ export const POST = withRouteHandler(async (req: NextRequest) => { mode: 'agent', messageId, isHosted: true, - workspaceContext, + workspaceContext: workspaceContextWithMothershipTools, + ...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), + ...(mothershipToolRuntime.tools.length > 0 + ? { mothershipTools: mothershipToolRuntime.tools } + : {}), ...(userPermission ? { userPermission } : {}), } let allowExplicitAbort = true let explicitAbortRequest: Promise | undefined - const onAbort = () => { + const lifecycleAbortController = new AbortController() + const requestExplicitAbortOnce = () => { if (!allowExplicitAbort || explicitAbortRequest || !messageId) { return } @@ -116,6 +165,15 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) }) } + const abortLifecycle = (reason?: unknown) => { + if (!lifecycleAbortController.signal.aborted) { + lifecycleAbortController.abort(reason ?? 'mothership_execute_aborted') + } + requestExplicitAbortOnce() + } + const onAbort = () => { + abortLifecycle(req.signal.reason ?? 'request_aborted') + } if (req.signal.aborted) { onAbort() @@ -123,8 +181,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { req.signal.addEventListener('abort', onAbort, { once: true }) } - try { - const result = await runHeadlessCopilotLifecycle(requestPayload, { + const runLifecycle = (onEvent?: (event: StreamEvent) => Promise) => + runHeadlessCopilotLifecycle(requestPayload, { userId, workspaceId, chatId: effectiveChatId, @@ -134,12 +192,145 @@ export const POST = withRouteHandler(async (req: NextRequest) => { goRoute: '/api/mothership/execute', autoExecuteTools: true, interactive: false, - abortSignal: req.signal, + abortSignal: lifecycleAbortController.signal, + onEvent, + }) + + if (wantsStreamedExecuteResponse(req)) { + let cancelled = false + let heartbeatId: ReturnType | undefined + + const stream = new ReadableStream({ + start(controller) { + let forwardedAssistantContent = '' + const send = (event: unknown) => { + if (!cancelled) { + controller.enqueue(encodeNdjson(event)) + } + } + + // Flush response headers promptly and keep long headless runs from + // looking idle to worker/proxy HTTP stacks. + send({ type: 'heartbeat', timestamp: new Date().toISOString() }) + heartbeatId = setInterval(() => { + send({ type: 'heartbeat', timestamp: new Date().toISOString() }) + }, MOTHERSHIP_EXECUTE_HEARTBEAT_INTERVAL_MS) + + void (async () => { + try { + const result = await runLifecycle(async (event) => { + if ( + event.type === MothershipStreamV1EventType.text && + event.payload.channel === MothershipStreamV1TextChannel.assistant && + event.payload.text + ) { + const text = event.payload.text + const content = text.startsWith(forwardedAssistantContent) + ? text.slice(forwardedAssistantContent.length) + : text + if (content) { + forwardedAssistantContent += content + send({ type: 'chunk', content }) + } + } + }) + allowExplicitAbort = false + + if (lifecycleAbortController.signal.aborted) { + send({ type: 'error', error: 'Mothership execution aborted' }) + return + } + + if (!result.success) { + logger.error( + messageId + ? `Mothership execute failed [messageId:${messageId}]` + : 'Mothership execute failed', + { + requestId, + workflowId, + executionId, + error: result.error, + errors: result.errors, + } + ) + send({ + type: 'error', + error: result.error || 'Mothership execution failed', + content: result.content || '', + }) + return + } + + send({ + type: 'final', + data: buildExecuteResponsePayload(result, effectiveChatId, integrationTools), + }) + } catch (error) { + if ( + lifecycleAbortController.signal.aborted || + req.signal.aborted || + isAbortError(error) + ) { + logger.info( + messageId + ? `Mothership execute aborted [messageId:${messageId}]` + : 'Mothership execute aborted', + { requestId } + ) + send({ type: 'error', error: 'Mothership execution aborted' }) + return + } + + logger.error( + messageId + ? `Mothership execute error [messageId:${messageId}]` + : 'Mothership execute error', + { + requestId, + error: error instanceof Error ? error.message : 'Unknown error', + } + ) + send({ + type: 'error', + error: error instanceof Error ? error.message : 'Internal server error', + }) + } finally { + allowExplicitAbort = false + if (heartbeatId) { + clearInterval(heartbeatId) + } + req.signal.removeEventListener('abort', onAbort) + await explicitAbortRequest + if (!cancelled) { + controller.close() + } + } + })() + }, + cancel(reason) { + cancelled = true + if (heartbeatId) { + clearInterval(heartbeatId) + } + abortLifecycle(reason ?? 'mothership_execute_stream_cancelled') + }, }) + return new Response(stream, { + headers: { + 'Content-Type': `${MOTHERSHIP_EXECUTE_STREAM_CONTENT_TYPE}; charset=utf-8`, + 'Cache-Control': 'no-cache, no-transform', + }, + }) + } + + try { + const result = await runLifecycle() + allowExplicitAbort = false - if (req.signal.aborted) { + if (lifecycleAbortController.signal.aborted || req.signal.aborted) { reqLogger.info('Mothership execute aborted after lifecycle completion') return NextResponse.json({ error: 'Mothership execution aborted' }, { status: 499 }) } @@ -166,37 +357,15 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - const clientToolNames = new Set(integrationTools.map((t) => t.name)) - const clientToolCalls = (result.toolCalls || []).filter( - (tc: { name: string }) => clientToolNames.has(tc.name) || tc.name.startsWith('mcp-') + return NextResponse.json( + buildExecuteResponsePayload(result, effectiveChatId, integrationTools) ) - - return NextResponse.json({ - content: result.content, - model: 'mothership', - tokens: result.usage - ? { - prompt: result.usage.prompt, - completion: result.usage.completion, - total: (result.usage.prompt || 0) + (result.usage.completion || 0), - } - : {}, - cost: result.cost || undefined, - toolCalls: clientToolCalls, - }) } finally { allowExplicitAbort = false req.signal.removeEventListener('abort', onAbort) 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 @@ -210,16 +379,20 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Mothership execution aborted' }, { status: 499 }) } + if (isWorkspaceAccessDeniedError(error)) { + return NextResponse.json({ error: 'Workspace access denied' }, { status: 403 }) + } + logger.error( messageId ? `Mothership execute error [messageId:${messageId}]` : 'Mothership execute error', { requestId, - error: error instanceof Error ? error.message : 'Unknown error', + error: getErrorMessage(error, 'Unknown error'), } ) return NextResponse.json( - { error: error instanceof Error ? error.message : 'Internal server error' }, + { error: getErrorMessage(error, 'Internal server error') }, { status: 500 } ) } diff --git a/apps/sim/app/api/mothership/settings/route.ts b/apps/sim/app/api/mothership/settings/route.ts new file mode 100644 index 00000000000..49845ad4e7a --- /dev/null +++ b/apps/sim/app/api/mothership/settings/route.ts @@ -0,0 +1,91 @@ +import { db, settings, user } from '@sim/db' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { + getMothershipSettingsContract, + updateMothershipSettingsContract, +} from '@/lib/api/contracts/mothership-settings' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + getMothershipSettings, + updateMothershipSettings, +} from '@/lib/mothership/settings/operations' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('MothershipSettingsAPI') + +async function isEffectiveSuperUser(userId: string): Promise { + const [row] = await db + .select({ + role: user.role, + superUserModeEnabled: settings.superUserModeEnabled, + }) + .from(user) + .leftJoin(settings, eq(settings.userId, user.id)) + .where(eq(user.id, userId)) + .limit(1) + + return row?.role === 'admin' && (row.superUserModeEnabled ?? false) +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if (!(await isEffectiveSuperUser(auth.userId))) { + return NextResponse.json({ error: 'Super admin mode required' }, { status: 403 }) + } + + const parsed = await parseRequest(getMothershipSettingsContract, request, {}) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + const userPermission = await getUserEntityPermissions(auth.userId, 'workspace', workspaceId) + if (!userPermission) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const settings = await getMothershipSettings(workspaceId) + return NextResponse.json({ data: settings }) + } catch (error) { + logger.error(`[${requestId}] Error fetching Mothership settings`, error) + return NextResponse.json({ error: 'Failed to fetch Mothership settings' }, { status: 500 }) + } +}) + +export const PUT = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if (!(await isEffectiveSuperUser(auth.userId))) { + return NextResponse.json({ error: 'Super admin mode required' }, { status: 403 }) + } + + const parsed = await parseRequest(updateMothershipSettingsContract, request, {}) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.body + const userPermission = await getUserEntityPermissions(auth.userId, 'workspace', workspaceId) + if (!userPermission || (userPermission !== 'admin' && userPermission !== 'write')) { + return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) + } + + const settings = await updateMothershipSettings(parsed.data.body) + return NextResponse.json({ success: true, data: settings }) + } catch (error) { + logger.error(`[${requestId}] Error updating Mothership settings`, error) + return NextResponse.json({ error: 'Failed to update Mothership settings' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/notifications/poll/route.ts b/apps/sim/app/api/notifications/poll/route.ts index ecfd4b419b4..f9e3c8c0c2b 100644 --- a/apps/sim/app/api/notifications/poll/route.ts +++ b/apps/sim/app/api/notifications/poll/route.ts @@ -1,6 +1,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { validationErrorResponse } 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 +19,10 @@ const LOCK_TTL_SECONDS = 120 export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateShortId() logger.info(`Inactivity alert polling triggered (${requestId})`) + const queryValidation = noInputSchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!queryValidation.success) return validationErrorResponse(queryValidation.error) let lockAcquired = false @@ -54,7 +61,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { { success: false, message: 'Inactivity alert polling failed', - error: error instanceof Error ? error.message : 'Unknown error', + error: getErrorMessage(error, 'Unknown error'), requestId, }, { status: 500 } diff --git a/apps/sim/app/api/organizations/[id]/data-drains/[drainId]/route.ts b/apps/sim/app/api/organizations/[id]/data-drains/[drainId]/route.ts new file mode 100644 index 00000000000..b0b291b0807 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/data-drains/[drainId]/route.ts @@ -0,0 +1,190 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { dataDrains } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { and, eq, ne } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { + deleteDataDrainContract, + getDataDrainContract, + updateDataDrainContract, +} from '@/lib/api/contracts/data-drains' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { authorizeDrainAccess, loadDrain } from '@/lib/data-drains/access' +import { getDestination } from '@/lib/data-drains/destinations/registry' +import { encryptCredentials } from '@/lib/data-drains/encryption' +import { serializeDrain } from '@/lib/data-drains/serializers' + +const logger = createLogger('DataDrainAPI') + +type RouteContext = { params: Promise<{ id: string; drainId: string }> } + +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const { id: organizationId, drainId } = await context.params + const access = await authorizeDrainAccess(organizationId, { requireMutating: false }) + if (!access.ok) return access.response + + const parsed = await parseRequest(getDataDrainContract, request, context) + if (!parsed.success) return parsed.response + + const drain = await loadDrain(organizationId, drainId) + if (!drain) { + return NextResponse.json({ error: 'Data drain not found' }, { status: 404 }) + } + return NextResponse.json({ drain: serializeDrain(drain) }) +}) + +export const PUT = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const { id: organizationId, drainId } = await context.params + const access = await authorizeDrainAccess(organizationId, { requireMutating: true }) + if (!access.ok) return access.response + + const parsed = await parseRequest(updateDataDrainContract, request, context) + if (!parsed.success) return parsed.response + + const body = parsed.data.body + + const drain = await loadDrain(organizationId, drainId) + if (!drain) { + return NextResponse.json({ error: 'Data drain not found' }, { status: 404 }) + } + + if (body.name !== undefined && body.name !== drain.name) { + const [conflict] = await db + .select({ id: dataDrains.id }) + .from(dataDrains) + .where( + and( + eq(dataDrains.organizationId, organizationId), + eq(dataDrains.name, body.name), + ne(dataDrains.id, drainId) + ) + ) + .limit(1) + if (conflict) { + return NextResponse.json( + { error: 'A data drain with this name already exists in this organization' }, + { status: 409 } + ) + } + } + + if (body.source !== undefined && body.source !== drain.source) { + return NextResponse.json({ error: 'source cannot be changed after creation' }, { status: 400 }) + } + + const updates: Partial = { updatedAt: new Date() } + if (body.name !== undefined) updates.name = body.name + if (body.scheduleCadence !== undefined) updates.scheduleCadence = body.scheduleCadence + if (body.enabled !== undefined) updates.enabled = body.enabled + + if (body.destinationType !== undefined && body.destinationType !== drain.destinationType) { + return NextResponse.json( + { error: 'destinationType cannot be changed after creation' }, + { status: 400 } + ) + } + if (body.destinationConfig !== undefined || body.destinationCredentials !== undefined) { + const destination = getDestination(drain.destinationType) + if (body.destinationConfig !== undefined) { + const configResult = destination.configSchema.safeParse(body.destinationConfig) + if (!configResult.success) return validationErrorResponse(configResult.error) + updates.destinationConfig = configResult.data as Record + } + if (body.destinationCredentials !== undefined) { + const credentialsResult = destination.credentialsSchema.safeParse(body.destinationCredentials) + if (!credentialsResult.success) return validationErrorResponse(credentialsResult.error) + updates.destinationCredentials = await encryptCredentials(credentialsResult.data) + } + } + + let updated: typeof dataDrains.$inferSelect | undefined + try { + ;[updated] = await db + .update(dataDrains) + .set(updates) + .where(eq(dataDrains.id, drainId)) + .returning() + } catch (error) { + if (getPostgresErrorCode(error) === '23505') { + return NextResponse.json( + { error: 'A data drain with this name already exists in this organization' }, + { status: 409 } + ) + } + throw error + } + + if (!updated) { + // Concurrent DELETE landed between loadDrain() and this UPDATE. + return NextResponse.json({ error: 'Data drain not found' }, { status: 404 }) + } + + logger.info('Data drain updated', { drainId, organizationId }) + + recordAudit({ + workspaceId: null, + actorId: access.session.user.id, + action: AuditAction.DATA_DRAIN_UPDATED, + resourceType: AuditResourceType.DATA_DRAIN, + resourceId: drainId, + actorName: access.session.user.name ?? undefined, + actorEmail: access.session.user.email ?? undefined, + resourceName: updated.name, + description: `Updated data drain '${updated.name}'`, + metadata: { + organizationId, + changes: { + name: body.name, + source: body.source, + scheduleCadence: body.scheduleCadence, + enabled: body.enabled, + destinationConfigChanged: body.destinationConfig !== undefined, + destinationCredentialsChanged: body.destinationCredentials !== undefined, + }, + }, + request, + }) + + return NextResponse.json({ drain: serializeDrain(updated) }) +}) + +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const { id: organizationId, drainId } = await context.params + const access = await authorizeDrainAccess(organizationId, { requireMutating: true }) + if (!access.ok) return access.response + + const parsed = await parseRequest(deleteDataDrainContract, request, context) + if (!parsed.success) return parsed.response + + const drain = await loadDrain(organizationId, drainId) + if (!drain) { + return NextResponse.json({ error: 'Data drain not found' }, { status: 404 }) + } + + await db.delete(dataDrains).where(eq(dataDrains.id, drainId)) + + logger.info('Data drain deleted', { drainId, organizationId }) + + recordAudit({ + workspaceId: null, + actorId: access.session.user.id, + action: AuditAction.DATA_DRAIN_DELETED, + resourceType: AuditResourceType.DATA_DRAIN, + resourceId: drainId, + actorName: access.session.user.name ?? undefined, + actorEmail: access.session.user.email ?? undefined, + resourceName: drain.name, + description: `Deleted data drain '${drain.name}'`, + metadata: { + organizationId, + source: drain.source, + destinationType: drain.destinationType, + }, + request, + }) + + return NextResponse.json({ success: true as const }) +}) diff --git a/apps/sim/app/api/organizations/[id]/data-drains/[drainId]/run/route.ts b/apps/sim/app/api/organizations/[id]/data-drains/[drainId]/run/route.ts new file mode 100644 index 00000000000..433f381143d --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/data-drains/[drainId]/run/route.ts @@ -0,0 +1,76 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { dataDrainRuns } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { runDataDrainContract } from '@/lib/api/contracts/data-drains' +import { parseRequest } from '@/lib/api/server' +import { getJobQueue } from '@/lib/core/async-jobs' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { authorizeDrainAccess, loadDrain } from '@/lib/data-drains/access' + +const logger = createLogger('DataDrainRunAPI') + +type RouteContext = { params: Promise<{ id: string; drainId: string }> } + +export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const { id: organizationId, drainId } = await context.params + const access = await authorizeDrainAccess(organizationId, { requireMutating: true }) + if (!access.ok) return access.response + + const parsed = await parseRequest(runDataDrainContract, request, context) + if (!parsed.success) return parsed.response + + const drain = await loadDrain(organizationId, drainId) + if (!drain) { + return NextResponse.json({ error: 'Data drain not found' }, { status: 404 }) + } + if (!drain.enabled) { + return NextResponse.json( + { error: 'Cannot run a disabled drain. Enable it first.' }, + { status: 400 } + ) + } + + // Reject obvious double-fires up-front. The job-queue concurrencyKey is the + // real serialization barrier (it covers the gap between enqueue and the + // runner inserting the `running` row), but this gives the user immediate + // feedback when a run is already in flight. + const [inFlight] = await db + .select({ id: dataDrainRuns.id }) + .from(dataDrainRuns) + .where(and(eq(dataDrainRuns.drainId, drainId), eq(dataDrainRuns.status, 'running'))) + .limit(1) + if (inFlight) { + return NextResponse.json( + { error: 'A run is already in progress for this drain' }, + { status: 409 } + ) + } + + const queue = await getJobQueue() + const jobId = await queue.enqueue( + 'run-data-drain', + { drainId, trigger: 'manual' }, + { concurrencyKey: `data-drain:${drainId}` } + ) + + logger.info('Manually enqueued data drain run', { drainId, organizationId, jobId }) + + recordAudit({ + workspaceId: null, + actorId: access.session.user.id, + action: AuditAction.DATA_DRAIN_RAN, + resourceType: AuditResourceType.DATA_DRAIN, + resourceId: drainId, + actorName: access.session.user.name ?? undefined, + actorEmail: access.session.user.email ?? undefined, + resourceName: drain.name, + description: `Triggered manual run for data drain '${drain.name}'`, + metadata: { organizationId, jobId, trigger: 'manual' }, + request, + }) + + return NextResponse.json({ jobId }) +}) diff --git a/apps/sim/app/api/organizations/[id]/data-drains/[drainId]/runs/route.ts b/apps/sim/app/api/organizations/[id]/data-drains/[drainId]/runs/route.ts new file mode 100644 index 00000000000..cd7ef667ab5 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/data-drains/[drainId]/runs/route.ts @@ -0,0 +1,37 @@ +import { db } from '@sim/db' +import { dataDrainRuns } from '@sim/db/schema' +import { desc, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { listDataDrainRunsContract } from '@/lib/api/contracts/data-drains' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { authorizeDrainAccess, loadDrain } from '@/lib/data-drains/access' +import { serializeDrainRun } from '@/lib/data-drains/serializers' + +const DEFAULT_LIMIT = 25 + +type RouteContext = { params: Promise<{ id: string; drainId: string }> } + +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const { id: organizationId, drainId } = await context.params + const access = await authorizeDrainAccess(organizationId, { requireMutating: false }) + if (!access.ok) return access.response + + const parsed = await parseRequest(listDataDrainRunsContract, request, context) + if (!parsed.success) return parsed.response + + const drain = await loadDrain(organizationId, drainId) + if (!drain) { + return NextResponse.json({ error: 'Data drain not found' }, { status: 404 }) + } + + const limit = parsed.data.query?.limit ?? DEFAULT_LIMIT + const runs = await db + .select() + .from(dataDrainRuns) + .where(eq(dataDrainRuns.drainId, drainId)) + .orderBy(desc(dataDrainRuns.startedAt)) + .limit(limit) + + return NextResponse.json({ runs: runs.map(serializeDrainRun) }) +}) diff --git a/apps/sim/app/api/organizations/[id]/data-drains/[drainId]/test/route.ts b/apps/sim/app/api/organizations/[id]/data-drains/[drainId]/test/route.ts new file mode 100644 index 00000000000..5550ff9eb4c --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/data-drains/[drainId]/test/route.ts @@ -0,0 +1,91 @@ +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 { testDataDrainContract } from '@/lib/api/contracts/data-drains' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { authorizeDrainAccess, loadDrain } from '@/lib/data-drains/access' +import { getDestination } from '@/lib/data-drains/destinations/registry' +import { decryptCredentials } from '@/lib/data-drains/encryption' + +const logger = createLogger('DataDrainTestAPI') + +const TEST_TIMEOUT_MS = 10_000 + +type RouteContext = { params: Promise<{ id: string; drainId: string }> } + +export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const { id: organizationId, drainId } = await context.params + const access = await authorizeDrainAccess(organizationId, { requireMutating: true }) + if (!access.ok) return access.response + + const parsed = await parseRequest(testDataDrainContract, request, context) + if (!parsed.success) return parsed.response + + const drain = await loadDrain(organizationId, drainId) + if (!drain) { + return NextResponse.json({ error: 'Data drain not found' }, { status: 404 }) + } + + const destination = getDestination(drain.destinationType) + if (!destination.test) { + return NextResponse.json( + { error: `Destination '${drain.destinationType}' does not support connection testing` }, + { status: 400 } + ) + } + + const config = destination.configSchema.parse(drain.destinationConfig) + const credentials = destination.credentialsSchema.parse( + await decryptCredentials(drain.destinationCredentials) + ) + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS) + try { + await destination.test({ config, credentials, signal: controller.signal }) + recordAudit({ + workspaceId: null, + actorId: access.session.user.id, + action: AuditAction.DATA_DRAIN_TESTED, + resourceType: AuditResourceType.DATA_DRAIN, + resourceId: drainId, + actorName: access.session.user.name ?? undefined, + actorEmail: access.session.user.email ?? undefined, + resourceName: drain.name, + description: `Tested connection for data drain '${drain.name}' (success)`, + metadata: { organizationId, destinationType: drain.destinationType, outcome: 'success' }, + request, + }) + return NextResponse.json({ ok: true as const }) + } catch (error) { + const message = toError(error).message + logger.warn('Data drain test connection failed', { + drainId, + destinationType: drain.destinationType, + error: message, + }) + recordAudit({ + workspaceId: null, + actorId: access.session.user.id, + action: AuditAction.DATA_DRAIN_TESTED, + resourceType: AuditResourceType.DATA_DRAIN, + resourceId: drainId, + actorName: access.session.user.name ?? undefined, + actorEmail: access.session.user.email ?? undefined, + resourceName: drain.name, + description: `Tested connection for data drain '${drain.name}' (failed)`, + metadata: { + organizationId, + destinationType: drain.destinationType, + outcome: 'failed', + error: message, + }, + request, + }) + return NextResponse.json({ error: message }, { status: 400 }) + } finally { + clearTimeout(timeout) + } +}) diff --git a/apps/sim/app/api/organizations/[id]/data-drains/route.ts b/apps/sim/app/api/organizations/[id]/data-drains/route.ts new file mode 100644 index 00000000000..d78655ae28b --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/data-drains/route.ts @@ -0,0 +1,136 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { dataDrains } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, asc, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { createDataDrainContract, listDataDrainsContract } from '@/lib/api/contracts/data-drains' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { authorizeDrainAccess } from '@/lib/data-drains/access' +import { getDestination } from '@/lib/data-drains/destinations/registry' +import { encryptCredentials } from '@/lib/data-drains/encryption' +import { serializeDrain } from '@/lib/data-drains/serializers' + +const logger = createLogger('DataDrainsAPI') + +type RouteContext = { params: Promise<{ id: string }> } + +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const { id: organizationId } = await context.params + const access = await authorizeDrainAccess(organizationId, { requireMutating: false }) + if (!access.ok) return access.response + + const parsed = await parseRequest(listDataDrainsContract, request, context) + if (!parsed.success) return parsed.response + + const rows = await db + .select() + .from(dataDrains) + .where(eq(dataDrains.organizationId, organizationId)) + .orderBy(asc(dataDrains.createdAt)) + + return NextResponse.json({ drains: rows.map(serializeDrain) }) +}) + +export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const { id: organizationId } = await context.params + const access = await authorizeDrainAccess(organizationId, { requireMutating: true }) + if (!access.ok) return access.response + + const parsed = await parseRequest(createDataDrainContract, request, context) + if (!parsed.success) return parsed.response + + const body = parsed.data.body + + if (!body.destinationCredentials) { + return NextResponse.json( + { error: 'destinationCredentials is required when creating a drain' }, + { status: 400 } + ) + } + const destination = getDestination(body.destinationType) + const configResult = destination.configSchema.safeParse(body.destinationConfig) + if (!configResult.success) return validationErrorResponse(configResult.error) + const credentialsResult = destination.credentialsSchema.safeParse(body.destinationCredentials) + if (!credentialsResult.success) return validationErrorResponse(credentialsResult.error) + const encryptedCredentials = await encryptCredentials(credentialsResult.data) + + const [existing] = await db + .select({ id: dataDrains.id }) + .from(dataDrains) + .where(and(eq(dataDrains.organizationId, organizationId), eq(dataDrains.name, body.name))) + .limit(1) + if (existing) { + return NextResponse.json( + { error: 'A data drain with this name already exists in this organization' }, + { status: 409 } + ) + } + + const id = generateId() + const now = new Date() + let inserted: typeof dataDrains.$inferSelect | undefined + try { + ;[inserted] = await db + .insert(dataDrains) + .values({ + id, + organizationId, + name: body.name, + source: body.source, + destinationType: body.destinationType, + destinationConfig: configResult.data as Record, + destinationCredentials: encryptedCredentials, + scheduleCadence: body.scheduleCadence, + enabled: body.enabled ?? true, + cursor: null, + createdBy: access.session.user.id, + createdAt: now, + updatedAt: now, + }) + .returning() + } catch (error) { + if (getPostgresErrorCode(error) === '23505') { + return NextResponse.json( + { error: 'A data drain with this name already exists in this organization' }, + { status: 409 } + ) + } + throw error + } + + if (!inserted) { + throw new Error('Insert returned no row') + } + + logger.info('Data drain created', { + drainId: id, + organizationId, + source: body.source, + destinationType: body.destinationType, + }) + + recordAudit({ + workspaceId: null, + actorId: access.session.user.id, + action: AuditAction.DATA_DRAIN_CREATED, + resourceType: AuditResourceType.DATA_DRAIN, + resourceId: id, + actorName: access.session.user.name ?? undefined, + actorEmail: access.session.user.email ?? undefined, + resourceName: body.name, + description: `Created data drain '${body.name}'`, + metadata: { + organizationId, + source: body.source, + destinationType: body.destinationType, + scheduleCadence: body.scheduleCadence, + }, + request, + }) + + return NextResponse.json({ drain: serializeDrain(inserted) }, { status: 201 }) +}) diff --git a/apps/sim/app/api/organizations/[id]/data-retention/route.ts b/apps/sim/app/api/organizations/[id]/data-retention/route.ts new file mode 100644 index 00000000000..ea2ae9c6aca --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/data-retention/route.ts @@ -0,0 +1,203 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +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 { updateOrganizationDataRetentionContract } from '@/lib/api/contracts/organization' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { + CLEANUP_CONFIG, + type OrganizationRetentionSettings, +} from '@/lib/billing/cleanup-dispatcher' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('DataRetentionAPI') + +function enterpriseDefaults(): OrganizationRetentionSettings { + return { + logRetentionHours: CLEANUP_CONFIG['cleanup-logs'].defaults.enterprise, + softDeleteRetentionHours: CLEANUP_CONFIG['cleanup-soft-deletes'].defaults.enterprise, + taskCleanupHours: CLEANUP_CONFIG['cleanup-tasks'].defaults.enterprise, + } +} + +function normalizeConfigured( + settings: Partial | null | undefined +): OrganizationRetentionSettings { + return { + logRetentionHours: settings?.logRetentionHours ?? null, + softDeleteRetentionHours: settings?.softDeleteRetentionHours ?? null, + taskCleanupHours: settings?.taskCleanupHours ?? null, + } +} + +/** + * GET /api/organizations/[id]/data-retention + * Returns the organization's data retention settings. + * Accessible by any member of the organization. + */ +export const GET = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: organizationId } = await params + + const [memberEntry] = await db + .select({ id: member.id }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + + const [org] = await db + .select({ dataRetentionSettings: organization.dataRetentionSettings }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + if (!org) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } + + const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId)) + const configured = normalizeConfigured(org.dataRetentionSettings) + const defaults = enterpriseDefaults() + + return NextResponse.json({ + success: true, + data: { + isEnterprise, + defaults, + configured, + effective: isEnterprise ? configured : defaults, + }, + }) + } +) + +/** + * PUT /api/organizations/[id]/data-retention + * Updates the organization's data retention settings. + * Requires enterprise plan and owner/admin role. + */ +export const PUT = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(updateOrganizationDataRetentionContract, request, context, { + validationErrorResponse: (err) => validationErrorResponse(err, 'Invalid request body'), + }) + if (!parsed.success) return parsed.response + + const { id: organizationId } = parsed.data.params + const body = parsed.data.body + + const [memberEntry] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + + if (memberEntry.role !== 'owner' && memberEntry.role !== 'admin') { + return NextResponse.json( + { error: 'Forbidden - Only organization owners and admins can update data retention' }, + { status: 403 } + ) + } + + if (isBillingEnabled) { + const hasEnterprise = await isOrganizationOnEnterprisePlan(organizationId) + if (!hasEnterprise) { + return NextResponse.json( + { error: 'Data Retention is available on Enterprise plans only' }, + { status: 403 } + ) + } + } + + const [currentOrg] = await db + .select({ + name: organization.name, + dataRetentionSettings: organization.dataRetentionSettings, + }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + if (!currentOrg) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } + + const current = normalizeConfigured(currentOrg.dataRetentionSettings) + const merged: OrganizationRetentionSettings = { ...current } + if (body.logRetentionHours !== undefined) { + merged.logRetentionHours = body.logRetentionHours + } + if (body.softDeleteRetentionHours !== undefined) { + merged.softDeleteRetentionHours = body.softDeleteRetentionHours + } + if (body.taskCleanupHours !== undefined) { + merged.taskCleanupHours = body.taskCleanupHours + } + + const [updated] = await db + .update(organization) + .set({ dataRetentionSettings: merged, updatedAt: new Date() }) + .where(eq(organization.id, organizationId)) + .returning({ dataRetentionSettings: organization.dataRetentionSettings }) + + if (!updated) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORGANIZATION_UPDATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: currentOrg.name, + description: 'Updated data retention settings', + metadata: { changes: body }, + request, + }) + + const configured = normalizeConfigured(updated.dataRetentionSettings) + const defaults = enterpriseDefaults() + + return NextResponse.json({ + success: true, + data: { + isEnterprise: true, + defaults, + configured, + effective: configured, + }, + }) + } +) diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts index fb81ec48e0e..d9abf178947 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { auditMock, createSession, loggerMock } from '@sim/testing' +import { auditMock, createMockRequest, createSession, loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -157,11 +157,12 @@ describe('POST /api/organizations/[id]/invitations', () => { ] const response = await POST( - new Request('http://localhost/api/organizations/org-1/invitations', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ emails: ['invitee@example.com'] }), - }) as any, + createMockRequest( + 'POST', + { emails: ['invitee@example.com'] }, + {}, + 'http://localhost/api/organizations/org-1/invitations' + ), { params: Promise.resolve({ id: 'org-1' }) } ) @@ -195,11 +196,12 @@ describe('POST /api/organizations/[id]/invitations', () => { mockSendInvitationEmail.mockResolvedValue({ success: false, error: 'mailer unavailable' }) const response = await POST( - new Request('http://localhost/api/organizations/org-1/invitations', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ emails: ['invitee@example.com'] }), - }) as any, + createMockRequest( + 'POST', + { emails: ['invitee@example.com'] }, + {}, + 'http://localhost/api/organizations/org-1/invitations' + ), { params: Promise.resolve({ id: 'org-1' }) } ) diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index dec09e2e1f8..677124dca52 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -4,6 +4,11 @@ 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 { + inviteOrganizationMembersContract, + organizationParamsSchema, +} from '@/lib/api/contracts/organization' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { validateBulkInvitations, @@ -38,7 +43,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() @@ -87,33 +100,30 @@ export const GET = withRouteHandler( ) export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { try { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: organizationId } = await params + const parsed = await parseRequest(inviteOrganizationMembersContract, request, context) + if (!parsed.success) return parsed.response + + const { id: organizationId } = parsed.data.params 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 validateOnly = parsed.data.query.validate === true + const isBatch = parsed.data.query.batch === true - const body = await request.json() - const { email, emails, role = 'member', workspaceInvitations } = body + const { email, emails, role = 'member', workspaceInvitations } = parsed.data.body 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 +164,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 +320,7 @@ export const POST = withRouteHandler( email, inviterId: session.user.id, organizationId, - role: role as 'admin' | 'member', + role, grants: validGrants, }) @@ -321,7 +331,7 @@ export const POST = withRouteHandler( email, inviterName, organizationId, - organizationRole: role as 'admin' | 'member', + organizationRole: role, grants: validGrants, }) @@ -381,7 +391,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 971b1e57c79..89d2cc81cb7 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -4,21 +4,23 @@ 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, + updateOrganizationMemberRoleContract, +} from '@/lib/api/contracts/organization' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' import { getUserUsageData } from '@/lib/billing/core/usage' -import { removeUserFromOrganization } from '@/lib/billing/organizations/membership' +import { + removeExternalUserFromOrganizationWorkspaces, + removeUserFromOrganization, +} from '@/lib/billing/organizations/membership' +import { reduceOrganizationSeatsByOne } from '@/lib/billing/organizations/seats' 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 @@ -138,10 +140,7 @@ export const GET = withRouteHandler( * Update organization member role */ export const PUT = withRouteHandler( - async ( - request: NextRequest, - { params }: { params: Promise<{ id: string; memberId: string }> } - ) => { + async (request: NextRequest, context: { params: Promise<{ id: string; memberId: string }> }) => { try { const session = await getSession() @@ -149,16 +148,11 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: organizationId, memberId } = await params - const body = await request.json() - - const validation = updateMemberSchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } + const parsed = await parseRequest(updateOrganizationMemberRoleContract, request, context) + if (!parsed.success) return parsed.response - const { role } = validation.data + const { id: organizationId, memberId } = parsed.data.params + const { role } = parsed.data.body const userMember = await db .select() @@ -255,8 +249,8 @@ export const PUT = withRouteHandler( }) } catch (error) { logger.error('Failed to update organization member role', { - organizationId: (await params).id, - memberId: (await params).memberId, + organizationId: (await context.params).id, + memberId: (await context.params).memberId, error, }) @@ -282,6 +276,12 @@ export const DELETE = withRouteHandler( } const { id: organizationId, memberId: targetUserId } = await params + const queryResult = removeOrganizationMemberQuerySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + const shouldReduceSeats = queryResult.success + ? queryResult.data.shouldReduceSeats === true + : false const userMember = await db .select() @@ -311,7 +311,79 @@ export const DELETE = withRouteHandler( .limit(1) if (targetMember.length === 0) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }) + const [targetUser] = await db + .select({ id: user.id, email: user.email, name: user.name }) + .from(user) + .where(eq(user.id, targetUserId)) + .limit(1) + + if (!targetUser) { + return NextResponse.json({ error: 'Member not found' }, { status: 404 }) + } + + const externalResult = await removeExternalUserFromOrganizationWorkspaces({ + userId: targetUserId, + organizationId, + }) + + if (!externalResult.success) { + const error = externalResult.error || 'External workspace member not found' + const status = + error === 'External workspace member not found' + ? 404 + : error === 'User is an organization member' + ? 409 + : 500 + + return NextResponse.json({ error }, { status }) + } + + logger.info('External workspace member removed from organization workspaces', { + organizationId, + removedMemberId: targetUserId, + removedBy: session.user.id, + workspaceAccessRevoked: externalResult.workspaceAccessRevoked, + permissionGroupsRevoked: externalResult.permissionGroupsRevoked, + credentialMembershipsRevoked: externalResult.credentialMembershipsRevoked, + pendingInvitationsCancelled: externalResult.pendingInvitationsCancelled, + }) + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORG_MEMBER_REMOVED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Removed external workspace member ${targetUserId} from organization`, + metadata: { + targetUserId, + targetEmail: targetUser.email ?? undefined, + targetName: targetUser.name ?? undefined, + membershipType: 'external', + workspaceAccessRevoked: externalResult.workspaceAccessRevoked, + permissionGroupsRevoked: externalResult.permissionGroupsRevoked, + credentialMembershipsRevoked: externalResult.credentialMembershipsRevoked, + pendingInvitationsCancelled: externalResult.pendingInvitationsCancelled, + }, + request, + }) + + return NextResponse.json({ + success: true, + message: 'External member removed successfully', + data: { + removedMemberId: targetUserId, + removedBy: session.user.id, + removedAt: new Date().toISOString(), + membershipType: 'external', + workspaceAccessRevoked: externalResult.workspaceAccessRevoked, + permissionGroupsRevoked: externalResult.permissionGroupsRevoked, + credentialMembershipsRevoked: externalResult.credentialMembershipsRevoked, + pendingInvitationsCancelled: externalResult.pendingInvitationsCancelled, + }, + }) } const result = await removeUserFromOrganization({ @@ -330,6 +402,28 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: result.error }, { status: 500 }) } + let seatReduction: Awaited> | null = null + if (shouldReduceSeats && session.user.id !== targetUserId) { + try { + seatReduction = await reduceOrganizationSeatsByOne({ + organizationId, + actorUserId: session.user.id, + removedUserId: targetUserId, + }) + } catch (seatError) { + logger.error('Failed to reduce seats after member removal', { + organizationId, + removedMemberId: targetUserId, + removedBy: session.user.id, + error: seatError, + }) + seatReduction = { + reduced: false, + reason: 'Failed to reduce seats after member removal', + } + } + } + if (session.user.id === targetUserId) { try { await setActiveOrganizationForCurrentSession(null) @@ -348,6 +442,7 @@ export const DELETE = withRouteHandler( removedBy: session.user.id, wasSelfRemoval: session.user.id === targetUserId, billingActions: result.billingActions, + seatReduction, }) recordAudit({ @@ -367,6 +462,7 @@ export const DELETE = withRouteHandler( targetEmail: targetMember[0].email ?? undefined, targetName: targetMember[0].name ?? undefined, wasSelfRemoval: session.user.id === targetUserId, + seatReduction, }, request, }) @@ -381,6 +477,7 @@ export const DELETE = withRouteHandler( removedMemberId: targetUserId, removedBy: session.user.id, removedAt: new Date().toISOString(), + seatReduction, }, }) } catch (error) { diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 36a4d1d4b65..48a356215cf 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 { + inviteOrganizationMemberContract, + organizationMemberQuerySchema, + organizationParamsSchema, +} from '@/lib/api/contracts/organization' +import { getValidationErrorMessage, parseRequest } 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 @@ -157,7 +179,7 @@ export const GET = withRouteHandler( * Invite new member to organization */ export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { try { const session = await getSession() @@ -165,19 +187,14 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: organizationId } = await params + const parsed = await parseRequest(inviteOrganizationMemberContract, request, context) + if (!parsed.success) return parsed.response - await validateInvitationsAllowed(session.user.id, { organizationId }) + const { id: organizationId } = parsed.data.params - const { email, role = 'member' } = await request.json() - - if (!email) { - return NextResponse.json({ error: 'Email is required' }, { status: 400 }) - } + await validateInvitationsAllowed(session.user.id, { organizationId }) - if (!['admin', 'member'].includes(role)) { - return NextResponse.json({ error: 'Invalid role' }, { status: 400 }) - } + const { email, role = 'member' } = parsed.data.body // Validate and normalize email const normalizedEmail = email.trim().toLowerCase() @@ -268,7 +285,7 @@ export const POST = withRouteHandler( email: normalizedEmail, inviterId: session.user.id, organizationId, - role: role as 'admin' | 'member', + role, grants: [], }) @@ -286,7 +303,7 @@ export const POST = withRouteHandler( email: normalizedEmail, inviterName, organizationId, - organizationRole: role as 'admin' | 'member', + organizationRole: role, grants: [], }) @@ -337,7 +354,7 @@ export const POST = withRouteHandler( return NextResponse.json({ error: error.message }, { status: 403 }) } logger.error('Failed to invite organization member', { - organizationId: (await params).id, + organizationId: (await context.params).id, error, }) diff --git a/apps/sim/app/api/organizations/[id]/roster/route.ts b/apps/sim/app/api/organizations/[id]/roster/route.ts index c1abe4d6d15..8ccdcfe6227 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.ts @@ -8,8 +8,10 @@ import { workspace, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray, sql } from 'drizzle-orm' +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 }) @@ -57,7 +67,7 @@ export const GET = withRouteHandler( const orgWorkspaces = await db .select({ id: workspace.id, name: workspace.name }) .from(workspace) - .where(eq(workspace.organizationId, organizationId)) + .where(and(eq(workspace.organizationId, organizationId), isNull(workspace.archivedAt))) const orgWorkspaceIds = orgWorkspaces.map((ws) => ws.id) const workspaceNameById = new Map(orgWorkspaces.map((ws) => [ws.id, ws.name])) @@ -118,12 +128,82 @@ export const GET = withRouteHandler( workspaces: permissionsByUser.get(row.userId) ?? [], })) + const externalPermissionRows = + orgWorkspaceIds.length > 0 + ? await db + .select({ + userId: user.id, + userName: user.name, + userEmail: user.email, + userImage: user.image, + workspaceId: permissions.entityId, + permission: permissions.permissionType, + createdAt: permissions.createdAt, + }) + .from(permissions) + .innerJoin(user, eq(permissions.userId, user.id)) + .leftJoin( + member, + and(eq(member.userId, user.id), eq(member.organizationId, organizationId)) + ) + .where( + and( + eq(permissions.entityType, 'workspace'), + inArray(permissions.entityId, orgWorkspaceIds), + isNull(member.id) + ) + ) + : [] + + const externalMembersByUser = new Map< + string, + { + memberId: string + userId: string + role: 'external' + createdAt: Date + name: string + email: string + image: string | null + workspaces: RosterWorkspaceAccess[] + } + >() + + for (const row of externalPermissionRows) { + const existing = externalMembersByUser.get(row.userId) + const workspaceAccess: RosterWorkspaceAccess = { + workspaceId: row.workspaceId, + workspaceName: workspaceNameById.get(row.workspaceId) ?? 'Workspace', + permission: row.permission, + } + + if (existing) { + existing.workspaces.push(workspaceAccess) + if (row.createdAt < existing.createdAt) existing.createdAt = row.createdAt + continue + } + + externalMembersByUser.set(row.userId, { + memberId: `external-${row.userId}`, + userId: row.userId, + role: 'external', + createdAt: row.createdAt, + name: row.userName, + email: row.userEmail, + image: row.userImage, + workspaces: [workspaceAccess], + }) + } + + const rosterMembers = [...members, ...externalMembersByUser.values()] + const pendingInvitationRows = await db .select({ id: invitation.id, email: invitation.email, role: invitation.role, kind: invitation.kind, + membershipIntent: invitation.membershipIntent, createdAt: invitation.createdAt, expiresAt: invitation.expiresAt, inviteeName: user.name, @@ -160,8 +240,9 @@ export const GET = withRouteHandler( const pendingInvitations = pendingInvitationRows.map((row) => ({ id: row.id, email: row.email, - role: row.role, + role: row.membershipIntent === 'external' ? 'external' : row.role, kind: row.kind, + membershipIntent: row.membershipIntent, createdAt: row.createdAt, expiresAt: row.expiresAt, inviteeName: row.inviteeName, @@ -172,7 +253,7 @@ export const GET = withRouteHandler( return NextResponse.json({ success: true, data: { - members, + members: rosterMembers, pendingInvitations, workspaces: orgWorkspaces, }, diff --git a/apps/sim/app/api/organizations/[id]/route.ts b/apps/sim/app/api/organizations/[id]/route.ts index 70326038cca..d0c8d6d5cf0 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 { updateOrganizationContract } from '@/lib/api/contracts/organization' +import { parseRequest } 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] @@ -45,7 +49,6 @@ export const GET = withRouteHandler( const url = new URL(request.url) const includeSeats = url.searchParams.get('include') === 'seats' - // Verify user has access to this organization const memberEntry = await db .select() .from(member) @@ -59,7 +62,6 @@ export const GET = withRouteHandler( ) } - // Get organization data const organizationEntry = await db .select() .from(organization) @@ -73,7 +75,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, @@ -88,14 +90,12 @@ export const GET = withRouteHandler( hasAdminAccess, } - // Include seat information if requested if (includeSeats) { const seatInfo = await getOrganizationSeatInfo(organizationId) if (seatInfo) { response.data.seats = seatInfo } - // Include analytics for admins if (hasAdminAccess) { const analytics = await getOrganizationSeatAnalytics(organizationId) if (analytics) { @@ -122,7 +122,7 @@ export const GET = withRouteHandler( * Note: For seat updates, use PUT /api/organizations/[id]/seats instead */ export const PUT = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { try { const session = await getSession() @@ -130,18 +130,12 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: organizationId } = await params - const body = await request.json() + const parsed = await parseRequest(updateOrganizationContract, request, context) + if (!parsed.success) return parsed.response - const validation = updateOrganizationSchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } + const { id: organizationId } = parsed.data.params + const { name, slug, logo } = parsed.data.body - const { name, slug, logo } = validation.data - - // Verify user has admin access const memberEntry = await db .select() .from(member) @@ -159,9 +153,7 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } - // Handle settings update if (name !== undefined || slug !== undefined || logo !== undefined) { - // Check if slug is already taken by another organization if (slug !== undefined) { const existingSlug = await db .select() @@ -174,13 +166,16 @@ 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 - // Update organization const updatedOrg = await db .update(organization) .set(updateData) @@ -227,7 +222,7 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'No valid fields provided for update' }, { status: 400 }) } catch (error) { logger.error('Failed to update organization', { - organizationId: (await params).id, + organizationId: (await context.params).id, error, }) @@ -235,7 +230,3 @@ export const PUT = withRouteHandler( } } ) - -// DELETE method removed - organization deletion not implemented -// If deletion is needed in the future, it should be implemented with proper -// cleanup of subscriptions, members, workspaces, and billing data diff --git a/apps/sim/app/api/organizations/[id]/seats/route.ts b/apps/sim/app/api/organizations/[id]/seats/route.ts index 0cfba281c62..594c0ef093d 100644 --- a/apps/sim/app/api/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/organizations/[id]/seats/route.ts @@ -1,9 +1,10 @@ import { db } from '@sim/db' import { invitation, member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, count, eq, inArray } from 'drizzle-orm' +import { and, count, eq, gt, inArray, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateSeatsContract } from '@/lib/api/contracts/organization' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' import { getPlanPricing } from '@/lib/billing/core/billing' @@ -20,17 +21,13 @@ 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. * This is the recommended approach for per-seat billing changes. */ export const PUT = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { try { const session = await getSession() @@ -42,18 +39,12 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Billing is not enabled' }, { status: 400 }) } - const { id: organizationId } = await params - const body = await request.json() - - const validation = updateSeatsSchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } + const parsed = await parseRequest(updateSeatsContract, request, context) + if (!parsed.success) return parsed.response - const { seats: newSeatCount } = validation.data + const { id: organizationId } = parsed.data.params + const { seats: newSeatCount } = parsed.data.body - // Verify user has admin access to this organization const memberEntry = await db .select() .from(member) @@ -71,7 +62,6 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } - // Get the organization's subscription const subscriptionRecord = await db .select() .from(subscription) @@ -93,7 +83,6 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 }) } - // Only team plans support seat changes (not enterprise - those are handled manually) if (!isTeam(orgSubscription.plan)) { return NextResponse.json( { error: 'Seat changes are only available for Team plans' }, @@ -116,7 +105,14 @@ export const PUT = withRouteHandler( const [pendingCountRow] = await db .select({ count: count() }) .from(invitation) - .where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending'))) + .where( + and( + eq(invitation.organizationId, organizationId), + eq(invitation.status, 'pending'), + ne(invitation.membershipIntent, 'external'), + gt(invitation.expiresAt, new Date()) + ) + ) const memberCount = memberCountRow?.count ?? 0 const pendingCount = pendingCountRow?.count ?? 0 @@ -136,7 +132,6 @@ export const PUT = withRouteHandler( const currentSeats = orgSubscription.seats || 1 - // If no change, return early if (newSeatCount === currentSeats) { return NextResponse.json({ success: true, @@ -150,7 +145,6 @@ export const PUT = withRouteHandler( const stripe = requireStripeClient() - // Get the Stripe subscription to find the subscription item ID const stripeSubscription = await stripe.subscriptions.retrieve( orgSubscription.stripeSubscriptionId ) @@ -159,7 +153,6 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 }) } - // Find the subscription item (there should be only one for team plans) const subscriptionItem = stripeSubscription.items.data[0] if (!subscriptionItem) { @@ -212,7 +205,6 @@ export const PUT = withRouteHandler( ? toNumber(toDecimal(orgData[0].orgUsageLimit)) : 0 - // Update if new minimum is higher than current limit if (newMinimumLimit > currentOrgLimit) { await db .update(organization) @@ -253,11 +245,10 @@ export const PUT = withRouteHandler( }, }) } catch (error) { - const { id: organizationId } = await params + const { id: organizationId } = await context.params - // 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, @@ -268,7 +259,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..32385b2421f 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 { transferOwnershipContract } from '@/lib/api/contracts/organization' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' import { @@ -15,30 +16,19 @@ 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 }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { try { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: organizationId } = await params - const body = await request.json().catch(() => ({})) - const validation = transferOwnershipSchema.safeParse(body) - if (!validation.success) { - return NextResponse.json( - { error: validation.error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } + const parsed = await parseRequest(transferOwnershipContract, request, context) + if (!parsed.success) return parsed.response - const { newOwnerUserId, alsoLeave } = validation.data + const { id: organizationId } = parsed.data.params + const { newOwnerUserId, alsoLeave } = parsed.data.body if (newOwnerUserId === session.user.id) { return NextResponse.json( @@ -218,7 +208,7 @@ export const POST = withRouteHandler( }) } catch (error) { logger.error('Failed to transfer organization ownership', { - organizationId: (await params).id, + organizationId: (await context.params).id, error, }) return NextResponse.json({ error: 'Failed to transfer ownership' }, { status: 500 }) diff --git a/apps/sim/app/api/organizations/[id]/whitelabel/route.ts b/apps/sim/app/api/organizations/[id]/whitelabel/route.ts index ef1dab40e7c..f74309815a5 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 { updateOrganizationWhitelabelContract } from '@/lib/api/contracts/organization' +import { parseRequest, validationErrorResponse } 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. @@ -109,7 +69,7 @@ export const GET = withRouteHandler( * Requires enterprise plan and owner/admin role. */ export const PUT = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { try { const session = await getSession() @@ -117,17 +77,13 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: organizationId } = await params - - const body = await request.json() - const parsed = updateWhitelabelSchema.safeParse(body) + const parsed = await parseRequest(updateOrganizationWhitelabelContract, request, context, { + validationErrorResponse: (err) => validationErrorResponse(err, 'Invalid request body'), + }) + if (!parsed.success) return parsed.response - if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.errors[0]?.message ?? 'Invalid request body' }, - { status: 400 } - ) - } + const { id: organizationId } = parsed.data.params + const incoming = parsed.data.body const [memberEntry] = await db .select({ role: member.role }) @@ -171,7 +127,6 @@ export const PUT = withRouteHandler( } const current: OrganizationWhitelabelSettings = currentOrg.whitelabelSettings ?? {} - const incoming = parsed.data const merged: OrganizationWhitelabelSettings = { ...current } diff --git a/apps/sim/app/api/organizations/route.ts b/apps/sim/app/api/organizations/route.ts index c79e30c017c..aa87abbcb7d 100644 --- a/apps/sim/app/api/organizations/route.ts +++ b/apps/sim/app/api/organizations/route.ts @@ -2,8 +2,13 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, organization, subscription as subscriptionTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { and, eq, inArray, or } from 'drizzle-orm' +import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { listCreatorOrganizationsContract } from '@/lib/api/contracts/creator-profile' +import { createOrganizationBodySchema } from '@/lib/api/contracts/organization' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' import { @@ -24,7 +29,7 @@ import { const logger = createLogger('OrganizationsAPI') -export const GET = withRouteHandler(async () => { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -32,6 +37,9 @@ export const GET = withRouteHandler(async () => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const parsed = await parseRequest(listCreatorOrganizationsContract, request, {}) + if (!parsed.success) return parsed.response + const userOrganizations = await db .select({ id: organization.id, @@ -59,7 +67,7 @@ export const GET = withRouteHandler(async () => { }) } catch (error) { logger.error('Failed to fetch organizations', { - error: error instanceof Error ? error.message : 'Unknown error', + error: getErrorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : undefined, }) @@ -77,20 +85,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 @@ -221,7 +231,7 @@ export const POST = withRouteHandler(async (request: Request) => { logger.error('Failed to activate organization after creation', { organizationId, userId: user.id, - error: error instanceof Error ? error.message : String(error), + error: getErrorMessage(error), }) } @@ -278,14 +288,14 @@ export const POST = withRouteHandler(async (request: Request) => { } logger.error('Failed to create organization for team plan', { - error: error instanceof Error ? error.message : 'Unknown error', + error: getErrorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : undefined, }) return NextResponse.json( { error: 'Failed to create organization', - message: error instanceof Error ? error.message : 'Unknown error', + message: getErrorMessage(error, 'Unknown error'), }, { status: 500 } ) 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..ef3c000d960 100644 --- a/apps/sim/app/api/providers/fireworks/models/route.ts +++ b/apps/sim/app/api/providers/fireworks/models/route.ts @@ -1,5 +1,12 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { + fireworksProviderModelsQuerySchema, + fireworksUpstreamResponseSchema, + providerModelsResponseSchema, +} from '@/lib/api/contracts/providers' +import { validationErrorResponse } 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 +36,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { let apiKey: string | undefined - const workspaceId = request.nextUrl.searchParams.get('workspaceId') + const queryValidation = fireworksProviderModelsQuerySchema.safeParse({ + workspaceId: request.nextUrl.searchParams.get('workspaceId') ?? undefined, + }) + if (!queryValidation.success) return validationErrorResponse(queryValidation.error) + const { workspaceId } = queryValidation.data if (workspaceId) { const session = await getSession() if (session?.user?.id) { @@ -69,7 +80,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,10 +97,10 @@ 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', + error: getErrorMessage(error, 'Unknown error'), }) return NextResponse.json({ models: [] }) } diff --git a/apps/sim/app/api/providers/litellm/models/route.ts b/apps/sim/app/api/providers/litellm/models/route.ts new file mode 100644 index 00000000000..bf40b54c424 --- /dev/null +++ b/apps/sim/app/api/providers/litellm/models/route.ts @@ -0,0 +1,70 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +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' + +const logger = createLogger('LiteLLMModelsAPI') + +export const GET = withRouteHandler(async (_request: NextRequest) => { + if (isProviderBlacklisted('litellm')) { + logger.info('LiteLLM provider is blacklisted, returning empty models') + return NextResponse.json({ models: [] }) + } + + const baseUrl = (env.LITELLM_BASE_URL || '').replace(/\/$/, '') + + if (!baseUrl) { + logger.info('LITELLM_BASE_URL not configured') + return NextResponse.json({ models: [] }) + } + + try { + logger.info('Fetching LiteLLM models', { baseUrl }) + + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (env.LITELLM_API_KEY) { + headers.Authorization = `Bearer ${env.LITELLM_API_KEY}` + } + + const response = await fetch(`${baseUrl}/v1/models`, { + headers, + next: { revalidate: 60 }, + }) + + if (!response.ok) { + logger.warn('LiteLLM service is not available', { + status: response.status, + statusText: response.statusText, + }) + return NextResponse.json({ models: [] }) + } + + const data = vllmUpstreamResponseSchema.parse(await response.json()) + const allModels = data.data.map((model) => `litellm/${model.id}`) + const models = filterBlacklistedModels(allModels) + + logger.info('Successfully fetched LiteLLM models', { + count: models.length, + filtered: allModels.length - models.length, + models, + }) + + return NextResponse.json(providerModelsResponseSchema.parse({ models })) + } catch (error) { + logger.error('Failed to fetch LiteLLM models', { + error: getErrorMessage(error, 'Unknown error'), + baseUrl, + }) + + return NextResponse.json({ models: [] }) + } +}) diff --git a/apps/sim/app/api/providers/ollama/models/route.ts b/apps/sim/app/api/providers/ollama/models/route.ts index eccdb717279..47c302ba28b 100644 --- a/apps/sim/app/api/providers/ollama/models/route.ts +++ b/apps/sim/app/api/providers/ollama/models/route.ts @@ -1,8 +1,12 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +41,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,10 +51,10 @@ 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', + error: getErrorMessage(error, 'Unknown error'), host: OLLAMA_HOST, }) diff --git a/apps/sim/app/api/providers/openrouter/models/route.ts b/apps/sim/app/api/providers/openrouter/models/route.ts index b0e3346d4a5..11e53803b70 100644 --- a/apps/sim/app/api/providers/openrouter/models/route.ts +++ b/apps/sim/app/api/providers/openrouter/models/route.ts @@ -1,5 +1,10 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +55,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,10 +92,10 @@ 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', + error: getErrorMessage(error, 'Unknown error'), }) return NextResponse.json({ models: [], modelInfo: {} }) } diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index ddf2f0d59c0..f0bfc2b4a45 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -1,9 +1,12 @@ import { db } from '@sim/db' import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' +import { getErrorMessage, toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { executeProviderContract } from '@/lib/api/contracts/providers' +import { parseRequest } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -44,7 +47,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { contentType: request.headers.get('Content-Type'), }) - const body = await request.json() + const validation = await parseRequest( + executeProviderContract, + request, + {}, + { + validationErrorResponse: () => + NextResponse.json({ error: 'Invalid request body' }, { status: 400 }), + invalidJsonResponse: () => + NextResponse.json({ error: 'Invalid request body' }, { status: 400 }), + } + ) + if (!validation.success) return validation.response + + const body = validation.data.body const { provider, model, @@ -126,6 +142,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let finalApiKey: string | undefined = apiKey try { if (provider === 'vertex' && vertexCredential) { + const vertexCredAccess = await authorizeCredentialUse(request, { + credentialId: vertexCredential, + workflowId: workflowId || undefined, + requireWorkflowIdForInternal: false, + }) + if (!vertexCredAccess.ok) { + logger.warn(`[${requestId}] Vertex credential access denied`, { + error: vertexCredAccess.error, + credentialId: vertexCredential, + }) + return NextResponse.json( + { error: vertexCredAccess.error || 'Unauthorized' }, + { status: 401 } + ) + } finalApiKey = await resolveVertexCredential(requestId, vertexCredential) } } catch (error) { @@ -136,7 +167,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { hasVertexCredential: !!vertexCredential, }) return NextResponse.json( - { error: error instanceof Error ? error.message : 'Credential error' }, + { error: getErrorMessage(error, 'Credential error') }, { status: 400 } ) } diff --git a/apps/sim/app/api/providers/vllm/models/route.ts b/apps/sim/app/api/providers/vllm/models/route.ts index 3f1dcc3a260..6e2e167220e 100644 --- a/apps/sim/app/api/providers/vllm/models/route.ts +++ b/apps/sim/app/api/providers/vllm/models/route.ts @@ -1,5 +1,10 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +53,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,10 +63,10 @@ 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', + error: getErrorMessage(error, 'Unknown error'), baseUrl, }) diff --git a/apps/sim/app/api/proxy/tts/stream/route.ts b/apps/sim/app/api/proxy/tts/stream/route.ts index ad6d51e7bb7..d8ea97d39cf 100644 --- a/apps/sim/app/api/proxy/tts/stream/route.ts +++ b/apps/sim/app/api/proxy/tts/stream/route.ts @@ -2,7 +2,9 @@ import { db } from '@sim/db' import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { ttsStreamContract } from '@/lib/api/contracts/media/tts-stream' +import { parseRequest } from '@/lib/api/server' import { env } from '@/lib/core/config/env' import { validateAuthToken } from '@/lib/core/security/deployment' import { validateAlphanumericId } from '@/lib/core/security/input-validation' @@ -54,22 +56,23 @@ async function validateChatAuth(request: NextRequest, chatId: string): Promise { try { - let body: any - try { - body = await request.json() - } catch { - return new Response('Invalid request body', { status: 400 }) - } - - const { text, voiceId, modelId = 'eleven_turbo_v2_5', chatId } = body - - if (!chatId) { - return new Response('chatId is required', { status: 400 }) - } + const parsed = await parseRequest( + ttsStreamContract, + request, + {}, + { + invalidJsonResponse: () => new NextResponse('Invalid request body', { status: 400 }), + validationErrorResponse: (error) => { + if (error.issues.some((issue) => issue.path[0] === 'chatId')) { + return new NextResponse('chatId is required', { status: 400 }) + } + return new NextResponse('Missing required parameters', { status: 400 }) + }, + } + ) + if (!parsed.success) return parsed.response - if (!text || !voiceId) { - return new Response('Missing required parameters', { status: 400 }) - } + const { text, voiceId, modelId, chatId } = parsed.data.body const isChatAuthed = await validateChatAuth(request, chatId) if (!isChatAuthed) { 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..47f2f381168 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/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 { resumeWorkflowExecutionContextContract } from '@/lib/api/contracts/workflows' +import { parseRequest } from '@/lib/api/server' import { AuthType } from '@/lib/auth/hybrid' import { getJobQueue } from '@/lib/core/async-jobs' import { generateRequestId } from '@/lib/core/utils/request' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { setExecutionMeta } from '@/lib/execution/event-buffer' import { preprocessExecution } from '@/lib/execution/preprocessing' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' @@ -42,13 +43,13 @@ function getStoredSnapshotConfig(pausedExecution: { executionSnapshot: unknown } export const POST = withRouteHandler( async ( request: NextRequest, - { - params, - }: { + context: { params: Promise<{ workflowId: string; executionId: string; contextId: string }> } ) => { - const { workflowId, executionId, contextId } = await params + const parsed = await parseRequest(resumeWorkflowExecutionContextContract, request, context) + if (!parsed.success) return parsed.response + const { workflowId, executionId, contextId } = parsed.data.params const access = await validateWorkflowAccess(request, workflowId, false) if (access.error) { @@ -57,14 +58,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' @@ -137,9 +141,11 @@ export const POST = withRouteHandler( try { const enqueueResult = await PauseResumeManager.enqueueOrStartResume({ executionId, + workflowId, contextId, resumeInput, userId, + allowedPauseKinds: ['human'], }) if (enqueueResult.status === 'queued') { @@ -151,12 +157,6 @@ export const POST = withRouteHandler( }) } - await setExecutionMeta(enqueueResult.resumeExecutionId, { - status: 'active', - userId, - workflowId, - }) - const resumeArgs = { resumeEntryId: enqueueResult.resumeEntryId, resumeExecutionId: enqueueResult.resumeExecutionId, @@ -180,6 +180,10 @@ export const POST = withRouteHandler( timeoutMs: preprocessResult.executionTimeout?.sync, }, executionId: enqueueResult.resumeExecutionId, + workspaceId: workflow.workspaceId || undefined, + workflowId, + userId: enqueueResult.userId, + allowLargeValueWorkflowScope: true, executeFn: async ({ onStream, onBlockComplete, abortSignal }) => PauseResumeManager.startResumeExecution({ ...resumeArgs, @@ -243,6 +247,14 @@ export const POST = withRouteHandler( error: toError(dispatchError).message, resumeExecutionId: enqueueResult.resumeExecutionId, }) + await PauseResumeManager.markResumeAttemptFailed({ + resumeEntryId: enqueueResult.resumeEntryId, + pausedExecutionId: enqueueResult.pausedExecution.id, + parentExecutionId: executionId, + contextId: enqueueResult.contextId, + failureReason: 'Failed to queue async resume execution', + }) + await PauseResumeManager.processQueuedResumes(executionId, workflowId) return NextResponse.json( { error: 'Failed to queue resume execution. Please try again.' }, { status: 503 } @@ -276,7 +288,7 @@ export const POST = withRouteHandler( executionId: enqueueResult.resumeExecutionId, message: 'Resume execution started.', }) - } catch (error: any) { + } catch (error) { logger.error('Resume request failed', { workflowId, executionId, @@ -284,7 +296,7 @@ export const POST = withRouteHandler( error, }) return NextResponse.json( - { error: error.message || 'Failed to queue resume request' }, + { error: toError(error).message || 'Failed to queue resume request' }, { status: 400 } ) } @@ -294,13 +306,13 @@ export const POST = withRouteHandler( export const GET = withRouteHandler( async ( request: NextRequest, - { - params, - }: { + context: { params: Promise<{ workflowId: string; executionId: string; contextId: string }> } ) => { - const { workflowId, executionId, contextId } = await params + const parsed = await parseRequest(resumeWorkflowExecutionContextContract, request, context) + if (!parsed.success) return parsed.response + const { workflowId, executionId, contextId } = parsed.data.params 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..244da8805c4 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 { resumeWorkflowExecutionContract } from '@/lib/api/contracts/workflows' +import { parseRequest } 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' @@ -12,13 +14,11 @@ export const dynamic = 'force-dynamic' export const GET = withRouteHandler( async ( request: NextRequest, - { - params, - }: { - params: Promise<{ workflowId: string; executionId: string }> - } + context: { params: Promise<{ workflowId: string; executionId: string }> } ) => { - const { workflowId, executionId } = await params + const parsed = await parseRequest(resumeWorkflowExecutionContract, request, context) + if (!parsed.success) return parsed.response + const { workflowId, executionId } = parsed.data.params const access = await validateWorkflowAccess(request, workflowId, false) if (access.error) { diff --git a/apps/sim/app/api/resume/poll/route.ts b/apps/sim/app/api/resume/poll/route.ts new file mode 100644 index 00000000000..86edf2f218c --- /dev/null +++ b/apps/sim/app/api/resume/poll/route.ts @@ -0,0 +1,187 @@ +import { db } from '@sim/db' +import { pausedExecutions } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' +import { and, asc, inArray, isNotNull, lte } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { acquireLock, releaseLock } from '@/lib/core/config/redis' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + computeEarliestResumeAt, + PauseResumeManager, +} from '@/lib/workflows/executor/human-in-the-loop-manager' +import type { PausePoint } from '@/executor/types' + +const logger = createLogger('TimePauseResumePoll') + +export const dynamic = 'force-dynamic' +export const maxDuration = 120 + +const LOCK_KEY = 'time-pause-resume-poll-lock' +const LOCK_TTL_SECONDS = 180 +const POLL_BATCH_LIMIT = 200 + +interface DispatchFailure { + executionId: string + contextId: string + error: string +} + +interface RowResult { + dispatched: number + failures: DispatchFailure[] +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateShortId() + + const authError = verifyCronAuth(request, 'Time-pause resume poll') + if (authError) return authError + + const lockAcquired = await acquireLock(LOCK_KEY, requestId, LOCK_TTL_SECONDS) + if (!lockAcquired) { + return NextResponse.json( + { success: true, message: 'Polling already in progress – skipped', requestId }, + { status: 202 } + ) + } + + try { + const now = new Date() + + const dueRows = await db + .select({ + id: pausedExecutions.id, + executionId: pausedExecutions.executionId, + workflowId: pausedExecutions.workflowId, + pausePoints: pausedExecutions.pausePoints, + metadata: pausedExecutions.metadata, + }) + .from(pausedExecutions) + .where( + and( + // 'partially_resumed' rows occur when a chained-pause workflow advanced past + // an earlier wait — e.g. wait1 → agent → wait2 — and now wait2's time pause + // is the one waiting for the cron. Include it alongside fresh 'paused' rows. + inArray(pausedExecutions.status, ['paused', 'partially_resumed']), + isNotNull(pausedExecutions.nextResumeAt), + lte(pausedExecutions.nextResumeAt, now) + ) + ) + .orderBy(asc(pausedExecutions.nextResumeAt)) + .limit(POLL_BATCH_LIMIT) + + const results = await Promise.all(dueRows.map((row) => dispatchRow(row, now))) + const dispatched = results.reduce((sum, r) => sum + r.dispatched, 0) + const failures = results.flatMap((r) => r.failures) + + logger.info('Time-pause resume poll completed', { + requestId, + claimedRows: dueRows.length, + dispatched, + failureCount: failures.length, + }) + + return NextResponse.json({ + success: true, + requestId, + claimedRows: dueRows.length, + dispatched, + failures, + }) + } catch (error) { + const message = toError(error).message + logger.error('Time-pause resume poll failed', { requestId, error: message }) + return NextResponse.json({ success: false, requestId, error: message }, { status: 500 }) + } finally { + await releaseLock(LOCK_KEY, requestId).catch(() => {}) + } +}) + +interface DueRow { + id: string + executionId: string + workflowId: string + pausePoints: unknown + metadata: unknown +} + +async function dispatchRow(row: DueRow, now: Date): Promise { + const points = (row.pausePoints ?? {}) as Record + const metadata = (row.metadata ?? {}) as Record + const userId = typeof metadata.executorUserId === 'string' ? metadata.executorUserId : '' + + const eligiblePoints = Object.values(points).filter( + (point) => + point.pauseKind === 'time' && (!point.resumeStatus || point.resumeStatus === 'paused') + ) + const duePoints = eligiblePoints.filter((point) => { + if (!point.resumeAt) return false + const at = new Date(point.resumeAt) + return !Number.isNaN(at.getTime()) && at <= now + }) + + const failures: DispatchFailure[] = [] + let dispatched = 0 + + for (const point of duePoints) { + if (!point.contextId) continue + try { + const enqueueResult = await PauseResumeManager.enqueueOrStartResume({ + executionId: row.executionId, + workflowId: row.workflowId, + contextId: point.contextId, + resumeInput: {}, + userId, + allowedPauseKinds: ['time'], + }) + + if (enqueueResult.status === 'starting') { + // Route through `executeResumeJob` (not `PauseResumeManager.startResumeExecution` + // directly) so cell-context restoration + cascade-loop continuation + // fires. This is the same primitive the trigger.dev `resumeExecutionTask` + // wraps — calling it directly handles both trigger.dev-disabled local + // dev and trigger.dev-enabled prod identically. + const { executeResumeJob } = await import('@/background/resume-execution') + void executeResumeJob({ + resumeEntryId: enqueueResult.resumeEntryId, + resumeExecutionId: enqueueResult.resumeExecutionId, + pausedExecutionId: enqueueResult.pausedExecution.id, + contextId: enqueueResult.contextId, + resumeInput: enqueueResult.resumeInput, + userId: enqueueResult.userId, + workflowId: row.workflowId, + parentExecutionId: row.executionId, + }).catch((error) => { + logger.error('Background time-pause resume failed', { + executionId: row.executionId, + contextId: point.contextId, + error: toError(error).message, + }) + }) + } + dispatched++ + } catch (error) { + const message = toError(error).message + logger.warn('Failed to dispatch time-pause resume', { + executionId: row.executionId, + contextId: point.contextId, + error: message, + }) + failures.push({ executionId: row.executionId, contextId: point.contextId, error: message }) + } + } + + // We never auto-retry a failed dispatch: workflow blocks aren't idempotent, and + // an operator must investigate stranded rows by hand. The status='paused' guard + // also prevents clobbering when a concurrent manual resume has already advanced + // the row's state since we read it. + await PauseResumeManager.setNextResumeAt({ + pausedExecutionId: row.id, + nextResumeAt: computeEarliestResumeAt(eligiblePoints, { after: now }), + }) + + return { dispatched, failures } +} diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index e8e3a486e60..129ec4c1582 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -2,14 +2,20 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflowSchedule } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { + assertWorkflowMutable, + authorizeWorkflowByWorkspacePermission, + WorkflowLockedError, +} from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateScheduleContract } from '@/lib/api/contracts/schedules' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' +import { performDeleteJob, performUpdateJob } from '@/lib/workflows/schedules/orchestration' import { validateCronExpression } from '@/lib/workflows/schedules/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' @@ -17,20 +23,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 @@ -108,30 +100,33 @@ async function fetchAndAuthorize( } export const PUT = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() try { - const { id: scheduleId } = await params - const session = await getSession() if (!session?.user?.id) { logger.warn(`[${requestId}] Unauthorized schedule update attempt`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validation = scheduleUpdateSchema.safeParse(body) + const parsed = await parseRequest(updateScheduleContract, request, context, { + validationErrorResponse: () => + NextResponse.json({ error: 'Invalid request body' }, { status: 400 }), + }) + if (!parsed.success) return parsed.response - if (!validation.success) { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) - } + const { id: scheduleId } = parsed.data.params + const validatedBody = parsed.data.body const result = await fetchAndAuthorize(requestId, scheduleId, session.user.id, 'write') if (result instanceof NextResponse) return result const { schedule, workspaceId } = result + if (schedule.workflowId) { + await assertWorkflowMutable(schedule.workflowId) + } - const { action } = validation.data + const { action } = validatedBody if (action === 'disable') { if (schedule.status === 'disabled') { @@ -174,58 +169,32 @@ export const PUT = withRouteHandler( ) } - const updates = validation.data - const setFields: Record = { updatedAt: new Date() } - - if (updates.title !== undefined) setFields.jobTitle = updates.title.trim() - if (updates.prompt !== undefined) setFields.prompt = updates.prompt.trim() - if (updates.timezone !== undefined) setFields.timezone = updates.timezone - if (updates.lifecycle !== undefined) { - setFields.lifecycle = updates.lifecycle - if (updates.lifecycle === 'persistent') { - setFields.maxRuns = null - } - } - if (updates.maxRuns !== undefined) setFields.maxRuns = updates.maxRuns - - if (updates.cronExpression !== undefined) { - const tz = updates.timezone ?? schedule.timezone ?? 'UTC' - const cronResult = validateCronExpression(updates.cronExpression, tz) - if (!cronResult.isValid) { - return NextResponse.json( - { error: cronResult.error || 'Invalid cron expression' }, - { status: 400 } - ) - } - setFields.cronExpression = updates.cronExpression - if (schedule.status === 'active' && cronResult.nextRun) { - setFields.nextRunAt = cronResult.nextRun - } + if (!workspaceId) { + return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 }) } - await db - .update(workflowSchedule) - .set(setFields) - .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) - - logger.info(`[${requestId}] Updated job schedule: ${scheduleId}`) - - recordAudit({ + const updateResult = await performUpdateJob({ + jobId: scheduleId, workspaceId, - actorId: session.user.id, + userId: session.user.id, actorName: session.user.name, actorEmail: session.user.email, - action: AuditAction.SCHEDULE_UPDATED, - resourceType: AuditResourceType.SCHEDULE, - resourceId: scheduleId, - resourceName: schedule.jobTitle ?? undefined, - description: `Updated job schedule "${schedule.jobTitle ?? scheduleId}"`, - metadata: { - operation: 'update', - updatedFields: Object.keys(setFields).filter((k) => k !== 'updatedAt'), - }, + title: validatedBody.title, + prompt: validatedBody.prompt, + timezone: validatedBody.timezone, + lifecycle: validatedBody.lifecycle, + maxRuns: validatedBody.maxRuns, + cronExpression: validatedBody.cronExpression, request, }) + if (!updateResult.success) { + return NextResponse.json( + { error: updateResult.error || 'Failed to update schedule' }, + { status: updateResult.errorCode === 'validation' ? 400 : 500 } + ) + } + + logger.info(`[${requestId}] Updated job schedule: ${scheduleId}`) return NextResponse.json({ message: 'Schedule updated successfully' }) } @@ -277,6 +246,10 @@ export const PUT = withRouteHandler( return NextResponse.json({ message: 'Schedule activated successfully', nextRunAt }) } catch (error) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error(`[${requestId}] Error updating schedule`, error) return NextResponse.json({ error: 'Failed to update schedule' }, { status: 500 }) } @@ -300,6 +273,27 @@ export const DELETE = withRouteHandler( if (result instanceof NextResponse) return result const { schedule, workspaceId } = result + if (schedule.sourceType === 'job') { + if (!workspaceId) { + return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 }) + } + const deleteResult = await performDeleteJob({ + jobId: scheduleId, + workspaceId, + userId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + request, + }) + if (!deleteResult.success) { + return NextResponse.json( + { error: deleteResult.error || 'Failed to delete schedule' }, + { status: deleteResult.errorCode === 'not_found' ? 404 : 500 } + ) + } + return NextResponse.json({ message: 'Schedule deleted successfully' }) + } + await db.delete(workflowSchedule).where(eq(workflowSchedule.id, scheduleId)) logger.info(`[${requestId}] Deleted schedule: ${scheduleId}`) diff --git a/apps/sim/app/api/schedules/execute/route.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts index d5c50c6c647..05434276654 100644 --- a/apps/sim/app/api/schedules/execute/route.test.ts +++ b/apps/sim/app/api/schedules/execute/route.test.ts @@ -20,6 +20,7 @@ const { mockExecuteJobInline, mockFeatureFlags, mockEnqueue, + mockGetJob, mockStartJob, mockCompleteJob, mockMarkJobFailed, @@ -34,6 +35,7 @@ const { isDev: true, }, mockEnqueue: vi.fn().mockResolvedValue('job-id-1'), + mockGetJob: vi.fn().mockResolvedValue(null), mockStartJob: vi.fn().mockResolvedValue(undefined), mockCompleteJob: vi.fn().mockResolvedValue(undefined), mockMarkJobFailed: vi.fn().mockResolvedValue(undefined), @@ -54,6 +56,7 @@ vi.mock('@/lib/core/config/feature-flags', () => mockFeatureFlags) vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: vi.fn().mockResolvedValue({ enqueue: mockEnqueue, + getJob: mockGetJob, startJob: mockStartJob, completeJob: mockCompleteJob, markJobFailed: mockMarkJobFailed, @@ -69,6 +72,7 @@ vi.mock('drizzle-orm', () => ({ ne: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'ne' })), lte: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'lte' })), lt: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'lt' })), + inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values, type: 'inArray' })), not: vi.fn((condition: unknown) => ({ type: 'not', condition })), isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })), @@ -166,6 +170,8 @@ function createMockRequest(): NextRequest { describe('Scheduled Workflow Execution API Route', () => { beforeEach(() => { vi.clearAllMocks() + dbChainMockFns.limit.mockReset() + dbChainMockFns.returning.mockReset() resetDbChainMock() requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-request-id') workflowsUtilsMockFns.mockGetWorkflowById.mockResolvedValue({ @@ -180,6 +186,7 @@ describe('Scheduled Workflow Execution API Route', () => { }) it('should execute scheduled workflows with Trigger.dev disabled', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'schedule-1' }]).mockResolvedValueOnce([]) dbChainMockFns.returning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([]) const response = await GET(createMockRequest()) @@ -193,6 +200,7 @@ describe('Scheduled Workflow Execution API Route', () => { it('should queue schedules to Trigger.dev when enabled', async () => { mockFeatureFlags.isTriggerDevEnabled = true + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'schedule-1' }]).mockResolvedValueOnce([]) dbChainMockFns.returning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([]) const response = await GET(createMockRequest()) @@ -215,6 +223,9 @@ describe('Scheduled Workflow Execution API Route', () => { }) it('should execute multiple schedules in parallel', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ id: 'schedule-1' }, { id: 'schedule-2' }]) + .mockResolvedValueOnce([]) dbChainMockFns.returning.mockReturnValueOnce(MULTIPLE_SCHEDULES).mockReturnValueOnce([]) const response = await GET(createMockRequest()) @@ -225,7 +236,8 @@ describe('Scheduled Workflow Execution API Route', () => { }) it('should execute mothership jobs inline', async () => { - dbChainMockFns.returning.mockReturnValueOnce([]).mockReturnValueOnce(SINGLE_JOB) + dbChainMockFns.limit.mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 'job-1' }]) + dbChainMockFns.returning.mockReturnValueOnce(SINGLE_JOB) const response = await GET(createMockRequest()) @@ -241,6 +253,7 @@ describe('Scheduled Workflow Execution API Route', () => { }) it('should enqueue schedule with correlation metadata via job queue', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'schedule-1' }]).mockResolvedValueOnce([]) dbChainMockFns.returning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([]) const response = await GET(createMockRequest()) @@ -255,6 +268,8 @@ describe('Scheduled Workflow Execution API Route', () => { requestId: 'test-request-id', }), expect.objectContaining({ + jobId: expect.stringMatching(/^schedule_[0-9a-f]{32}$/), + concurrencyKey: expect.stringMatching(/^schedule_[0-9a-f]{32}$/), metadata: expect.objectContaining({ workflowId: 'workflow-1', workspaceId: 'workspace-1', diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 584425196f5..7891fcd01b1 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -1,11 +1,14 @@ import { db, workflowDeploymentVersion, workflowSchedule } from '@sim/db' import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq, isNull, lt, lte, ne, not, or, sql } from 'drizzle-orm' +import { Cron } from 'croner' +import { and, eq, inArray, isNull, lt, lte, ne, not, or, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' +import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -15,8 +18,13 @@ import { } from '@/background/schedule-execution' export const dynamic = 'force-dynamic' +export const maxDuration = 3600 const logger = createLogger('ScheduledExecuteAPI') +const WORKFLOW_CHUNK_SIZE = 100 +const JOB_CHUNK_SIZE = 100 +const MAX_TICK_DURATION_MS = 3 * 60 * 1000 +const STALE_SCHEDULE_CLAIM_MS = getMaxExecutionTimeout() const dueFilter = (queuedAt: Date) => and( @@ -26,31 +34,63 @@ const dueFilter = (queuedAt: Date) => ne(workflowSchedule.status, 'completed'), or( isNull(workflowSchedule.lastQueuedAt), - lt(workflowSchedule.lastQueuedAt, workflowSchedule.nextRunAt) + lt(workflowSchedule.lastQueuedAt, workflowSchedule.nextRunAt), + lt(workflowSchedule.lastQueuedAt, new Date(queuedAt.getTime() - STALE_SCHEDULE_CLAIM_MS)) ) ) -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - logger.info(`[${requestId}] Scheduled execution triggered at ${new Date().toISOString()}`) +const activeWorkflowDeploymentFilter = () => + sql`${workflowSchedule.deploymentVersionId} = (select ${workflowDeploymentVersion.id} from ${workflowDeploymentVersion} where ${workflowDeploymentVersion.workflowId} = ${workflowSchedule.workflowId} and ${workflowDeploymentVersion.isActive} = true)` - const authError = verifyCronAuth(request, 'Schedule execution') - if (authError) { - return authError - } +const workflowScheduleFilter = (queuedAt: Date) => + and( + dueFilter(queuedAt), + or(eq(workflowSchedule.sourceType, 'workflow'), isNull(workflowSchedule.sourceType)), + activeWorkflowDeploymentFilter() + ) - const queuedAt = new Date() +const jobScheduleFilter = (queuedAt: Date) => + and(dueFilter(queuedAt), eq(workflowSchedule.sourceType, 'job')) - try { - // Workflow schedules (require active deployment) - const dueSchedules = await db +function buildScheduleExecutionJobId(schedule: { + id: string + nextRunAt?: Date | null + lastQueuedAt?: Date | null +}): string { + const occurrence = + schedule.nextRunAt?.toISOString() ?? schedule.lastQueuedAt?.toISOString() ?? 'due' + return `schedule_${sha256Hex(`${schedule.id}:${occurrence}`).slice(0, 32)}` +} + +function getNextRunFromCronExpression(cronExpression?: string | null): Date | null { + if (!cronExpression) return null + const cron = new Cron(cronExpression) + return cron.nextRun() +} + +async function claimWorkflowSchedules(queuedAt: Date, limit: number) { + if (limit <= 0) return [] + + return db.transaction(async (tx) => { + const rows = await tx + .select({ id: workflowSchedule.id }) + .from(workflowSchedule) + .where(workflowScheduleFilter(queuedAt)) + .for('update', { skipLocked: true }) + .limit(limit) + + if (rows.length === 0) return [] + + return tx .update(workflowSchedule) .set({ lastQueuedAt: queuedAt, updatedAt: queuedAt }) .where( and( - dueFilter(queuedAt), - or(eq(workflowSchedule.sourceType, 'workflow'), isNull(workflowSchedule.sourceType)), - sql`${workflowSchedule.deploymentVersionId} = (select ${workflowDeploymentVersion.id} from ${workflowDeploymentVersion} where ${workflowDeploymentVersion.workflowId} = ${workflowSchedule.workflowId} and ${workflowDeploymentVersion.isActive} = true)` + workflowScheduleFilter(queuedAt), + inArray( + workflowSchedule.id, + rows.map((row) => row.id) + ) ) ) .returning({ @@ -62,14 +102,37 @@ export const GET = withRouteHandler(async (request: NextRequest) => { failedCount: workflowSchedule.failedCount, nextRunAt: workflowSchedule.nextRunAt, lastQueuedAt: workflowSchedule.lastQueuedAt, + deploymentVersionId: workflowSchedule.deploymentVersionId, sourceType: workflowSchedule.sourceType, }) + }) +} + +async function claimJobSchedules(queuedAt: Date, limit: number) { + if (limit <= 0) return [] + + return db.transaction(async (tx) => { + const rows = await tx + .select({ id: workflowSchedule.id }) + .from(workflowSchedule) + .where(jobScheduleFilter(queuedAt)) + .for('update', { skipLocked: true }) + .limit(limit) + + if (rows.length === 0) return [] - // Jobs (no deployment, dispatch inline) - const dueJobs = await db + return tx .update(workflowSchedule) .set({ lastQueuedAt: queuedAt, updatedAt: queuedAt }) - .where(and(dueFilter(queuedAt), eq(workflowSchedule.sourceType, 'job'))) + .where( + and( + jobScheduleFilter(queuedAt), + inArray( + workflowSchedule.id, + rows.map((row) => row.id) + ) + ) + ) .returning({ id: workflowSchedule.id, cronExpression: workflowSchedule.cronExpression, @@ -77,136 +140,243 @@ export const GET = withRouteHandler(async (request: NextRequest) => { lastQueuedAt: workflowSchedule.lastQueuedAt, sourceType: workflowSchedule.sourceType, }) + }) +} - const totalCount = dueSchedules.length + dueJobs.length - logger.info( - `[${requestId}] Processing ${totalCount} due items (${dueSchedules.length} schedules, ${dueJobs.length} jobs)` - ) +type ClaimedSchedule = Awaited>[number] +type ClaimedJob = Awaited>[number] +type WorkflowUtils = typeof import('@/lib/workflows/utils') +type JobQueue = Awaited> - const jobQueue = await getJobQueue() +async function processScheduleItem( + schedule: ClaimedSchedule, + queuedAt: Date, + requestId: string, + jobQueue: JobQueue, + workflowUtils: WorkflowUtils +) { + const queueTime = schedule.lastQueuedAt ?? queuedAt + const executionId = generateId() + const correlation = { + executionId, + requestId, + source: 'schedule' as const, + workflowId: schedule.workflowId!, + scheduleId: schedule.id, + triggerType: 'schedule', + scheduledFor: schedule.nextRunAt?.toISOString(), + } - const workflowUtils = - dueSchedules.length > 0 ? await import('@/lib/workflows/utils') : undefined + const payload = { + scheduleId: schedule.id, + workflowId: schedule.workflowId!, + executionId, + requestId, + correlation, + blockId: schedule.blockId || undefined, + deploymentVersionId: schedule.deploymentVersionId || undefined, + cronExpression: schedule.cronExpression || undefined, + lastRanAt: schedule.lastRanAt?.toISOString(), + failedCount: schedule.failedCount || 0, + now: queueTime.toISOString(), + scheduledFor: schedule.nextRunAt?.toISOString(), + } - const schedulePromises = dueSchedules.map(async (schedule) => { - const queueTime = schedule.lastQueuedAt ?? queuedAt - const executionId = generateId() - const correlation = { - executionId, - requestId, - source: 'schedule' as const, - workflowId: schedule.workflowId!, + try { + const scheduleJobId = buildScheduleExecutionJobId(schedule) + const existingJob = await jobQueue.getJob(scheduleJobId) + if (existingJob && ['pending', 'processing'].includes(existingJob.status)) { + logger.info(`[${requestId}] Schedule execution job already exists`, { scheduleId: schedule.id, - triggerType: 'schedule', - scheduledFor: schedule.nextRunAt?.toISOString(), - } - - const payload = { + jobId: scheduleJobId, + status: existingJob.status, + }) + return + } + if (existingJob) { + logger.info(`[${requestId}] Releasing stale schedule claim for finished job`, { scheduleId: schedule.id, - workflowId: schedule.workflowId!, - executionId, + jobId: scheduleJobId, + status: existingJob.status, + }) + await releaseScheduleLock( + schedule.id, requestId, + queuedAt, + `Released stale schedule ${schedule.id} for finished job ${scheduleJobId}`, + getNextRunFromCronExpression(schedule.cronExpression) + ) + return + } + + const resolvedWorkflow = schedule.workflowId + ? await workflowUtils.getWorkflowById(schedule.workflowId) + : null + const resolvedWorkspaceId = resolvedWorkflow?.workspaceId + + const jobId = await jobQueue.enqueue('schedule-execution', payload, { + jobId: scheduleJobId, + concurrencyKey: scheduleJobId, + metadata: { + workflowId: schedule.workflowId ?? undefined, + workspaceId: resolvedWorkspaceId ?? undefined, correlation, - blockId: schedule.blockId || undefined, - cronExpression: schedule.cronExpression || undefined, - lastRanAt: schedule.lastRanAt?.toISOString(), - failedCount: schedule.failedCount || 0, - now: queueTime.toISOString(), - scheduledFor: schedule.nextRunAt?.toISOString(), - } + }, + }) + logger.info( + `[${requestId}] Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}` + ) - try { - const resolvedWorkflow = schedule.workflowId - ? await workflowUtils?.getWorkflowById(schedule.workflowId) - : null - const resolvedWorkspaceId = resolvedWorkflow?.workspaceId - - const jobId = await jobQueue.enqueue('schedule-execution', payload, { - metadata: { - workflowId: schedule.workflowId ?? undefined, - workspaceId: resolvedWorkspaceId ?? undefined, - correlation, - }, - }) - logger.info( - `[${requestId}] Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}` - ) + const queuedJob = await jobQueue.getJob(jobId) + if (queuedJob && !['pending', 'processing'].includes(queuedJob.status)) { + logger.info(`[${requestId}] Schedule execution job already finished`, { + scheduleId: schedule.id, + jobId, + status: queuedJob.status, + }) + await releaseScheduleLock( + schedule.id, + requestId, + queuedAt, + `Released stale schedule ${schedule.id} for finished job ${jobId}`, + getNextRunFromCronExpression(schedule.cronExpression) + ) + return + } - if (shouldExecuteInline()) { - try { - await jobQueue.startJob(jobId) - const output = await executeScheduleJob(payload) - await jobQueue.completeJob(jobId, output) - } catch (error) { - const errorMessage = toError(error).message - logger.error( - `[${requestId}] Schedule execution failed for workflow ${schedule.workflowId}`, - { - jobId, - error: errorMessage, - } - ) - try { - await jobQueue.markJobFailed(jobId, errorMessage) - } catch (markFailedError) { - logger.error(`[${requestId}] Failed to mark job as failed`, { - jobId, - error: - markFailedError instanceof Error - ? markFailedError.message - : String(markFailedError), - }) - } - await releaseScheduleLock( - schedule.id, - requestId, - queuedAt, - `Failed to release lock for schedule ${schedule.id} after inline execution failure` - ) - } - } + if (shouldExecuteInline()) { + try { + await jobQueue.startJob(jobId) + const output = await executeScheduleJob(payload) + await jobQueue.completeJob(jobId, output) } catch (error) { + const errorMessage = toError(error).message logger.error( - `[${requestId}] Failed to queue schedule execution for workflow ${schedule.workflowId}`, - error + `[${requestId}] Schedule execution failed for workflow ${schedule.workflowId}`, + { + jobId, + error: errorMessage, + } ) + try { + await jobQueue.markJobFailed(jobId, errorMessage) + } catch (markFailedError) { + logger.error(`[${requestId}] Failed to mark job as failed`, { + jobId, + error: toError(markFailedError).message, + }) + } await releaseScheduleLock( schedule.id, requestId, queuedAt, - `Failed to release lock for schedule ${schedule.id} after queue failure` + `Failed to release lock for schedule ${schedule.id} after inline execution failure` ) } + } + } catch (error) { + logger.error( + `[${requestId}] Failed to queue schedule execution for workflow ${schedule.workflowId}`, + error + ) + await releaseScheduleLock( + schedule.id, + requestId, + queuedAt, + `Failed to release lock for schedule ${schedule.id} after queue failure` + ) + } +} + +async function processJobItem(job: ClaimedJob, queuedAt: Date, requestId: string) { + const queueTime = job.lastQueuedAt ?? queuedAt + const payload = { + scheduleId: job.id, + cronExpression: job.cronExpression || undefined, + failedCount: job.failedCount || 0, + now: queueTime.toISOString(), + } + + try { + await executeJobInline(payload) + } catch (error) { + logger.error(`[${requestId}] Job execution failed for ${job.id}`, { + error: toError(error).message, }) + await releaseScheduleLock( + job.id, + requestId, + queuedAt, + `Failed to release lock for job ${job.id}` + ) + } +} - // Mothership jobs are executed inline directly. - const jobPromises = dueJobs.map(async (job) => { - const queueTime = job.lastQueuedAt ?? queuedAt - const payload = { - scheduleId: job.id, - cronExpression: job.cronExpression || undefined, - failedCount: job.failedCount || 0, - now: queueTime.toISOString(), - } +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + const tickStart = Date.now() + logger.info(`[${requestId}] Scheduled execution triggered at ${new Date().toISOString()}`) - try { - await executeJobInline(payload) - } catch (error) { - logger.error(`[${requestId}] Job execution failed for ${job.id}`, { - error: toError(error).message, - }) - await releaseScheduleLock( - job.id, - requestId, - queuedAt, - `Failed to release lock for job ${job.id}` - ) + const authError = verifyCronAuth(request, 'Schedule execution') + if (authError) { + return authError + } + + try { + const jobQueue = await getJobQueue() + let workflowUtils: WorkflowUtils | undefined + + let totalSchedules = 0 + let totalJobs = 0 + let iterations = 0 + let schedulesExhausted = false + let jobsExhausted = false + + while (Date.now() - tickStart < MAX_TICK_DURATION_MS) { + if (schedulesExhausted && jobsExhausted) break + const queuedAt = new Date() + + const [dueSchedules, dueJobs] = await Promise.all([ + schedulesExhausted ? [] : claimWorkflowSchedules(queuedAt, WORKFLOW_CHUNK_SIZE), + jobsExhausted ? [] : claimJobSchedules(queuedAt, JOB_CHUNK_SIZE), + ]) + + if (dueSchedules.length < WORKFLOW_CHUNK_SIZE) schedulesExhausted = true + if (dueJobs.length < JOB_CHUNK_SIZE) jobsExhausted = true + + if (dueSchedules.length === 0 && dueJobs.length === 0) break + + iterations += 1 + totalSchedules += dueSchedules.length + totalJobs += dueJobs.length + + logger.info( + `[${requestId}] Iteration ${iterations}: claimed ${dueSchedules.length} schedules, ${dueJobs.length} jobs` + ) + + if (dueSchedules.length > 0 && !workflowUtils) { + workflowUtils = await import('@/lib/workflows/utils') } - }) - await Promise.allSettled([...schedulePromises, ...jobPromises]) + const loadedWorkflowUtils = workflowUtils + const schedulePromises = + loadedWorkflowUtils && dueSchedules.length > 0 + ? dueSchedules.map((schedule) => + processScheduleItem(schedule, queuedAt, requestId, jobQueue, loadedWorkflowUtils) + ) + : [] + + await Promise.allSettled([ + ...schedulePromises, + ...dueJobs.map((job) => processJobItem(job, queuedAt, requestId)), + ]) + } - logger.info(`[${requestId}] Processed ${totalCount} items`) + const totalCount = totalSchedules + totalJobs + const durationMs = Date.now() - tickStart + logger.info( + `[${requestId}] Processed ${totalCount} items across ${iterations} iteration(s) in ${durationMs}ms (${totalSchedules} schedules, ${totalJobs} jobs)` + ) return NextResponse.json({ message: 'Scheduled workflow executions processed', diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index 6b0b17a8450..37bdc00ab24 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -1,16 +1,15 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workflowDeploymentVersion, workflowSchedule } from '@sim/db/schema' import { createLogger } from '@sim/logger' -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 { createScheduleContract, scheduleQuerySchema } from '@/lib/api/contracts/schedules' +import { parseRequest, 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 { captureServerEvent } from '@/lib/posthog/server' -import { validateCronExpression } from '@/lib/workflows/schedules/utils' +import { performCreateJob } from '@/lib/workflows/schedules/orchestration' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' const logger = createLogger('ScheduledAPI') @@ -25,9 +24,11 @@ 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 = scheduleQuerySchema.safeParse( + Object.fromEntries(url.searchParams.entries()) + ) + if (!queryValidation.success) return validationErrorResponse(queryValidation.error) + const { workflowId, workspaceId, blockId } = queryValidation.data try { const session = await getSession() @@ -202,113 +203,65 @@ 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 - } + const parsed = await parseRequest( + createScheduleContract, + req, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { error: 'Invalid request body', details: error.issues }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response - if (!workspaceId || !title?.trim() || !prompt?.trim() || !cronExpression?.trim()) { - return NextResponse.json( - { error: 'Missing required fields: workspaceId, title, prompt, cronExpression' }, - { status: 400 } - ) - } + const { workspaceId, title, prompt, cronExpression, timezone, lifecycle, maxRuns, startDate } = + parsed.data.body const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId) if (!hasPermission) { return NextResponse.json({ error: 'Not authorized' }, { status: 403 }) } - const validation = validateCronExpression(cronExpression, timezone) - if (!validation.isValid) { - return NextResponse.json( - { error: validation.error || 'Invalid cron expression' }, - { status: 400 } - ) - } - - let nextRunAt = validation.nextRun! - if (startDate) { - const start = new Date(startDate) - if (start > new Date()) { - nextRunAt = start - } - } - - const now = new Date() - const id = generateId() - - await db.insert(workflowSchedule).values({ - id, + const result = await performCreateJob({ + workspaceId, + userId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + title, + prompt, cronExpression, - triggerType: 'schedule', - sourceType: 'job', - status: 'active', timezone, - nextRunAt, - createdAt: now, - updatedAt: now, - failedCount: 0, - jobTitle: title.trim(), - prompt: prompt.trim(), lifecycle, - maxRuns: maxRuns ?? null, - runCount: 0, - sourceWorkspaceId: workspaceId, - sourceUserId: session.user.id, + maxRuns, + startDate, + request: req, }) + if (!result.success || !result.schedule) { + return NextResponse.json( + { error: result.error || 'Failed to create schedule' }, + { status: result.errorCode === 'validation' ? 400 : 500 } + ) + } - logger.info(`[${requestId}] Created job schedule ${id}`, { + logger.info(`[${requestId}] Created job schedule ${result.schedule.id}`, { title, cronExpression, timezone, lifecycle, }) - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.SCHEDULE_CREATED, - resourceType: AuditResourceType.SCHEDULE, - resourceId: id, - resourceName: title.trim(), - description: `Created job schedule "${title.trim()}"`, - metadata: { - cronExpression, - timezone, - lifecycle, - maxRuns: maxRuns ?? null, - }, - request: req, - }) - - captureServerEvent( - session.user.id, - 'scheduled_task_created', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json( - { schedule: { id, status: 'active', cronExpression, nextRunAt } }, + { + schedule: { + id: result.schedule.id, + status: result.schedule.status, + cronExpression: result.schedule.cronExpression, + nextRunAt: result.schedule.nextRunAt, + }, + }, { status: 201 } ) } catch (error) { 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..571f4764078 100644 --- a/apps/sim/app/api/skills/import/route.ts +++ b/apps/sim/app/api/skills/import/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { importSkillContract } 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' @@ -9,10 +11,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. * @@ -53,14 +51,15 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await req.json() - const { url } = ImportSchema.parse(body) + const validation = await parseRequest(importSkillContract, req, {}) + if (!validation.success) return validation.response + const { url } = validation.data.body let rawUrl: string try { rawUrl = toRawGitHubUrl(url) } catch (err) { - const message = err instanceof Error ? err.message : 'Invalid URL' + const message = getErrorMessage(err, 'Invalid URL') return NextResponse.json({ error: message }, { status: 400 }) } @@ -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..943d4c982f5 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, + upsertSkillsContract, +} from '@/lib/api/contracts' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -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) { @@ -75,24 +67,31 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } const userId = authResult.userId - 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 parsed = await parseRequest( + upsertSkillsContract, + req, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid skills data`, { errors: error.issues }) + return validationErrorResponse(error, 'Invalid request data') + }, } + ) + if (!parsed.success) return parsed.response - const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!userPermission || (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 { skills, workspaceId, source } = parsed.data.body + + const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!userPermission || (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 }) + } + try { const resultSkills = await upsertSkills({ skills, workspaceId, @@ -122,20 +121,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } return NextResponse.json({ success: true, data: resultSkills }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid skills data`, { - errors: validationError.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) - } - if (validationError instanceof Error && validationError.message.includes('already exists')) { - return NextResponse.json({ error: validationError.message }, { status: 409 }) + } catch (upsertError) { + if (upsertError instanceof Error && upsertError.message.includes('already exists')) { + return NextResponse.json({ error: upsertError.message }, { status: 409 }) } - throw validationError + throw upsertError } } catch (error) { logger.error(`[${requestId}] Error updating skills`, error) @@ -146,12 +136,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 +145,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..70da8c28870 100644 --- a/apps/sim/app/api/speech/token/route.ts +++ b/apps/sim/app/api/speech/token/route.ts @@ -1,8 +1,10 @@ import { db } from '@sim/db' import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { speechTokenBodySchema } from '@/lib/api/contracts/media/speech' import { getSession } from '@/lib/auth' import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { recordUsage } from '@/lib/billing/core/usage-log' @@ -73,8 +75,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 @@ -168,7 +172,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ token: data.token }) } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to generate speech token' + const message = getErrorMessage(error, 'Failed to generate speech token') logger.error('Speech token error:', error) return NextResponse.json({ error: message }, { status: 500 }) } diff --git a/apps/sim/app/api/stars/route.ts b/apps/sim/app/api/stars/route.ts index 9d2c9bb15de..7ecdf330b2e 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 { validationErrorResponse } from '@/lib/api/server' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,8 +13,13 @@ 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 = noInputSchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!queryValidation.success) return validationErrorResponse(queryValidation.error) + 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..58a47741ed7 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 { validationErrorResponse } 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,13 @@ 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 = noInputSchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!queryValidation.success) return validationErrorResponse(queryValidation.error) + const now = Date.now() if (cachedResponse && now - cachedResponse.timestamp < CACHE_TTL) { diff --git a/apps/sim/app/api/status/types.ts b/apps/sim/app/api/status/types.ts index 09b6b0ac042..791a399e4a3 100644 --- a/apps/sim/app/api/status/types.ts +++ b/apps/sim/app/api/status/types.ts @@ -1,11 +1,11 @@ -export interface IncidentIOComponent { +interface IncidentIOComponent { id: string name: string group_name?: string current_status: 'operational' | 'degraded_performance' | 'partial_outage' | 'full_outage' } -export interface IncidentIOIncident { +interface IncidentIOIncident { id: string name: string status: 'investigating' | 'identified' | 'monitoring' @@ -16,7 +16,7 @@ export interface IncidentIOIncident { affected_components: IncidentIOComponent[] } -export interface IncidentIOMaintenance { +interface IncidentIOMaintenance { id: string name: string status: 'maintenance_scheduled' | 'maintenance_in_progress' diff --git a/apps/sim/app/api/superuser/import-workflow/route.ts b/apps/sim/app/api/superuser/import-workflow/route.ts index 72a9cf0af80..7b09bef0e78 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 { importWorkflowAsSuperuserContract } from '@/lib/api/contracts/workflows' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { verifyEffectiveSuperUser } from '@/lib/templates/permissions' @@ -17,11 +19,6 @@ import { deduplicateWorkflowName } from '@/lib/workflows/utils' const logger = createLogger('SuperUserImportWorkflow') -interface ImportWorkflowRequest { - workflowId: string - targetWorkspaceId: string -} - /** * POST /api/superuser/import-workflow * @@ -51,16 +48,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Forbidden: Superuser access required' }, { status: 403 }) } - const body: ImportWorkflowRequest = await request.json() - const { workflowId, targetWorkspaceId } = body - - if (!workflowId) { - return NextResponse.json({ error: 'workflowId is required' }, { status: 400 }) - } + const parsed = await parseRequest( + importWorkflowAsSuperuserContract, + request, + {}, + { + validationErrorResponse: (error) => { + const missingPath = error.issues[0]?.path[0] + if (missingPath === 'workflowId') { + return NextResponse.json({ error: 'workflowId is required' }, { status: 400 }) + } + if (missingPath === 'targetWorkspaceId') { + return NextResponse.json({ error: 'targetWorkspaceId is required' }, { status: 400 }) + } + return NextResponse.json({ error: 'workflowId is required' }, { status: 400 }) + }, + } + ) + if (!parsed.success) return parsed.response - if (!targetWorkspaceId) { - return NextResponse.json({ error: 'targetWorkspaceId is required' }, { status: 400 }) - } + const { workflowId, targetWorkspaceId } = parsed.data.body // Verify target workspace exists const [targetWorkspace] = await db diff --git a/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts new file mode 100644 index 00000000000..be89633d7e9 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts @@ -0,0 +1,57 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { cancelTableRunsContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableCancelRunsAPI') + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/table/[tableId]/cancel-runs + * + * Cancels in-flight and pending workflow-column runs for this table. Scopes: + * `all` (every cell) or `row` (every cell for `rowId`). + */ +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(cancelTableRunsContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { workspaceId, scope, rowId } = parsed.data.body + + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + const { table } = result + + if (table.workspaceId !== workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const cancelled = await cancelWorkflowGroupRuns(tableId, scope === 'row' ? rowId : undefined) + logger.info( + `[${requestId}] cancel-runs: tableId=${tableId} scope=${scope}${ + rowId ? ` rowId=${rowId}` : '' + } cancelled=${cancelled}` + ) + + return NextResponse.json({ success: true, data: { cancelled } }) + } catch (error) { + logger.error(`[${requestId}] cancel-runs failed:`, error) + return NextResponse.json({ error: 'Failed to cancel runs' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index 9b6864c33d4..6b87c84f644 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -1,6 +1,12 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + addTableColumnContract, + deleteTableColumnContract, + updateTableColumnContract, +} from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +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,14 +17,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' const logger = createLogger('TableColumnsAPI') @@ -27,162 +26,154 @@ interface ColumnsRouteParams { } /** POST /api/table/[tableId]/columns - Adds a column to the table schema. */ -export const POST = withRouteHandler( - async (request: NextRequest, { params }: ColumnsRouteParams) => { - const requestId = generateRequestId() - const { tableId } = await params - - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized column creation attempt`) - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } +export const POST = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => { + const requestId = generateRequestId() + const { tableId } = await context.params + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized column creation attempt`) + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } - const body = await request.json() - const validated = CreateColumnSchema.parse(body) + const validation = await parseRequest(addTableColumnContract, request, context) + if (!validation.success) return validation.response + const validated = validation.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) - const { table } = result + const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + if (table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - const updatedTable = await addTableColumn(tableId, validated.column, requestId) + const updatedTable = await addTableColumn(tableId, validated.column, requestId) + + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + }, + }) + } catch (error) { + if (isZodError(error)) { + return validationErrorResponse(error, 'Invalid request data') + } - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { 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 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 ${tableId}:`, error) + return NextResponse.json({ error: 'Failed to add column' }, { status: 500 }) } -) +}) /** PATCH /api/table/[tableId]/columns - Updates a column (rename, type change, constraints). */ -export const PATCH = withRouteHandler( - async (request: NextRequest, { params }: ColumnsRouteParams) => { - const requestId = generateRequestId() - const { tableId } = await params - - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized column update attempt`) - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } +export const PATCH = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => { + const requestId = generateRequestId() + const { tableId } = await context.params + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized column update attempt`) + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } - const body = await request.json() - const validated = UpdateColumnSchema.parse(body) + const validation = await parseRequest(updateTableColumnContract, request, context) + if (!validation.success) return validation.response + const validated = validation.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) - const { table } = result + const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + if (table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - const { updates } = validated - let updatedTable = null + const { updates } = validated + let updatedTable = null - if (updates.name) { - updatedTable = await renameColumn( - { tableId, oldName: validated.columnName, newName: updates.name }, - requestId - ) - } + if (updates.name) { + updatedTable = await renameColumn( + { tableId, oldName: validated.columnName, newName: updates.name }, + requestId + ) + } - if (updates.type) { - updatedTable = await updateColumnType( - { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, - requestId - ) - } + if (updates.type) { + updatedTable = await updateColumnType( + { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, + requestId + ) + } - if (updates.required !== undefined || updates.unique !== undefined) { - updatedTable = await updateColumnConstraints( - { - tableId, - columnName: updates.name ?? validated.columnName, - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - }, - requestId - ) - } + if (updates.required !== undefined || updates.unique !== undefined) { + updatedTable = await updateColumnConstraints( + { + tableId, + columnName: updates.name ?? validated.columnName, + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + }, + requestId + ) + } - if (!updatedTable) { - return NextResponse.json({ error: 'No updates specified' }, { status: 400 }) - } + 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: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + }, + }) + } catch (error) { + if (isZodError(error)) { + return validationErrorResponse(error, 'Invalid request data') + } - 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 (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 }) } - - 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 ${tableId}:`, error) + return NextResponse.json({ error: 'Failed to update column' }, { status: 500 }) } -) +}) /** DELETE /api/table/[tableId]/columns - Deletes 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 + const { tableId } = await context.params try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -191,8 +182,9 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - const body = await request.json() - const validated = DeleteColumnSchema.parse(body) + const validation = await parseRequest(deleteTableColumnContract, request, context) + if (!validation.success) return validation.response + const validated = validation.data.body const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) @@ -215,11 +207,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]/columns/run/route.ts b/apps/sim/app/api/table/[tableId]/columns/run/route.ts new file mode 100644 index 00000000000..2b96981d115 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/columns/run/route.ts @@ -0,0 +1,49 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { runColumnContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { runWorkflowColumn } from '@/lib/table/workflow-columns' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableRunColumnAPI') + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** POST /api/table/[tableId]/columns/run */ +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(runColumnContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { workspaceId, groupIds, runMode, rowIds } = parsed.data.body + const access = await checkAccess(tableId, auth.userId, 'write') + if (!access.ok) return accessError(access, requestId, tableId) + + const { dispatchId } = await runWorkflowColumn({ + tableId, + workspaceId, + groupIds, + mode: runMode, + rowIds, + requestId, + }) + + return NextResponse.json({ success: true, data: { dispatchId } }) + } catch (error) { + if (error instanceof Error && error.message === 'Invalid workspace ID') { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + logger.error(`run-column failed:`, error) + return NextResponse.json({ error: 'Failed to run columns' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/table/[tableId]/dispatches/route.ts b/apps/sim/app/api/table/[tableId]/dispatches/route.ts new file mode 100644 index 00000000000..7682ba82994 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/dispatches/route.ts @@ -0,0 +1,63 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { type ActiveDispatch, listActiveDispatchesContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { countActiveRunCells, listActiveDispatches } from '@/lib/table/dispatcher' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableDispatchesAPI') + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** + * GET /api/table/[tableId]/dispatches + * + * Returns active (`pending` / `dispatching`) dispatches for the table. Drives + * the client's "about to run" overlay so refresh during a long Run-all keeps + * the queued indicators on rows the dispatcher hasn't reached yet. + */ +export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(listActiveDispatchesContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + + const result = await checkAccess(tableId, authResult.userId, 'read') + if (!result.ok) return accessError(result, requestId, tableId) + + const rows = await listActiveDispatches(tableId) + const running = await countActiveRunCells(tableId, rows) + const dispatches: ActiveDispatch[] = rows.map((r) => ({ + id: r.id, + status: r.status as 'pending' | 'dispatching', + mode: r.mode, + isManualRun: r.isManualRun, + cursor: r.cursor, + scope: r.scope, + })) + + return NextResponse.json({ + success: true, + data: { + dispatches, + runningCellCount: running.total, + runningByRowId: running.byRowId, + }, + }) + } catch (error) { + logger.error(`[${requestId}] list-dispatches failed:`, error) + return NextResponse.json({ error: 'Failed to list active dispatches' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/table/[tableId]/events/stream/route.ts b/apps/sim/app/api/table/[tableId]/events/stream/route.ts new file mode 100644 index 00000000000..d938c80c3ec --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/events/stream/route.ts @@ -0,0 +1,161 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { type NextRequest, NextResponse } from 'next/server' +import { tableEventStreamContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { SSE_HEADERS } from '@/lib/core/utils/sse' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { readTableEventsSince, type TableEventEntry } from '@/lib/table/events' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableEventStreamAPI') + +const POLL_INTERVAL_MS = 500 +const HEARTBEAT_INTERVAL_MS = 15_000 +const MAX_STREAM_DURATION_MS = 4 * 60 * 60 * 1000 // 4 hours; client reconnects past this + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface RouteContext { + params: Promise<{ tableId: string }> +} + +/** GET /api/table/[tableId]/events/stream?from= + * + * SSE stream of cell-state transitions. Replay-on-reconnect via `from`. + * Pruning (buffer cap exceeded or TTL expired) sends a `pruned` event and + * closes; the client responds with a full row-query refetch and reconnects + * from the new earliest. */ +export const GET = withRouteHandler(async (req: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + const parsed = await parseRequest(tableEventStreamContract, req, context) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { from: fromEventId } = parsed.data.query + + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const access = await checkAccess(tableId, auth.userId, 'read') + if (!access.ok) return accessError(access, requestId, tableId) + + logger.info(`[${requestId}] Table event stream opened`, { tableId, fromEventId }) + + const encoder = new TextEncoder() + let closed = false + + const stream = new ReadableStream({ + async start(controller) { + let lastEventId = fromEventId + const deadline = Date.now() + MAX_STREAM_DURATION_MS + let nextHeartbeatAt = Date.now() + HEARTBEAT_INTERVAL_MS + + const enqueue = (text: string) => { + if (closed) return + try { + controller.enqueue(encoder.encode(text)) + } catch { + closed = true + } + } + + const sendEvents = (events: TableEventEntry[]) => { + for (const entry of events) { + if (closed) return + enqueue(`data: ${JSON.stringify(entry)}\n\n`) + lastEventId = entry.eventId + } + } + + const sendPrunedAndClose = (earliestEventId: number | undefined) => { + enqueue( + `event: pruned\ndata: ${JSON.stringify({ earliestEventId: earliestEventId ?? null })}\n\n` + ) + if (!closed) { + closed = true + try { + controller.close() + } catch {} + } + } + + const sendHeartbeat = () => { + // SSE comment line — keeps proxies (ALB default 60s idle) from closing + // the connection during quiet periods. + enqueue(`: ping ${Date.now()}\n\n`) + } + + try { + // Initial replay from buffer. + const initial = await readTableEventsSince(tableId, lastEventId) + if (initial.status === 'pruned') { + sendPrunedAndClose(initial.earliestEventId) + return + } + if (initial.status === 'unavailable') { + throw new Error(`Table event buffer unavailable: ${initial.error}`) + } + sendEvents(initial.events) + + // Stream loop — poll the buffer and forward new events. Workflow + // execution stream uses the same shape; pub/sub wakeups are an + // optimization we can add later if 500ms polling becomes a problem. + while (!closed && Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS) + if (closed) return + + const result = await readTableEventsSince(tableId, lastEventId) + if (result.status === 'pruned') { + sendPrunedAndClose(result.earliestEventId) + return + } + if (result.status === 'unavailable') { + throw new Error(`Table event buffer unavailable: ${result.error}`) + } + if (result.events.length > 0) { + sendEvents(result.events) + } + + if (Date.now() >= nextHeartbeatAt) { + sendHeartbeat() + nextHeartbeatAt = Date.now() + HEARTBEAT_INTERVAL_MS + } + } + + // Reached the defensive duration ceiling — close cleanly so the client + // reconnects with the latest lastEventId. + if (!closed) { + enqueue(`event: rotate\ndata: {}\n\n`) + closed = true + try { + controller.close() + } catch {} + } + } catch (error) { + logger.error(`[${requestId}] Table event stream error`, { + tableId, + error: toError(error).message, + }) + if (!closed) { + try { + controller.error(error) + } catch {} + } + } + }, + cancel() { + closed = true + logger.info(`[${requestId}] Client disconnected from table event stream`, { tableId }) + }, + }) + + return new NextResponse(stream, { + headers: { ...SSE_HEADERS, 'X-Table-Id': tableId }, + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts new file mode 100644 index 00000000000..8f9fa34b807 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/export/route.ts @@ -0,0 +1,130 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { tableExportFormatSchema, tableIdParamsSchema } from '@/lib/api/contracts/tables' +import { getValidationErrorMessage } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { queryRows } from '@/lib/table/service' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableExport') + +const EXPORT_BATCH_SIZE = 1000 + +type ExportFormat = 'csv' | 'json' + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** GET /api/table/[tableId]/export - Streams the full table contents as CSV or JSON. */ +export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + const { tableId } = tableIdParamsSchema.parse(await params) + + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const formatValidation = tableExportFormatSchema.safeParse( + searchParams.get('format') ?? undefined + ) + if (!formatValidation.success) { + return NextResponse.json( + { error: getValidationErrorMessage(formatValidation.error) }, + { status: 400 } + ) + } + const format: ExportFormat = formatValidation.data + + const access = await checkAccess(tableId, auth.userId, 'read') + if (!access.ok) return accessError(access, requestId, tableId) + const { table } = access + + const columns = table.schema.columns + const safeName = sanitizeFilename(table.name) + const filename = `${safeName}.${format}` + + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder() + try { + if (format === 'csv') { + controller.enqueue(encoder.encode(`${toCsvRow(columns.map((c) => c.name))}\n`)) + } else { + controller.enqueue(encoder.encode('[')) + } + + let offset = 0 + let firstJsonRow = true + while (true) { + const result = await queryRows( + table, + { limit: EXPORT_BATCH_SIZE, offset, includeTotal: false }, + requestId + ) + + for (const row of result.rows) { + if (format === 'csv') { + const values = columns.map((c) => formatCsvValue(row.data[c.name])) + controller.enqueue(encoder.encode(`${toCsvRow(values)}\n`)) + } else { + const prefix = firstJsonRow ? '' : ',' + firstJsonRow = false + controller.enqueue(encoder.encode(prefix + JSON.stringify({ ...row.data }))) + } + } + + if (result.rows.length < EXPORT_BATCH_SIZE) break + offset += result.rows.length + } + + if (format === 'json') controller.enqueue(encoder.encode(']')) + controller.close() + + logger.info(`[${requestId}] Exported table ${tableId}`, { + format, + rowCount: table.rowCount, + }) + } catch (err) { + logger.error(`[${requestId}] Export failed for table ${tableId}`, err) + controller.error(err) + } + }, + }) + + return new NextResponse(stream, { + status: 200, + headers: { + 'Content-Type': format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-store', + }, + }) +}) + +function sanitizeFilename(name: string): string { + const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') + return cleaned || 'table' +} + +function formatCsvValue(value: unknown): string { + if (value === null || value === undefined) return '' + if (value instanceof Date) return value.toISOString() + if (typeof value === 'object') return JSON.stringify(value) + return String(value) +} + +function toCsvRow(values: string[]): string { + return values.map(escapeCsvField).join(',') +} + +function escapeCsvField(field: string): string { + if (/[",\n\r]/.test(field)) { + return `"${field.replace(/"/g, '""')}"` + } + return field +} diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts new file mode 100644 index 00000000000..bf74653212a --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -0,0 +1,163 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { + addWorkflowGroupContract, + deleteWorkflowGroupContract, + updateWorkflowGroupContract, +} from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { addWorkflowGroup, deleteWorkflowGroup, updateWorkflowGroup } from '@/lib/table/service' +import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' + +const logger = createLogger('TableWorkflowGroupsAPI') + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** + * Maps known service-layer error messages onto HTTP responses; falls through + * to a 500 with a generic message for anything unrecognized. The three + * group-route handlers all surface the same error shapes from + * `addWorkflowGroup` / `updateWorkflowGroup` / `deleteWorkflowGroup`, so they + * share this mapper instead of repeating the if-chain three times. + */ +function mapWorkflowGroupError(error: unknown, fallbackMessage: string): NextResponse { + if (error instanceof Error) { + const msg = error.message + if (msg === 'Table not found' || msg.includes('not found')) { + return NextResponse.json({ error: msg }, { status: 404 }) + } + if ( + msg.includes('Schema validation') || + msg.includes('Missing column definition') || + msg.includes('already exists') || + msg.includes('exceed') + ) { + return NextResponse.json({ error: msg }, { status: 400 }) + } + } + logger.error(fallbackMessage, error) + return NextResponse.json({ error: fallbackMessage }, { status: 500 }) +} + +/** POST /api/table/[tableId]/groups — create a workflow group + its output columns. */ +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(addWorkflowGroupContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const validated = parsed.data.body + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + if (result.table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + const updatedTable = await addWorkflowGroup( + { + tableId, + group: validated.group, + outputColumns: validated.outputColumns, + autoRun: validated.autoRun, + }, + requestId + ) + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + workflowGroups: updatedTable.schema.workflowGroups ?? [], + }, + }) + } catch (error) { + return mapWorkflowGroupError(error, 'Failed to add workflow group') + } +}) + +/** PATCH /api/table/[tableId]/groups — update a workflow group (deps / outputs). */ +export const PATCH = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(updateWorkflowGroupContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const validated = parsed.data.body + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + if (result.table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + const updatedTable = await updateWorkflowGroup( + { + tableId, + groupId: validated.groupId, + ...(validated.workflowId !== undefined ? { workflowId: validated.workflowId } : {}), + ...(validated.name !== undefined ? { name: validated.name } : {}), + ...(validated.dependencies !== undefined ? { dependencies: validated.dependencies } : {}), + ...(validated.outputs !== undefined ? { outputs: validated.outputs } : {}), + ...(validated.newOutputColumns !== undefined + ? { newOutputColumns: validated.newOutputColumns } + : {}), + ...(validated.mappingUpdates !== undefined + ? { mappingUpdates: validated.mappingUpdates } + : {}), + ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), + }, + requestId + ) + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + workflowGroups: updatedTable.schema.workflowGroups ?? [], + }, + }) + } catch (error) { + return mapWorkflowGroupError(error, 'Failed to update workflow group') + } +}) + +/** DELETE /api/table/[tableId]/groups — remove a workflow group + its columns. */ +export const DELETE = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(deleteWorkflowGroupContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const validated = parsed.data.body + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + if (result.table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + const updatedTable = await deleteWorkflowGroup( + { tableId, groupId: validated.groupId }, + requestId + ) + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + workflowGroups: updatedTable.schema.workflowGroups ?? [], + }, + }) + } catch (error) { + return mapWorkflowGroupError(error, 'Failed to delete workflow group') + } +}) diff --git a/apps/sim/app/api/table/[tableId]/import-csv/route.test.ts b/apps/sim/app/api/table/[tableId]/import-csv/route.test.ts deleted file mode 100644 index fc9a24c3072..00000000000 --- a/apps/sim/app/api/table/[tableId]/import-csv/route.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** - * @vitest-environment node - */ -import { hybridAuthMockFns } from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { TableDefinition } from '@/lib/table' - -const { mockCheckAccess, mockBatchInsertRows, mockReplaceTableRows } = vi.hoisted(() => ({ - mockCheckAccess: vi.fn(), - mockBatchInsertRows: vi.fn(), - mockReplaceTableRows: vi.fn(), -})) - -vi.mock('@sim/utils/id', () => ({ - generateId: vi.fn().mockReturnValue('deadbeefcafef00d'), - generateShortId: vi.fn().mockReturnValue('short-id'), -})) - -vi.mock('@/app/api/table/utils', async () => { - const { NextResponse } = await import('next/server') - return { - checkAccess: mockCheckAccess, - accessError: (result: { status: number }) => { - const message = result.status === 404 ? 'Table not found' : 'Access denied' - return NextResponse.json({ error: message }, { status: result.status }) - }, - } -}) - -/** - * The route imports `batchInsertRows` and `replaceTableRows` from the barrel, - * which forwards them from `./service`. Mocking the service module replaces - * both without having to touch the other real helpers (`parseCsvBuffer`, - * `coerceRowsForTable`, etc.) exported through the barrel. - */ -vi.mock('@/lib/table/service', () => ({ - batchInsertRows: mockBatchInsertRows, - replaceTableRows: mockReplaceTableRows, -})) - -import { POST } from '@/app/api/table/[tableId]/import-csv/route' - -function createCsvFile(contents: string, name = 'data.csv', type = 'text/csv'): File { - return new File([contents], name, { type }) -} - -function createFormData( - file: File, - options?: { - workspaceId?: string | null - mode?: string | null - mapping?: unknown - } -): FormData { - const form = new FormData() - form.append('file', file) - if (options?.workspaceId !== null) { - form.append('workspaceId', options?.workspaceId ?? 'workspace-1') - } - if (options?.mode !== null) { - form.append('mode', options?.mode ?? 'append') - } - if (options?.mapping !== undefined) { - form.append( - 'mapping', - typeof options.mapping === 'string' ? options.mapping : JSON.stringify(options.mapping) - ) - } - return form -} - -function buildTable(overrides: Partial = {}): TableDefinition { - return { - id: 'tbl_1', - name: 'People', - description: null, - schema: { - columns: [ - { name: 'name', type: 'string', required: true }, - { name: 'age', type: 'number' }, - ], - }, - metadata: null, - rowCount: 0, - maxRows: 100, - workspaceId: 'workspace-1', - createdBy: 'user-1', - archivedAt: null, - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - ...overrides, - } -} - -async function callPost(form: FormData, { tableId }: { tableId: string } = { tableId: 'tbl_1' }) { - const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/import-csv`, { - method: 'POST', - body: form, - }) - return POST(req, { params: Promise.resolve({ tableId }) }) -} - -describe('POST /api/table/[tableId]/import-csv', () => { - beforeEach(() => { - vi.clearAllMocks() - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - authType: 'session', - }) - mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) - mockBatchInsertRows.mockImplementation(async (data: { rows: unknown[] }) => - data.rows.map((_, i) => ({ id: `row_${i}` })) - ) - mockReplaceTableRows.mockResolvedValue({ deletedCount: 0, insertedCount: 0 }) - }) - - it('returns 401 when the user is not authenticated', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ - success: false, - error: 'Authentication required', - }) - const response = await callPost(createFormData(createCsvFile('name,age\nAlice,30'))) - expect(response.status).toBe(401) - }) - - it('returns 400 when the mode is invalid', async () => { - const response = await callPost( - createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'bogus' }) - ) - expect(response.status).toBe(400) - const data = await response.json() - expect(data.error).toMatch(/Invalid mode/) - }) - - it('returns 403 when the user lacks workspace write access', async () => { - mockCheckAccess.mockResolvedValueOnce({ ok: false, status: 403 }) - const response = await callPost(createFormData(createCsvFile('name,age\nAlice,30'))) - expect(response.status).toBe(403) - }) - - it('returns 400 when the target table is archived', async () => { - mockCheckAccess.mockResolvedValueOnce({ - ok: true, - table: buildTable({ archivedAt: new Date('2024-01-02') }), - }) - const response = await callPost(createFormData(createCsvFile('name,age\nAlice,30'))) - expect(response.status).toBe(400) - const data = await response.json() - expect(data.error).toMatch(/archived/i) - }) - - it('returns 400 when the CSV is missing a required column', async () => { - const response = await callPost(createFormData(createCsvFile('age\n30'))) - expect(response.status).toBe(400) - const data = await response.json() - expect(data.error).toMatch(/missing required columns/i) - expect(data.details?.missingRequired).toEqual(['name']) - expect(mockBatchInsertRows).not.toHaveBeenCalled() - }) - - it('appends rows via batchInsertRows', async () => { - const response = await callPost( - createFormData(createCsvFile('name,age\nAlice,30\nBob,40'), { mode: 'append' }) - ) - expect(response.status).toBe(200) - const data = await response.json() - expect(data.data.mode).toBe('append') - expect(data.data.insertedCount).toBe(2) - expect(mockBatchInsertRows).toHaveBeenCalledTimes(1) - const callArgs = mockBatchInsertRows.mock.calls[0][0] as { rows: unknown[] } - expect(callArgs.rows).toEqual([ - { name: 'Alice', age: 30 }, - { name: 'Bob', age: 40 }, - ]) - expect(mockReplaceTableRows).not.toHaveBeenCalled() - }) - - it('rejects append when it would exceed maxRows', async () => { - mockCheckAccess.mockResolvedValueOnce({ - ok: true, - table: buildTable({ rowCount: 99, maxRows: 100 }), - }) - const response = await callPost( - createFormData(createCsvFile('name,age\nAlice,30\nBob,40'), { mode: 'append' }) - ) - expect(response.status).toBe(400) - const data = await response.json() - expect(data.error).toMatch(/exceed table row limit/) - expect(mockBatchInsertRows).not.toHaveBeenCalled() - }) - - it('replaces rows via replaceTableRows', async () => { - mockReplaceTableRows.mockResolvedValueOnce({ deletedCount: 5, insertedCount: 2 }) - const response = await callPost( - createFormData(createCsvFile('name,age\nAlice,30\nBob,40'), { mode: 'replace' }) - ) - expect(response.status).toBe(200) - const data = await response.json() - expect(data.data.mode).toBe('replace') - expect(data.data.deletedCount).toBe(5) - expect(data.data.insertedCount).toBe(2) - expect(mockReplaceTableRows).toHaveBeenCalledTimes(1) - expect(mockBatchInsertRows).not.toHaveBeenCalled() - }) - - it('uses an explicit mapping when provided', async () => { - const response = await callPost( - createFormData(createCsvFile('First Name,Years\nAlice,30\nBob,40', 'people.csv'), { - mode: 'append', - mapping: { 'First Name': 'name', Years: 'age' }, - }) - ) - expect(response.status).toBe(200) - const data = await response.json() - expect(data.data.mappedColumns).toEqual(['First Name', 'Years']) - const callArgs = mockBatchInsertRows.mock.calls[0][0] as { rows: unknown[] } - expect(callArgs.rows).toEqual([ - { name: 'Alice', age: 30 }, - { name: 'Bob', age: 40 }, - ]) - }) - - it('returns 400 when the mapping targets a non-existent column', async () => { - const response = await callPost( - createFormData(createCsvFile('a\nAlice'), { - mode: 'append', - mapping: { a: 'nonexistent' }, - }) - ) - expect(response.status).toBe(400) - const data = await response.json() - expect(data.error).toMatch(/do not exist on the table/) - }) - - it('returns 400 when a mapping value is not a string or null', async () => { - const response = await callPost( - createFormData(createCsvFile('name,age\nAlice,30'), { - mode: 'append', - mapping: { name: 42 }, - }) - ) - expect(response.status).toBe(400) - const data = await response.json() - expect(data.error).toMatch(/Mapping values must be/) - }) - - it('surfaces unique violations from batchInsertRows as 400', async () => { - mockBatchInsertRows.mockRejectedValueOnce( - new Error('Row 1: Column "name" must be unique. Value "Alice" already exists in row row_xxx') - ) - const response = await callPost( - createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' }) - ) - expect(response.status).toBe(400) - const data = await response.json() - expect(data.error).toMatch(/must be unique/) - expect(data.data?.insertedCount).toBe(0) - }) - - it('accepts TSV files', async () => { - const response = await callPost( - createFormData( - createCsvFile('name\tage\nAlice\t30', 'data.tsv', 'text/tab-separated-values'), - { mode: 'append' } - ) - ) - expect(response.status).toBe(200) - expect(mockBatchInsertRows).toHaveBeenCalledTimes(1) - }) - - it('returns 400 for unsupported file extensions', async () => { - const response = await callPost( - createFormData(createCsvFile('name,age', 'data.json', 'application/json')) - ) - expect(response.status).toBe(400) - const data = await response.json() - expect(data.error).toMatch(/CSV and TSV/) - }) -}) diff --git a/apps/sim/app/api/table/[tableId]/import-csv/route.ts b/apps/sim/app/api/table/[tableId]/import-csv/route.ts deleted file mode 100644 index 771f145e8b4..00000000000 --- a/apps/sim/app/api/table/[tableId]/import-csv/route.ts +++ /dev/null @@ -1,269 +0,0 @@ -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 { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - batchInsertRows, - buildAutoMapping, - CSV_MAX_BATCH_SIZE, - CSV_MAX_FILE_SIZE_BYTES, - type CsvHeaderMapping, - CsvImportValidationError, - coerceRowsForTable, - parseCsvBuffer, - replaceTableRows, - validateMapping, -} from '@/lib/table' -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 }> -} - -export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - const { tableId } = await params - - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - 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 }) - } - - if (file.size > CSV_MAX_FILE_SIZE_BYTES) { - return NextResponse.json( - { - error: `File exceeds maximum allowed size of ${CSV_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB`, - }, - { status: 400 } - ) - } - - if (!workspaceId) { - return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) - } - - if (!IMPORT_MODES.has(rawMode)) { - return NextResponse.json( - { error: `Invalid mode "${rawMode}". Must be "append" or "replace".` }, - { status: 400 } - ) - } - const mode = rawMode as 'append' | 'replace' - - 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 accessResult = await checkAccess(tableId, authResult.userId, 'write') - if (!accessResult.ok) return accessError(accessResult, requestId, tableId) - - const { table } = accessResult - - if (table.workspaceId !== workspaceId) { - logger.warn( - `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${workspaceId}, Actual: ${table.workspaceId}` - ) - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - - if (table.archivedAt) { - return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 }) - } - - 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 buffer = Buffer.from(await file.arrayBuffer()) - const delimiter = ext === 'tsv' ? '\t' : ',' - const { headers, rows } = await parseCsvBuffer(buffer, delimiter) - - const effectiveMapping = mapping ?? buildAutoMapping(headers, table.schema) - - let validation: ReturnType - try { - validation = validateMapping({ - csvHeaders: headers, - mapping: effectiveMapping, - tableSchema: table.schema, - }) - } catch (err) { - if (err instanceof CsvImportValidationError) { - return NextResponse.json({ error: err.message, details: err.details }, { status: 400 }) - } - throw err - } - - if (validation.mappedHeaders.length === 0) { - return NextResponse.json( - { - error: `No CSV headers map to columns on the table. CSV headers: ${headers.join(', ')}. Table columns: ${table.schema.columns.map((c) => c.name).join(', ')}`, - }, - { status: 400 } - ) - } - - const coerced = coerceRowsForTable(rows, table.schema, validation.effectiveMap) - - if (mode === 'append') { - if (table.rowCount + coerced.length > table.maxRows) { - const deficit = table.rowCount + coerced.length - table.maxRows - return NextResponse.json( - { - error: `Append would exceed table row limit (${table.maxRows}). Currently ${table.rowCount} rows, ${coerced.length} new rows, ${deficit} over.`, - }, - { status: 400 } - ) - } - - let inserted = 0 - try { - for (let i = 0; i < coerced.length; i += CSV_MAX_BATCH_SIZE) { - const batch = coerced.slice(i, i + CSV_MAX_BATCH_SIZE) - const batchRequestId = generateId().slice(0, 8) - const result = await batchInsertRows( - { - tableId: table.id, - rows: batch, - workspaceId, - userId: authResult.userId, - }, - table, - batchRequestId - ) - inserted += result.length - } - } catch (err) { - const message = toError(err).message - logger.warn(`[${requestId}] Append failed mid-import for table ${tableId}`, { - inserted, - total: coerced.length, - error: message, - }) - const isClientError = - message.includes('row limit') || - message.includes('Insufficient capacity') || - message.includes('Schema validation') || - message.includes('must be unique') || - message.includes('Row size exceeds') || - /^Row \d+:/.test(message) - return NextResponse.json( - { - error: isClientError ? message : 'Failed to import CSV', - data: { insertedCount: inserted }, - }, - { status: isClientError ? 400 : 500 } - ) - } - - logger.info(`[${requestId}] Append CSV imported`, { - tableId: table.id, - fileName: file.name, - mode, - inserted, - mappedColumns: validation.mappedHeaders.length, - skippedHeaders: validation.skippedHeaders.length, - }) - - return NextResponse.json({ - success: true, - data: { - tableId: table.id, - mode, - insertedCount: inserted, - mappedColumns: validation.mappedHeaders, - skippedHeaders: validation.skippedHeaders, - unmappedColumns: validation.unmappedColumns, - sourceFile: file.name, - }, - }) - } - - try { - const result = await replaceTableRows( - { tableId: table.id, rows: coerced, workspaceId, userId: authResult.userId }, - table, - requestId - ) - - logger.info(`[${requestId}] Replace CSV imported`, { - tableId: table.id, - fileName: file.name, - mode, - deleted: result.deletedCount, - inserted: result.insertedCount, - mappedColumns: validation.mappedHeaders.length, - }) - - return NextResponse.json({ - success: true, - data: { - tableId: table.id, - mode, - deletedCount: result.deletedCount, - insertedCount: result.insertedCount, - mappedColumns: validation.mappedHeaders, - skippedHeaders: validation.skippedHeaders, - unmappedColumns: validation.unmappedColumns, - sourceFile: file.name, - }, - }) - } catch (err) { - const message = toError(err).message - const isClientError = - message.includes('row limit') || - message.includes('Schema validation') || - message.includes('must be unique') || - message.includes('Row size exceeds') || - /^Row \d+:/.test(message) - if (isClientError) { - return NextResponse.json({ error: message }, { status: 400 }) - } - throw err - } - } catch (error) { - const message = toError(error).message - logger.error(`[${requestId}] CSV import into existing table failed:`, error) - - const isClientError = - message.includes('CSV file has no') || - message.includes('already exists') || - message.includes('Invalid column name') - - return NextResponse.json( - { error: isClientError ? message : 'Failed to import CSV' }, - { status: isClientError ? 400 : 500 } - ) - } -}) diff --git a/apps/sim/app/api/table/[tableId]/import/route.test.ts b/apps/sim/app/api/table/[tableId]/import/route.test.ts new file mode 100644 index 00000000000..b51b35ecece --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts @@ -0,0 +1,481 @@ +/** + * @vitest-environment node + */ +import { hybridAuthMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table' + +const { + mockCheckAccess, + mockBatchInsertRowsWithTx, + mockReplaceTableRowsWithTx, + mockAddTableColumnsWithTx, +} = vi.hoisted(() => ({ + mockCheckAccess: vi.fn(), + mockBatchInsertRowsWithTx: vi.fn(), + mockReplaceTableRowsWithTx: vi.fn(), + mockAddTableColumnsWithTx: vi.fn(), +})) + +vi.mock('@sim/utils/id', () => ({ + generateId: vi.fn().mockReturnValue('deadbeefcafef00d'), + generateShortId: vi.fn().mockReturnValue('short-id'), +})) + +vi.mock('@/app/api/table/utils', async () => { + const { NextResponse } = await import('next/server') + return { + checkAccess: mockCheckAccess, + accessError: (result: { status: number }) => { + const message = result.status === 404 ? 'Table not found' : 'Access denied' + return NextResponse.json({ error: message }, { status: result.status }) + }, + } +}) + +/** + * The route imports `batchInsertRows` and `replaceTableRows` from the barrel, + * which forwards them from `./service`. Mocking the service module replaces + * both without having to touch the other real helpers (`parseCsvBuffer`, + * `coerceRowsForTable`, etc.) exported through the barrel. + */ +vi.mock('@/lib/table/service', () => ({ + batchInsertRowsWithTx: mockBatchInsertRowsWithTx, + replaceTableRowsWithTx: mockReplaceTableRowsWithTx, + addTableColumnsWithTx: mockAddTableColumnsWithTx, +})) + +import { POST } from '@/app/api/table/[tableId]/import/route' + +function createCsvFile(contents: string, name = 'data.csv', type = 'text/csv'): File { + return new File([contents], name, { type }) +} + +function createFormData( + file: File, + options?: { + workspaceId?: string | null + mode?: string | null + mapping?: unknown + createColumns?: unknown + } +): FormData { + const form = new FormData() + form.append('file', file) + if (options?.workspaceId !== null) { + form.append('workspaceId', options?.workspaceId ?? 'workspace-1') + } + if (options?.mode !== null) { + form.append('mode', options?.mode ?? 'append') + } + if (options?.mapping !== undefined) { + form.append( + 'mapping', + typeof options.mapping === 'string' ? options.mapping : JSON.stringify(options.mapping) + ) + } + if (options?.createColumns !== undefined) { + form.append( + 'createColumns', + typeof options.createColumns === 'string' + ? options.createColumns + : JSON.stringify(options.createColumns) + ) + } + return form +} + +function buildTable(overrides: Partial = {}): TableDefinition { + return { + id: 'tbl_1', + name: 'People', + description: null, + schema: { + columns: [ + { name: 'name', type: 'string', required: true }, + { name: 'age', type: 'number' }, + ], + }, + metadata: null, + rowCount: 0, + maxRows: 100, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + ...overrides, + } +} + +async function callPost(form: FormData, { tableId }: { tableId: string } = { tableId: 'tbl_1' }) { + const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/import`, { + method: 'POST', + headers: { 'content-length': '1024' }, + body: form, + }) + return POST(req, { params: Promise.resolve({ tableId }) }) +} + +describe('POST /api/table/[tableId]/import', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) + mockBatchInsertRowsWithTx.mockImplementation(async (_trx, data: { rows: unknown[] }) => + data.rows.map((_, i) => ({ id: `row_${i}` })) + ) + mockReplaceTableRowsWithTx.mockResolvedValue({ deletedCount: 0, insertedCount: 0 }) + mockAddTableColumnsWithTx.mockImplementation( + async ( + _trx, + table: { schema: { columns: { name: string; type: string }[] } }, + columns: { name: string; type: string }[] + ) => ({ + ...table, + schema: { + columns: [ + ...table.schema.columns, + ...columns.map((c) => ({ name: c.name, type: c.type as 'string' })), + ], + }, + }) + ) + }) + + it('returns 401 when the user is not authenticated', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: false, + error: 'Authentication required', + }) + const response = await callPost(createFormData(createCsvFile('name,age\nAlice,30'))) + expect(response.status).toBe(401) + }) + + it('returns 400 when the mode is invalid', async () => { + const response = await callPost( + createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'bogus' }) + ) + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toMatch(/Invalid mode/) + }) + + it('returns 403 when the user lacks workspace write access', async () => { + mockCheckAccess.mockResolvedValueOnce({ ok: false, status: 403 }) + const response = await callPost(createFormData(createCsvFile('name,age\nAlice,30'))) + expect(response.status).toBe(403) + }) + + it('returns 400 when the target table is archived', async () => { + mockCheckAccess.mockResolvedValueOnce({ + ok: true, + table: buildTable({ archivedAt: new Date('2024-01-02') }), + }) + const response = await callPost(createFormData(createCsvFile('name,age\nAlice,30'))) + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toMatch(/archived/i) + }) + + it('returns 413 for oversized CSV files before reading their contents', async () => { + const file = createCsvFile('name,age\nAlice,30') + Object.defineProperty(file, 'size', { + value: 26 * 1024 * 1024, + }) + const arrayBufferSpy = vi.spyOn(file, 'arrayBuffer') + + const req = { + formData: async () => createFormData(file), + } as unknown as NextRequest + + const response = await POST(req, { params: Promise.resolve({ tableId: 'tbl_1' }) }) + expect(response.status).toBe(413) + const data = await response.json() + expect(data.error).toMatch(/CSV import file exceeds maximum size/) + expect(arrayBufferSpy).not.toHaveBeenCalled() + expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled() + expect(mockReplaceTableRowsWithTx).not.toHaveBeenCalled() + }) + + it('returns 400 when the CSV is missing a required column', async () => { + const response = await callPost(createFormData(createCsvFile('age\n30'))) + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toMatch(/missing required columns/i) + expect(data.details?.missingRequired).toEqual(['name']) + expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled() + }) + + it('appends rows via batchInsertRows', async () => { + const response = await callPost( + createFormData(createCsvFile('name,age\nAlice,30\nBob,40'), { mode: 'append' }) + ) + expect(response.status).toBe(200) + const data = await response.json() + expect(data.data.mode).toBe('append') + expect(data.data.insertedCount).toBe(2) + expect(mockBatchInsertRowsWithTx).toHaveBeenCalledTimes(1) + const callArgs = mockBatchInsertRowsWithTx.mock.calls[0][1] as { rows: unknown[] } + expect(callArgs.rows).toEqual([ + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 40 }, + ]) + expect(mockReplaceTableRowsWithTx).not.toHaveBeenCalled() + }) + + it('accepts chunked multipart imports without a content-length header', async () => { + const form = createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' }) + const req = new NextRequest('http://localhost:3000/api/table/tbl_1/import', { + method: 'POST', + body: form, + }) + + expect(req.headers.get('content-length')).toBeNull() + + const response = await POST(req, { params: Promise.resolve({ tableId: 'tbl_1' }) }) + + expect(response.status).toBe(200) + expect(mockBatchInsertRowsWithTx).toHaveBeenCalledTimes(1) + }) + + it('rejects append when it would exceed maxRows', async () => { + mockCheckAccess.mockResolvedValueOnce({ + ok: true, + table: buildTable({ rowCount: 99, maxRows: 100 }), + }) + const response = await callPost( + createFormData(createCsvFile('name,age\nAlice,30\nBob,40'), { mode: 'append' }) + ) + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toMatch(/exceed table row limit/) + expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled() + }) + + it('replaces rows via replaceTableRows', async () => { + mockReplaceTableRowsWithTx.mockResolvedValueOnce({ deletedCount: 5, insertedCount: 2 }) + const response = await callPost( + createFormData(createCsvFile('name,age\nAlice,30\nBob,40'), { mode: 'replace' }) + ) + expect(response.status).toBe(200) + const data = await response.json() + expect(data.data.mode).toBe('replace') + expect(data.data.deletedCount).toBe(5) + expect(data.data.insertedCount).toBe(2) + expect(mockReplaceTableRowsWithTx).toHaveBeenCalledTimes(1) + expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled() + }) + + it('uses an explicit mapping when provided', async () => { + const response = await callPost( + createFormData(createCsvFile('First Name,Years\nAlice,30\nBob,40', 'people.csv'), { + mode: 'append', + mapping: { 'First Name': 'name', Years: 'age' }, + }) + ) + expect(response.status).toBe(200) + const data = await response.json() + expect(data.data.mappedColumns).toEqual(['First Name', 'Years']) + const callArgs = mockBatchInsertRowsWithTx.mock.calls[0][1] as { rows: unknown[] } + expect(callArgs.rows).toEqual([ + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 40 }, + ]) + }) + + it('returns 400 when the mapping targets a non-existent column', async () => { + const response = await callPost( + createFormData(createCsvFile('a\nAlice'), { + mode: 'append', + mapping: { a: 'nonexistent' }, + }) + ) + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toMatch(/do not exist on the table/) + }) + + it('returns 400 when a mapping value is not a string or null', async () => { + const response = await callPost( + createFormData(createCsvFile('name,age\nAlice,30'), { + mode: 'append', + mapping: { name: 42 }, + }) + ) + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toMatch(/Mapping values must be/) + }) + + it('surfaces unique violations from batchInsertRows as 400', async () => { + mockBatchInsertRowsWithTx.mockRejectedValueOnce( + new Error('Row 1: Column "name" must be unique. Value "Alice" already exists in row row_xxx') + ) + const response = await callPost( + createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' }) + ) + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toMatch(/must be unique/) + expect(data.data?.insertedCount).toBe(0) + }) + + it('accepts TSV files', async () => { + const response = await callPost( + createFormData( + createCsvFile('name\tage\nAlice\t30', 'data.tsv', 'text/tab-separated-values'), + { mode: 'append' } + ) + ) + expect(response.status).toBe(200) + expect(mockBatchInsertRowsWithTx).toHaveBeenCalledTimes(1) + }) + + it('returns 400 for unsupported file extensions', async () => { + const response = await callPost( + createFormData(createCsvFile('name,age', 'data.json', 'application/json')) + ) + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toMatch(/CSV and TSV/) + }) + + describe('createColumns', () => { + it('auto-creates columns for unmapped CSV headers', async () => { + const response = await callPost( + createFormData(createCsvFile('name,age,email\nAlice,30,a@x.io\nBob,40,b@x.io'), { + mode: 'append', + createColumns: ['email'], + }) + ) + expect(response.status).toBe(200) + expect(mockAddTableColumnsWithTx).toHaveBeenCalledTimes(1) + const [, , columns] = mockAddTableColumnsWithTx.mock.calls[0] + expect(columns).toEqual([{ name: 'email', type: 'string' }]) + + const callArgs = mockBatchInsertRowsWithTx.mock.calls[0][1] as { rows: unknown[] } + expect(callArgs.rows).toEqual([ + { name: 'Alice', age: 30, email: 'a@x.io' }, + { name: 'Bob', age: 40, email: 'b@x.io' }, + ]) + }) + + it('infers column type from CSV row values', async () => { + const response = await callPost( + createFormData(createCsvFile('name,score\nAlice,42\nBob,17'), { + mode: 'append', + createColumns: ['score'], + }) + ) + expect(response.status).toBe(200) + const [, , columns] = mockAddTableColumnsWithTx.mock.calls[0] + expect(columns).toEqual([{ name: 'score', type: 'number' }]) + }) + + it('dedupes when sanitized name collides with an existing column', async () => { + mockCheckAccess.mockResolvedValueOnce({ + ok: true, + table: buildTable({ + schema: { + columns: [ + { name: 'name', type: 'string', required: true }, + { name: 'age', type: 'number' }, + { name: 'email', type: 'string' }, + ], + }, + }), + }) + const response = await callPost( + createFormData(createCsvFile('name,age,Email\nAlice,30,a@x.io'), { + mode: 'append', + createColumns: ['Email'], + }) + ) + expect(response.status).toBe(200) + const [, , columns] = mockAddTableColumnsWithTx.mock.calls[0] + expect(columns).toEqual([{ name: 'Email_2', type: 'string' }]) + }) + + it('returns 400 when createColumns references a header not in the CSV', async () => { + const response = await callPost( + createFormData(createCsvFile('name,age\nAlice,30'), { + mode: 'append', + createColumns: ['nonexistent'], + }) + ) + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toMatch(/unknown CSV headers/) + expect(mockAddTableColumnsWithTx).not.toHaveBeenCalled() + expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled() + }) + + it('returns 400 when createColumns is not an array of strings', async () => { + const response = await callPost( + createFormData(createCsvFile('name,age\nAlice,30'), { + mode: 'append', + createColumns: [1, 2], + }) + ) + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toMatch(/createColumns must be a JSON array/) + expect(mockAddTableColumnsWithTx).not.toHaveBeenCalled() + }) + + it('returns 400 when createColumns is invalid JSON', async () => { + const response = await callPost( + createFormData(createCsvFile('name,age\nAlice,30'), { + mode: 'append', + createColumns: '{not-json', + }) + ) + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toMatch(/createColumns must be valid JSON/) + }) + + it('surfaces addTableColumns failures as 400', async () => { + mockAddTableColumnsWithTx.mockRejectedValueOnce(new Error('Column "email" already exists')) + const response = await callPost( + createFormData(createCsvFile('name,age,email\nAlice,30,a@x.io'), { + mode: 'append', + createColumns: ['email'], + }) + ) + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toMatch(/already exists/) + expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled() + }) + + it('surfaces row insert failures without success when schema was mutated', async () => { + mockBatchInsertRowsWithTx.mockRejectedValueOnce(new Error('must be unique')) + const response = await callPost( + createFormData(createCsvFile('name,age,email\nAlice,30,a@x.io'), { + mode: 'append', + createColumns: ['email'], + }) + ) + expect(mockAddTableColumnsWithTx).toHaveBeenCalled() + expect(response.status).toBe(400) + const data = await response.json() + expect(data.success).toBeUndefined() + expect(data.error).toMatch(/must be unique/) + }) + + it('does not call addTableColumns when createColumns is omitted', async () => { + const response = await callPost( + createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' }) + ) + expect(response.status).toBe(200) + expect(mockAddTableColumnsWithTx).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts new file mode 100644 index 00000000000..9d9ddcfd96d --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -0,0 +1,376 @@ +import { db } from '@sim/db' +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, + csvImportCreateColumnsSchema, + csvImportFormSchema, + csvImportMappingSchema, + csvImportModeSchema, + tableIdParamsSchema, +} 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 { + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + addTableColumnsWithTx, + batchInsertRowsWithTx, + buildAutoMapping, + CSV_MAX_BATCH_SIZE, + CSV_MAX_FILE_SIZE_BYTES, + type CsvHeaderMapping, + CsvImportValidationError, + coerceRowsForTable, + inferColumnType, + parseCsvBuffer, + replaceTableRowsWithTx, + sanitizeName, + type TableDefinition, + type TableSchema, + validateMapping, +} from '@/lib/table' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableImportCSVExisting') +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + const { tableId } = tableIdParamsSchema.parse(await params) + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const formData = await readFormDataWithLimit(request, { + maxBytes: CSV_MAX_FILE_SIZE_BYTES + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'CSV import body', + }) + const formValidation = csvImportFormSchema.safeParse({ + file: formData.get('file'), + workspaceId: formData.get('workspaceId'), + }) + const rawMode = formData.get('mode') ?? 'append' + const rawMapping = formData.get('mapping') + const rawCreateColumns = formData.get('createColumns') + + if (!formValidation.success) { + const message = getValidationErrorMessage(formValidation.error) + const isSizeLimit = message.includes('File exceeds maximum allowed size') + return NextResponse.json( + { error: isSizeLimit ? 'CSV import file exceeds maximum size' : message }, + { status: isSizeLimit ? 413 : 400 } + ) + } + + const { file, workspaceId } = formValidation.data + + const modeValidation = csvImportModeSchema.safeParse(rawMode) + if (!modeValidation.success) { + return NextResponse.json( + { error: `Invalid mode "${String(rawMode)}". Must be "append" or "replace".` }, + { status: 400 } + ) + } + const mode = modeValidation.data + + const ext = file.name.split('.').pop()?.toLowerCase() + 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') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + + const { table } = accessResult + + if (table.workspaceId !== workspaceId) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + if (table.archivedAt) { + return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 }) + } + + let mapping: CsvHeaderMapping | undefined + if (rawMapping) { + const mappingValidation = csvImportMappingSchema.safeParse(rawMapping) + if (!mappingValidation.success) { + return NextResponse.json( + { error: getValidationErrorMessage(mappingValidation.error) }, + { status: 400 } + ) + } + mapping = mappingValidation.data + } + + let createColumns: string[] | undefined + if (rawCreateColumns) { + const createColumnsValidation = csvImportCreateColumnsSchema.safeParse(rawCreateColumns) + if (!createColumnsValidation.success) { + return NextResponse.json( + { error: getValidationErrorMessage(createColumnsValidation.error) }, + { status: 400 } + ) + } + createColumns = createColumnsValidation.data + } + + const buffer = await readFileToBufferWithLimit(file, { + maxBytes: CSV_MAX_FILE_SIZE_BYTES, + label: 'CSV import file', + }) + const delimiter = extensionValidation.data === 'tsv' ? '\t' : ',' + const { headers, rows } = await parseCsvBuffer(buffer, delimiter) + + let effectiveMapping = mapping ?? buildAutoMapping(headers, table.schema) + let prospectiveTable: TableDefinition = table + const additions: { name: string; type: string }[] = [] + + if (createColumns && createColumns.length > 0) { + const headerSet = new Set(headers) + const unknownHeaders = createColumns.filter((h) => !headerSet.has(h)) + if (unknownHeaders.length > 0) { + return NextResponse.json( + { + error: `createColumns references unknown CSV headers: ${unknownHeaders.join(', ')}`, + }, + { status: 400 } + ) + } + + const usedNames = new Set(table.schema.columns.map((c) => c.name.toLowerCase())) + const updatedMapping: CsvHeaderMapping = { ...effectiveMapping } + const newColumns: TableSchema['columns'] = [] + + for (const header of createColumns) { + const base = sanitizeName(header) + let columnName = base + let suffix = 2 + while (usedNames.has(columnName.toLowerCase())) { + columnName = `${base}_${suffix}` + suffix++ + } + usedNames.add(columnName.toLowerCase()) + const inferredType = inferColumnType(rows.map((r) => r[header])) + additions.push({ name: columnName, type: inferredType }) + newColumns.push({ + name: columnName, + type: inferredType as TableSchema['columns'][number]['type'], + required: false, + unique: false, + }) + updatedMapping[header] = columnName + } + + prospectiveTable = { + ...table, + schema: { columns: [...table.schema.columns, ...newColumns] }, + } + effectiveMapping = updatedMapping + } + + let validation: ReturnType + try { + validation = validateMapping({ + csvHeaders: headers, + mapping: effectiveMapping, + tableSchema: prospectiveTable.schema, + }) + } catch (err) { + if (err instanceof CsvImportValidationError) { + return NextResponse.json({ error: err.message, details: err.details }, { status: 400 }) + } + throw err + } + + if (validation.mappedHeaders.length === 0) { + return NextResponse.json( + { + error: `No CSV headers map to columns on the table. CSV headers: ${headers.join(', ')}. Table columns: ${prospectiveTable.schema.columns.map((c) => c.name).join(', ')}`, + }, + { status: 400 } + ) + } + + const coerced = coerceRowsForTable(rows, prospectiveTable.schema, validation.effectiveMap) + + if (mode === 'append') { + if (prospectiveTable.rowCount + coerced.length > prospectiveTable.maxRows) { + const deficit = prospectiveTable.rowCount + coerced.length - prospectiveTable.maxRows + return NextResponse.json( + { + error: `Append would exceed table row limit (${prospectiveTable.maxRows}). Currently ${prospectiveTable.rowCount} rows, ${coerced.length} new rows, ${deficit} over.`, + }, + { status: 400 } + ) + } + + try { + const inserted = await db.transaction(async (trx) => { + let working = table + if (additions.length > 0) { + working = await addTableColumnsWithTx(trx, table, additions, requestId) + } + + let total = 0 + for (let i = 0; i < coerced.length; i += CSV_MAX_BATCH_SIZE) { + const batch = coerced.slice(i, i + CSV_MAX_BATCH_SIZE) + const batchRequestId = generateId().slice(0, 8) + const result = await batchInsertRowsWithTx( + trx, + { + tableId: working.id, + rows: batch, + workspaceId, + userId: authResult.userId, + }, + working, + batchRequestId + ) + total += result.length + } + return total + }) + + logger.info(`[${requestId}] Append CSV imported`, { + tableId: table.id, + fileName: file.name, + mode, + inserted, + createdColumns: additions.length, + mappedColumns: validation.mappedHeaders.length, + skippedHeaders: validation.skippedHeaders.length, + }) + + return NextResponse.json({ + success: true, + data: { + tableId: table.id, + mode, + insertedCount: inserted, + mappedColumns: validation.mappedHeaders, + skippedHeaders: validation.skippedHeaders, + unmappedColumns: validation.unmappedColumns, + sourceFile: file.name, + }, + }) + } catch (err) { + const message = toError(err).message + logger.warn(`[${requestId}] Append failed for table ${tableId}`, { + total: coerced.length, + createdColumns: additions.length, + error: message, + }) + const isClientError = + message.includes('row limit') || + message.includes('Insufficient capacity') || + message.includes('Schema validation') || + message.includes('must be unique') || + message.includes('Row size exceeds') || + message.includes('already exists') || + message.includes('Invalid column name') || + /^Row \d+:/.test(message) + return NextResponse.json( + { + error: isClientError ? message : 'Failed to import CSV', + data: { insertedCount: 0 }, + }, + { status: isClientError ? 400 : 500 } + ) + } + } + + try { + const result = await db.transaction(async (trx) => { + let working = table + if (additions.length > 0) { + working = await addTableColumnsWithTx(trx, table, additions, requestId) + } + return replaceTableRowsWithTx( + trx, + { tableId: working.id, rows: coerced, workspaceId, userId: authResult.userId }, + working, + requestId + ) + }) + + logger.info(`[${requestId}] Replace CSV imported`, { + tableId: table.id, + fileName: file.name, + mode, + deleted: result.deletedCount, + inserted: result.insertedCount, + createdColumns: additions.length, + mappedColumns: validation.mappedHeaders.length, + }) + + return NextResponse.json({ + success: true, + data: { + tableId: table.id, + mode, + deletedCount: result.deletedCount, + insertedCount: result.insertedCount, + mappedColumns: validation.mappedHeaders, + skippedHeaders: validation.skippedHeaders, + unmappedColumns: validation.unmappedColumns, + sourceFile: file.name, + }, + }) + } catch (err) { + const message = toError(err).message + const isClientError = + message.includes('row limit') || + message.includes('Schema validation') || + message.includes('must be unique') || + message.includes('Row size exceeds') || + message.includes('already exists') || + message.includes('Invalid column name') || + /^Row \d+:/.test(message) + if (isClientError) { + return NextResponse.json({ error: message }, { status: 400 }) + } + throw err + } + } catch (error) { + const message = toError(error).message + logger.error(`[${requestId}] CSV import into existing table failed:`, error) + + const isSizeLimitError = + isPayloadSizeLimitError(error) || message.includes('CSV import file exceeds maximum size') + const isClientError = + message.includes('CSV file has no') || + message.includes('already exists') || + message.includes('Invalid column name') || + isSizeLimitError + + return NextResponse.json( + { error: isClientError ? message : 'Failed to import CSV' }, + { + status: isSizeLimitError ? 413 : isClientError ? 400 : 500, + } + ) + } +}) diff --git a/apps/sim/app/api/table/[tableId]/metadata/route.ts b/apps/sim/app/api/table/[tableId]/metadata/route.ts index 4634bf428ed..f85df0af4bd 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 { updateTableMetadataContract } from '@/lib/api/contracts/tables' +import { parseRequest, 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,22 +11,13 @@ 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 }> } /** PUT /api/table/[tableId]/metadata - Update table UI metadata (column widths, etc.) */ -export const PUT = withRouteHandler(async (request: NextRequest, { params }: TableRouteParams) => { +export const PUT = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { const requestId = generateRequestId() - const { tableId } = await params try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -34,8 +26,13 @@ export const PUT = withRouteHandler(async (request: NextRequest, { params }: Tab return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - const body = await request.json() - const validated = MetadataSchema.parse(body) + const parsed = await parseRequest(updateTableMetadataContract, request, context, { + validationErrorResponse: (error) => validationErrorResponse(error), + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) @@ -54,13 +51,6 @@ 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 } - ) - } - logger.error(`[${requestId}] Error updating table metadata:`, error) return NextResponse.json({ error: 'Failed to update metadata' }, { status: 500 }) } diff --git a/apps/sim/app/api/table/[tableId]/restore/route.ts b/apps/sim/app/api/table/[tableId]/restore/route.ts index 6e5ee48c0a1..066b03480c1 100644 --- a/apps/sim/app/api/table/[tableId]/restore/route.ts +++ b/apps/sim/app/api/table/[tableId]/restore/route.ts @@ -1,10 +1,12 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { 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' -import { getTableById, restoreTable, TableConflictError } from '@/lib/table' +import { getTableById } from '@/lib/table' +import { performRestoreTable } from '@/lib/table/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreTableAPI') @@ -12,7 +14,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 }) @@ -30,36 +32,23 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - await restoreTable(tableId, requestId) + const result = await performRestoreTable({ tableId, userId: auth.userId, requestId }) + if (!result.success) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) + } logger.info(`[${requestId}] Restored table ${tableId}`) - recordAudit({ - workspaceId: table.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.TABLE_RESTORED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Restored table "${table.name}"`, - metadata: { - tableName: table.name, - workspaceId: table.workspaceId, - }, - request, + return NextResponse.json({ + success: true, + data: { table: result.table }, }) - - return NextResponse.json({ success: true }) } catch (error) { - if (error instanceof TableConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } - logger.error(`[${requestId}] Error restoring table ${tableId}`, error) return NextResponse.json( - { error: error instanceof Error ? error.message : 'Internal server error' }, + { error: getErrorMessage(error, 'Internal server error') }, { status: 500 } ) } diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index bdcb42a8a92..0e73ecaaeba 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -1,26 +1,17 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { getTableQuerySchema, renameTableContract } from '@/lib/api/contracts/tables' +import { isZodError, parseRequest, 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 +29,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'), }) @@ -64,6 +55,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab description: table.description, schema: { columns: schemaData.columns.map(normalizeColumn), + ...(schemaData.workflowGroups ? { workflowGroups: schemaData.workflowGroups } : {}), }, metadata: table.metadata ?? null, rowCount: table.rowCount, @@ -80,11 +72,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,26 +81,10 @@ 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) => { const requestId = generateRequestId() - const { tableId } = await params try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -120,8 +93,18 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - const body = await request.json() - const validated = PatchTableSchema.parse(body) + const parsed = await parseRequest( + renameTableContract, + request, + { params }, + { + validationErrorResponse: (error) => validationErrorResponse(error), + } + ) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) @@ -139,20 +122,13 @@ 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 (error instanceof TableConflictError) { return NextResponse.json({ error: error.message }, { status: 409 }) } logger.error(`[${requestId}] Error renaming table:`, error) return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to rename table' }, + { error: getErrorMessage(error, 'Failed to rename table') }, { status: 500 } ) } @@ -173,7 +149,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 +178,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..fe7452230ee 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 { + deleteTableRowContract, + getTableQuerySchema, + updateTableRowContract, +} from '@/lib/api/contracts/tables' +import { isZodError, parseRequest, 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) @@ -108,9 +97,8 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Row }) /** PATCH /api/table/[tableId]/rows/[rowId] - Updates a single row (supports partial updates). */ -export const PATCH = withRouteHandler(async (request: NextRequest, { params }: RowRouteParams) => { +export const PATCH = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { const requestId = generateRequestId() - const { tableId, rowId } = await params try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -118,14 +106,13 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - 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(updateTableRowContract, request, context, { + validationErrorResponse: (error) => validationErrorResponse(error), + }) + if (!parsed.success) return parsed.response - const validated = UpdateRowSchema.parse(body) + const { tableId, rowId } = parsed.data.params + const validated = parsed.data.body const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) @@ -146,6 +133,14 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R table, requestId ) + // Only `null` when a `cancellationGuard` is supplied and the SQL guard + // rejects the write — this route doesn't pass one, so reaching null is a bug. + if (!updatedRow) throw new Error('updateRow returned null without a cancellationGuard') + // Auto-dispatch for user edits is handled inside `updateRow` (mode: 'new'). + // Firing a second mode: 'incomplete' dispatch here would race with the + // `mode: 'new'` one AND bulk-clear sibling-group outputs (the incomplete + // bulk-clear wipes ALL targeted columns when any one column on the row + // is empty). return NextResponse.json({ success: true, @@ -167,13 +162,6 @@ 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 errorMessage = toError(error).message if (errorMessage === 'Row not found') { @@ -196,9 +184,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R }) /** DELETE /api/table/[tableId]/rows/[rowId] - Deletes a single row. */ -export const DELETE = withRouteHandler(async (request: NextRequest, { params }: RowRouteParams) => { +export const DELETE = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { const requestId = generateRequestId() - const { tableId, rowId } = await params try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -206,14 +193,13 @@ export const DELETE = withRouteHandler(async (request: NextRequest, { params }: return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - 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(deleteTableRowContract, request, context, { + validationErrorResponse: (error) => validationErrorResponse(error), + }) + if (!parsed.success) return parsed.response - const validated = DeleteRowSchema.parse(body) + const { tableId, rowId } = parsed.data.params + const validated = parsed.data.body const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) @@ -234,13 +220,6 @@ 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 } - ) - } - const errorMessage = toError(error).message if (errorMessage === 'Row not found') { diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index 151437fcdd8..8e29e12005c 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -1,127 +1,47 @@ import { db } from '@sim/db' -import { userTableRows } from '@sim/db/schema' +import { tableRowExecutions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { and, eq, sql } from 'drizzle-orm' +import { and, eq, inArray, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + type BatchInsertTableRowsBodyInput, + batchUpdateTableRowsBodySchema, + deleteTableRowsBodySchema, + insertTableRowsContract, + tableRowsQuerySchema, + updateRowsByFilterBodySchema, +} from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +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 type { Filter, RowData, Sort, TableSchema } from '@/lib/table' +import type { + Filter, + RowData, + RowExecutionMetadata, + RowExecutions, + Sort, + TableSchema, +} from '@/lib/table' import { batchInsertRows, batchUpdateRows, deleteRowsByFilter, deleteRowsByIds, insertRow, - TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME, updateRowsByFilter, validateBatchRows, validateRowData, validateRowSize, } from '@/lib/table' -import { buildFilterClause, buildSortClause } from '@/lib/table/sql' +import { buildFilterClause, buildSortClause, TableQueryValidationError } from '@/lib/table/sql' 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), -}) - -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 }> } @@ -129,11 +49,9 @@ interface TableRowsRouteParams { async function handleBatchInsert( requestId: string, tableId: string, - body: z.infer, + validated: BatchInsertTableRowsBodyInput, userId: string ): Promise { - const validated = BatchInsertRowsSchema.parse(body) - const accessResult = await checkAccess(tableId, userId, 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) @@ -203,9 +121,8 @@ async function handleBatchInsert( /** POST /api/table/[tableId]/rows - Inserts row(s). Supports single or batch insert. */ export const POST = withRouteHandler( - async (request: NextRequest, { params }: TableRowsRouteParams) => { + async (request: NextRequest, context: TableRowsRouteParams) => { const requestId = generateRequestId() - const { tableId } = await params try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -213,28 +130,17 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - 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(insertTableRowsContract, request, context) + if (!parsed.success) return parsed.response - if ( - typeof body === 'object' && - body !== null && - 'rows' in body && - Array.isArray((body as Record).rows) - ) { - return handleBatchInsert( - requestId, - tableId, - body as z.infer, - authResult.userId - ) + const { tableId } = parsed.data.params + const body = parsed.data.body + + if ('rows' in body) { + return handleBatchInsert(requestId, tableId, body, authResult.userId) } - const validated = InsertRowSchema.parse(body) + const validated = body const accessResult = await checkAccess(tableId, authResult.userId, 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) @@ -285,11 +191,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 @@ -328,6 +231,7 @@ export const GET = withRouteHandler( const sortParam = searchParams.get('sort') const limit = searchParams.get('limit') const offset = searchParams.get('offset') + const includeTotalParam = searchParams.get('includeTotal') let filter: Record | undefined let sort: Sort | undefined @@ -343,12 +247,13 @@ 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, limit, offset, + includeTotal: includeTotalParam, }) const accessResult = await checkAccess(tableId, authResult.userId, 'read') @@ -368,8 +273,14 @@ export const GET = withRouteHandler( eq(userTableRows.workspaceId, validated.workspaceId), ] + const schema = table.schema as TableSchema + if (validated.filter) { - const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME) + const filterClause = buildFilterClause( + validated.filter as Filter, + USER_TABLE_ROWS_SQL_NAME, + schema.columns + ) if (filterClause) { baseConditions.push(filterClause) } @@ -387,7 +298,6 @@ export const GET = withRouteHandler( .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 @@ -398,17 +308,54 @@ export const GET = withRouteHandler( query = query.orderBy(userTableRows.position) as typeof query } - const countQuery = db - .select({ count: sql`count(*)` }) - .from(userTableRows) - .where(and(...baseConditions)) - - const [{ count: totalCount }] = await countQuery + let totalCount: number | null = null + if (validated.includeTotal) { + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(userTableRows) + .where(and(...baseConditions)) + totalCount = Number(count) + } const rows = await query.limit(validated.limit).offset(validated.offset) + // Sidecar: fetch per-(row, group) execution state and group into a map + // so the response preserves the legacy `row.executions[groupId]` wire + // shape. One indexed-IN scan against table_row_executions. + const executionsByRow = new Map() + if (rows.length > 0) { + const execRows = await db + .select() + .from(tableRowExecutions) + .where( + inArray( + tableRowExecutions.rowId, + rows.map((r) => r.id) + ) + ) + for (const e of execRows) { + const existing = executionsByRow.get(e.rowId) ?? {} + const meta: RowExecutionMetadata = { + status: e.status as RowExecutionMetadata['status'], + executionId: e.executionId ?? null, + jobId: e.jobId ?? null, + workflowId: e.workflowId, + error: e.error ?? null, + ...(e.runningBlockIds && e.runningBlockIds.length > 0 + ? { runningBlockIds: e.runningBlockIds } + : {}), + ...(e.blockErrors && Object.keys(e.blockErrors as Record).length > 0 + ? { blockErrors: e.blockErrors as Record } + : {}), + ...(e.cancelledAt ? { cancelledAt: e.cancelledAt.toISOString() } : {}), + } + existing[e.groupId] = meta + executionsByRow.set(e.rowId, existing) + } + } + logger.info( - `[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount})` + `[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount ?? 'n/a'})` ) return NextResponse.json({ @@ -417,6 +364,7 @@ export const GET = withRouteHandler( rows: rows.map((r) => ({ id: r.id, data: r.data, + executions: executionsByRow.get(r.id) ?? {}, position: r.position, createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), @@ -424,17 +372,18 @@ export const GET = withRouteHandler( r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), })), rowCount: rows.length, - totalCount: Number(totalCount), + totalCount, limit: validated.limit, offset: validated.offset, }, }) } 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 TableQueryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }) } logger.error(`[${requestId}] Error querying rows:`, error) @@ -462,7 +411,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) @@ -485,14 +434,12 @@ export const PUT = withRouteHandler( } const result = await updateRowsByFilter( + table, { - tableId, filter: validated.filter as Filter, data: validated.data as RowData, limit: validated.limit, - workspaceId: validated.workspaceId, }, - table, requestId ) @@ -518,11 +465,12 @@ 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) + } + + if (error instanceof TableQueryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }) } const errorMessage = toError(error).message @@ -563,7 +511,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) @@ -577,7 +525,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 @@ -599,11 +547,10 @@ export const DELETE = withRouteHandler( } const result = await deleteRowsByFilter( + table, { - tableId, filter: validated.filter as Filter, limit: validated.limit, - workspaceId: validated.workspaceId, }, requestId ) @@ -620,11 +567,12 @@ 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) + } + + if (error instanceof TableQueryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }) } const errorMessage = toError(error).message @@ -658,7 +606,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) @@ -691,11 +639,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..c34ae686c0b 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,9 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { upsertTableRowContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +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,102 +13,85 @@ 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 }> } /** POST /api/table/[tableId]/rows/upsert - Inserts or updates based on unique columns. */ -export const POST = withRouteHandler( - async (request: NextRequest, { params }: UpsertRouteParams) => { - const requestId = generateRequestId() - const { tableId } = await params - - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } +export const POST = withRouteHandler(async (request: NextRequest, context: UpsertRouteParams) => { + const requestId = generateRequestId() + const { tableId } = await context.params - let body: unknown - try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } - const validated = UpsertRowSchema.parse(body) + const validation = await parseRequest(upsertTableRowContract, request, context) + if (!validation.success) return validation.response + const validated = validation.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) - const { table } = result + const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + if (table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - const upsertResult = await upsertRow( - { - tableId, - workspaceId: validated.workspaceId, - data: validated.data as RowData, - userId: authResult.userId, - conflictTarget: validated.conflictTarget, - }, - table, - requestId - ) + const upsertResult = await upsertRow( + { + tableId, + workspaceId: validated.workspaceId, + data: validated.data as RowData, + userId: authResult.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, - }, - operation: upsertResult.operation, - message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`, + 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 + operation: upsertResult.operation, + message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`, + }, + }) + } catch (error) { + if (isZodError(error)) { + return validationErrorResponse(error) + } - // Service layer throws descriptive errors for validation/capacity issues - if ( - errorMessage.includes('unique column') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('conflictTarget') || - errorMessage.includes('row limit') || - errorMessage.includes('Schema validation') || - errorMessage.includes('Upsert requires') || - errorMessage.includes('Row size exceeds') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const errorMessage = toError(error).message - logger.error(`[${requestId}] Error upserting row:`, error) - return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 }) + 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/table/import-csv/route.test.ts b/apps/sim/app/api/table/import-csv/route.test.ts new file mode 100644 index 00000000000..9844bf69664 --- /dev/null +++ b/apps/sim/app/api/table/import-csv/route.test.ts @@ -0,0 +1,104 @@ +/** + * @vitest-environment node + */ +import { hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' +import type { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCreateTable, mockParseCsvBuffer, mockGetWorkspaceTableLimits } = vi.hoisted(() => ({ + mockCreateTable: vi.fn(), + mockParseCsvBuffer: vi.fn(), + mockGetWorkspaceTableLimits: vi.fn(), +})) + +vi.mock('@sim/utils/id', () => ({ + generateId: vi.fn().mockReturnValue('deadbeefcafef00d'), + generateShortId: vi.fn().mockReturnValue('short-id'), +})) + +vi.mock('@/lib/table', () => ({ + batchInsertRows: vi.fn(), + CSV_MAX_BATCH_SIZE: 1000, + CSV_MAX_FILE_SIZE_BYTES: 25 * 1024 * 1024, + coerceRowsForTable: vi.fn(), + createTable: mockCreateTable, + deleteTable: vi.fn(), + getWorkspaceTableLimits: mockGetWorkspaceTableLimits, + inferSchemaFromCsv: vi.fn(), + parseCsvBuffer: mockParseCsvBuffer, + sanitizeName: vi.fn((name: string) => name), + TABLE_LIMITS: { + MAX_TABLE_NAME_LENGTH: 64, + }, +})) + +vi.mock('@/app/api/table/utils', () => ({ + normalizeColumn: vi.fn((column) => column), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) + +import { POST } from '@/app/api/table/import-csv/route' + +function createCsvFile(contents: string, name = 'data.csv', type = 'text/csv'): File { + return new File([contents], name, { type }) +} + +function createFormData(file: File): FormData { + const form = new FormData() + form.append('file', file) + form.append('workspaceId', 'workspace-1') + return form +} + +async function callPost(form: FormData) { + const req = { + formData: async () => form, + } as unknown as NextRequest + return POST(req) +} + +describe('POST /api/table/import-csv', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + mockGetWorkspaceTableLimits.mockResolvedValue({ + maxRowsPerTable: 1000, + maxTables: 10, + }) + }) + + it('returns 413 for oversized CSV files before reading their contents or creating a table', async () => { + const file = createCsvFile('name,age\nAlice,30') + Object.defineProperty(file, 'size', { + value: 26 * 1024 * 1024, + }) + const arrayBufferSpy = vi.spyOn(file, 'arrayBuffer') + + const response = await callPost(createFormData(file)) + const data = await response.json() + + expect(response.status).toBe(413) + expect(data.error).toMatch(/CSV import file exceeds maximum size/) + expect(arrayBufferSpy).not.toHaveBeenCalled() + expect(mockParseCsvBuffer).not.toHaveBeenCalled() + expect(mockCreateTable).not.toHaveBeenCalled() + }) + + it('accepts chunked multipart requests without a content-length header', async () => { + const req = { + headers: new Headers({ 'transfer-encoding': 'chunked' }), + formData: vi.fn(async () => createFormData(createCsvFile('name\nAlice'))), + } as unknown as NextRequest + + const response = await POST(req) + + expect(response.status).not.toBe(411) + expect(req.formData).toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index 42127780360..31927889202 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -2,8 +2,15 @@ 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 { + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { batchInsertRows, @@ -16,12 +23,14 @@ import { inferSchemaFromCsv, parseCsvBuffer, sanitizeName, + TABLE_LIMITS, type TableSchema, } from '@/lib/table' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { normalizeColumn } from '@/app/api/table/utils' const logger = createLogger('TableImportCSV') +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -32,26 +41,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - const formData = await request.formData() - const file = formData.get('file') - const workspaceId = formData.get('workspaceId') as string | null - - if (!file || !(file instanceof File)) { - return NextResponse.json({ error: 'CSV file is required' }, { status: 400 }) - } - - if (file.size > CSV_MAX_FILE_SIZE_BYTES) { + const formData = await readFormDataWithLimit(request, { + maxBytes: CSV_MAX_FILE_SIZE_BYTES + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'CSV import body', + }) + const validation = csvImportFormSchema.safeParse({ + file: formData.get('file'), + workspaceId: formData.get('workspaceId'), + }) + + if (!validation.success) { + const message = getValidationErrorMessage(validation.error) + const isSizeLimit = message.includes('File exceeds maximum allowed size') return NextResponse.json( - { - error: `File exceeds maximum allowed size of ${CSV_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB`, - }, - { status: 400 } + { error: isSizeLimit ? 'CSV import file exceeds maximum size' : message }, + { status: isSizeLimit ? 413 : 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,16 +67,26 @@ 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 buffer = await readFileToBufferWithLimit(file, { + maxBytes: CSV_MAX_FILE_SIZE_BYTES, + label: 'CSV import file', + }) + const delimiter = extensionValidation.data === 'tsv' ? '\t' : ',' const { headers, rows } = await parseCsvBuffer(buffer, delimiter) const { columns, headerToColumn } = inferSchemaFromCsv(headers, rows) - const tableName = sanitizeName(file.name.replace(/\.[^.]+$/, ''), 'imported_table') + const tableName = sanitizeName(file.name.replace(/\.[^.]+$/, ''), 'imported_table').slice( + 0, + TABLE_LIMITS.MAX_TABLE_NAME_LENGTH + ) const planLimits = await getWorkspaceTableLimits(workspaceId) const normalizedSchema: TableSchema = { @@ -129,16 +147,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const message = toError(error).message logger.error(`[${requestId}] CSV import failed:`, error) + const isSizeLimitError = + isPayloadSizeLimitError(error) || message.includes('CSV import file exceeds maximum size') const isClientError = message.includes('maximum table limit') || message.includes('CSV file has no') || message.includes('Invalid table name') || message.includes('Invalid schema') || - message.includes('already exists') + message.includes('already exists') || + isSizeLimitError return NextResponse.json( { error: isClientError ? message : 'Failed to import CSV' }, - { status: isClientError ? 400 : 500 } + { + status: isSizeLimitError ? 413 : isClientError ? 400 : 500, + } ) } }) diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index c18a0ca87e3..89a48b80896 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 { createTableContract, listTablesQuerySchema } from '@/lib/api/contracts/tables' +import { isZodError, parseRequest, 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 @@ -105,14 +47,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - 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( + createTableContract, + request, + {}, + { + validationErrorResponse: (error) => validationErrorResponse(error), + } + ) + if (!parsed.success) return parsed.response - const params = CreateTableSchema.parse(body) + const params = parsed.data.body const { hasAccess, canWrite } = await checkWorkspaceAccess( params.workspaceId, @@ -182,13 +127,6 @@ 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 (error instanceof Error) { if (error.message.includes('maximum table limit')) { return NextResponse.json({ error: error.message }, { status: 403 }) @@ -221,10 +159,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 } ) } @@ -241,36 +182,40 @@ export const GET = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Listed ${tables.length} tables in workspace ${params.workspaceId}`) + const responseTables = tables.map((t) => { + const schemaData = t.schema as TableSchema + return { + id: t.id, + name: t.name, + description: t.description, + schema: { + columns: schemaData.columns.map(normalizeColumn), + }, + rowCount: t.rowCount, + maxRows: t.maxRows, + workspaceId: t.workspaceId, + createdBy: t.createdBy, + createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), + updatedAt: t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt), + archivedAt: + t.archivedAt instanceof Date + ? t.archivedAt.toISOString() + : t.archivedAt + ? String(t.archivedAt) + : null, + } + }) + return NextResponse.json({ success: true, data: { - tables: tables.map((t) => { - const schemaData = t.schema as TableSchema - return { - id: t.id, - name: t.name, - description: t.description, - schema: { - columns: schemaData.columns.map(normalizeColumn), - }, - rowCount: t.rowCount, - maxRows: t.maxRows, - createdBy: t.createdBy, - createdAt: - t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), - updatedAt: - t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt), - } - }), + tables: responseTables, totalCount: tables.length, }, }) } 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..114271a9401 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -1,18 +1,22 @@ 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') -export interface TableAccessResult { +interface TableAccessResult { hasAccess: true table: TableDefinition } -export interface TableAccessDenied { +interface TableAccessDenied { hasAccess: false notFound?: boolean reason?: string @@ -22,7 +26,7 @@ export type TableAccessCheck = TableAccessResult | TableAccessDenied export type AccessResult = { ok: true; table: TableDefinition } | { ok: false; status: 404 | 403 } -export interface ApiErrorResponse { +interface ApiErrorResponse { error: string details?: unknown } @@ -31,7 +35,7 @@ export interface ApiErrorResponse { * Check if a user has read access to a table. * Read access requires any workspace permission (read, write, or admin). */ -export async function checkTableAccess(tableId: string, userId: string): Promise { +async function checkTableAccess(tableId: string, userId: string): Promise { const table = await getTableById(tableId) if (!table) { @@ -50,10 +54,7 @@ export async function checkTableAccess(tableId: string, userId: string): Promise * Check if a user has write access to a table. * Write access requires write or admin workspace permission. */ -export async function checkTableWriteAccess( - tableId: string, - userId: string -): Promise { +async function checkTableWriteAccess(tableId: string, userId: string): Promise { const table = await getTableById(tableId) if (!table) { @@ -118,7 +119,7 @@ export function tableAccessError( return NextResponse.json({ error: message }, { status }) } -export async function verifyTableWorkspace(tableId: string, workspaceId: string): Promise { +async function verifyTableWorkspace(tableId: string, workspaceId: string): Promise { const table = await getTableById(tableId) return table?.workspaceId === workspaceId } @@ -155,36 +156,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 { @@ -192,5 +170,6 @@ export function normalizeColumn(col: ColumnDefinition): ColumnDefinition { type: col.type, required: col.required ?? false, unique: col.unique ?? false, + ...(col.workflowGroupId ? { workflowGroupId: col.workflowGroupId } : {}), } } diff --git a/apps/sim/app/api/telemetry/route.ts b/apps/sim/app/api/telemetry/route.ts index 5bac3854d9c..aed019188cc 100644 --- a/apps/sim/app/api/telemetry/route.ts +++ b/apps/sim/app/api/telemetry/route.ts @@ -1,49 +1,20 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { telemetryContract } from '@/lib/api/contracts/telemetry' +import { parseRequest } from '@/lib/api/server' import { env } from '@/lib/core/config/env' import { isProd } from '@/lib/core/config/feature-flags' +import { enforceIpRateLimit } from '@/lib/core/rate-limiter' 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 +30,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 +53,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 @@ -178,22 +149,18 @@ async function forwardToCollector(data: any): Promise { * Endpoint that receives telemetry events and forwards them to OpenTelemetry collector */ 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 }) - } + const rateLimited = await enforceIpRateLimit('telemetry', req, { + maxTokens: 60, + refillRate: 30, + refillIntervalMs: 60_000, + }) + if (rateLimited) return rateLimited - if (!validateTelemetryData(eventData)) { - return NextResponse.json( - { error: 'Invalid telemetry data format or contains sensitive information' }, - { status: 400 } - ) - } + try { + const parsed = await parseRequest(telemetryContract, req, {}) + if (!parsed.success) return parsed.response - const forwarded = await forwardToCollector(eventData) + const forwarded = await forwardToCollector(parsed.data.body) 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..94c429ad6dc 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,8 @@ import { templates } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { updateTemplateOgImageContract } from '@/lib/api/contracts/templates' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -19,17 +21,21 @@ const logger = createLogger('TemplateOGImageAPI') * Accepts base64-encoded image data in the request body. */ export const PUT = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params try { const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized OG image upload attempt for template: ${id}`) + logger.warn(`[${requestId}] Unauthorized OG image upload attempt`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const parsed = await parseRequest(updateTemplateOgImageContract, request, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params + const { imageData } = parsed.data.body + const { authorized, error, status } = await verifyTemplateOwnership( id, session.user.id, @@ -40,16 +46,6 @@ export const PUT = withRouteHandler( return NextResponse.json({ error }, { status: status || 403 }) } - const body = await request.json() - const { imageData } = body - - if (!imageData || typeof imageData !== 'string') { - return NextResponse.json( - { error: 'Missing or invalid imageData (expected base64 string)' }, - { status: 400 } - ) - } - const base64Data = imageData.includes(',') ? imageData.split(',')[1] : imageData const imageBuffer = Buffer.from(base64Data, 'base64') @@ -97,7 +93,7 @@ export const PUT = withRouteHandler( ogImageUrl, }) } catch (error: unknown) { - logger.error(`[${requestId}] Error uploading OG image for template ${id}:`, error) + logger.error(`[${requestId}] Error uploading OG image:`, error) return NextResponse.json({ error: 'Failed to upload OG image' }, { status: 500 }) } } diff --git a/apps/sim/app/api/templates/[id]/route.ts b/apps/sim/app/api/templates/[id]/route.ts index 55b1a25445f..8f4c0e367c5 100644 --- a/apps/sim/app/api/templates/[id]/route.ts +++ b/apps/sim/app/api/templates/[id]/route.ts @@ -4,9 +4,11 @@ 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, updateTemplateContract } from '@/lib/api/contracts/templates' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' +import { RateLimiter } from '@/lib/core/rate-limiter' +import { generateRequestId, getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { canAccessTemplate } from '@/lib/templates/permissions' import { @@ -17,12 +19,24 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('TemplateByIdAPI') +const viewRateLimiter = new RateLimiter() + +/** + * Per-IP, per-template view-counter dedup bucket: one increment per 10 minutes. + * Prevents scripted inflation of `templates.views` from the public GET handler. + */ +const TEMPLATE_VIEW_DEDUP = { + maxTokens: 1, + refillRate: 1, + refillIntervalMs: 10 * 60_000, +} + 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() @@ -62,21 +76,31 @@ export const GET = withRouteHandler( isStarred = starResult.length > 0 } - const shouldIncrementView = template.status === 'approved' + let shouldIncrementView = template.status === 'approved' if (shouldIncrementView) { - try { - await db - .update(templates) - .set({ - views: sql`${templates.views} + 1`, - }) - .where(eq(templates.id, id)) - } catch (viewError) { - logger.warn( - `[${requestId}] Failed to increment view count for template: ${id}`, - viewError - ) + const viewer = session?.user?.id ?? `ip:${getClientIp(request)}` + const dedupKey = `template-view:${id}:${viewer}` + const { allowed } = await viewRateLimiter.checkRateLimitDirect( + dedupKey, + TEMPLATE_VIEW_DEDUP + ) + if (!allowed) { + shouldIncrementView = false + } else { + try { + await db + .update(templates) + .set({ + views: sql`${templates.views} + 1`, + }) + .where(eq(templates.id, id)) + } catch (viewError) { + logger.warn( + `[${requestId}] Failed to increment view count for template: ${id}`, + viewError + ) + } } } @@ -89,55 +113,38 @@ 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 }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params try { const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized template update attempt for ID: ${id}`) + logger.warn(`[${requestId}] Unauthorized template update attempt`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validationResult = updateTemplateSchema.safeParse(body) - - if (!validationResult.success) { - logger.warn( - `[${requestId}] Invalid template data for update: ${id}`, - validationResult.error - ) - return NextResponse.json( - { error: 'Invalid template data', details: validationResult.error.errors }, - { status: 400 } - ) - } + const parsed = await parseRequest(updateTemplateContract, request, context, { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid template data for update`, error) + return NextResponse.json( + { error: 'Invalid template data', details: error.issues }, + { status: 400 } + ) + }, + }) + if (!parsed.success) return parsed.response - const { name, details, creatorId, tags, updateState, status } = validationResult.data + const { id } = parsed.data.params + const { name, details, creatorId, tags, updateState, status } = parsed.data.body const existingTemplate = await db .select() @@ -192,7 +199,7 @@ export const PUT = withRouteHandler( } } - const updateData: any = { + const updateData: Record = { updatedAt: new Date(), } @@ -270,8 +277,8 @@ export const PUT = withRouteHandler( description: `Updated template "${name ?? template.name}"`, metadata: { templateName: name ?? template.name, - updatedFields: Object.keys(validationResult.data).filter( - (k) => validationResult.data[k as keyof typeof validationResult.data] !== undefined + updatedFields: Object.keys(parsed.data.body).filter( + (k) => parsed.data.body[k as keyof typeof parsed.data.body] !== undefined ), statusChange: status !== undefined ? { from: template.status, to: status } : undefined, stateUpdated: updateState || false, @@ -284,8 +291,8 @@ export const PUT = withRouteHandler( data: updatedTemplate[0], message: 'Template updated successfully', }) - } catch (error: any) { - logger.error(`[${requestId}] Error updating template: ${id}`, error) + } catch (error) { + logger.error(`[${requestId}] Error updating template`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } @@ -295,7 +302,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() @@ -304,7 +311,17 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const existing = await db.select().from(templates).where(eq(templates.id, id)).limit(1) + const existing = await db + .select({ + name: templates.name, + workflowId: templates.workflowId, + creatorId: templates.creatorId, + status: templates.status, + tags: templates.tags, + }) + .from(templates) + .where(eq(templates.id, id)) + .limit(1) if (existing.length === 0) { logger.warn(`[${requestId}] Template not found for delete: ${id}`) return NextResponse.json({ error: 'Template not found' }, { status: 404 }) @@ -353,7 +370,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..874093c7773 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 { useTemplateContract } from '@/lib/api/contracts/templates' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' @@ -29,20 +31,20 @@ interface TemplateDetails { // POST /api/templates/[id]/use - Use a template (increment views and create workflow) export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params try { const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized use attempt for template: ${id}`) + logger.warn(`[${requestId}] Unauthorized template use attempt`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // Get workspace ID and connectToTemplate flag from request body - const body = await request.json() - const { workspaceId, connectToTemplate = false } = body + const parsed = await parseRequest(useTemplateContract, request, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params + const { workspaceId, connectToTemplate = false } = parsed.data.body if (!workspaceId) { logger.warn(`[${requestId}] Missing workspaceId in request body`) @@ -226,7 +228,7 @@ export const POST = withRouteHandler( { status: 201 } ) } catch (error: any) { - logger.error(`[${requestId}] Error using template: ${id}`, error) + logger.error(`[${requestId}] Error using template`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/sim/app/api/templates/approved/sanitized/route.ts b/apps/sim/app/api/templates/approved/sanitized/route.ts index 3e4db735db0..6fa0e69d7f5 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 { validationErrorResponse } 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,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const url = new URL(request.url) + const queryValidation = noInputSchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams.entries()) + ) + if (!queryValidation.success) return validationErrorResponse(queryValidation.error) const hasApiKey = !!request.headers.get('x-api-key') // Check internal API key authentication @@ -126,17 +131,3 @@ 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() - logger.info(`[${requestId}] OPTIONS request received for /api/templates/approved/sanitized`) - - return new NextResponse(null, { - status: 200, - headers: { - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'X-API-Key, Content-Type', - }, - }) -}) diff --git a/apps/sim/app/api/templates/route.ts b/apps/sim/app/api/templates/route.ts index 96bb31b15ad..8a7b1e9aac3 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 { createTemplateContract, listTemplatesContract } from '@/lib/api/contracts/templates' +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' @@ -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() @@ -67,8 +43,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) + const parsed = await parseRequest(listTemplatesContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.query // Check if user is a super user const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id) @@ -178,7 +155,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .from(templates) .where(whereCondition) - const total = totalCount[0]?.count || 0 + const total = Number(totalCount[0]?.count ?? 0) const visibleResults = params.workflowId && !isSuperUser @@ -209,15 +186,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 }) } @@ -234,8 +203,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const data = CreateTemplateSchema.parse(body) + const parsed = await parseRequest(createTemplateContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ workflowId: data.workflowId, @@ -297,7 +267,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 +278,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { stateWithVariables = { ...stateWithVariables, - variables: workflowRecord?.variables || undefined, + variables: (workflowRecord?.variables as WorkflowState['variables']) || undefined, } } @@ -365,15 +335,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..92935a001a0 100644 --- a/apps/sim/app/api/tools/a2a/cancel-task/route.ts +++ b/apps/sim/app/api/tools/a2a/cancel-task/route.ts @@ -1,9 +1,11 @@ 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/tools/a2a' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,12 +13,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 +30,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = A2ACancelTaskSchema.parse(body) + const rateLimited = await enforceUserOrIpRateLimit( + 'a2a-cancel-task', + authResult.userId, + request + ) + if (rateLimited) return rateLimited + + 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 +78,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..cf93e9e2f36 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,8 +1,10 @@ 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/tools/a2a' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,13 +12,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() @@ -36,6 +31,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const rateLimited = await enforceUserOrIpRateLimit( + 'a2a-delete-push-notification', + authResult.userId, + request + ) + if (rateLimited) return rateLimited + logger.info( `[${requestId}] Authenticated A2A delete push notification request via ${authResult.authType}`, { @@ -43,8 +45,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 +88,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..fed318b8330 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,8 +1,10 @@ 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/tools/a2a' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,11 +12,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() @@ -32,6 +29,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const rateLimited = await enforceUserOrIpRateLimit( + 'a2a-get-agent-card', + authResult.userId, + request + ) + if (rateLimited) return rateLimited + logger.info( `[${requestId}] Authenticated A2A get agent card request via ${authResult.authType}`, { @@ -39,8 +43,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 +88,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..6c48da2648c 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,8 +1,10 @@ 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/tools/a2a' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -10,12 +12,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() @@ -35,6 +31,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const rateLimited = await enforceUserOrIpRateLimit( + 'a2a-get-push-notification', + authResult.userId, + request + ) + if (rateLimited) return rateLimited + logger.info( `[${requestId}] Authenticated A2A get push notification request via ${authResult.authType}`, { @@ -42,8 +45,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 +100,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..3e38b82f80c 100644 --- a/apps/sim/app/api/tools/a2a/get-task/route.ts +++ b/apps/sim/app/api/tools/a2a/get-task/route.ts @@ -1,9 +1,11 @@ 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/tools/a2a' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,13 +13,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() @@ -35,12 +30,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const rateLimited = await enforceUserOrIpRateLimit('a2a-get-task', authResult.userId, request) + if (rateLimited) return rateLimited + logger.info(`[${requestId}] Authenticated A2A get task request via ${authResult.authType}`, { 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 +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 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..bd4bdebabc7 100644 --- a/apps/sim/app/api/tools/a2a/resubscribe/route.ts +++ b/apps/sim/app/api/tools/a2a/resubscribe/route.ts @@ -8,9 +8,11 @@ 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/tools/a2a' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,12 +20,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 +37,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = A2AResubscribeSchema.parse(body) + const rateLimited = await enforceUserOrIpRateLimit( + 'a2a-resubscribe', + authResult.userId, + request + ) + if (rateLimited) return rateLimited + + 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 +115,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..708863a8715 100644 --- a/apps/sim/app/api/tools/a2a/send-message/route.ts +++ b/apps/sim/app/api/tools/a2a/send-message/route.ts @@ -3,9 +3,11 @@ 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/tools/a2a' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter' 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' @@ -14,23 +16,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() @@ -48,6 +33,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const rateLimited = await enforceUserOrIpRateLimit( + 'a2a-send-message', + authResult.userId, + request + ) + if (rateLimited) return rateLimited + logger.info( `[${requestId}] Authenticated A2A send message request via ${authResult.authType}`, { @@ -55,8 +47,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 +212,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..5511da2d2cc 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,8 +1,10 @@ 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/tools/a2a' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter' 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' @@ -11,14 +13,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 +32,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = A2ASetPushNotificationSchema.parse(body) + const rateLimited = await enforceUserOrIpRateLimit( + 'a2a-set-push-notification', + authResult.userId, + request + ) + if (rateLimited) return rateLimited + + 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 +99,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.test.ts b/apps/sim/app/api/tools/agiloft/attach/route.test.ts new file mode 100644 index 00000000000..f1e4c8c4264 --- /dev/null +++ b/apps/sim/app/api/tools/agiloft/attach/route.test.ts @@ -0,0 +1,144 @@ +/** + * @vitest-environment node + */ +import { + createMockRequest, + hybridAuthMockFns, + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockProcessFilesToUserFiles, mockDownloadFileFromStorage, mockAssertToolFileAccess } = + vi.hoisted(() => ({ + mockProcessFilesToUserFiles: vi.fn(), + mockDownloadFileFromStorage: vi.fn(), + mockAssertToolFileAccess: vi.fn(), + })) + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) +vi.mock('@/lib/uploads/utils/file-utils', () => ({ + processFilesToUserFiles: mockProcessFilesToUserFiles, +})) +vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ + downloadFileFromStorage: mockDownloadFileFromStorage, +})) +vi.mock('@/app/api/files/authorization', () => ({ + assertToolFileAccess: mockAssertToolFileAccess, +})) + +import { POST } from '@/app/api/tools/agiloft/attach/route' + +const PINNED_IP = '93.184.216.34' + +const baseBody = { + instanceUrl: 'https://example.agiloft.com', + knowledgeBase: 'demo', + login: 'admin', + password: 'secret', + table: 'contracts', + recordId: '42', + fieldName: 'attachments', + file: { key: 's3://bucket/file.txt', name: 'file.txt', size: 5, type: 'text/plain' }, + fileName: 'file.txt', +} + +function mockSecureFetchResponse(body: { + ok?: boolean + status?: number + json?: unknown + text?: string +}) { + return { + ok: body.ok ?? true, + status: body.status ?? 200, + statusText: '', + headers: new Headers(), + body: null, + text: async () => body.text ?? '', + json: async () => body.json ?? {}, + arrayBuffer: async () => new ArrayBuffer(0), + } +} + +beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: PINNED_IP, + originalHostname: 'example.agiloft.com', + }) + mockProcessFilesToUserFiles.mockReturnValue([ + { key: 's3://bucket/file.txt', name: 'file.txt', size: 5, type: 'text/plain' }, + ]) + mockAssertToolFileAccess.mockResolvedValue(null) + mockDownloadFileFromStorage.mockResolvedValue(Buffer.from('hello')) +}) + +describe('POST /api/tools/agiloft/attach', () => { + it('rejects unauthenticated requests', async () => { + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValueOnce({ + success: false, + error: 'unauthorized', + }) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(401) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + }) + + it('blocks SSRF when the instance URL fails DNS validation', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValueOnce({ + isValid: false, + error: 'instanceUrl resolves to a blocked IP address', + }) + + const response = await POST( + createMockRequest('POST', { ...baseBody, instanceUrl: 'https://attacker.example.com' }) + ) + + expect(response.status).toBe(400) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + }) + + it('pins the resolved IP for login, attach, and logout (TOCTOU fix)', async () => { + inputValidationMockFns.mockSecureFetchWithPinnedIP + .mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-att' } })) + .mockResolvedValueOnce(mockSecureFetchResponse({ text: '1' })) + .mockResolvedValueOnce(mockSecureFetchResponse({})) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(200) + const data = (await response.json()) as { + success: true + output: { totalAttachments: number; fileName: string } + } + expect(data.output.totalAttachments).toBe(1) + expect(data.output.fileName).toBe('file.txt') + + const calls = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls + expect(calls).toHaveLength(3) + for (const call of calls) { + expect(call[1]).toBe(PINNED_IP) + } + + expect(calls[0][0]).toContain('https://example.agiloft.com/ewws/EWLogin') + expect(calls[1][0]).toContain('https://example.agiloft.com/ewws/EWAttach') + expect(calls[1][2]).toMatchObject({ + method: 'PUT', + headers: { + Authorization: 'Bearer tok-att', + 'Content-Type': 'application/octet-stream', + }, + }) + expect(calls[2][0]).toContain('https://example.agiloft.com/ewws/EWLogout') + + // DNS only resolved once. + expect(inputValidationMockFns.mockValidateUrlWithDNS).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/api/tools/agiloft/attach/route.ts b/apps/sim/app/api/tools/agiloft/attach/route.ts index 792d3df1235..b0fcb351751 100644 --- a/apps/sim/app/api/tools/agiloft/attach/route.ts +++ b/apps/sim/app/api/tools/agiloft/attach/route.ts @@ -1,38 +1,34 @@ import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' 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 { secureFetchWithPinnedIP } 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' +import { assertToolFileAccess } from '@/app/api/files/authorization' +import { buildAttachFileUrl } from '@/tools/agiloft/utils' +import { + agiloftLoginPinned, + agiloftLogoutPinned, + resolveAgiloftInstance, +} from '@/tools/agiloft/utils.server' 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Agiloft attach attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, @@ -40,8 +36,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 }) @@ -58,21 +72,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => { `[${requestId}] Downloading file for Agiloft attach: ${userFile.name} (${userFile.size} bytes)` ) + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) const resolvedFileName = data.fileName || userFile.name || 'attachment' - const urlValidation = await validateUrlWithDNS(data.instanceUrl, 'instanceUrl') - if (!urlValidation.isValid) { + let resolvedIP: string + try { + resolvedIP = await resolveAgiloftInstance(data.instanceUrl) + } catch (error) { logger.warn(`[${requestId}] SSRF attempt blocked for Agiloft instance URL`, { instanceUrl: data.instanceUrl, }) - return NextResponse.json( - { success: false, error: urlValidation.error || 'Invalid instance URL' }, - { status: 400 } - ) + return NextResponse.json({ success: false, error: toError(error).message }, { status: 400 }) } - const token = await agiloftLogin(data) + const token = await agiloftLoginPinned(data, resolvedIP) const base = data.instanceUrl.replace(/\/$/, '') try { @@ -80,10 +95,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Uploading file to Agiloft: ${resolvedFileName}`) - const agiloftResponse = await fetch(url, { + const agiloftResponse = await secureFetchWithPinnedIP(url, resolvedIP, { method: 'PUT', headers: { - 'Content-Type': userFile.type || 'application/octet-stream', + 'Content-Type': 'application/octet-stream', Authorization: `Bearer ${token}`, }, body: new Uint8Array(fileBuffer), @@ -124,22 +139,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } finally { - await agiloftLogout(data.instanceUrl, data.knowledgeBase, token) + await agiloftLogoutPinned(data.instanceUrl, data.knowledgeBase, token, resolvedIP) } } 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( - { success: false, error: error instanceof Error ? error.message : 'Internal server error' }, - { status: 500 } - ) + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) } }) diff --git a/apps/sim/app/api/tools/agiloft/retrieve/route.test.ts b/apps/sim/app/api/tools/agiloft/retrieve/route.test.ts new file mode 100644 index 00000000000..efd435b5b04 --- /dev/null +++ b/apps/sim/app/api/tools/agiloft/retrieve/route.test.ts @@ -0,0 +1,163 @@ +/** + * @vitest-environment node + */ +import { + createMockRequest, + hybridAuthMockFns, + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +import { POST } from '@/app/api/tools/agiloft/retrieve/route' + +const PINNED_IP = '93.184.216.34' + +const baseBody = { + instanceUrl: 'https://example.agiloft.com', + knowledgeBase: 'demo', + login: 'admin', + password: 'secret', + table: 'contracts', + recordId: '42', + fieldName: 'attachments', + position: '0', +} + +function mockSecureFetchResponse(body: { + ok?: boolean + status?: number + json?: unknown + text?: string + arrayBuffer?: ArrayBuffer + headers?: Headers +}) { + return { + ok: body.ok ?? true, + status: body.status ?? 200, + statusText: '', + headers: body.headers ?? new Headers(), + body: null, + text: async () => body.text ?? '', + json: async () => body.json ?? {}, + arrayBuffer: async () => body.arrayBuffer ?? new ArrayBuffer(0), + } +} + +beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: PINNED_IP, + originalHostname: 'example.agiloft.com', + }) +}) + +describe('POST /api/tools/agiloft/retrieve', () => { + it('rejects unauthenticated requests', async () => { + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValueOnce({ + success: false, + error: 'unauthorized', + }) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(401) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + }) + + it('blocks SSRF when the instance URL fails DNS validation', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValueOnce({ + isValid: false, + error: 'instanceUrl resolves to a blocked IP address', + }) + + const response = await POST( + createMockRequest('POST', { ...baseBody, instanceUrl: 'https://attacker.example.com' }) + ) + + expect(response.status).toBe(400) + const data = (await response.json()) as { success: false; error: string } + expect(data.success).toBe(false) + expect(data.error).toContain('blocked IP') + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + }) + + it('pins the resolved IP for login, retrieve, and logout (TOCTOU fix)', async () => { + const fileBytes = Buffer.from('hello-attachment', 'utf-8') + + inputValidationMockFns.mockSecureFetchWithPinnedIP + .mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-xyz' } })) + .mockResolvedValueOnce( + mockSecureFetchResponse({ + arrayBuffer: fileBytes.buffer.slice( + fileBytes.byteOffset, + fileBytes.byteOffset + fileBytes.byteLength + ) as ArrayBuffer, + headers: new Headers({ + 'content-type': 'text/plain', + 'content-disposition': 'attachment; filename="report.txt"', + }), + }) + ) + .mockResolvedValueOnce(mockSecureFetchResponse({})) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(200) + const data = (await response.json()) as { + success: true + output: { file: { name: string; mimeType: string; data: string; size: number } } + } + + expect(data.output.file.name).toBe('report.txt') + expect(data.output.file.mimeType).toBe('text/plain') + expect(data.output.file.size).toBe(fileBytes.length) + expect(Buffer.from(data.output.file.data, 'base64').toString('utf-8')).toBe('hello-attachment') + + const calls = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls + expect(calls).toHaveLength(3) + + // All three outbound calls must use the pre-resolved IP. + for (const call of calls) { + expect(call[1]).toBe(PINNED_IP) + } + + // Original hostname is preserved in the URL (so TLS SNI works). + expect(calls[0][0]).toContain('https://example.agiloft.com/ewws/EWLogin') + expect(calls[1][0]).toContain('https://example.agiloft.com/ewws/EWRetrieve') + expect(calls[1][2]).toMatchObject({ + method: 'GET', + headers: { Authorization: 'Bearer tok-xyz' }, + }) + expect(calls[2][0]).toContain('https://example.agiloft.com/ewws/EWLogout') + + // DNS only resolved once — no second lookup that could rebind. + expect(inputValidationMockFns.mockValidateUrlWithDNS).toHaveBeenCalledTimes(1) + }) + + it('propagates upstream errors and still calls logout', async () => { + inputValidationMockFns.mockSecureFetchWithPinnedIP + .mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-err' } })) + .mockResolvedValueOnce( + mockSecureFetchResponse({ ok: false, status: 404, text: 'Record not found' }) + ) + .mockResolvedValueOnce(mockSecureFetchResponse({})) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(404) + const data = (await response.json()) as { success: false; error: string } + expect(data.error).toContain('Record not found') + + // Logout still runs. + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(3) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[2][0]).toContain( + '/ewws/EWLogout' + ) + }) +}) diff --git a/apps/sim/app/api/tools/agiloft/retrieve/route.ts b/apps/sim/app/api/tools/agiloft/retrieve/route.ts index f7154d8d7f8..539f0bf7c2e 100644 --- a/apps/sim/app/api/tools/agiloft/retrieve/route.ts +++ b/apps/sim/app/api/tools/agiloft/retrieve/route.ts @@ -1,27 +1,23 @@ import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' 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 { secureFetchWithPinnedIP } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { agiloftLogin, agiloftLogout, buildRetrieveAttachmentUrl } from '@/tools/agiloft/utils' +import { buildRetrieveAttachmentUrl } from '@/tools/agiloft/utils' +import { + agiloftLoginPinned, + agiloftLogoutPinned, + resolveAgiloftInstance, +} from '@/tools/agiloft/utils.server' 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,21 +32,38 @@ 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) { + let resolvedIP: string + try { + resolvedIP = await resolveAgiloftInstance(data.instanceUrl) + } catch (error) { logger.warn(`[${requestId}] SSRF attempt blocked for Agiloft instance URL`, { instanceUrl: data.instanceUrl, }) - return NextResponse.json( - { success: false, error: urlValidation.error || 'Invalid instance URL' }, - { status: 400 } - ) + return NextResponse.json({ success: false, error: toError(error).message }, { status: 400 }) } - const token = await agiloftLogin(data) + const token = await agiloftLoginPinned(data, resolvedIP) const base = data.instanceUrl.replace(/\/$/, '') try { @@ -62,7 +75,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { position: data.position, }) - const agiloftResponse = await fetch(url, { + const agiloftResponse = await secureFetchWithPinnedIP(url, resolvedIP, { method: 'GET', headers: { Authorization: `Bearer ${token}`, @@ -114,22 +127,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } finally { - await agiloftLogout(data.instanceUrl, data.knowledgeBase, token) + await agiloftLogoutPinned(data.instanceUrl, data.knowledgeBase, token, resolvedIP) } } 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( - { success: false, error: error instanceof Error ? error.message : 'Internal server error' }, - { status: 500 } - ) + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) } }) diff --git a/apps/sim/app/api/tools/airtable/bases/route.ts b/apps/sim/app/api/tools/airtable/bases/route.ts index 12e5486e2d3..b2545df0e84 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 { type NextRequest, NextResponse } from 'next/server' +import { airtableBasesSelectorContract } 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,17 @@ const logger = createLogger('AirtableBasesAPI') 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 - - if (!credential) { + const parsed = await parseRequest(airtableBasesSelectorContract, request, {}) + if (!parsed.success) { logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + return parsed.response } + const { credential, workflowId } = 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/airtable/tables/route.ts b/apps/sim/app/api/tools/airtable/tables/route.ts index be7ba8e38eb..5d08b698747 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 { type NextRequest, NextResponse } from 'next/server' +import { airtableTablesSelectorContract } from '@/lib/api/contracts/selectors' +import { parseRequest } 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' @@ -10,21 +12,12 @@ const logger = createLogger('AirtableTablesAPI') 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, 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 parsed = await parseRequest(airtableTablesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, baseId } = parsed.data.body const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') if (!baseIdValidation.isValid) { @@ -32,7 +25,7 @@ export const POST = withRouteHandler(async (request: Request) => { return NextResponse.json({ error: baseIdValidation.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/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..df188ea652c 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 { getErrorMessage, 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, @@ -123,7 +112,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } 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..44dabf2404a 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 { getErrorMessage, 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, @@ -122,7 +116,7 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } 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..4eb3c56acf1 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,15 @@ import { CreateNamedQueryCommand } from '@aws-sdk/client-athena' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsAthenaCreateNamedQueryContract } from '@/lib/api/contracts/tools/aws/athena-create-named-query' +import { parseToolRequest } 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 +17,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 parseToolRequest(awsAthenaCreateNamedQueryContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = createAthenaClient({ region: data.region, @@ -56,14 +51,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Failed to create Athena named query') logger.error('CreateNamedQuery failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } 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..abf3b978387 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,15 @@ import { GetNamedQueryCommand } from '@aws-sdk/client-athena' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsAthenaGetNamedQueryContract } from '@/lib/api/contracts/tools/aws/athena-get-named-query' +import { parseToolRequest } 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 +17,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 parseToolRequest(awsAthenaGetNamedQueryContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = createAthenaClient({ region: data.region, @@ -54,13 +53,7 @@ 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' + const errorMessage = getErrorMessage(error, '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..899bc096adf 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,15 @@ import { GetQueryExecutionCommand } from '@aws-sdk/client-athena' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsAthenaGetQueryExecutionContract } from '@/lib/api/contracts/tools/aws/athena-get-query-execution' +import { parseToolRequest } 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 +17,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 parseToolRequest(awsAthenaGetQueryExecutionContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = createAthenaClient({ region: data.region, @@ -64,14 +63,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Failed to get Athena query execution') logger.error('GetQueryExecution failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } 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..2f1289db064 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,15 @@ import { GetQueryResultsCommand } from '@aws-sdk/client-athena' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsAthenaGetQueryResultsContract } from '@/lib/api/contracts/tools/aws/athena-get-query-results' +import { parseToolRequest } 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 +17,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 parseToolRequest(awsAthenaGetQueryResultsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = createAthenaClient({ region: data.region, @@ -75,14 +69,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Failed to get Athena query results') logger.error('GetQueryResults failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } 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..4b9c53d1a6e 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,15 @@ import { ListNamedQueriesCommand } from '@aws-sdk/client-athena' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsAthenaListNamedQueriesContract } from '@/lib/api/contracts/tools/aws/athena-list-named-queries' +import { parseToolRequest } 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 +17,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 parseToolRequest(awsAthenaListNamedQueriesContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = createAthenaClient({ region: data.region, @@ -52,14 +46,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Failed to list Athena named queries') logger.error('ListNamedQueries failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } 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..51602fdc937 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,15 @@ import { ListQueryExecutionsCommand } from '@aws-sdk/client-athena' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsAthenaListQueryExecutionsContract } from '@/lib/api/contracts/tools/aws/athena-list-query-executions' +import { parseToolRequest } 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 +17,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 parseToolRequest(awsAthenaListQueryExecutionsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = createAthenaClient({ region: data.region, @@ -52,14 +46,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Failed to list Athena query executions') logger.error('ListQueryExecutions failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } 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..4d4687d0a65 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,15 @@ import { StartQueryExecutionCommand } from '@aws-sdk/client-athena' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsAthenaStartQueryContract } from '@/lib/api/contracts/tools/aws/athena-start-query' +import { parseToolRequest } 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 +17,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 parseToolRequest(awsAthenaStartQueryContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = createAthenaClient({ region: data.region, @@ -68,13 +63,7 @@ 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' + const errorMessage = getErrorMessage(error, '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..186c8df8b90 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,15 @@ import { StopQueryExecutionCommand } from '@aws-sdk/client-athena' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsAthenaStopQueryContract } from '@/lib/api/contracts/tools/aws/athena-stop-query' +import { parseToolRequest } 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 +17,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 parseToolRequest(awsAthenaStopQueryContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = createAthenaClient({ region: data.region, @@ -44,13 +43,7 @@ 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' + const errorMessage = getErrorMessage(error, '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..ea30238d737 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 { type NextRequest, NextResponse } from 'next/server' +import { attioListsSelectorContract } from '@/lib/api/contracts/selectors/attio' +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' @@ -9,16 +11,26 @@ const logger = createLogger('AttioListsAPI') 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( + attioListsSelectorContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.error('Missing credential in request') + return NextResponse.json( + { error: getValidationErrorMessage(error, 'Invalid request') }, + { 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 } = parsed.data.body 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..38cc19d4bbc 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 { type NextRequest, NextResponse } from 'next/server' +import { attioObjectsSelectorContract } from '@/lib/api/contracts/selectors/attio' +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' @@ -9,16 +11,26 @@ const logger = createLogger('AttioObjectsAPI') 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( + attioObjectsSelectorContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.error('Missing credential in request') + return NextResponse.json( + { error: getValidationErrorMessage(error, 'Invalid request') }, + { 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 } = parsed.data.body 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..95ca9979054 100644 --- a/apps/sim/app/api/tools/box/upload/route.ts +++ b/apps/sim/app/api/tools/box/upload/route.ts @@ -1,32 +1,26 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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' +import { assertToolFileAccess } from '@/app/api/files/authorization' 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Box upload attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, @@ -36,8 +30,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 @@ -56,6 +51,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userFile = userFiles[0] logger.info(`[${requestId}] Downloading file: ${userFile.name} (${userFile.size} bytes)`) + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) fileName = validatedData.fileName || userFile.name } else if (validatedData.fileContent) { @@ -124,17 +121,9 @@ 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' }, + { success: false, error: getErrorMessage(error, 'Unknown error') }, { status: 500 } ) } 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..a46a22deb57 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 @@ -3,20 +3,15 @@ import { DescribeStackDriftDetectionStatusCommand, } from '@aws-sdk/client-cloudformation' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 { parseToolRequest } 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 +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 = DescribeStackDriftDetectionStatusSchema.parse(body) + const parsed = await parseToolRequest( + awsCloudformationDescribeStackDriftDetectionStatusContract, + request, + { + errorFormat: 'details', + logger, + } + ) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const client = new CloudFormationClient({ region: validatedData.region, @@ -54,14 +57,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Failed to describe stack drift detection status') logger.error('DescribeStackDriftDetectionStatus failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } 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..64775f37125 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 @@ -4,24 +4,15 @@ import { type StackEvent, } from '@aws-sdk/client-cloudformation' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudformationDescribeStackEventsContract } from '@/lib/api/contracts/tools/aws/cloudformation-describe-stack-events' +import { parseToolRequest } 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 +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 = DescribeStackEventsSchema.parse(body) + const parsed = await parseToolRequest(awsCloudformationDescribeStackEventsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const client = new CloudFormationClient({ region: validatedData.region, @@ -71,14 +66,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Failed to describe CloudFormation stack events') logger.error('DescribeStackEvents failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } 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..b2ff65c9723 100644 --- a/apps/sim/app/api/tools/cloudformation/describe-stacks/route.ts +++ b/apps/sim/app/api/tools/cloudformation/describe-stacks/route.ts @@ -4,20 +4,15 @@ import { type Stack, } from '@aws-sdk/client-cloudformation' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudformationDescribeStacksContract } from '@/lib/api/contracts/tools/aws/cloudformation-describe-stacks' +import { parseToolRequest } 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 +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 = DescribeStacksSchema.parse(body) + const parsed = await parseToolRequest(awsCloudformationDescribeStacksContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const client = new CloudFormationClient({ region: validatedData.region, @@ -79,14 +78,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Failed to describe CloudFormation stacks') logger.error('DescribeStacks failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } 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..d0c8a719574 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,14 @@ import { CloudFormationClient, DetectStackDriftCommand } from '@aws-sdk/client-cloudformation' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudformationDetectStackDriftContract } from '@/lib/api/contracts/tools/aws/cloudformation-detect-stack-drift' +import { parseToolRequest } 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 +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 = DetectStackDriftSchema.parse(body) + const parsed = await parseToolRequest(awsCloudformationDetectStackDriftContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const client = new CloudFormationClient({ region: validatedData.region, @@ -49,14 +48,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Failed to detect CloudFormation stack drift') logger.error('DetectStackDrift failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } 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..3b695de8ca0 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,14 @@ import { CloudFormationClient, GetTemplateCommand } from '@aws-sdk/client-cloudformation' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudformationGetTemplateContract } from '@/lib/api/contracts/tools/aws/cloudformation-get-template' +import { parseToolRequest } 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 +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 = GetTemplateSchema.parse(body) + const parsed = await parseToolRequest(awsCloudformationGetTemplateContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const client = new CloudFormationClient({ region: validatedData.region, @@ -46,14 +45,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Failed to get CloudFormation template') logger.error('GetTemplate failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } 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..b10c02872ce 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 @@ -4,20 +4,15 @@ import { type StackResourceSummary, } from '@aws-sdk/client-cloudformation' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudformationListStackResourcesContract } from '@/lib/api/contracts/tools/aws/cloudformation-list-stack-resources' +import { parseToolRequest } 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 +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 = ListStackResourcesSchema.parse(body) + const parsed = await parseToolRequest(awsCloudformationListStackResourcesContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const client = new CloudFormationClient({ region: validatedData.region, @@ -68,14 +67,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Failed to list CloudFormation stack resources') logger.error('ListStackResources failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } 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..da1af83142f 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,14 @@ import { CloudFormationClient, ValidateTemplateCommand } from '@aws-sdk/client-cloudformation' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsCloudformationValidateTemplateContract } from '@/lib/api/contracts/tools/aws/cloudformation-validate-template' +import { parseToolRequest } 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 +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 = ValidateTemplateSchema.parse(body) + const parsed = await parseToolRequest(awsCloudformationValidateTemplateContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const client = new CloudFormationClient({ region: validatedData.region, @@ -54,14 +53,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Failed to validate CloudFormation template') logger.error('ValidateTemplate failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } 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..1d7686266ec 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 { parseToolRequest } 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 parseToolRequest(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..bb0bff43904 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 { parseToolRequest } 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 parseToolRequest(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..8a8a5617797 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 { parseToolRequest } 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 parseToolRequest(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..57d8a14523a 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 { parseToolRequest } 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 parseToolRequest(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..615122c0da8 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 { parseToolRequest } 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 parseToolRequest(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..62660f290cb 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 { parseToolRequest } 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 parseToolRequest(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/mute-alarm/route.ts b/apps/sim/app/api/tools/cloudwatch/mute-alarm/route.ts new file mode 100644 index 00000000000..016b0e922bb --- /dev/null +++ b/apps/sim/app/api/tools/cloudwatch/mute-alarm/route.ts @@ -0,0 +1,100 @@ +import { CloudWatchClient, PutAlarmMuteRuleCommand } from '@aws-sdk/client-cloudwatch' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCloudwatchMuteAlarmContract } from '@/lib/api/contracts/tools/aws/cloudwatch-mute-alarm' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CloudWatchMuteAlarm') + +function toAtExpression(date: Date): string { + const yyyy = date.getUTCFullYear() + const mm = String(date.getUTCMonth() + 1).padStart(2, '0') + const dd = String(date.getUTCDate()).padStart(2, '0') + const hh = String(date.getUTCHours()).padStart(2, '0') + const min = String(date.getUTCMinutes()).padStart(2, '0') + return `at(${yyyy}-${mm}-${dd}T${hh}:${min})` +} + +function toIsoDuration(value: number, unit: 'minutes' | 'hours' | 'days'): string { + switch (unit) { + case 'minutes': + return `PT${value}M` + case 'hours': + return `PT${value}H` + case 'days': + return `P${value}D` + } +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCloudwatchMuteAlarmContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + const startDate = + validatedData.startDate !== undefined ? new Date(validatedData.startDate * 1000) : new Date() + const expression = toAtExpression(startDate) + const duration = toIsoDuration(validatedData.durationValue, validatedData.durationUnit) + + logger.info( + `Creating CloudWatch alarm mute rule "${validatedData.muteRuleName}" for ${validatedData.alarmNames.length} alarm(s) (${expression}, duration ${duration})` + ) + + const client = new CloudWatchClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new PutAlarmMuteRuleCommand({ + Name: validatedData.muteRuleName, + ...(validatedData.description && { Description: validatedData.description }), + Rule: { + Schedule: { + Expression: expression, + Duration: duration, + }, + }, + MuteTargets: { AlarmNames: validatedData.alarmNames }, + }) + + await client.send(command) + + logger.info(`Successfully created mute rule "${validatedData.muteRuleName}"`) + + return NextResponse.json({ + success: true, + output: { + success: true, + muteRuleName: validatedData.muteRuleName, + alarmNames: validatedData.alarmNames, + expression, + duration, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('MuteAlarm failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to create CloudWatch alarm mute rule: ${toError(error).message}` }, + { status: 500 } + ) + } +}) 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..5cf71338abe 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 { parseToolRequest } 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 parseToolRequest(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..8fb74b75351 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 { parseToolRequest } 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 parseToolRequest(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/cloudwatch/unmute-alarm/route.ts b/apps/sim/app/api/tools/cloudwatch/unmute-alarm/route.ts new file mode 100644 index 00000000000..f950d9146af --- /dev/null +++ b/apps/sim/app/api/tools/cloudwatch/unmute-alarm/route.ts @@ -0,0 +1,62 @@ +import { CloudWatchClient, DeleteAlarmMuteRuleCommand } from '@aws-sdk/client-cloudwatch' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCloudwatchUnmuteAlarmContract } from '@/lib/api/contracts/tools/aws/cloudwatch-unmute-alarm' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CloudWatchUnmuteAlarm') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCloudwatchUnmuteAlarmContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info(`Deleting CloudWatch alarm mute rule "${validatedData.muteRuleName}"`) + + const client = new CloudWatchClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new DeleteAlarmMuteRuleCommand({ + AlarmMuteRuleName: validatedData.muteRuleName, + }) + + await client.send(command) + + logger.info(`Successfully deleted mute rule "${validatedData.muteRuleName}"`) + + return NextResponse.json({ + success: true, + output: { + success: true, + muteRuleName: validatedData.muteRuleName, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('UnmuteAlarm failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to delete CloudWatch alarm mute rule: ${toError(error).message}` }, + { status: 500 } + ) + } +}) 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..07ddef573d1 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)) @@ -73,15 +35,26 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } - // Get current comment version - const getUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/footer-comments/${commentId}` - const getResponse = await fetch(getUrl, { + // Detect comment type — try footer-comments first, fall back to inline-comments + const apiBase = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2` + let commentEndpoint = 'footer-comments' + let getResponse = await fetch(`${apiBase}/footer-comments/${commentId}`, { headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}`, }, }) + if (getResponse.status === 404) { + commentEndpoint = 'inline-comments' + getResponse = await fetch(`${apiBase}/inline-comments/${commentId}`, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }) + } + if (!getResponse.ok) { const errorText = await getResponse.text() throw new Error( @@ -92,7 +65,7 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { const currentComment = await getResponse.json() const currentVersion = currentComment.version?.number || 1 - const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/footer-comments/${commentId}` + const url = `${apiBase}/${commentEndpoint}/${commentId}` const updateBody = { body: { @@ -147,15 +120,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)) @@ -164,9 +132,48 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } - const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/footer-comments/${commentId}` + const apiBase = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2` - const response = await fetch(url, { + // Detect comment type with a non-destructive GET so a 404 from a prior + // deletion isn't masked by a second DELETE attempt against the wrong endpoint. + let commentEndpoint = 'footer-comments' + let detectResponse = await fetch(`${apiBase}/footer-comments/${commentId}`, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (detectResponse.status === 404) { + commentEndpoint = 'inline-comments' + detectResponse = await fetch(`${apiBase}/inline-comments/${commentId}`, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }) + } + + if (!detectResponse.ok) { + const errorText = await detectResponse.text() + logger.error('Confluence API error response:', { + status: detectResponse.status, + statusText: detectResponse.statusText, + error: errorText, + }) + return NextResponse.json( + { + error: parseAtlassianErrorMessage( + detectResponse.status, + detectResponse.statusText, + errorText + ), + }, + { status: detectResponse.status } + ) + } + + const response = await fetch(`${apiBase}/${commentEndpoint}/${commentId}`, { method: 'DELETE', headers: { Accept: 'application/json', 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..03ee8d1630e 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) { @@ -256,6 +228,39 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/properties/${propertyId}` + let nextVersion = versionNumber + if (nextVersion === undefined) { + const lookupResponse = await fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }) + if (!lookupResponse.ok) { + const errorText = await lookupResponse.text() + return NextResponse.json( + { + error: parseAtlassianErrorMessage( + lookupResponse.status, + lookupResponse.statusText, + errorText + ), + }, + { status: lookupResponse.status } + ) + } + const current = await lookupResponse.json() + const currentNumber = current?.version?.number + if (typeof currentNumber !== 'number') { + return NextResponse.json( + { error: 'Could not determine current property version' }, + { status: 500 } + ) + } + nextVersion = currentNumber + 1 + } + const response = await fetch(url, { method: 'PUT', headers: { @@ -266,7 +271,7 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { body: JSON.stringify({ key, value, - version: { number: versionNumber }, + version: { number: nextVersion }, }), }) @@ -310,15 +315,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..e8a8b032480 100644 --- a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts +++ b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts @@ -1,10 +1,17 @@ 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' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' +import { + getAtlassianServiceAccountSecret, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' import { getConfluenceCloudId } from '@/tools/confluence/utils' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -12,11 +19,33 @@ const logger = createLogger('ConfluenceSelectorSpacesAPI') export const dynamic = 'force-dynamic' -export const POST = withRouteHandler(async (request: Request) => { +const PAGE_LIMIT = 250 + +type SpaceStatus = 'current' | 'archived' + +/** + * Cursor format: `:`. Empty inner cursor means "first page + * of that status". When current is exhausted we hand back `archived:` so the + * client transparently flips to the archived stream — listing both surfaces + * archived spaces in the dropdown, which would otherwise only be reachable by + * typing the space key manually even though sync works against archived spaces. + */ +function parseCursor(raw: string | undefined): { status: SpaceStatus; inner?: string } { + if (!raw) return { status: 'current' } + const idx = raw.indexOf(':') + if (idx === -1) return { status: 'current' } + const status = raw.slice(0, idx) === 'archived' ? 'archived' : 'current' + const inner = raw.slice(idx + 1) + return { status, inner: inner || undefined } +} + +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, cursor } = parsed.data.body if (!credential) { logger.error('Missing credential in request') @@ -27,7 +56,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, }) @@ -35,50 +64,57 @@ export const POST = withRouteHandler(async (request: Request) => { return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } + const resolved = await resolveOAuthAccountId(credential) + const isAtlassianServiceAccount = + resolved?.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID && !!resolved.credentialId + + let accessToken: string | null + let cloudId: string + if (isAtlassianServiceAccount) { + const secret = await getAtlassianServiceAccountSecret(resolved.credentialId!) + accessToken = secret.apiToken + cloudId = secret.cloudId + } else { + accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + cloudId = await getConfluenceCloudId(domain, accessToken) } - const cloudId = await getConfluenceCloudId(domain, accessToken) - const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') if (!cloudIdValidation.isValid) { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } - const url = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/spaces?limit=250` + const baseUrl = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/spaces` + const { status, inner } = parseCursor(cursor) + + const params = new URLSearchParams({ limit: String(PAGE_LIMIT), status }) + if (inner) params.set('cursor', inner) + const url = `${baseUrl}?${params.toString()}` const response = await fetch(url, { method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${accessToken}`, - }, + headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` }, }) if (!response.ok) { const errorText = await response.text() - logger.error('Confluence API error response:', { - status: response.status, - statusText: response.statusText, - error: errorText, - }) - return NextResponse.json( - { error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) }, - { status: response.status } - ) + const message = parseAtlassianErrorMessage(response.status, response.statusText, errorText) + logger.error('Confluence API error response', { error: message, status: response.status }) + return NextResponse.json({ error: message }, { status: 502 }) } const data = await response.json() @@ -86,9 +122,27 @@ export const POST = withRouteHandler(async (request: Request) => { id: space.id, name: space.name, key: space.key, + status, })) - return NextResponse.json({ spaces }) + let nextInner: string | undefined + const nextLink = data._links?.next as string | undefined + if (nextLink) { + try { + nextInner = new URL(nextLink, 'https://placeholder').searchParams.get('cursor') || undefined + } catch { + nextInner = undefined + } + } + + let nextCursor: string | undefined + if (nextInner) { + nextCursor = `${status}:${nextInner}` + } else if (status === 'current') { + nextCursor = 'archived:' + } + + return NextResponse.json({ spaces, nextCursor }) } catch (error) { logger.error('Error listing Confluence spaces:', error) return NextResponse.json( 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..e7dc345ef56 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 }) @@ -200,8 +224,6 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } - const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}` - if (!name && description === undefined) { return NextResponse.json( { error: 'At least one of name or description is required for update' }, @@ -209,39 +231,38 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { ) } - const updateBody: Record = {} - - if (name) { - updateBody.name = name - } else { - const currentResponse = await fetch(url, { - headers: { - Accept: 'application/json', - Authorization: `Bearer ${accessToken}`, + const lookupUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}` + const lookupResponse = await fetch(lookupUrl, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }) + if (!lookupResponse.ok) { + const errorText = await lookupResponse.text() + return NextResponse.json( + { + error: parseAtlassianErrorMessage( + lookupResponse.status, + lookupResponse.statusText, + errorText + ), }, - }) - if (!currentResponse.ok) { - const errorText = await currentResponse.text() - return NextResponse.json( - { - error: parseAtlassianErrorMessage( - currentResponse.status, - currentResponse.statusText, - errorText - ), - }, - { status: currentResponse.status } - ) - } - const currentSpace = await currentResponse.json() - updateBody.name = currentSpace.name + { status: lookupResponse.status } + ) } + const currentSpace = await lookupResponse.json() + const spaceKey = currentSpace.key + const updateBody: Record = { + name: name || currentSpace.name, + } if (description !== undefined) { - updateBody.description = { value: description, representation: 'plain' } + updateBody.description = { plain: { value: description, representation: 'plain' } } } - logger.info(`Updating space ${spaceId}`) + const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/rest/api/space/${encodeURIComponent(spaceKey)}` + logger.info(`Updating space ${spaceKey}`) const response = await fetch(url, { method: 'PUT', @@ -288,8 +309,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 }) @@ -315,9 +338,32 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } - const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}` + const lookupUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}` + const lookupResponse = await fetch(lookupUrl, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }) + if (!lookupResponse.ok) { + const errorText = await lookupResponse.text() + return NextResponse.json( + { + error: parseAtlassianErrorMessage( + lookupResponse.status, + lookupResponse.statusText, + errorText + ), + }, + { status: lookupResponse.status } + ) + } + const currentSpace = await lookupResponse.json() + const spaceKey = currentSpace.key + + const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/rest/api/space/${encodeURIComponent(spaceKey)}` - logger.info(`Deleting space ${spaceId}`) + logger.info(`Deleting space ${spaceKey}`) const response = await fetch(url, { method: 'DELETE', @@ -340,7 +386,26 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { ) } - return NextResponse.json({ spaceId, deleted: true }) + let longTask: { id?: string; statusLink?: string } = {} + try { + const text = await response.text() + if (text) { + const data = JSON.parse(text) + longTask = { + id: data?.id, + statusLink: data?.links?.status, + } + } + } catch { + // 204 No Content or non-JSON body — ignore + } + + return NextResponse.json({ + spaceId, + deleted: true, + longTaskId: longTask.id, + longTaskStatusLink: longTask.statusLink, + }) } catch (error) { logger.error('Error deleting Confluence space:', error) return NextResponse.json( 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..be4932a9964 100644 --- a/apps/sim/app/api/tools/confluence/upload-attachment/route.ts +++ b/apps/sim/app/api/tools/confluence/upload-attachment/route.ts @@ -1,10 +1,14 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 { assertToolFileAccess } from '@/app/api/files/authorization' import { getConfluenceCloudId } from '@/tools/confluence/utils' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -19,8 +23,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 +64,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 @@ -63,11 +77,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userFile = processSingleFileToUserFile(fileToProcess, 'confluence-upload', logger) } catch (error) { return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to process file' }, + { error: getErrorMessage(error, 'Failed to process file') }, { status: 400 } ) } + const denied = await assertToolFileAccess( + userFile.key, + auth.userId, + 'confluence-upload', + logger + ) + if (denied) return denied + let fileBuffer: Buffer try { fileBuffer = await downloadFileFromStorage(userFile, 'confluence-upload', logger) @@ -75,7 +97,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.error('Failed to download file from storage:', error) return NextResponse.json( { - error: `Failed to download file: ${error instanceof Error ? error.message : 'Unknown error'}`, + error: `Failed to download file: ${getErrorMessage(error, 'Unknown error')}`, }, { status: 500 } ) 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..92335d15c5e 100644 --- a/apps/sim/app/api/tools/crowdstrike/query/route.ts +++ b/apps/sim/app/api/tools/crowdstrike/query/route.ts @@ -1,102 +1,24 @@ 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 { 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 +123,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 +158,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 +282,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,18 +406,7 @@ 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' + const message = toError(error).message 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..b32e78ddd15 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { cursorDownloadArtifactContract } from '@/lib/api/contracts/tools/cursor' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -13,12 +15,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 +38,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')}` @@ -140,7 +152,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] Error downloading Cursor artifact:`, error) return NextResponse.json( - { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }, + { success: false, error: getErrorMessage(error, 'Unknown error occurred') }, { status: 500 } ) } diff --git a/apps/sim/app/api/tools/custom/route.ts b/apps/sim/app/api/tools/custom/route.ts index 9145c585524..05f76cef2a2 100644 --- a/apps/sim/app/api/tools/custom/route.ts +++ b/apps/sim/app/api/tools/custom/route.ts @@ -2,10 +2,16 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { customTools } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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/tools/custom' +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 +21,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 +62,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 +76,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,117 +97,121 @@ 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' + const errorMessage = getErrorMessage(error, 'Failed to update custom tools') return NextResponse.json({ error: errorMessage }, { status: 500 }) } }) -// 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 +220,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 +233,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 +247,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 +254,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..170dc658ad7 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 { discordChannelsContract } from '@/lib/api/contracts/tools/communication/discord' +import { parseRequest } 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,9 @@ 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 parsed = await parseRequest(discordChannelsContract, request, {}) + if (!parsed.success) return parsed.response + const { botToken, serverId, channelId } = parsed.data.body 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..5efc1b44d21 100644 --- a/apps/sim/app/api/tools/discord/send-message/route.ts +++ b/apps/sim/app/api/tools/discord/send-message/route.ts @@ -1,32 +1,27 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { discordSendMessageContract } from '@/lib/api/contracts/tools/communication/discord' +import { parseRequest } 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' +import { assertToolFileAccess } from '@/app/api/files/authorization' 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Discord send attempt: ${authResult.error}`) return NextResponse.json( { @@ -37,12 +32,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Discord send request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) - const body = await request.json() - const validatedData = DiscordSendMessageSchema.parse(body) + const parsed = await parseRequest(discordSendMessageContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const channelIdValidation = validateNumericId(validatedData.channelId, 'channelId') if (!channelIdValidation.isValid) { @@ -140,21 +137,38 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } formData.append('payload_json', JSON.stringify(payload)) + const accessResults = await Promise.all( + userFiles.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( + userFiles.map(async (file, i) => { + try { + logger.info(`[${requestId}] Downloading file ${i}: ${file.name}`) + return await downloadFileFromStorage(file, requestId, logger) + } catch (error) { + logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) + throw new Error( + `Failed to download attachment "${file.name}": ${getErrorMessage(error, 'Unknown error')}` + ) + } + }) + ) + for (let i = 0; i < userFiles.length; i++) { const userFile = userFiles[i] - logger.info(`[${requestId}] Downloading file ${i}: ${userFile.name}`) - - const buffer = await downloadFileFromStorage(userFile, requestId, logger) + const buffer = buffers[i] + logger.info(`[${requestId}] Added file ${i}: ${userFile.name} (${buffer.length} bytes)`) filesOutput.push({ name: userFile.name, mimeType: userFile.type || 'application/octet-stream', data: buffer.toString('base64'), size: buffer.length, }) - const blob = new Blob([new Uint8Array(buffer)], { type: userFile.type }) formData.append(`files[${i}]`, blob, userFile.name) - logger.info(`[${requestId}] Added file ${i}: ${userFile.name} (${buffer.length} bytes)`) } logger.info(`[${requestId}] Sending multipart request with ${userFiles.length} file(s)`) @@ -195,7 +209,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/discord/servers/route.ts b/apps/sim/app/api/tools/discord/servers/route.ts index e853e3e7291..6cd82e79028 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 { discordServersContract } from '@/lib/api/contracts/tools/communication/discord' +import { parseRequest } 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,9 @@ 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 parsed = await parseRequest(discordServersContract, request, {}) + if (!parsed.success) return parsed.response + const { botToken, serverId } = parsed.data.body 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..594587fc510 100644 --- a/apps/sim/app/api/tools/docusign/route.ts +++ b/apps/sim/app/api/tools/docusign/route.ts @@ -1,29 +1,95 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { docusignToolContract } from '@/lib/api/contracts/tools/docusign' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + assertKnownSizeWithinLimit, + DEFAULT_MAX_ERROR_BODY_BYTES, + isPayloadSizeLimitError, + readResponseJsonWithLimit, + readResponseTextWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot' +import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' 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' +import { assertToolFileAccess } from '@/app/api/files/authorization' const logger = createLogger('DocuSignAPI') +const MAX_DOCUSIGN_DOCUMENT_BYTES = 25 * 1024 * 1024 +const MAX_LEGACY_INLINE_DOCUMENT_BYTES = 7 * 1024 * 1024 +const MAX_DOCUSIGN_JSON_BYTES = 2 * 1024 * 1024 +const DOCUSIGN_FETCH_TIMEOUT_MS = 30_000 interface DocuSignAccountInfo { accountId: string baseUri: string } +async function readDocusignJson( + response: Response, + label: string +): Promise> { + return readResponseJsonWithLimit>(response, { + maxBytes: MAX_DOCUSIGN_JSON_BYTES, + label, + }) +} + +function docusignError(data: Record, fallback: string): string { + return ( + (typeof data.message === 'string' && data.message) || + (typeof data.errorCode === 'string' && data.errorCode) || + fallback + ) +} + +async function fetchDocusign( + input: string, + init: RequestInit = {}, + parentSignal?: AbortSignal +): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort(new Error('DocuSign request timed out')) + }, DOCUSIGN_FETCH_TIMEOUT_MS) + const abort = () => controller.abort(parentSignal?.reason ?? new Error('Request aborted')) + parentSignal?.addEventListener('abort', abort, { once: true }) + + try { + return await fetch(input, { ...init, signal: controller.signal }) + } finally { + clearTimeout(timeout) + parentSignal?.removeEventListener('abort', abort) + } +} + /** * Resolves the user's DocuSign account info from their access token * by calling the DocuSign userinfo endpoint. */ -async function resolveAccount(accessToken: string): Promise { - const response = await fetch('https://account-d.docusign.com/oauth/userinfo', { - headers: { Authorization: `Bearer ${accessToken}` }, - }) +async function resolveAccount( + accessToken: string, + signal?: AbortSignal +): Promise { + const response = await fetchDocusign( + 'https://account-d.docusign.com/oauth/userinfo', + { + headers: { Authorization: `Bearer ${accessToken}` }, + }, + signal + ) if (!response.ok) { - const errorText = await response.text() + const errorText = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'DocuSign account error response', + }).catch(() => '') logger.error('Failed to resolve DocuSign account', { status: response.status, error: errorText, @@ -31,10 +97,16 @@ async function resolveAccount(accessToken: string): Promise throw new Error(`Failed to resolve DocuSign account: ${response.status}`) } - const data = await response.json() - const accounts = data.accounts ?? [] + const data = await readDocusignJson(response, 'DocuSign account response') + const accounts = Array.isArray(data.accounts) + ? (data.accounts as Array<{ + is_default?: boolean + base_uri?: string + account_id?: string + }>) + : [] - const defaultAccount = accounts.find((a: { is_default: boolean }) => a.is_default) ?? accounts[0] + const defaultAccount = accounts.find((account) => account.is_default) ?? accounts[0] if (!defaultAccount) { throw new Error('No DocuSign accounts found for this user') } @@ -43,32 +115,41 @@ async function resolveAccount(accessToken: string): Promise if (!baseUri) { throw new Error('DocuSign account is missing base_uri') } + const accountId = defaultAccount.account_id + if (!accountId) { + throw new Error('DocuSign account is missing account_id') + } return { - accountId: defaultAccount.account_id, + accountId, baseUri, } } export const POST = withRouteHandler(async (request: NextRequest) => { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { 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) + const account = await resolveAccount(accessToken, request.signal) const apiBase = `${account.baseUri}/restapi/v2.1/accounts/${account.accountId}` const headers: Record = { Authorization: `Bearer ${accessToken}`, @@ -77,21 +158,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => { switch (operation) { case 'send_envelope': - return await handleSendEnvelope(apiBase, headers, params) + return await handleSendEnvelope(apiBase, headers, params, authResult.userId, request.signal) case 'create_from_template': - return await handleCreateFromTemplate(apiBase, headers, params) + return await handleCreateFromTemplate(apiBase, headers, params, request.signal) case 'get_envelope': - return await handleGetEnvelope(apiBase, headers, params) + return await handleGetEnvelope(apiBase, headers, params, request.signal) case 'list_envelopes': - return await handleListEnvelopes(apiBase, headers, params) + return await handleListEnvelopes(apiBase, headers, params, request.signal) case 'void_envelope': - return await handleVoidEnvelope(apiBase, headers, params) + return await handleVoidEnvelope(apiBase, headers, params, request.signal) case 'download_document': - return await handleDownloadDocument(apiBase, headers, params) + return await handleDownloadDocument( + apiBase, + headers, + params, + authResult.userId, + request.signal + ) case 'list_templates': - return await handleListTemplates(apiBase, headers, params) + return await handleListTemplates(apiBase, headers, params, request.signal) case 'list_recipients': - return await handleListRecipients(apiBase, headers, params) + return await handleListRecipients(apiBase, headers, params, request.signal) default: return NextResponse.json( { success: false, error: `Unknown operation: ${operation}` }, @@ -100,15 +187,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } catch (error) { logger.error('DocuSign API error', { operation, error }) - const message = error instanceof Error ? error.message : 'Internal server error' - return NextResponse.json({ success: false, error: message }, { status: 500 }) + const message = getErrorMessage(error, 'Internal server error') + return NextResponse.json( + { success: false, error: message }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) } }) async function handleSendEnvelope( apiBase: string, headers: Record, - params: Record + params: Record, + userId: string, + signal?: AbortSignal ) { const { signerEmail, signerName, emailSubject, emailBody, ccEmail, ccName, file, status } = params @@ -128,15 +220,31 @@ async function handleSendEnvelope( const userFiles = processFilesToUserFiles([parsed as RawFileInput], 'docusign-send', logger) if (userFiles.length > 0) { const userFile = userFiles[0] - const buffer = await downloadFileFromStorage(userFile, 'docusign-send', logger) + const denied = await assertToolFileAccess(userFile.key, userId, 'docusign-send', logger) + if (denied) return denied + if (userFile.size > MAX_DOCUSIGN_DOCUMENT_BYTES) { + return NextResponse.json( + { success: false, error: 'Document is too large to send through DocuSign' }, + { status: 413 } + ) + } + const buffer = await downloadFileFromStorage(userFile, 'docusign-send', logger, { + maxBytes: MAX_DOCUSIGN_DOCUMENT_BYTES, + }) + assertKnownSizeWithinLimit(buffer.length, MAX_DOCUSIGN_DOCUMENT_BYTES, 'DocuSign document') documentBase64 = buffer.toString('base64') documentName = userFile.name } } catch (fileError) { logger.error('Failed to process file for DocuSign envelope', { fileError }) return NextResponse.json( - { success: false, error: 'Failed to process uploaded file' }, - { status: 400 } + { + success: false, + error: isPayloadSizeLimitError(fileError) + ? getErrorMessage(fileError, 'Document is too large to send through DocuSign') + : 'Failed to process uploaded file', + }, + { status: isPayloadSizeLimitError(fileError) ? 413 : 400 } ) } } @@ -204,17 +312,21 @@ async function handleSendEnvelope( ) } - const response = await fetch(`${apiBase}/envelopes`, { - method: 'POST', - headers, - body: JSON.stringify(envelopeBody), - }) + const response = await fetchDocusign( + `${apiBase}/envelopes`, + { + method: 'POST', + headers, + body: JSON.stringify(envelopeBody), + }, + signal + ) - const data = await response.json() + const data = await readDocusignJson(response, 'DocuSign send envelope response') if (!response.ok) { logger.error('DocuSign send envelope failed', { data, status: response.status }) return NextResponse.json( - { success: false, error: data.message || data.errorCode || 'Failed to send envelope' }, + { success: false, error: docusignError(data, 'Failed to send envelope') }, { status: response.status } ) } @@ -225,7 +337,8 @@ async function handleSendEnvelope( async function handleCreateFromTemplate( apiBase: string, headers: Record, - params: Record + params: Record, + signal?: AbortSignal ) { const { templateId, emailSubject, emailBody, templateRoles, status } = params @@ -258,19 +371,23 @@ async function handleCreateFromTemplate( if (emailSubject) envelopeBody.emailSubject = emailSubject if (emailBody) envelopeBody.emailBlurb = emailBody - const response = await fetch(`${apiBase}/envelopes`, { - method: 'POST', - headers, - body: JSON.stringify(envelopeBody), - }) + const response = await fetchDocusign( + `${apiBase}/envelopes`, + { + method: 'POST', + headers, + body: JSON.stringify(envelopeBody), + }, + signal + ) - const data = await response.json() + const data = await readDocusignJson(response, 'DocuSign create from template response') if (!response.ok) { logger.error('DocuSign create from template failed', { data, status: response.status }) return NextResponse.json( { success: false, - error: data.message || data.errorCode || 'Failed to create envelope from template', + error: docusignError(data, 'Failed to create envelope from template'), }, { status: response.status } ) @@ -282,22 +399,24 @@ async function handleCreateFromTemplate( async function handleGetEnvelope( apiBase: string, headers: Record, - params: Record + params: Record, + signal?: AbortSignal ) { const { envelopeId } = params if (!envelopeId) { return NextResponse.json({ success: false, error: 'envelopeId is required' }, { status: 400 }) } - const response = await fetch( + const response = await fetchDocusign( `${apiBase}/envelopes/${(envelopeId as string).trim()}?include=recipients,documents`, - { headers } + { headers }, + signal ) - const data = await response.json() + const data = await readDocusignJson(response, 'DocuSign envelope response') if (!response.ok) { return NextResponse.json( - { success: false, error: data.message || data.errorCode || 'Failed to get envelope' }, + { success: false, error: docusignError(data, 'Failed to get envelope') }, { status: response.status } ) } @@ -308,7 +427,8 @@ async function handleGetEnvelope( async function handleListEnvelopes( apiBase: string, headers: Record, - params: Record + params: Record, + signal?: AbortSignal ) { const queryParams = new URLSearchParams() @@ -326,12 +446,12 @@ async function handleListEnvelopes( if (params.searchText) queryParams.append('search_text', params.searchText as string) if (params.count) queryParams.append('count', params.count as string) - const response = await fetch(`${apiBase}/envelopes?${queryParams}`, { headers }) - const data = await response.json() + const response = await fetchDocusign(`${apiBase}/envelopes?${queryParams}`, { headers }, signal) + const data = await readDocusignJson(response, 'DocuSign envelope list response') if (!response.ok) { return NextResponse.json( - { success: false, error: data.message || data.errorCode || 'Failed to list envelopes' }, + { success: false, error: docusignError(data, 'Failed to list envelopes') }, { status: response.status } ) } @@ -342,7 +462,8 @@ async function handleListEnvelopes( async function handleVoidEnvelope( apiBase: string, headers: Record, - params: Record + params: Record, + signal?: AbortSignal ) { const { envelopeId, voidedReason } = params if (!envelopeId) { @@ -352,16 +473,20 @@ async function handleVoidEnvelope( return NextResponse.json({ success: false, error: 'voidedReason is required' }, { status: 400 }) } - const response = await fetch(`${apiBase}/envelopes/${(envelopeId as string).trim()}`, { - method: 'PUT', - headers, - body: JSON.stringify({ status: 'voided', voidedReason }), - }) + const response = await fetchDocusign( + `${apiBase}/envelopes/${(envelopeId as string).trim()}`, + { + method: 'PUT', + headers, + body: JSON.stringify({ status: 'voided', voidedReason }), + }, + signal + ) - const data = await response.json() + const data = await readDocusignJson(response, 'DocuSign void envelope response') if (!response.ok) { return NextResponse.json( - { success: false, error: data.message || data.errorCode || 'Failed to void envelope' }, + { success: false, error: docusignError(data, 'Failed to void envelope') }, { status: response.status } ) } @@ -372,7 +497,9 @@ async function handleVoidEnvelope( async function handleDownloadDocument( apiBase: string, headers: Record, - params: Record + params: Record, + userId: string, + signal?: AbortSignal ) { const { envelopeId, documentId } = params if (!envelopeId) { @@ -381,17 +508,21 @@ async function handleDownloadDocument( const docId = (documentId as string) || 'combined' - const response = await fetch( + const response = await fetchDocusign( `${apiBase}/envelopes/${(envelopeId as string).trim()}/documents/${docId}`, { headers: { Authorization: headers.Authorization }, - } + }, + signal ) if (!response.ok) { let errorText = '' try { - errorText = await response.text() + errorText = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'DocuSign document error response', + }) } catch { // ignore } @@ -410,16 +541,50 @@ async function handleDownloadDocument( fileName = filenameMatch[1].replace(/['"]/g, '') } - const buffer = Buffer.from(await response.arrayBuffer()) - const base64Content = buffer.toString('base64') + const buffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_DOCUSIGN_DOCUMENT_BYTES, + label: 'DocuSign document download', + }) + + const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined + const workflowId = typeof params.workflowId === 'string' ? params.workflowId : undefined + const executionId = typeof params.executionId === 'string' ? params.executionId : undefined + const legacyInlineContent = + buffer.length <= MAX_LEGACY_INLINE_DOCUMENT_BYTES + ? { base64Content: buffer.toString('base64') } + : {} + + if (workspaceId && workflowId && executionId) { + const file = await uploadExecutionFile( + { workspaceId, workflowId, executionId }, + buffer, + fileName, + contentType, + userId + ) + return NextResponse.json({ + file, + mimeType: contentType, + fileName, + ...legacyInlineContent, + }) + } - return NextResponse.json({ base64Content, mimeType: contentType, fileName }) + const file = await uploadCopilotFile({ + buffer, + fileName, + contentType, + userId, + }) + + return NextResponse.json({ file, mimeType: contentType, fileName, ...legacyInlineContent }) } async function handleListTemplates( apiBase: string, headers: Record, - params: Record + params: Record, + signal?: AbortSignal ) { const queryParams = new URLSearchParams() if (params.searchText) queryParams.append('search_text', params.searchText as string) @@ -428,12 +593,12 @@ async function handleListTemplates( const queryString = queryParams.toString() const url = queryString ? `${apiBase}/templates?${queryString}` : `${apiBase}/templates` - const response = await fetch(url, { headers }) - const data = await response.json() + const response = await fetchDocusign(url, { headers }, signal) + const data = await readDocusignJson(response, 'DocuSign template list response') if (!response.ok) { return NextResponse.json( - { success: false, error: data.message || data.errorCode || 'Failed to list templates' }, + { success: false, error: docusignError(data, 'Failed to list templates') }, { status: response.status } ) } @@ -444,21 +609,26 @@ async function handleListTemplates( async function handleListRecipients( apiBase: string, headers: Record, - params: Record + params: Record, + signal?: AbortSignal ) { const { envelopeId } = params if (!envelopeId) { return NextResponse.json({ success: false, error: 'envelopeId is required' }, { status: 400 }) } - const response = await fetch(`${apiBase}/envelopes/${(envelopeId as string).trim()}/recipients`, { - headers, - }) - const data = await response.json() + const response = await fetchDocusign( + `${apiBase}/envelopes/${(envelopeId as string).trim()}/recipients`, + { + headers, + }, + signal + ) + const data = await readDocusignJson(response, 'DocuSign recipients response') if (!response.ok) { return NextResponse.json( - { success: false, error: data.message || data.errorCode || 'Failed to list recipients' }, + { success: false, error: docusignError(data, 'Failed to list recipients') }, { status: response.status } ) } 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..d9f375c819a 100644 --- a/apps/sim/app/api/tools/dropbox/upload/route.ts +++ b/apps/sim/app/api/tools/dropbox/upload/route.ts @@ -1,37 +1,27 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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' +import { assertToolFileAccess } from '@/app/api/files/authorization' 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Dropbox upload attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, @@ -41,8 +31,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 @@ -63,6 +54,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userFile = userFiles[0] logger.info(`[${requestId}] Downloading file: ${userFile.name} (${userFile.size} bytes)`) + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) fileName = userFile.name } else if (validatedData.fileContent) { @@ -116,17 +109,9 @@ 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' }, + { success: false, error: getErrorMessage(error, 'Unknown error') }, { status: 500 } ) } diff --git a/apps/sim/app/api/tools/dynamodb/delete/route.ts b/apps/sim/app/api/tools/dynamodb/delete/route.ts index f51c9704c56..e3097142d7c 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 { parseToolRequest } 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 parseToolRequest(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..a66bb21e090 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 { parseToolRequest } 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 parseToolRequest(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..c0e5dd3e2e0 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 { parseToolRequest } 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 parseToolRequest(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..5eabcc20d3f 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 { parseToolRequest } 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 parseToolRequest(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..62beffffe38 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 { parseToolRequest } 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 parseToolRequest(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..8d9ffe1d68e 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 { parseToolRequest } 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 parseToolRequest(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..02e1ee33687 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 { parseToolRequest } 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 parseToolRequest(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..cddd0c2d404 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +18,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({ @@ -32,7 +42,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: { note }, }) } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' + const message = getErrorMessage(error, 'Unknown error') logger.error('Failed to copy note', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } 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..3e8aa3f1e97 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +18,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 = @@ -45,7 +55,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: { note }, }) } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' + const message = getErrorMessage(error, 'Unknown error') logger.error('Failed to create note', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } 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..c8a5ddd9c7d 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +18,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({ @@ -32,7 +42,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: { notebook }, }) } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' + const message = getErrorMessage(error, 'Unknown error') logger.error('Failed to create notebook', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } 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..d0bd66517c2 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +18,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({ @@ -32,7 +42,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: { tag }, }) } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' + const message = getErrorMessage(error, 'Unknown error') logger.error('Failed to create tag', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } 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..c1d4d6f4e9c 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +18,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({ @@ -35,7 +45,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' + const message = getErrorMessage(error, 'Unknown error') logger.error('Failed to delete note', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } 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..5947a28ed98 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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,24 +18,31 @@ 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, output: { note }, }) } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' + const message = getErrorMessage(error, 'Unknown error') logger.error('Failed to get note', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } 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..08bb24f26c0 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +18,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({ @@ -32,7 +42,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: { notebook }, }) } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' + const message = getErrorMessage(error, 'Unknown error') logger.error('Failed to get notebook', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } diff --git a/apps/sim/app/api/tools/evernote/lib/client.ts b/apps/sim/app/api/tools/evernote/lib/client.ts index 05b80eb4829..795f61a8a40 100644 --- a/apps/sim/app/api/tools/evernote/lib/client.ts +++ b/apps/sim/app/api/tools/evernote/lib/client.ts @@ -37,7 +37,7 @@ export interface EvernoteNote { tagNames: string[] } -export interface EvernoteNoteMetadata { +interface EvernoteNoteMetadata { guid: string title: string | null contentLength: number | null diff --git a/apps/sim/app/api/tools/evernote/lib/thrift.ts b/apps/sim/app/api/tools/evernote/lib/thrift.ts index 3f51b6933b4..811bf3462a3 100644 --- a/apps/sim/app/api/tools/evernote/lib/thrift.ts +++ b/apps/sim/app/api/tools/evernote/lib/thrift.ts @@ -252,4 +252,4 @@ export class ThriftReader { } } -export { TYPE_BOOL, TYPE_I32, TYPE_I64, TYPE_LIST, TYPE_STOP, TYPE_STRING, TYPE_STRUCT } +export { TYPE_BOOL, TYPE_I32, TYPE_I64, TYPE_LIST, TYPE_STRING, TYPE_STRUCT } 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..bf55d7a87cb 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +18,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({ @@ -29,7 +42,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: { notebooks }, }) } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' + const message = getErrorMessage(error, 'Unknown error') logger.error('Failed to list notebooks', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } 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..ffbe255c16d 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +18,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({ @@ -29,7 +42,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: { tags }, }) } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' + const message = getErrorMessage(error, 'Unknown error') logger.error('Failed to list tags', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } 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..ae9a3120249 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +18,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( @@ -43,7 +53,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' + const message = getErrorMessage(error, 'Unknown error') logger.error('Failed to search notes', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } 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..f1d816328ef 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +18,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 = @@ -52,7 +62,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: { note }, }) } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' + const message = getErrorMessage(error, 'Unknown error') logger.error('Failed to update note', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } diff --git a/apps/sim/app/api/tools/extend/parse/route.ts b/apps/sim/app/api/tools/extend/parse/route.ts index c7f2a4da888..2c5a2e73722 100644 --- a/apps/sim/app/api/tools/extend/parse/route.ts +++ b/apps/sim/app/api/tools/extend/parse/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { extendParseContract } from '@/lib/api/contracts/tools/media/document-parse' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -8,7 +10,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 +17,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() @@ -45,8 +37,28 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = authResult.userId - const body = await request.json() - const validatedData = ExtendParseSchema.parse(body) + + const parsed = await parseRequest( + extendParseContract, + 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 validatedData = parsed.data.body logger.info(`[${requestId}] Extend parse request`, { fileName: validatedData.file?.name, @@ -164,24 +176,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 07519a30abd..61648a2a4d9 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -1,77 +1,253 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' +import { fileManageContract } from '@/lib/api/contracts/tools/file' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { splitWorkspaceFilePath } from '@/lib/copilot/tools/server/files/workspace-file' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { ensureAbsoluteUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { - downloadWorkspaceFile, - getWorkspaceFileByName, + fetchWorkspaceFileBuffer, + getWorkspaceFile, + resolveWorkspaceFileReference, updateWorkspaceFileContent, uploadWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { + assertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' const logger = createLogger('FileManageAPI') +const workspaceFileToUserFile = (file: Awaited>) => { + if (!file) return null + + return { + id: file.id, + name: file.name, + url: ensureAbsoluteUrl(file.path), + size: file.size, + type: file.type, + key: file.key, + context: 'workspace', + } +} + +const fileInputToUserFile = (fileInput: unknown) => { + if (!fileInput || typeof fileInput !== 'object' || Array.isArray(fileInput)) return null + + const record = fileInput as Record + const id = + typeof record.id === 'string' + ? record.id.trim() + : typeof record.fileId === 'string' + ? record.fileId.trim() + : '' + + // Objects with ids are resolved through workspace metadata. This fallback is for + // picker/upload values that only carry storage fields. + if (id) return null + + const key = typeof record.key === 'string' ? record.key.trim() : '' + const path = typeof record.path === 'string' ? record.path.trim() : '' + const url = typeof record.url === 'string' ? record.url.trim() : '' + const fileUrl = + url || path || (key ? `/api/files/serve/${encodeURIComponent(key)}?context=workspace` : '') + + if (!fileUrl && !key) return null + + return { + id: key || fileUrl, + name: + typeof record.name === 'string' && record.name.trim() ? record.name.trim() : 'workspace-file', + url: fileUrl ? ensureAbsoluteUrl(fileUrl) : '', + size: typeof record.size === 'number' ? record.size : 0, + type: + typeof record.type === 'string' && record.type.trim() + ? record.type.trim() + : 'application/octet-stream', + key, + context: 'workspace', + } +} + +const normalizeFileIdList = (value: unknown): string[] => { + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return [] + + try { + return normalizeFileIdList(JSON.parse(trimmed)) + } catch { + return [trimmed] + } + } + + if (!Array.isArray(value)) return [] + + return value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter((id) => id.length > 0) +} + +const extractUserFilesFromInput = (fileInput: unknown) => { + const inputs = Array.isArray(fileInput) ? fileInput : fileInput ? [fileInput] : [] + return inputs + .map((input) => fileInputToUserFile(input)) + .filter((file): file is NonNullable> => Boolean(file)) +} + +const extractFileIdsFromInput = (fileInput: unknown): string[] => { + const inputs = Array.isArray(fileInput) ? fileInput : fileInput ? [fileInput] : [] + + return inputs + .flatMap((input) => { + if (typeof input === 'string') return normalizeFileIdList(input) + if (input && typeof input === 'object') { + const record = input as Record + if (typeof record.id === 'string') return normalizeFileIdList(record.id) + if (typeof record.fileId === 'string') return normalizeFileIdList(record.fileId) + } + return [] + }) + .filter((id) => id.length > 0) +} + export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request, { requireWorkflowId: false }) if (!auth.success) { return NextResponse.json({ success: false, error: auth.error }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const userId = auth.userId || searchParams.get('userId') + const parsed = await parseRequest(fileManageContract, request, {}) + if (!parsed.success) return parsed.response + const { query, body } = parsed.data + const userId = auth.userId || query.userId if (!userId) { return NextResponse.json({ success: false, error: 'userId is required' }, { status: 400 }) } - let body: Record - 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 workspaceId = body.workspaceId || query.workspaceId if (!workspaceId) { return NextResponse.json({ success: false, error: 'workspaceId is required' }, { status: 400 }) } - const operation = body.operation as string - 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 + await assertActiveWorkspaceAccess(workspaceId, userId) + + switch (body.operation) { + case 'get': { + const { fileId, fileInput } = body + const selectedFileId = + fileId || + (fileInput && typeof fileInput === 'object' && !Array.isArray(fileInput) + ? (() => { + const obj = fileInput as Record + return typeof obj.id === 'string' + ? obj.id + : typeof obj.fileId === 'string' + ? obj.fileId + : '' + })() + : '') - if (!fileName) { + if (!selectedFileId) { + return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) + } + + const file = await getWorkspaceFile(workspaceId, selectedFileId) + if (!file) { return NextResponse.json( - { success: false, error: 'fileName is required for write operation' }, - { status: 400 } + { success: false, error: `File not found: "${selectedFileId}"` }, + { status: 404 } ) } - if (!content && content !== '') { + logger.info('File retrieved', { + fileId: file.id, + name: file.name, + }) + + return NextResponse.json({ + success: true, + data: { + file: workspaceFileToUserFile(file), + }, + }) + } + + case 'read': { + const { fileId, fileInput } = body + const selectedFileIds = Array.isArray(fileId) + ? fileId.map((id) => id.trim()).filter(Boolean) + : fileId + ? normalizeFileIdList(fileId) + : extractFileIdsFromInput(fileInput) + const selectedInputFiles = fileId ? [] : extractUserFilesFromInput(fileInput) + + if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) { + return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) + } + + const files = await Promise.all( + selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) + ) + const missingFileId = selectedFileIds.find((_, index) => !files[index]) + if (missingFileId) { return NextResponse.json( - { success: false, error: 'content is required for write operation' }, - { status: 400 } + { success: false, error: `File not found: "${missingFileId}"` }, + { status: 404 } ) } - const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(fileName)) + const userFiles = files + .map((file) => workspaceFileToUserFile(file)) + .filter((file): file is NonNullable> => + Boolean(file) + ) + .concat(selectedInputFiles) + + logger.info('Files retrieved', { + count: userFiles.length, + fileIds: userFiles.map((file) => file.id), + }) + + return NextResponse.json({ + success: true, + data: { + file: userFiles[0], + files: userFiles, + }, + }) + } + + case 'write': { + const { fileName, content, contentType } = body + const { folderSegments, leafName } = splitWorkspaceFilePath(fileName) + const folderId = await ensureWorkspaceFileFolderPath({ + workspaceId, + userId, + pathSegments: folderSegments, + }) + const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(leafName)) const fileBuffer = Buffer.from(content ?? '', 'utf-8') const result = await uploadWorkspaceFile( workspaceId, userId, fileBuffer, - fileName, - mimeType + leafName, + mimeType, + { folderId } ) logger.info('File created', { @@ -91,25 +267,50 @@ 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) { + case 'move': { + const { fileId, targetFolder } = body + const pathSegments = targetFolder.trim() + ? targetFolder + .trim() + .split('/') + .map((s) => s.trim()) + .filter(Boolean) + : [] + const targetFolderId = await ensureWorkspaceFileFolderPath({ + workspaceId, + userId, + pathSegments, + }) + const moveResult = await performMoveWorkspaceFileItems({ + workspaceId, + userId, + fileIds: [fileId], + targetFolderId, + }) + if (!moveResult.success) { return NextResponse.json( - { success: false, error: 'fileName is required for append operation' }, - { status: 400 } + { success: false, error: moveResult.error }, + { + status: + moveResult.errorCode === 'conflict' + ? 409 + : moveResult.errorCode === 'not_found' + ? 404 + : 400, + } ) } + logger.info('File moved', { fileId, targetFolder: targetFolder || '(root)' }) + return NextResponse.json({ + success: true, + data: { fileId, targetFolder: targetFolder || '(root)' }, + }) + } - if (!content && content !== '') { - return NextResponse.json( - { success: false, error: 'content is required for append operation' }, - { status: 400 } - ) - } + case 'append': { + const { fileName, content } = body - const existing = await getWorkspaceFileByName(workspaceId, fileName) + const existing = await resolveWorkspaceFileReference(workspaceId, fileName) if (!existing) { return NextResponse.json( { success: false, error: `File not found: "${fileName}"` }, @@ -118,7 +319,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const lockKey = `file-append:${workspaceId}:${existing.id}` - const lockValue = `${Date.now()}-${Math.random().toString(36).slice(2)}` + const lockValue = `${Date.now()}-${generateShortId()}` const acquired = await acquireLock(lockKey, lockValue, 30) if (!acquired) { return NextResponse.json( @@ -128,7 +329,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const existingBuffer = await downloadWorkspaceFile(existing) + const existingBuffer = await fetchWorkspaceFileBuffer(existing) const finalContent = existingBuffer.toString('utf-8') + content const fileBuffer = Buffer.from(finalContent, 'utf-8') await updateWorkspaceFileContent(workspaceId, existing.id, userId, fileBuffer) @@ -152,16 +353,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await releaseLock(lockKey, lockValue) } } - - default: - return NextResponse.json( - { success: false, error: `Unknown operation: ${operation}. Supported: write, append` }, - { status: 400 } - ) } } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - logger.error('File operation failed', { operation, error: message }) + if (isWorkspaceAccessDeniedError(error)) { + return NextResponse.json( + { success: false, error: 'Workspace access denied' }, + { status: 403 } + ) + } + const message = getErrorMessage(error, 'Unknown error') + logger.error('File operation failed', { operation: body.operation, error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } }) diff --git a/apps/sim/app/api/tools/firecrawl/parse/route.ts b/apps/sim/app/api/tools/firecrawl/parse/route.ts new file mode 100644 index 00000000000..409f74a6f16 --- /dev/null +++ b/apps/sim/app/api/tools/firecrawl/parse/route.ts @@ -0,0 +1,94 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { firecrawlParseContract } from '@/lib/api/contracts/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 { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('FirecrawlParseAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Firecrawl parse attempt`, { + error: authResult.error || 'Missing userId', + }) + return NextResponse.json( + { success: false, error: authResult.error || 'Unauthorized' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(firecrawlParseContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + const [userFile] = processFilesToUserFiles([validatedData.file], requestId, logger) + if (!userFile) { + return NextResponse.json({ success: false, error: 'File input is required' }, { status: 400 }) + } + + logger.info(`[${requestId}] Firecrawl parse request`, { + fileName: userFile.name, + size: userFile.size, + }) + + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + + const buffer = await downloadFileFromStorage(userFile, requestId, logger) + + const formData = new FormData() + const blob = new Blob([new Uint8Array(buffer)], { + type: userFile.type || 'application/octet-stream', + }) + formData.append('file', blob, userFile.name) + + if (validatedData.options && Object.keys(validatedData.options).length > 0) { + formData.append('options', JSON.stringify(validatedData.options)) + } + + const firecrawlResponse = await fetch('https://api.firecrawl.dev/v2/parse', { + method: 'POST', + headers: { + Authorization: `Bearer ${validatedData.apiKey}`, + }, + body: formData, + }) + + if (!firecrawlResponse.ok) { + const errorText = await firecrawlResponse.text() + logger.error(`[${requestId}] Firecrawl API error:`, errorText) + return NextResponse.json( + { + success: false, + error: `Firecrawl API error: ${errorText || firecrawlResponse.statusText}`, + }, + { status: firecrawlResponse.status } + ) + } + + const firecrawlData = await firecrawlResponse.json() + + logger.info(`[${requestId}] Firecrawl parse successful`) + + return NextResponse.json({ + success: true, + output: firecrawlData.data ?? firecrawlData, + }) + } catch (error) { + logger.error(`[${requestId}] Error in Firecrawl parse:`, error) + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } +}) 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..ed9575c9976 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +42,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 +59,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` @@ -188,7 +183,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) 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..41da810a2f0 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailAddLabelContract } from '@/lib/api/contracts/tools/google' +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 +14,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 +35,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,24 +114,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/gmail/archive/route.ts b/apps/sim/app/api/tools/gmail/archive/route.ts index 605209ebd44..46161021ffc 100644 --- a/apps/sim/app/api/tools/gmail/archive/route.ts +++ b/apps/sim/app/api/tools/gmail/archive/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailArchiveContract } from '@/lib/api/contracts/tools/google' +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 +13,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 +34,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,24 +84,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/gmail/delete/route.ts b/apps/sim/app/api/tools/gmail/delete/route.ts index 720faab7e80..ba83d8bf740 100644 --- a/apps/sim/app/api/tools/gmail/delete/route.ts +++ b/apps/sim/app/api/tools/gmail/delete/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailDeleteContract } from '@/lib/api/contracts/tools/google' +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 +13,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 +34,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,24 +81,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/gmail/draft/route.ts b/apps/sim/app/api/tools/gmail/draft/route.ts index 3186d7c1ee9..ca4ab7b2599 100644 --- a/apps/sim/app/api/tools/gmail/draft/route.ts +++ b/apps/sim/app/api/tools/gmail/draft/route.ts @@ -1,12 +1,14 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailDraftContract } from '@/lib/api/contracts/tools/google' +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 { assertToolFileAccess } from '@/app/api/files/authorization' import { base64UrlEncode, buildMimeMessage, @@ -20,26 +22,13 @@ 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Gmail draft attempt: ${authResult.error}`) return NextResponse.json( { @@ -50,12 +39,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Gmail draft request via ${authResult.authType}`, { - userId: authResult.userId, + 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, @@ -97,29 +88,34 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const attachmentBuffers = await Promise.all( + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( attachments.map(async (file) => { try { logger.info( `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` ) - - const buffer = await downloadFileFromStorage(file, requestId, logger) - - return { - filename: file.name, - mimeType: file.type || 'application/octet-stream', - content: buffer, - } + return await downloadFileFromStorage(file, requestId, logger) } catch (error) { logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) throw new Error( - `Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` + `Failed to download attachment "${file.name}": ${getErrorMessage(error, 'Unknown error')}` ) } }) ) + const attachmentBuffers = attachments.map((file, i) => ({ + filename: file.name, + mimeType: file.type || 'application/octet-stream', + content: buffers[i], + })) + const mimeMessage = buildMimeMessage({ to: validatedData.to, cc: validatedData.cc ?? undefined, @@ -198,24 +194,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/gmail/edit-draft/route.ts b/apps/sim/app/api/tools/gmail/edit-draft/route.ts new file mode 100644 index 00000000000..cac63c2ab92 --- /dev/null +++ b/apps/sim/app/api/tools/gmail/edit-draft/route.ts @@ -0,0 +1,200 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { gmailEditDraftContract } from '@/lib/api/contracts/tools/google' +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 { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' +import { + base64UrlEncode, + buildMimeMessage, + buildSimpleEmailMessage, + fetchThreadingHeaders, + GMAIL_API_BASE, +} from '@/tools/gmail/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('GmailEditDraftAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Gmail edit draft attempt: ${authResult.error}`) + return NextResponse.json( + { + success: false, + error: authResult.error || 'Authentication required', + }, + { status: 401 } + ) + } + + const userId = authResult.userId + logger.info( + `[${requestId}] Authenticated Gmail edit draft request via ${authResult.authType}`, + { userId } + ) + + const parsed = await parseRequest(gmailEditDraftContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info(`[${requestId}] Updating Gmail draft`, { + draftId: validatedData.draftId, + to: validatedData.to, + hasAttachments: !!(validatedData.attachments && validatedData.attachments.length > 0), + attachmentCount: validatedData.attachments?.length || 0, + }) + + const threadingHeaders = validatedData.replyToMessageId + ? await fetchThreadingHeaders(validatedData.replyToMessageId, validatedData.accessToken) + : {} + + const originalMessageId = threadingHeaders.messageId + const originalReferences = threadingHeaders.references + const originalSubject = threadingHeaders.subject + + let rawMessage: string | undefined + + if (validatedData.attachments && validatedData.attachments.length > 0) { + const rawAttachments = validatedData.attachments + const attachments = processFilesToUserFiles(rawAttachments, requestId, logger) + + if (attachments.length > 0) { + const totalSize = attachments.reduce((sum, file) => sum + file.size, 0) + const maxSize = 25 * 1024 * 1024 + + if (totalSize > maxSize) { + const sizeMB = (totalSize / (1024 * 1024)).toFixed(2) + return NextResponse.json( + { + success: false, + error: `Total attachment size (${sizeMB}MB) exceeds Gmail's limit of 25MB`, + }, + { status: 400 } + ) + } + + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( + attachments.map(async (file) => { + try { + logger.info( + `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` + ) + return await downloadFileFromStorage(file, requestId, logger) + } catch (error) { + logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) + throw new Error( + `Failed to download attachment "${file.name}": ${getErrorMessage(error, 'Unknown error')}` + ) + } + }) + ) + + const attachmentBuffers = attachments.map((file, i) => ({ + filename: file.name, + mimeType: file.type || 'application/octet-stream', + content: buffers[i], + })) + + const mimeMessage = buildMimeMessage({ + to: validatedData.to, + cc: validatedData.cc ?? undefined, + bcc: validatedData.bcc ?? undefined, + subject: validatedData.subject || originalSubject || '', + body: validatedData.body, + contentType: validatedData.contentType || 'text', + inReplyTo: originalMessageId, + references: originalReferences, + attachments: attachmentBuffers, + }) + + rawMessage = base64UrlEncode(mimeMessage) + } + } + + if (!rawMessage) { + rawMessage = buildSimpleEmailMessage({ + to: validatedData.to, + cc: validatedData.cc, + bcc: validatedData.bcc, + subject: validatedData.subject || originalSubject, + body: validatedData.body, + contentType: validatedData.contentType || 'text', + inReplyTo: originalMessageId, + references: originalReferences, + }) + } + + const draftMessage: { raw: string; threadId?: string } = { raw: rawMessage } + if (validatedData.threadId) { + draftMessage.threadId = validatedData.threadId + } + + const gmailResponse = await fetch( + `${GMAIL_API_BASE}/drafts/${encodeURIComponent(validatedData.draftId)}`, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${validatedData.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id: validatedData.draftId, + message: draftMessage, + }), + } + ) + + if (!gmailResponse.ok) { + const errorText = await gmailResponse.text() + logger.error(`[${requestId}] Gmail API error:`, errorText) + return NextResponse.json( + { + success: false, + error: `Gmail API error: ${gmailResponse.statusText}`, + }, + { status: gmailResponse.status } + ) + } + + const data = await gmailResponse.json() + + logger.info(`[${requestId}] Draft updated successfully`, { draftId: data.id }) + + return NextResponse.json({ + success: true, + output: { + draftId: data.id ?? null, + messageId: data.message?.id ?? null, + threadId: data.message?.threadId ?? null, + labelIds: data.message?.labelIds ?? null, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error updating Gmail draft:`, error) + + return NextResponse.json( + { + success: false, + error: getErrorMessage(error, 'Internal server error'), + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/gmail/label/route.ts b/apps/sim/app/api/tools/gmail/label/route.ts index 3524f76420e..75cd890a522 100644 --- a/apps/sim/app/api/tools/gmail/label/route.ts +++ b/apps/sim/app/api/tools/gmail/label/route.ts @@ -1,19 +1,13 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' +import { gmailLabelSelectorContract } from '@/lib/api/contracts/selectors/google' +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' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getScopesForService } from '@/lib/oauth/utils' -import { - getServiceAccountToken, - refreshAccessTokenIfNeeded, - resolveOAuthAccountId, - ServiceAccountTokenError, -} from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -23,25 +17,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated label request rejected`) - 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) { @@ -49,56 +28,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: labelIdValidation.error }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) + return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) } - let accessToken: string | null = null - - if (resolved.credentialType === 'service_account' && resolved.credentialId) { - accessToken = await getServiceAccountToken( - resolved.credentialId, - getScopesForService('gmail'), - impersonateEmail - ) - } else { - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - const accountRow = credentials[0] - - logger.info( - `[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}` - ) - - accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, - requestId, - getScopesForService('gmail') - ) - } + const accessToken = await refreshAccessTokenIfNeeded( + credentialId, + credAccess.credentialOwnerUserId, + requestId, + getScopesForService('gmail'), + impersonateEmail + ) if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) diff --git a/apps/sim/app/api/tools/gmail/labels/route.ts b/apps/sim/app/api/tools/gmail/labels/route.ts index 073c0226f2b..3b05cf12a9e 100644 --- a/apps/sim/app/api/tools/gmail/labels/route.ts +++ b/apps/sim/app/api/tools/gmail/labels/route.ts @@ -1,9 +1,8 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' +import { gmailLabelsSelectorContract } from '@/lib/api/contracts/selectors/google' +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' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -11,7 +10,6 @@ import { getScopesForService } from '@/lib/oauth/utils' import { getServiceAccountToken, refreshAccessTokenIfNeeded, - resolveOAuthAccountId, ServiceAccountTokenError, } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -30,22 +28,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated labels request rejected`) - 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) { @@ -53,52 +39,26 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } + const authz = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } let accessToken: string | null = null - if (resolved.credentialType === 'service_account' && resolved.credentialId) { + if (authz.credentialType === 'service_account') { accessToken = await getServiceAccountToken( - resolved.credentialId, + authz.resolvedCredentialId, getScopesForService('gmail'), impersonateEmail ) } else { - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - const accountRow = credentials[0] - - logger.info( - `[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}` - ) - accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credentialId, + authz.credentialOwnerUserId, requestId, getScopesForService('gmail') ) 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..a9843788a65 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailMarkReadContract } from '@/lib/api/contracts/tools/google' +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 +13,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 +34,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,24 +84,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) 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..d56f8328c1f 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailMarkUnreadContract } from '@/lib/api/contracts/tools/google' +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 +13,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 +37,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,24 +87,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/gmail/move/route.ts b/apps/sim/app/api/tools/gmail/move/route.ts index 6b599f8edc0..fb16b0c363e 100644 --- a/apps/sim/app/api/tools/gmail/move/route.ts +++ b/apps/sim/app/api/tools/gmail/move/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailMoveContract } from '@/lib/api/contracts/tools/google' +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 +13,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 +34,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,24 +106,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) 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..537f245217a 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailRemoveLabelContract } from '@/lib/api/contracts/tools/google' +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 +14,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 +38,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,24 +117,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/gmail/send/route.ts b/apps/sim/app/api/tools/gmail/send/route.ts index ee1f7767021..81818bfc98e 100644 --- a/apps/sim/app/api/tools/gmail/send/route.ts +++ b/apps/sim/app/api/tools/gmail/send/route.ts @@ -1,12 +1,14 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailSendContract } from '@/lib/api/contracts/tools/google' +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 { assertToolFileAccess } from '@/app/api/files/authorization' import { base64UrlEncode, buildMimeMessage, @@ -20,26 +22,13 @@ 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Gmail send attempt: ${authResult.error}`) return NextResponse.json( { @@ -50,12 +39,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Gmail send request via ${authResult.authType}`, { - userId: authResult.userId, + 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, @@ -97,29 +88,34 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const attachmentBuffers = await Promise.all( + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( attachments.map(async (file) => { try { logger.info( `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` ) - - const buffer = await downloadFileFromStorage(file, requestId, logger) - - return { - filename: file.name, - mimeType: file.type || 'application/octet-stream', - content: buffer, - } + return await downloadFileFromStorage(file, requestId, logger) } catch (error) { logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) throw new Error( - `Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` + `Failed to download attachment "${file.name}": ${getErrorMessage(error, 'Unknown error')}` ) } }) ) + const attachmentBuffers = attachments.map((file, i) => ({ + filename: file.name, + mimeType: file.type || 'application/octet-stream', + content: buffers[i], + })) + const mimeMessage = buildMimeMessage({ to: validatedData.to, cc: validatedData.cc ?? undefined, @@ -193,24 +189,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/gmail/unarchive/route.ts b/apps/sim/app/api/tools/gmail/unarchive/route.ts index f6f774e1574..3f81f633891 100644 --- a/apps/sim/app/api/tools/gmail/unarchive/route.ts +++ b/apps/sim/app/api/tools/gmail/unarchive/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { gmailUnarchiveContract } from '@/lib/api/contracts/tools/google' +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 +13,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 +34,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,24 +84,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) 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..8d063b8058c 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { googleDriveDownloadContract } from '@/lib/api/contracts/tools/google' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -36,14 +38,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 +55,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,17 +268,11 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) 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..9c0cd1ccd9f 100644 --- a/apps/sim/app/api/tools/google_drive/upload/route.ts +++ b/apps/sim/app/api/tools/google_drive/upload/route.ts @@ -1,12 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { googleDriveUploadContract } from '@/lib/api/contracts/tools/google' +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 { assertToolFileAccess } from '@/app/api/files/authorization' import { GOOGLE_WORKSPACE_MIME_TYPES, handleSheetsFormat, @@ -19,14 +22,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 */ @@ -60,7 +55,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Google Drive upload attempt: ${authResult.error}`) return NextResponse.json( { @@ -78,8 +73,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, @@ -108,7 +104,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Failed to process file', + error: getErrorMessage(error, 'Failed to process file'), }, { status: 400 } ) @@ -120,6 +116,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { size: userFile.size, }) + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + let fileBuffer: Buffer try { @@ -129,7 +128,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: `Failed to download file: ${error instanceof Error ? error.message : 'Unknown error'}`, + error: `Failed to download file: ${getErrorMessage(error, 'Unknown error')}`, }, { status: 500 } ) @@ -173,7 +172,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { metadata.parents = [validatedData.folderId.trim()] } - const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substring(7)}` + const boundary = `boundary_${Date.now()}_${generateShortId(7)}` const multipartBody = buildMultipartBody(metadata, fileBuffer, uploadMimeType, boundary) @@ -276,24 +275,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) 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_slides/export-presentation/route.ts b/apps/sim/app/api/tools/google_slides/export-presentation/route.ts new file mode 100644 index 00000000000..5c36785f8eb --- /dev/null +++ b/apps/sim/app/api/tools/google_slides/export-presentation/route.ts @@ -0,0 +1,176 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { googleSlidesExportPresentationContract } from '@/lib/api/contracts/tools/google' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + isPayloadSizeLimitError, + readResponseTextWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot' +import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' +import { presentationUrl } from '@/tools/google_slides/utils' + +const logger = createLogger('GoogleSlidesExportAPI') +const MAX_GOOGLE_SLIDES_EXPORT_BYTES = 10 * 1024 * 1024 +const MAX_LEGACY_INLINE_EXPORT_BYTES = 7 * 1024 * 1024 + +const FORMAT_TO_MIME = { + PDF: 'application/pdf', + PPTX: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + ODP: 'application/vnd.oasis.opendocument.presentation', + TXT: 'text/plain', + PNG: 'image/png', + JPEG: 'image/jpeg', + SVG: 'image/svg+xml', +} as const + +export const dynamic = 'force-dynamic' + +function buildExportUrl(presentationId: string, exportFormat: keyof typeof FORMAT_TO_MIME): string { + const mimeType = FORMAT_TO_MIME[exportFormat] + return `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(presentationId)}/export?mimeType=${encodeURIComponent(mimeType)}` +} + +function buildExportFilename( + presentationId: string, + exportFormat: keyof typeof FORMAT_TO_MIME +): string { + return `${presentationId}.${exportFormat.toLowerCase()}` +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest( + googleSlidesExportPresentationContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Invalid request data') }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + + try { + const body = parsed.data.body + const exportFormat = body.exportFormat ?? 'PDF' + const mimeType = FORMAT_TO_MIME[exportFormat] + const exportUrl = buildExportUrl(body.presentationId, exportFormat) + const urlValidation = await validateUrlWithDNS(exportUrl, 'googleSlidesExportUrl') + if (!urlValidation.isValid) { + return NextResponse.json( + { success: false, error: urlValidation.error || 'Invalid Google Slides export URL' }, + { status: 400 } + ) + } + + const response = await secureFetchWithPinnedIP(exportUrl, urlValidation.resolvedIP!, { + headers: { Authorization: `Bearer ${body.accessToken}` }, + maxResponseBytes: MAX_GOOGLE_SLIDES_EXPORT_BYTES, + }) + + if (!response.ok) { + const errorText = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Google Slides export error response', + }).catch(() => '') + return NextResponse.json( + { + success: false, + error: `Failed to export presentation: ${response.status} ${errorText}`, + }, + { status: response.status } + ) + } + + const buffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_GOOGLE_SLIDES_EXPORT_BYTES, + label: 'Google Slides export response', + }) + const filename = buildExportFilename(body.presentationId, exportFormat) + const legacyInlineContent = + buffer.length <= MAX_LEGACY_INLINE_EXPORT_BYTES + ? { contentBase64: buffer.toString('base64') } + : {} + const executionContext = + body.workspaceId && body.workflowId && body.executionId + ? { + workspaceId: body.workspaceId, + workflowId: body.workflowId, + executionId: body.executionId, + } + : undefined + + if (executionContext) { + const file = await uploadExecutionFile( + executionContext, + buffer, + filename, + mimeType, + authResult.userId + ) + return NextResponse.json({ + success: true, + output: { + file: { ...file, mimeType }, + exportFormat, + mimeType, + sizeBytes: buffer.length, + exportUrl: file.url, + ...legacyInlineContent, + metadata: { + presentationId: body.presentationId, + url: presentationUrl(body.presentationId), + exportFormat, + }, + }, + }) + } + + const file = await uploadCopilotFile({ + buffer, + fileName: filename, + contentType: mimeType, + userId: authResult.userId, + }) + + return NextResponse.json({ + success: true, + output: { + file, + exportUrl: file.url, + exportFormat, + mimeType, + sizeBytes: buffer.length, + ...legacyInlineContent, + metadata: { + presentationId: body.presentationId, + url: presentationUrl(body.presentationId), + exportFormat, + }, + }, + }) + } catch (error) { + logger.error('Google Slides export failed', { error }) + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Failed to export presentation') }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) + } +}) 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..9a492698afa 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { googleVaultDownloadExportFileContract } from '@/lib/api/contracts/tools/google' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -14,13 +16,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 +33,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 @@ -124,7 +120,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/hubspot/lists/route.ts b/apps/sim/app/api/tools/hubspot/lists/route.ts new file mode 100644 index 00000000000..ab7cf55230e --- /dev/null +++ b/apps/sim/app/api/tools/hubspot/lists/route.ts @@ -0,0 +1,106 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { hubspotListsSelectorContract } from '@/lib/api/contracts/selectors/hubspot' +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' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('HubSpotListsAPI') + +interface HubSpotList { + listId: string + name: string + objectTypeId?: string + processingType?: string + deletedAt?: string | null +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const parsed = await parseRequest(hubspotListsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credentialId, objectTypeId, query } = parsed.data.query + + const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) + if (!credentialIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`) + return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credentialId, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + } + + const params = new URLSearchParams() + if (objectTypeId) params.set('objectTypeId', objectTypeId as string) + params.set('count', '500') + + const response = await fetch( + `https://api.hubapi.com/crm/v3/lists/search?${params.toString()}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: '', + processingTypes: ['MANUAL', 'DYNAMIC', 'SNAPSHOT'], + ...(objectTypeId ? { additionalProperties: ['hs_object_id'] } : {}), + }), + } + ) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error(`[${requestId}] HubSpot lists API error ${response.status}: ${errorText}`) + return NextResponse.json( + { error: errorText || 'Failed to fetch HubSpot lists' }, + { status: response.status } + ) + } + + const data = (await response.json()) as { lists?: HubSpotList[] } + const filterTerm = (query as string | undefined)?.toLowerCase() + const lists = (data.lists ?? []) + .filter((l) => !l.deletedAt) + .map((l) => ({ + id: l.listId, + name: l.name, + objectType: l.objectTypeId, + processingType: l.processingType, + })) + .filter( + (l) => + !filterTerm || + l.id.toLowerCase().includes(filterTerm) || + l.name.toLowerCase().includes(filterTerm) + ) + .sort((a, b) => a.name.localeCompare(b.name)) + + return NextResponse.json({ lists }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching HubSpot lists:`, error) + return NextResponse.json({ error: 'Failed to fetch HubSpot lists' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/hubspot/owners/route.ts b/apps/sim/app/api/tools/hubspot/owners/route.ts new file mode 100644 index 00000000000..be34256def9 --- /dev/null +++ b/apps/sim/app/api/tools/hubspot/owners/route.ts @@ -0,0 +1,103 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { hubspotOwnersSelectorContract } from '@/lib/api/contracts/selectors/hubspot' +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' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('HubSpotOwnersAPI') + +interface HubSpotOwner { + id: string + email?: string + firstName?: string + lastName?: string + archived?: boolean +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const parsed = await parseRequest(hubspotOwnersSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credentialId, query } = parsed.data.query + + const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) + if (!credentialIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`) + return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credentialId, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + } + + const collected: HubSpotOwner[] = [] + let after: string | undefined + let pages = 0 + do { + const params = new URLSearchParams({ limit: '100' }) + if (after) params.set('after', after) + const response = await fetch(`https://api.hubapi.com/crm/v3/owners?${params.toString()}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error(`[${requestId}] HubSpot owners API error ${response.status}: ${errorText}`) + return NextResponse.json( + { error: errorText || 'Failed to fetch HubSpot owners' }, + { status: response.status } + ) + } + + const data = (await response.json()) as { + results?: HubSpotOwner[] + paging?: { next?: { after?: string } } + } + if (data.results?.length) collected.push(...data.results) + after = data.paging?.next?.after + pages++ + } while (after && pages < 10) + + const filterTerm = (query as string | undefined)?.toLowerCase() + const owners = collected + .filter((o) => !o.archived) + .map((o) => ({ + id: o.id, + name: [o.firstName, o.lastName].filter(Boolean).join(' ') || o.email || o.id, + email: o.email, + })) + .filter( + (o) => + !filterTerm || + o.name.toLowerCase().includes(filterTerm) || + (o.email?.toLowerCase().includes(filterTerm) ?? false) + ) + .sort((a, b) => a.name.localeCompare(b.name)) + + return NextResponse.json({ owners }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching HubSpot owners:`, error) + return NextResponse.json({ error: 'Failed to fetch HubSpot owners' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/hubspot/pipelines/route.ts b/apps/sim/app/api/tools/hubspot/pipelines/route.ts new file mode 100644 index 00000000000..fd9643bed3a --- /dev/null +++ b/apps/sim/app/api/tools/hubspot/pipelines/route.ts @@ -0,0 +1,90 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { hubspotPipelinesSelectorContract } from '@/lib/api/contracts/selectors/hubspot' +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' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('HubSpotPipelinesAPI') + +const BUILT_IN_PATH: Record = { + contact: 'contacts', + company: 'companies', + deal: 'deals', + ticket: 'tickets', +} + +interface HubSpotPipeline { + id: string + label: string + stages?: Array<{ id: string; label: string }> + archived?: boolean +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const parsed = await parseRequest(hubspotPipelinesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credentialId, objectType } = parsed.data.query + + const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) + if (!credentialIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`) + return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credentialId, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + } + + const pathSegment = BUILT_IN_PATH[objectType] ?? objectType + const response = await fetch( + `https://api.hubapi.com/crm/v3/pipelines/${encodeURIComponent(pathSegment)}`, + { headers: { Authorization: `Bearer ${accessToken}` } } + ) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error(`[${requestId}] HubSpot pipelines API error ${response.status}: ${errorText}`) + return NextResponse.json( + { error: errorText || 'Failed to fetch HubSpot pipelines' }, + { status: response.status } + ) + } + + const data = (await response.json()) as { results?: HubSpotPipeline[] } + const pipelines = (data.results ?? []) + .filter((p) => !p.archived) + .map((p) => ({ + id: p.id, + name: p.label, + stages: p.stages?.map((s) => ({ id: s.id, label: s.label })), + })) + .sort((a, b) => a.name.localeCompare(b.name)) + + return NextResponse.json({ pipelines }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching HubSpot pipelines:`, error) + return NextResponse.json({ error: 'Failed to fetch HubSpot pipelines' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/hubspot/properties/route.ts b/apps/sim/app/api/tools/hubspot/properties/route.ts new file mode 100644 index 00000000000..e52185455fc --- /dev/null +++ b/apps/sim/app/api/tools/hubspot/properties/route.ts @@ -0,0 +1,106 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { hubspotPropertiesSelectorContract } from '@/lib/api/contracts/selectors/hubspot' +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' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('HubSpotPropertiesAPI') + +const BUILT_IN_PATH: Record = { + contact: 'contacts', + company: 'companies', + deal: 'deals', + ticket: 'tickets', +} + +interface HubSpotProperty { + name: string + label: string + type?: string + fieldType?: string + groupName?: string + hidden?: boolean + archived?: boolean +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const parsed = await parseRequest(hubspotPropertiesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credentialId, objectType, query } = parsed.data.query + + const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) + if (!credentialIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`) + return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credentialId, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + } + + const pathSegment = BUILT_IN_PATH[objectType] ?? objectType + const response = await fetch( + `https://api.hubapi.com/crm/v3/properties/${encodeURIComponent(pathSegment)}`, + { headers: { Authorization: `Bearer ${accessToken}` } } + ) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error(`[${requestId}] HubSpot properties API error ${response.status}: ${errorText}`) + return NextResponse.json( + { error: errorText || 'Failed to fetch HubSpot properties' }, + { status: response.status } + ) + } + + const data = (await response.json()) as { results?: HubSpotProperty[] } + if (!Array.isArray(data.results)) { + return NextResponse.json({ error: 'Invalid HubSpot properties response' }, { status: 500 }) + } + + const filterTerm = (query as string | undefined)?.toLowerCase() + const properties = data.results + .filter((p) => !p.hidden && !p.archived) + .map((p) => ({ + id: p.name, + name: p.label || p.name, + type: p.type, + fieldType: p.fieldType, + groupName: p.groupName, + })) + .filter( + (p) => + !filterTerm || + p.id.toLowerCase().includes(filterTerm) || + p.name.toLowerCase().includes(filterTerm) + ) + .sort((a, b) => a.name.localeCompare(b.name)) + + return NextResponse.json({ properties }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching HubSpot properties:`, error) + return NextResponse.json({ error: 'Failed to fetch HubSpot properties' }, { status: 500 }) + } +}) 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..e0b0825368d 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 { parseToolRequest } 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 parseToolRequest(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..9ebed7c6438 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 { parseToolRequest } 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 parseToolRequest(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..f5c238808e0 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 { parseToolRequest } 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 parseToolRequest(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..1c0dded954e 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 { parseToolRequest } 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 parseToolRequest(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..bb8f857a764 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 { parseToolRequest } 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 parseToolRequest(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..73c82caad72 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 { parseToolRequest } 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 parseToolRequest(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..edf6acc38a7 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 { parseToolRequest } 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 parseToolRequest(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..77a7d2c8184 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 { parseToolRequest } 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 parseToolRequest(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..6ed84011d1d 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 { parseToolRequest } 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 parseToolRequest(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..e16e69843b0 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 { parseToolRequest } 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 parseToolRequest(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..9968b2f9b4c 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 { parseToolRequest } 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 parseToolRequest(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..4be677db5e7 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 { parseToolRequest } 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 parseToolRequest(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..b734fc8aa53 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 { parseToolRequest } 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 parseToolRequest(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..d49445e9bc4 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 { parseToolRequest } 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 parseToolRequest(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..9e3da6d05e5 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 { parseToolRequest } 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 parseToolRequest(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..ebf2214d0d9 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 { parseToolRequest } 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 parseToolRequest(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..4e160687c20 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 { parseToolRequest } 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 parseToolRequest(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..8d38bf0baa5 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 { parseToolRequest } 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 parseToolRequest(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..831d2cd9a62 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 { parseToolRequest } 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 parseToolRequest(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..da2728bf9d5 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 { parseToolRequest } 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 parseToolRequest(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..42dffe952a5 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 { parseToolRequest } 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 parseToolRequest(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..3d7c6dc0a05 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 { parseToolRequest } 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 parseToolRequest( + 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..dc39e6fb7a3 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 { parseToolRequest } 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,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = Schema.parse(body) + const parsed = await parseToolRequest(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 +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 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..82524418fea 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 { parseToolRequest } 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 parseToolRequest( + 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..ddf80ba8319 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 { parseToolRequest } 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 parseToolRequest( + 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..ca0c28eb931 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 { parseToolRequest } 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 parseToolRequest(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..e9c2fb0b44c 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 { parseToolRequest } 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 parseToolRequest(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..965222bf5f6 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 { parseToolRequest } 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 parseToolRequest(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..8528d277108 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 { parseToolRequest } 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 parseToolRequest( + 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..18be304cbbc 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 { parseToolRequest } 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 parseToolRequest(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..512a6444f5d 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 { parseToolRequest } 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 parseToolRequest(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..0c059a841f4 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 { parseToolRequest } 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 parseToolRequest(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..f72e80b3991 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 { parseToolRequest } 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 parseToolRequest(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..6476b53f5c9 100644 --- a/apps/sim/app/api/tools/image/route.ts +++ b/apps/sim/app/api/tools/image/route.ts @@ -1,23 +1,166 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' import { type NextRequest, NextResponse } from 'next/server' +import { + type ImageToolBody, + type imageProviders, + imageProxyQuerySchema, + imageToolContract, +} from '@/lib/api/contracts/tools/media/image' +import { + getValidationErrorMessage, + parseRequest, + searchParamsToObject, + validationErrorResponse, +} from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { secureFetchWithPinnedIP, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { + assertKnownSizeWithinLimit, + consumeOrCancelBody, + DEFAULT_MAX_ERROR_BODY_BYTES, + isPayloadSizeLimitError, + readResponseJsonWithLimit, + readResponseTextWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' +import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing' const logger = createLogger('ImageProxyAPI') +const MAX_IMAGE_BYTES = 25 * 1024 * 1024 +const MAX_IMAGE_JSON_BYTES = Math.ceil((MAX_IMAGE_BYTES * 4) / 3) + 256 * 1024 + +export const dynamic = 'force-dynamic' +export const maxDuration = 600 + +type ImageProvider = (typeof imageProviders)[number] + +interface GeneratedImageResult { + buffer: Buffer + contentType: string + fileName: string + provider: ImageProvider + model: string + sourceUrl?: string + description?: string + revisedPrompt?: string + seed?: number + jobId?: string + falaiCost?: FalAICostMetadata +} + +interface StoredImageResponse { + content: string + imageUrl: string + imageFile?: unknown + fileName: string + contentType: string + provider: ImageProvider + model: string + metadata: { + provider: ImageProvider + model: string + description?: string + revisedPrompt?: string + seed?: number + jobId?: string + contentType: string + } + __falaiCostDollars?: number + __falaiBilling?: FalAICostMetadata +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + logger.info(`[${requestId}] Image generation request started`) + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest( + imageToolContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid image generation request:`, error.issues) + return validationErrorResponse( + error, + getValidationErrorMessage(error, 'Invalid request data') + ) + }, + } + ) + if (!parsed.success) return parsed.response + + const body = parsed.data.body + const provider = body.provider as ImageProvider + const { apiKey, model, prompt } = body + + if (prompt.length < 3 || prompt.length > 4000) { + return NextResponse.json( + { error: 'Prompt must be between 3 and 4000 characters' }, + { status: 400 } + ) + } + + logger.info(`[${requestId}] Generating image with ${provider}, model: ${model || 'default'}`) + + let imageResult: GeneratedImageResult + try { + if (provider === 'openai') { + imageResult = await generateWithOpenAI(apiKey, body, requestId, logger) + } else if (provider === 'gemini') { + imageResult = await generateWithGemini(apiKey, body, requestId, logger) + } else if (provider === 'falai') { + imageResult = await generateWithFalAI(apiKey, body, requestId, logger) + } else { + return NextResponse.json({ error: `Unknown provider: ${provider}` }, { status: 400 }) + } + } catch (error) { + logger.error(`[${requestId}] Image generation failed:`, error) + const errorMessage = getErrorMessage(error, 'Image generation failed') + return NextResponse.json( + { error: errorMessage }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) + } + + const storedImage = await storeGeneratedImage(imageResult, body, authResult.userId, requestId) + + logger.info(`[${requestId}] Image generation completed successfully`, { + provider, + model: storedImage.model, + contentType: storedImage.contentType, + }) + + return NextResponse.json(storedImage) + } catch (error) { + logger.error(`[${requestId}] Image generation route error:`, error) + const errorMessage = getErrorMessage(error, 'Unknown error') + return NextResponse.json( + { error: errorMessage }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) + } +}) /** * Proxy for fetching images * 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 +169,15 @@ 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 = imageProxyQuerySchema.safeParse( + 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) { @@ -45,6 +193,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { const imageResponse = await secureFetchWithPinnedIP(imageUrl, urlValidation.resolvedIP!, { method: 'GET', + maxResponseBytes: MAX_IMAGE_BYTES, headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', @@ -59,6 +208,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) if (!imageResponse.ok) { + await consumeOrCancelBody(imageResponse) logger.error(`[${requestId}] Image fetch failed:`, { status: imageResponse.status, statusText: imageResponse.statusText, @@ -70,14 +220,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const contentType = imageResponse.headers.get('content-type') || 'image/jpeg' - const imageArrayBuffer = await imageResponse.arrayBuffer() + const imageBuffer = await readResponseToBufferWithLimit(imageResponse, { + maxBytes: MAX_IMAGE_BYTES, + label: 'image proxy response', + }) - if (imageArrayBuffer.byteLength === 0) { + if (imageBuffer.length === 0) { logger.error(`[${requestId}] Empty image received`) return new NextResponse('Empty image received', { status: 404 }) } - return new NextResponse(imageArrayBuffer, { + return new NextResponse(new Uint8Array(imageBuffer), { headers: { 'Content-Type': contentType, 'Access-Control-Allow-Origin': '*', @@ -89,19 +242,802 @@ export const GET = withRouteHandler(async (request: NextRequest) => { logger.error(`[${requestId}] Image proxy error:`, { error: errorMessage }) return new NextResponse(`Failed to proxy image: ${errorMessage}`, { - status: 500, + status: isPayloadSizeLimitError(error) ? 413 : 500, }) } }) -export const OPTIONS = withRouteHandler(async () => { - return new NextResponse(null, { - status: 204, +const OPENAI_IMAGE_MODELS = [ + 'gpt-image-2', + 'gpt-image-1.5', + 'gpt-image-1', + 'gpt-image-1-mini', +] as const +const OPENAI_IMAGE_SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const +const OPENAI_IMAGE_2_SIZES = [...OPENAI_IMAGE_SIZES, '2560x1440', '3840x2160'] as const +const OPENAI_IMAGE_QUALITIES = ['auto', 'low', 'medium', 'high'] as const +const OPENAI_IMAGE_BACKGROUNDS = ['auto', 'transparent', 'opaque'] as const +const IMAGE_OUTPUT_FORMATS = ['png', 'jpeg', 'webp'] as const +const OPENAI_MODERATION_LEVELS = ['auto', 'low'] as const + +const GEMINI_IMAGE_MODELS = [ + 'gemini-3.1-flash-image-preview', + 'gemini-3-pro-image-preview', + 'gemini-2.5-flash-image', +] as const +const GEMINI_BASE_ASPECT_RATIOS = [ + '1:1', + '2:3', + '3:2', + '3:4', + '4:3', + '4:5', + '5:4', + '9:16', + '16:9', + '21:9', +] as const +const GEMINI_EXTREME_ASPECT_RATIOS = ['1:4', '1:8', '4:1', '8:1'] as const +const GEMINI_IMAGE_SIZES = ['512', '1K', '2K', '4K'] as const +const GEMINI_PRO_IMAGE_SIZES = ['1K', '2K', '4K'] as const + +interface FalAIImageModelConfig { + endpoint: string + defaultSize?: string + sizeOptions?: readonly string[] + defaultAspectRatio?: string + aspectRatios?: readonly string[] + defaultResolution?: string + resolutionOptions?: readonly string[] + defaultOutputFormat?: string + outputFormats?: readonly string[] + defaultQuality?: string + qualityOptions?: readonly string[] + defaultBackground?: string + backgroundOptions?: readonly string[] + defaultSafetyTolerance?: string + safetyToleranceOptions?: readonly string[] + maxNumImages?: number + supportsSeed?: boolean + supportsEnableSafetyChecker?: boolean + supportsEnableWebSearch?: boolean + supportsThinkingLevel?: boolean +} + +const FALAI_NANO_BANANA_ASPECT_RATIOS = [ + 'auto', + '21:9', + '16:9', + '3:2', + '4:3', + '5:4', + '1:1', + '4:5', + '3:4', + '2:3', + '9:16', +] as const +const FALAI_EXTREME_ASPECT_RATIOS = ['4:1', '1:4', '8:1', '1:8'] as const +const FALAI_STANDARD_IMAGE_SIZES = [ + 'square_hd', + 'square', + 'portrait_4_3', + 'portrait_16_9', + 'landscape_4_3', + 'landscape_16_9', +] as const +const FALAI_SEEDREAM_IMAGE_SIZES = [...FALAI_STANDARD_IMAGE_SIZES, 'auto_2K', 'auto_4K'] as const + +const FALAI_IMAGE_MODEL_CONFIGS: Record = { + 'nano-banana-2': { + endpoint: 'fal-ai/nano-banana-2', + defaultAspectRatio: 'auto', + aspectRatios: [...FALAI_NANO_BANANA_ASPECT_RATIOS, ...FALAI_EXTREME_ASPECT_RATIOS], + defaultResolution: '1K', + resolutionOptions: ['0.5K', '1K', '2K', '4K'], + defaultOutputFormat: 'png', + outputFormats: IMAGE_OUTPUT_FORMATS, + defaultSafetyTolerance: '4', + safetyToleranceOptions: ['1', '2', '3', '4', '5', '6'], + maxNumImages: 4, + supportsSeed: true, + supportsEnableWebSearch: true, + supportsThinkingLevel: true, + }, + 'nano-banana-pro': { + endpoint: 'fal-ai/nano-banana-pro', + defaultAspectRatio: '1:1', + aspectRatios: FALAI_NANO_BANANA_ASPECT_RATIOS, + defaultResolution: '1K', + resolutionOptions: ['1K', '2K', '4K'], + defaultOutputFormat: 'png', + outputFormats: IMAGE_OUTPUT_FORMATS, + defaultSafetyTolerance: '4', + safetyToleranceOptions: ['1', '2', '3', '4', '5', '6'], + maxNumImages: 4, + supportsSeed: true, + supportsEnableWebSearch: true, + }, + 'nano-banana': { + endpoint: 'fal-ai/nano-banana', + defaultAspectRatio: '1:1', + aspectRatios: FALAI_NANO_BANANA_ASPECT_RATIOS.filter((ratio) => ratio !== 'auto'), + defaultOutputFormat: 'png', + outputFormats: IMAGE_OUTPUT_FORMATS, + defaultSafetyTolerance: '4', + safetyToleranceOptions: ['1', '2', '3', '4', '5', '6'], + maxNumImages: 4, + supportsSeed: true, + }, + 'gpt-image-1.5': { + endpoint: 'fal-ai/gpt-image-1.5', + defaultSize: '1024x1024', + sizeOptions: ['1024x1024', '1536x1024', '1024x1536'], + defaultQuality: 'high', + qualityOptions: ['low', 'medium', 'high'], + defaultBackground: 'auto', + backgroundOptions: OPENAI_IMAGE_BACKGROUNDS, + defaultOutputFormat: 'png', + outputFormats: IMAGE_OUTPUT_FORMATS, + maxNumImages: 4, + }, + 'seedream-v4.5': { + endpoint: 'fal-ai/bytedance/seedream/v4.5/text-to-image', + defaultSize: 'auto_2K', + sizeOptions: FALAI_SEEDREAM_IMAGE_SIZES, + maxNumImages: 6, + supportsSeed: true, + supportsEnableSafetyChecker: true, + }, + 'flux-2-pro': { + endpoint: 'fal-ai/flux-2-pro', + defaultSize: 'landscape_4_3', + sizeOptions: FALAI_STANDARD_IMAGE_SIZES, + defaultOutputFormat: 'jpeg', + outputFormats: ['jpeg', 'png'], + defaultSafetyTolerance: '2', + safetyToleranceOptions: ['1', '2', '3', '4', '5'], + supportsSeed: true, + supportsEnableSafetyChecker: true, + }, + 'grok-imagine-image': { + endpoint: 'xai/grok-imagine-image', + defaultAspectRatio: '1:1', + aspectRatios: [ + '2:1', + '20:9', + '19.5:9', + '16:9', + '4:3', + '3:2', + '1:1', + '2:3', + '3:4', + '9:16', + '9:19.5', + '9:20', + '1:2', + ], + defaultResolution: '1k', + resolutionOptions: ['1k', '2k'], + defaultOutputFormat: 'jpeg', + outputFormats: IMAGE_OUTPUT_FORMATS, + maxNumImages: 4, + }, +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function getStringProperty( + record: Record | undefined, + key: string +): string | undefined { + const value = record?.[key] + return typeof value === 'string' ? value : undefined +} + +function getNumberProperty( + record: Record | undefined, + key: string +): number | undefined { + const value = record?.[key] + return typeof value === 'number' ? value : undefined +} + +function firstRecord(value: unknown): Record | undefined { + return Array.isArray(value) ? value.find(isRecord) : undefined +} + +function pickAllowed( + value: string | undefined, + allowed: readonly string[], + fallback: string +): string { + return value && allowed.includes(value) ? value : fallback +} + +function clampInteger( + value: number | undefined, + min: number, + max: number, + fallback: number +): number { + if (typeof value !== 'number' || !Number.isInteger(value)) return fallback + return Math.min(Math.max(value, min), max) +} + +function getContentTypeForFormat(format: string | undefined): string { + if (format === 'jpeg') return 'image/jpeg' + if (format === 'webp') return 'image/webp' + return 'image/png' +} + +function extensionFromContentType(contentType: string): string { + if (contentType.includes('jpeg') || contentType.includes('jpg')) return 'jpg' + if (contentType.includes('webp')) return 'webp' + return 'png' +} + +async function bufferFromImageUrl(url: string): Promise<{ buffer: Buffer; contentType: string }> { + if (url.startsWith('data:')) { + const match = /^data:([^;]+);base64,(.+)$/u.exec(url) + if (!match) throw new Error('Invalid data URI image response') + const buffer = Buffer.from(match[2], 'base64') + assertKnownSizeWithinLimit(buffer.length, MAX_IMAGE_BYTES, 'inline image response') + return { + contentType: match[1], + buffer, + } + } + + const urlValidation = await validateUrlWithDNS(url, 'imageUrl') + if (!urlValidation.isValid || !urlValidation.resolvedIP) { + throw new Error(urlValidation.error || 'Generated image URL failed validation') + } + + const imageResponse = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP, { + method: 'GET', + maxResponseBytes: MAX_IMAGE_BYTES, + }) + if (!imageResponse.ok) { + await readResponseTextWithLimit(imageResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'generated image error response', + }).catch(() => '') + throw new Error(`Failed to download generated image: ${imageResponse.status}`) + } + + const contentType = imageResponse.headers.get('content-type') || 'image/png' + const buffer = await readResponseToBufferWithLimit(imageResponse, { + maxBytes: MAX_IMAGE_BYTES, + label: 'generated image download', + }) + return { buffer, contentType } +} + +async function generateWithOpenAI( + apiKey: string, + body: ImageToolBody, + requestId: string, + logger: ReturnType +): Promise { + const model = pickAllowed(body.model, OPENAI_IMAGE_MODELS, 'gpt-image-1.5') + const size = + model === 'gpt-image-2' + ? pickAllowed(body.size, OPENAI_IMAGE_2_SIZES, 'auto') + : pickAllowed(body.size, OPENAI_IMAGE_SIZES, 'auto') + const outputFormat = pickAllowed(body.outputFormat, IMAGE_OUTPUT_FORMATS, 'png') + const requestBody: Record = { + model, + prompt: body.prompt, + size, + n: 1, + } + + if (body.quality) { + requestBody.quality = pickAllowed(body.quality, OPENAI_IMAGE_QUALITIES, 'auto') + } + if (body.background) { + requestBody.background = pickAllowed(body.background, OPENAI_IMAGE_BACKGROUNDS, 'auto') + } + if (body.outputFormat) { + requestBody.output_format = outputFormat + } + if (body.moderation) { + requestBody.moderation = pickAllowed(body.moderation, OPENAI_MODERATION_LEVELS, 'auto') + } + + const openaiResponse = await fetch('https://api.openai.com/v1/images/generations', { + method: 'POST', headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - 'Access-Control-Max-Age': '86400', + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', }, + body: JSON.stringify(requestBody), }) -}) + + if (!openaiResponse.ok) { + const error = await readResponseTextWithLimit(openaiResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'OpenAI image error response', + }) + throw new Error(`OpenAI API error: ${openaiResponse.status} - ${error}`) + } + + const data = await readResponseJsonWithLimit(openaiResponse, { + maxBytes: MAX_IMAGE_JSON_BYTES, + label: 'OpenAI image response', + }) + if (!isRecord(data)) { + throw new Error('Invalid OpenAI image response') + } + + const firstImage = firstRecord(data.data) + const base64Image = getStringProperty(firstImage, 'b64_json') + const imageUrl = getStringProperty(firstImage, 'url') + const revisedPrompt = getStringProperty(firstImage, 'revised_prompt') + let buffer: Buffer + let contentType = getContentTypeForFormat(outputFormat) + + if (base64Image) { + buffer = Buffer.from(base64Image, 'base64') + assertKnownSizeWithinLimit(buffer.length, MAX_IMAGE_BYTES, 'OpenAI image response') + } else if (imageUrl) { + const downloaded = await bufferFromImageUrl(imageUrl) + buffer = downloaded.buffer + contentType = downloaded.contentType + } else { + logger.error(`[${requestId}] OpenAI response missing image payload`) + throw new Error('No image data found in OpenAI response') + } + + return { + buffer, + contentType, + fileName: `openai-${model}.${extensionFromContentType(contentType)}`, + provider: 'openai', + model, + sourceUrl: imageUrl, + revisedPrompt, + } +} + +async function generateWithGemini( + apiKey: string, + body: ImageToolBody, + requestId: string, + logger: ReturnType +): Promise { + const model = pickAllowed(body.model, GEMINI_IMAGE_MODELS, 'gemini-3.1-flash-image-preview') + const aspectRatios = + model === 'gemini-3.1-flash-image-preview' + ? [...GEMINI_BASE_ASPECT_RATIOS, ...GEMINI_EXTREME_ASPECT_RATIOS] + : GEMINI_BASE_ASPECT_RATIOS + const imageConfig: Record = {} + + if (body.aspectRatio) { + imageConfig.aspectRatio = pickAllowed(body.aspectRatio, aspectRatios, '1:1') + } + + if (model === 'gemini-3.1-flash-image-preview' && body.resolution) { + imageConfig.imageSize = pickAllowed(body.resolution, GEMINI_IMAGE_SIZES, '1K') + } else if (model === 'gemini-3-pro-image-preview' && body.resolution) { + imageConfig.imageSize = pickAllowed(body.resolution, GEMINI_PRO_IMAGE_SIZES, '1K') + } + + const requestBody: Record = { + contents: [ + { + parts: [{ text: body.prompt }], + }, + ], + } + + requestBody.generationConfig = { + responseModalities: ['TEXT', 'IMAGE'], + ...(Object.keys(imageConfig).length > 0 && { imageConfig }), + } + + const geminiResponse = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, + { + method: 'POST', + headers: { + 'x-goog-api-key': apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + } + ) + + if (!geminiResponse.ok) { + const error = await readResponseTextWithLimit(geminiResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Gemini image error response', + }) + throw new Error(`Gemini API error: ${geminiResponse.status} - ${error}`) + } + + const data = await readResponseJsonWithLimit(geminiResponse, { + maxBytes: MAX_IMAGE_JSON_BYTES, + label: 'Gemini image response', + }) + if (!isRecord(data)) { + throw new Error('Invalid Gemini image response') + } + + const candidate = firstRecord(data.candidates) + const content = isRecord(candidate?.content) ? candidate.content : undefined + const parts = Array.isArray(content?.parts) ? content.parts : [] + const textPart = parts.find((part) => isRecord(part) && typeof part.text === 'string') + const imagePart = parts.find((part) => { + if (!isRecord(part)) return false + return isRecord(part.inlineData) || isRecord(part.inline_data) + }) + + if (!isRecord(imagePart)) { + logger.error(`[${requestId}] Gemini response missing image part`) + throw new Error('No image data found in Gemini response') + } + + const inlineData = isRecord(imagePart.inlineData) + ? imagePart.inlineData + : isRecord(imagePart.inline_data) + ? imagePart.inline_data + : undefined + const base64Image = getStringProperty(inlineData, 'data') + const contentType = + getStringProperty(inlineData, 'mimeType') || + getStringProperty(inlineData, 'mime_type') || + 'image/png' + + if (!base64Image) { + throw new Error('Gemini image response missing inline image data') + } + + return { + buffer: (() => { + const buffer = Buffer.from(base64Image, 'base64') + assertKnownSizeWithinLimit(buffer.length, MAX_IMAGE_BYTES, 'Gemini image response') + return buffer + })(), + contentType, + fileName: `gemini-${model}.${extensionFromContentType(contentType)}`, + provider: 'gemini', + model, + description: isRecord(textPart) ? getStringProperty(textPart, 'text') : undefined, + } +} + +function buildFalAIQueueUrl(endpoint: string, requestId: string, path: 'status' | 'response') { + return `https://queue.fal.run/${endpoint}/requests/${requestId}/${path}` +} + +function getFalAIErrorMessage(error: unknown): string { + if (typeof error === 'string') return error + if (isRecord(error)) { + return ( + getStringProperty(error, 'message') || + getStringProperty(error, 'detail') || + JSON.stringify(error) + ) + } + return 'Unknown Fal.ai error' +} + +async function generateWithFalAI( + apiKey: string, + body: ImageToolBody, + requestId: string, + logger: ReturnType +): Promise { + const model = body.model || 'nano-banana-2' + const modelConfig = FALAI_IMAGE_MODEL_CONFIGS[model] + if (!modelConfig) { + throw new Error(`Unknown Fal.ai image model: ${model}`) + } + + const requestBody: Record = { + prompt: body.prompt, + sync_mode: false, + } + + if (modelConfig.maxNumImages) { + requestBody.num_images = clampInteger(body.numImages, 1, modelConfig.maxNumImages, 1) + } + if (modelConfig.supportsSeed && body.seed !== undefined) { + requestBody.seed = body.seed + } + if (modelConfig.sizeOptions && modelConfig.defaultSize) { + requestBody.image_size = pickAllowed( + body.size, + modelConfig.sizeOptions, + modelConfig.defaultSize + ) + } + if (modelConfig.aspectRatios && modelConfig.defaultAspectRatio) { + requestBody.aspect_ratio = pickAllowed( + body.aspectRatio, + modelConfig.aspectRatios, + modelConfig.defaultAspectRatio + ) + } + if (modelConfig.resolutionOptions && modelConfig.defaultResolution) { + requestBody.resolution = pickAllowed( + body.resolution, + modelConfig.resolutionOptions, + modelConfig.defaultResolution + ) + } + if (modelConfig.outputFormats && modelConfig.defaultOutputFormat) { + requestBody.output_format = pickAllowed( + body.outputFormat, + modelConfig.outputFormats, + modelConfig.defaultOutputFormat + ) + } + if (modelConfig.qualityOptions && modelConfig.defaultQuality) { + requestBody.quality = pickAllowed( + body.quality, + modelConfig.qualityOptions, + modelConfig.defaultQuality + ) + } + if (modelConfig.backgroundOptions && modelConfig.defaultBackground) { + requestBody.background = pickAllowed( + body.background, + modelConfig.backgroundOptions, + modelConfig.defaultBackground + ) + } + if (modelConfig.safetyToleranceOptions && modelConfig.defaultSafetyTolerance) { + requestBody.safety_tolerance = pickAllowed( + body.safetyTolerance, + modelConfig.safetyToleranceOptions, + modelConfig.defaultSafetyTolerance + ) + } + if (modelConfig.supportsEnableSafetyChecker && body.enableSafetyChecker !== undefined) { + requestBody.enable_safety_checker = body.enableSafetyChecker + } + if (modelConfig.supportsEnableWebSearch && body.enableWebSearch !== undefined) { + requestBody.enable_web_search = body.enableWebSearch + } + if (modelConfig.supportsThinkingLevel && body.thinkingLevel) { + requestBody.thinking_level = pickAllowed(body.thinkingLevel, ['minimal', 'high'], 'minimal') + } + + const createResponse = await fetch(`https://queue.fal.run/${modelConfig.endpoint}`, { + method: 'POST', + headers: { + Authorization: `Key ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + if (!createResponse.ok) { + const error = await readResponseTextWithLimit(createResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Fal.ai create error response', + }) + throw new Error(`Fal.ai API error: ${createResponse.status} - ${error}`) + } + + const createData = await readResponseJsonWithLimit(createResponse, { + maxBytes: MAX_IMAGE_JSON_BYTES, + label: 'Fal.ai create response', + }) + if (!isRecord(createData)) { + throw new Error('Invalid Fal.ai queue response') + } + + const falRequestId = getStringProperty(createData, 'request_id') + if (!falRequestId) { + throw new Error('Fal.ai queue response missing request_id') + } + + const statusUrl = + getStringProperty(createData, 'status_url') || + buildFalAIQueueUrl(modelConfig.endpoint, falRequestId, 'status') + const responseUrl = + getStringProperty(createData, 'response_url') || + buildFalAIQueueUrl(modelConfig.endpoint, falRequestId, 'response') + + logger.info(`[${requestId}] Fal.ai image request created: ${falRequestId}`) + + const pollIntervalMs = 3000 + const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs) + let attempts = 0 + + while (attempts < maxAttempts) { + await sleep(pollIntervalMs) + + const statusResponse = await fetch(statusUrl, { + headers: { + Authorization: `Key ${apiKey}`, + }, + }) + + if (!statusResponse.ok) { + await readResponseTextWithLimit(statusResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Fal.ai status error response', + }).catch(() => '') + throw new Error(`Fal.ai status check failed: ${statusResponse.status}`) + } + + const statusData = await readResponseJsonWithLimit(statusResponse, { + maxBytes: MAX_IMAGE_JSON_BYTES, + label: 'Fal.ai status response', + }) + if (!isRecord(statusData)) { + throw new Error('Invalid Fal.ai status response') + } + + const status = getStringProperty(statusData, 'status') + if (status === 'COMPLETED') { + const statusError = statusData.error + if (statusError) { + throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusError)}`) + } + + const resultResponse = await fetch( + getStringProperty(statusData, 'response_url') || responseUrl, + { + headers: { + Authorization: `Key ${apiKey}`, + }, + } + ) + + if (!resultResponse.ok) { + await readResponseTextWithLimit(resultResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Fal.ai result error response', + }).catch(() => '') + throw new Error(`Failed to fetch Fal.ai result: ${resultResponse.status}`) + } + + const resultData = await readResponseJsonWithLimit(resultResponse, { + maxBytes: MAX_IMAGE_JSON_BYTES, + label: 'Fal.ai result response', + }) + if (!isRecord(resultData)) { + throw new Error('Invalid Fal.ai result response') + } + + const firstImage = firstRecord(resultData.images) + const imageUrl = + getStringProperty(firstImage, 'url') || + getStringProperty(firstImage, 'data') || + getStringProperty(firstImage, 'content') + if (!imageUrl) { + throw new Error('No image URL in Fal.ai response') + } + + const downloaded = await bufferFromImageUrl(imageUrl) + const contentType = + getStringProperty(firstImage, 'content_type') || + getStringProperty(firstImage, 'contentType') || + downloaded.contentType + const fileName = + getStringProperty(firstImage, 'file_name') || + getStringProperty(firstImage, 'fileName') || + `falai-${model}.${extensionFromContentType(contentType)}` + + return { + buffer: downloaded.buffer, + contentType, + fileName, + provider: 'falai', + model, + sourceUrl: imageUrl.startsWith('data:') ? undefined : imageUrl, + description: getStringProperty(resultData, 'description'), + revisedPrompt: getStringProperty(resultData, 'revised_prompt'), + seed: getNumberProperty(resultData, 'seed'), + jobId: falRequestId, + falaiCost: body.useHostedCostTracking + ? await getFalAICostMetadata({ + apiKey, + endpointId: modelConfig.endpoint, + requestId: falRequestId, + }) + : undefined, + } + } + + if (['ERROR', 'FAILED', 'CANCELLED'].includes(status || '')) { + throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusData.error)}`) + } + + attempts += 1 + } + + throw new Error('Fal.ai image generation timed out') +} + +async function storeGeneratedImage( + imageResult: GeneratedImageResult, + body: ImageToolBody, + userId: string, + requestId: string +): Promise { + const timestamp = Date.now() + const safeFileName = imageResult.fileName || `image-${imageResult.provider}-${timestamp}.png` + const executionContext = + body.workspaceId && body.workflowId && body.executionId + ? { + workspaceId: body.workspaceId, + workflowId: body.workflowId, + executionId: body.executionId, + } + : null + + if (executionContext) { + const { uploadExecutionFile } = await import('@/lib/uploads/contexts/execution') + const imageFile = await uploadExecutionFile( + executionContext, + imageResult.buffer, + safeFileName, + imageResult.contentType, + userId + ) + + return { + content: imageFile.url, + imageUrl: imageFile.url, + imageFile, + fileName: safeFileName, + contentType: imageResult.contentType, + provider: imageResult.provider, + model: imageResult.model, + metadata: { + provider: imageResult.provider, + model: imageResult.model, + description: imageResult.description, + revisedPrompt: imageResult.revisedPrompt, + seed: imageResult.seed, + jobId: imageResult.jobId, + contentType: imageResult.contentType, + }, + __falaiCostDollars: imageResult.falaiCost?.costDollars, + __falaiBilling: imageResult.falaiCost, + } + } + + const { StorageService } = await import('@/lib/uploads') + const fileInfo = await StorageService.uploadFile({ + file: imageResult.buffer, + fileName: safeFileName, + contentType: imageResult.contentType, + context: 'copilot', + }) + const imageUrl = `${getBaseUrl()}${fileInfo.path}` + logger.info(`[${requestId}] Stored generated image fallback`, { + fileName: safeFileName, + size: imageResult.buffer.length, + }) + + return { + content: imageUrl, + imageUrl, + fileName: safeFileName, + contentType: imageResult.contentType, + provider: imageResult.provider, + model: imageResult.model, + metadata: { + provider: imageResult.provider, + model: imageResult.model, + description: imageResult.description, + revisedPrompt: imageResult.revisedPrompt, + seed: imageResult.seed, + jobId: imageResult.jobId, + contentType: imageResult.contentType, + }, + __falaiCostDollars: imageResult.falaiCost?.costDollars, + __falaiBilling: imageResult.falaiCost, + } +} diff --git a/apps/sim/app/api/tools/imap/mailboxes/route.ts b/apps/sim/app/api/tools/imap/mailboxes/route.ts index 02a8b787a77..b66c9eb34d4 100644 --- a/apps/sim/app/api/tools/imap/mailboxes/route.ts +++ b/apps/sim/app/api/tools/imap/mailboxes/route.ts @@ -1,37 +1,48 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +51,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, @@ -83,7 +94,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { throw error } } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error' + const errorMessage = getErrorMessage(error, 'Unknown error') logger.error('Error fetching IMAP mailboxes:', errorMessage) let userMessage = 'Failed to connect to IMAP server. Please check your connection settings.' 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..2fc2e89f2a7 100644 --- a/apps/sim/app/api/tools/jira/add-attachment/route.ts +++ b/apps/sim/app/api/tools/jira/add-attachment/route.ts @@ -1,39 +1,34 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 { assertToolFileAccess } from '@/app/api/files/authorization' import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' 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()}` try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { return NextResponse.json( { success: false, error: authResult.error || 'Unauthorized' }, { status: 401 } ) } - 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) { @@ -50,6 +45,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const formData = new FormData() for (const file of userFiles) { + const denied = await assertToolFileAccess(file.key, authResult.userId, requestId, logger) + if (denied) return denied const buffer = await downloadFileFromStorage(file, requestId, logger) const blob = new Blob([new Uint8Array(buffer)], { type: file.type || 'application/octet-stream', @@ -107,16 +104,9 @@ 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' }, + { success: false, error: getErrorMessage(error, 'Internal server error') }, { status: 500 } ) } diff --git a/apps/sim/app/api/tools/jira/issues/route.ts b/apps/sim/app/api/tools/jira/issues/route.ts index 897719c0528..34e92befb13 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,31 @@ 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 ISSUE_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*-\d+$/ + const sanitizedKeys: string[] = [] + for (const k of issueKeys) { + if (typeof k !== 'string') continue + const trimmed = k.trim() + if (!ISSUE_KEY_RE.test(trimmed)) { + return NextResponse.json({ error: `Invalid Jira issue key: "${trimmed}"` }, { status: 400 }) + } + sanitizedKeys.push(trimmed) + } + if (sanitizedKeys.length === 0) { + return NextResponse.json({ issues: [] }) + } + + const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken)) const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') if (!cloudIdValidation.isValid) { @@ -49,11 +58,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } // Use search/jql endpoint (GET) with URL parameters - const jql = `issueKey in (${issueKeys.map((k: string) => k.trim()).join(',')})` + const jql = `issueKey in (${sanitizedKeys.join(',')})` const params = new URLSearchParams({ jql, fields: 'summary,status,assignee,updated,project', - maxResults: String(Math.min(issueKeys.length, 100)), + maxResults: String(Math.min(sanitizedKeys.length, 100)), }) const searchUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${params.toString()}` @@ -73,7 +82,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { error: errorMessage, authRequired: true, - requiredScopes: ['read:jira-work', 'read:project:jira'], + requiredScopes: ['read:jira-work'], }, { status: response.status } ) @@ -108,21 +117,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 parsed = await parseRequest(jiraIssuesSelectorContract, request, {}) + if (!parsed.success) return parsed.response - const validationError = validateRequiredParams(domain || null, accessToken || null) - if (validationError) return validationError + const { + domain, + accessToken, + cloudId: providedCloudId, + query = '', + projectId = '', + manualProjectId = '', + all, + limit, + } = parsed.data.query - const cloudId = providedCloudId || (await getJiraCloudId(domain!, accessToken!)) + const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken)) const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') if (!cloudIdValidation.isValid) { @@ -154,14 +163,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const target = Math.min(all ? limit || SAFETY_CAP : 25, SAFETY_CAP) const projectKey = (projectId || manualProjectId || '').trim() - const escapeJql = (s: string) => s.replace(/"/g, '\\"') + const escapeJql = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') - const buildJql = (startAt: number) => { + const buildUrl = (token?: string) => { const jqlParts: string[] = [] - if (projectKey) jqlParts.push(`project = ${projectKey}`) + if (projectKey) jqlParts.push(`project = "${escapeJql(projectKey)}"`) if (query) { const q = escapeJql(query) - // Match by key prefix or summary text jqlParts.push(`(key ~ "${q}" OR summary ~ "${q}")`) } const jql = `${jqlParts.length ? `${jqlParts.join(' AND ')} ` : ''}ORDER BY updated DESC` @@ -170,20 +178,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { fields: 'summary,key,updated', maxResults: String(Math.min(PAGE_SIZE, target)), }) - if (startAt > 0) { - params.set('startAt', String(startAt)) - } - return { - url: `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${params.toString()}`, - } + if (token) params.set('nextPageToken', token) + return `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${params.toString()}` } - let startAt = 0 + let nextPageToken: string | undefined let collected: any[] = [] - let total = 0 do { - const { url: apiUrl } = buildJql(startAt) + const apiUrl = buildUrl(nextPageToken) const response = await fetch(apiUrl, { method: 'GET', headers: { @@ -199,7 +202,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { { error: errorMessage, authRequired: true, - requiredScopes: ['read:jira-work', 'read:project:jira'], + requiredScopes: ['read:jira-work'], }, { status: response.status } ) @@ -209,10 +212,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const page = await response.json() const issues = page.issues || [] - total = page.total || issues.length collected = collected.concat(issues) - startAt += PAGE_SIZE - } while (all && collected.length < Math.min(total, target)) + nextPageToken = page.nextPageToken + if (!nextPageToken || issues.length === 0) break + } while (all && collected.length < target) const issues = collected.slice(0, target).map((it: any) => ({ key: it.key, 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..f8357b18bd4 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 { getErrorMessage, 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) @@ -80,7 +55,8 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: issueKeyValidation.error }, { status: 400 }) } - const notifyParam = notifyUsers === false ? '?notifyUsers=false' : '' + const notifyParam = + notifyUsers === false ? '?notifyUsers=false' : notifyUsers === true ? '?notifyUsers=true' : '' const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${issueKey}${notifyParam}` logger.info('Updating Jira issue at:', url) @@ -170,7 +146,8 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { ) } - const responseData = response.status === 204 ? {} : await response.json() + const responseData = + response.status === 204 ? {} : await response.json().catch(() => ({}) as Record) logger.info('Successfully updated Jira issue:', issueKey) return NextResponse.json({ @@ -178,7 +155,7 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { output: { ts: new Date().toISOString(), issueKey: responseData.key || issueKey, - summary: responseData.fields?.summary || 'Issue updated', + summary: responseData.fields?.summary || summaryValue || 'Issue updated', success: true, }, }) @@ -190,7 +167,7 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } diff --git a/apps/sim/app/api/tools/jira/write/route.ts b/apps/sim/app/api/tools/jira/write/route.ts index be0bb063ef6..916ee57c5ac 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 { getErrorMessage, 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') @@ -91,7 +96,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } if (parent !== undefined && parent !== null && parent !== '') { - fields.parent = parent + if (typeof parent === 'string') { + fields.parent = /^\d+$/.test(parent) ? { id: parent } : { key: parent } + } else if (typeof parent === 'object') { + fields.parent = parent + } } if (priority !== undefined && priority !== null && priority !== '') { @@ -233,7 +242,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } diff --git a/apps/sim/app/api/tools/jsm/approvals/route.ts b/apps/sim/app/api/tools/jsm/approvals/route.ts index ef9576f52da..ba8f2692045 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 { getErrorMessage, 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') @@ -207,7 +211,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } diff --git a/apps/sim/app/api/tools/jsm/comment/route.ts b/apps/sim/app/api/tools/jsm/comment/route.ts index 4282f3655cc..fb48eef55c6 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 { getErrorMessage, 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') @@ -120,7 +125,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } diff --git a/apps/sim/app/api/tools/jsm/comments/route.ts b/apps/sim/app/api/tools/jsm/comments/route.ts index ad5f0a58bbd..8741ff4366b 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 { getErrorMessage, 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') @@ -113,7 +117,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } diff --git a/apps/sim/app/api/tools/jsm/customers/route.ts b/apps/sim/app/api/tools/jsm/customers/route.ts index 5924cada69c..4b52715ea00 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 { getErrorMessage, 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') @@ -46,6 +50,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Service Desk ID is required' }, { status: 400 }) } + if (emails !== undefined) { + return NextResponse.json( + { + error: + 'The `emails` parameter is no longer supported. Use `accountIds` (Atlassian account IDs) instead.', + }, + { status: 400 } + ) + } + const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken)) const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') @@ -60,33 +74,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const baseUrl = getJsmApiBaseUrl(cloudId) - const rawIds = accountIds || emails - const parsedAccountIds = rawIds - ? typeof rawIds === 'string' - ? rawIds - .split(',') - .map((id: string) => id.trim()) - .filter((id: string) => id) - : Array.isArray(rawIds) - ? rawIds - : [] - : [] - - const isAddOperation = parsedAccountIds.length > 0 - - if (isAddOperation) { + const splitCsv = (value: unknown): string[] => + value + ? typeof value === 'string' + ? value + .split(',') + .map((v: string) => v.trim()) + .filter((v: string) => v) + : Array.isArray(value) + ? (value as string[]) + : [] + : [] + + const parsedAccountIds = splitCsv(accountIds) + + if (parsedAccountIds.length > 0) { const url = `${baseUrl}/servicedesk/${serviceDeskId}/customer` - logger.info('Adding customers to:', url, { accountIds: parsedAccountIds }) - - const requestBody: Record = { + logger.info('Adding customers to:', url, { accountIds: parsedAccountIds, - } + }) const response = await fetch(url, { method: 'POST', headers: getJsmHeaders(accessToken), - body: JSON.stringify(requestBody), + body: JSON.stringify({ accountIds: parsedAccountIds }), }) if (!response.ok) { @@ -165,7 +177,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } 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..7e435308527 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 { getErrorMessage, 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') @@ -104,7 +108,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } 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..0a25aee3746 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 { getErrorMessage, 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') @@ -112,7 +122,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } 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..dc32fec761a 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 { getErrorMessage, 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') @@ -119,7 +123,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } 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..67c8f5fc66c 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 { getErrorMessage, 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') @@ -104,7 +108,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } 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..a41823e211d 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 { getErrorMessage, 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') @@ -105,7 +109,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } 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..278cd6b1ca8 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 { getErrorMessage, 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') @@ -106,7 +110,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } 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..d38975aa89e 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 { getErrorMessage, 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') @@ -105,7 +109,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } 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..2f944aabc3d 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 { getErrorMessage, 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') @@ -104,7 +108,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } 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..718a1ff7282 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 { getErrorMessage, 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') @@ -105,7 +109,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } 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..76d1000da31 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 { getErrorMessage, 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') @@ -111,7 +122,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } 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..b82b1f1ea49 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 { getErrorMessage, 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') @@ -106,7 +110,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } 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..a23e98c7d3f 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 { getErrorMessage, 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') @@ -105,7 +109,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } 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..681ff6903c9 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 { getErrorMessage, 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') @@ -104,7 +108,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } diff --git a/apps/sim/app/api/tools/jsm/organization/route.ts b/apps/sim/app/api/tools/jsm/organization/route.ts index 6fb3fe54f94..3e0fb3ccd5c 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 { getErrorMessage, 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') @@ -130,6 +134,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: organizationIdValidation.error }, { status: 400 }) } + const orgIdNumeric = Number.parseInt(String(organizationId).trim(), 10) + if (!Number.isFinite(orgIdNumeric) || orgIdNumeric <= 0) { + return NextResponse.json( + { error: 'organizationId must be a positive integer' }, + { status: 400 } + ) + } + const url = `${baseUrl}/servicedesk/${serviceDeskId}/organization` logger.info('Adding organization to service desk:', { serviceDeskId, organizationId }) @@ -137,7 +149,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const response = await fetch(url, { method: 'POST', headers: getJsmHeaders(accessToken), - body: JSON.stringify({ organizationId: Number.parseInt(organizationId, 10) }), + body: JSON.stringify({ organizationId: orgIdNumeric }), }) if (response.status === 204 || response.ok) { @@ -177,7 +189,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } diff --git a/apps/sim/app/api/tools/jsm/organizations/route.ts b/apps/sim/app/api/tools/jsm/organizations/route.ts index 411160cb0ad..769dfec7eb1 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 { getErrorMessage, 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') @@ -99,7 +110,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } diff --git a/apps/sim/app/api/tools/jsm/participants/route.ts b/apps/sim/app/api/tools/jsm/participants/route.ts index 2b835d0de5d..496c587c81c 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 { getErrorMessage, 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') @@ -182,7 +186,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } diff --git a/apps/sim/app/api/tools/jsm/queues/route.ts b/apps/sim/app/api/tools/jsm/queues/route.ts index 4f53dba106a..d966c682f03 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 { getErrorMessage, 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') @@ -108,7 +112,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } diff --git a/apps/sim/app/api/tools/jsm/request/route.ts b/apps/sim/app/api/tools/jsm/request/route.ts index 6874c8e5a84..3b2e9922df0 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 { getErrorMessage, 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') @@ -258,7 +262,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } diff --git a/apps/sim/app/api/tools/jsm/requests/route.ts b/apps/sim/app/api/tools/jsm/requests/route.ts index 8e18855cdf7..449d0d52253 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 { getErrorMessage, 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') @@ -148,7 +152,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } diff --git a/apps/sim/app/api/tools/jsm/requesttypefields/route.ts b/apps/sim/app/api/tools/jsm/requesttypefields/route.ts index 23a09e55b39..cf6d0439439 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 { getErrorMessage, 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') @@ -116,7 +126,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } diff --git a/apps/sim/app/api/tools/jsm/requesttypes/route.ts b/apps/sim/app/api/tools/jsm/requesttypes/route.ts index b79eb42329f..e16a39022ee 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 { getErrorMessage, 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') @@ -112,7 +116,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } 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..3ca85dd9169 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 { getErrorMessage, 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') @@ -90,7 +94,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } diff --git a/apps/sim/app/api/tools/jsm/sla/route.ts b/apps/sim/app/api/tools/jsm/sla/route.ts index 9e8e22735d4..64bd97a035e 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 { getErrorMessage, 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') @@ -100,7 +111,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } diff --git a/apps/sim/app/api/tools/jsm/transition/route.ts b/apps/sim/app/api/tools/jsm/transition/route.ts index 3614367c99a..62b2adfa961 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 { getErrorMessage, 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') @@ -124,7 +129,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } diff --git a/apps/sim/app/api/tools/jsm/transitions/route.ts b/apps/sim/app/api/tools/jsm/transitions/route.ts index 67176399589..364c999b08d 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 { getErrorMessage, 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') @@ -100,7 +111,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), success: false, }, { status: 500 } diff --git a/apps/sim/app/api/tools/linear/projects/route.ts b/apps/sim/app/api/tools/linear/projects/route.ts index b0370fd3793..e7ca9bab1a7 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,26 +39,46 @@ 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 = [] - const team = await linearClient.team(teamId) - const projectsResult = await team.projects() - projects = projectsResult.nodes.map((project: Project) => ({ - id: project.id, - name: project.name, - })) + /** + * teamId may be a single ID or a comma-separated list when the basic-mode + * team selector is in multi-select. Fetch projects from each team in + * parallel and dedupe by project ID (Linear projects can be cross-team). + */ + const teamIds = teamId + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + + const perTeam = await Promise.all( + teamIds.map(async (id) => { + const team = await linearClient.team(id) + const result = await team.projects() + return result.nodes.map((project: Project) => ({ + id: project.id, + name: project.name, + })) + }) + ) + + const seen = new Set() + const projects: Array<{ id: string; name: string }> = [] + for (const teamProjects of perTeam) { + for (const project of teamProjects) { + if (seen.has(project.id)) continue + seen.add(project.id) + projects.push(project) + } + } if (projects.length === 0) { - logger.info('No projects found for team', { teamId }) + logger.info('No projects found for team(s)', { teamIds }) } return NextResponse.json({ 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..3af8e3c264a 100644 --- a/apps/sim/app/api/tools/mail/send/route.ts +++ b/apps/sim/app/api/tools/mail/send/route.ts @@ -1,7 +1,9 @@ import { createLogger } from '@sim/logger' +import { convert } from 'html-to-text' 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 +12,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 +33,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 +64,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: convert(validatedData.body, { wordwrap: false }), + } } else { - emailData.text = validatedData.body + emailData = { + ...emailBase, + text: validatedData.body, + } } if (validatedData.cc) { @@ -110,9 +114,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 +140,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..dbf5b10093b 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,35 +1,27 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { dataverseUploadFileContract } from '@/lib/api/contracts/tools/microsoft' +import { parseRequest } 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 { 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 { assertToolFileAccess } from '@/app/api/files/authorization' 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Dataverse upload attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, @@ -44,8 +36,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = DataverseUploadFileSchema.parse(body) + const parsed = await parseRequest(dataverseUploadFileContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Uploading file to Dataverse`, { entitySetName: validatedData.entitySetName, @@ -69,12 +62,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Failed to process file', + error: getErrorMessage(error, 'Failed to process file'), }, { status: 400 } ) } + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) } else if (validatedData.fileContent) { fileBuffer = Buffer.from(validatedData.fileContent, 'base64') @@ -88,20 +84,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const baseUrl = validatedData.environmentUrl.replace(/\/$/, '') const uploadUrl = `${baseUrl}/api/data/v9.2/${validatedData.entitySetName}(${validatedData.recordId})/${validatedData.fileColumn}` - const response = await fetch(uploadUrl, { - method: 'PATCH', - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - 'Content-Type': 'application/octet-stream', - 'OData-MaxVersion': '4.0', - 'OData-Version': '4.0', - 'x-ms-file-name': validatedData.fileName, + const response = await secureFetchWithValidation( + uploadUrl, + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${validatedData.accessToken}`, + 'Content-Type': 'application/octet-stream', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + 'x-ms-file-name': validatedData.fileName, + }, + body: fileBuffer, }, - body: new Uint8Array(fileBuffer), - }) + 'environmentUrl' + ) if (!response.ok) { - const errorData = await response.json().catch(() => ({})) + const errorData = (await response.json().catch(() => ({}))) as { + error?: { message?: string } + } const errorMessage = errorData?.error?.message ?? `Dataverse API error: ${response.status} ${response.statusText}` @@ -128,18 +130,10 @@ 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( - { success: false, error: error instanceof Error ? error.message : 'Internal server error' }, + { success: false, error: getErrorMessage(error, 'Internal server error') }, { status: 500 } ) } 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..2e0d1d80e43 100644 --- a/apps/sim/app/api/tools/microsoft_excel/drives/route.ts +++ b/apps/sim/app/api/tools/microsoft_excel/drives/route.ts @@ -1,11 +1,13 @@ 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' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' -import { GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils' +import { extractGraphError, GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils' export const dynamic = 'force-dynamic' @@ -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) { @@ -83,13 +76,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ error: { message: 'Unknown error' } })) - return NextResponse.json( - { error: errorData.error?.message || 'Failed to fetch drive' }, - { status: response.status } - ) + const errorMessage = await extractGraphError(response) + return NextResponse.json({ error: errorMessage }, { status: response.status }) } const data: GraphDrive = await response.json() @@ -109,15 +97,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) + const errorMessage = await extractGraphError(response) logger.error(`[${requestId}] Microsoft Graph API error fetching drives`, { status: response.status, - error: errorData.error?.message, + error: errorMessage, }) - return NextResponse.json( - { error: errorData.error?.message || 'Failed to fetch drives' }, - { status: response.status } - ) + return NextResponse.json({ error: errorMessage }, { status: response.status }) } const data = await response.json() 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..f08f968734c 100644 --- a/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts +++ b/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts @@ -1,10 +1,13 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' -import { getItemBasePath } from '@/tools/microsoft_excel/utils' +import { extractGraphError, getItemBasePath } from '@/tools/microsoft_excel/utils' export const dynamic = 'force-dynamic' @@ -29,21 +32,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) { @@ -69,7 +60,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { basePath = getItemBasePath(spreadsheetId, driveId) } catch (error) { return NextResponse.json( - { error: error instanceof Error ? error.message : 'Invalid parameters' }, + { error: getErrorMessage(error, 'Invalid parameters') }, { status: 400 } ) } @@ -83,18 +74,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) if (!worksheetsResponse.ok) { - const errorData = await worksheetsResponse - .text() - .then((text) => JSON.parse(text)) - .catch(() => ({ error: { message: 'Unknown error' } })) + const errorMessage = await extractGraphError(worksheetsResponse) logger.error(`[${requestId}] Microsoft Graph API error`, { status: worksheetsResponse.status, - error: errorData.error?.message || 'Failed to fetch worksheets', + error: errorMessage, }) - return NextResponse.json( - { error: errorData.error?.message || 'Failed to fetch worksheets' }, - { status: worksheetsResponse.status } - ) + return NextResponse.json({ error: errorMessage }, { status: worksheetsResponse.status }) } const data: WorksheetsResponse = await worksheetsResponse.json() 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..113cd533318 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { teamsDeleteChatMessageContract } from '@/lib/api/contracts/tools/microsoft' +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' @@ -9,12 +11,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 +35,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = TeamsDeleteChatMessageSchema.parse(body) + const parsed = await parseRequest(teamsDeleteChatMessageContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Deleting Teams chat message`, { chatId: validatedData.chatId, @@ -114,7 +111,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) 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..58fb3cfd94a 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,13 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { teamsWriteChannelContract } from '@/lib/api/contracts/tools/microsoft' +import { parseRequest } 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 { FileAccessDeniedError } from '@/app/api/files/authorization' 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,21 +16,13 @@ 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Teams channel write attempt: ${authResult.error}`) return NextResponse.json( { @@ -39,15 +33,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info( `[${requestId}] Authenticated Teams channel write request via ${authResult.authType}`, { - userId: authResult.userId, + userId, } ) - const body = await request.json() - const validatedData = TeamsWriteChannelSchema.parse(body) + const parsed = await parseRequest(teamsWriteChannelContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Sending Teams channel message`, { teamId: validatedData.teamId, @@ -61,6 +57,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { accessToken: validatedData.accessToken, requestId, logger, + userId, }) let messageContent = validatedData.content @@ -167,11 +164,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { + if (error instanceof FileAccessDeniedError) { + return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) + } logger.error(`[${requestId}] Error sending Teams channel message:`, error) return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) 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..96a4dada98c 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,13 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { teamsWriteChatContract } from '@/lib/api/contracts/tools/microsoft' +import { parseRequest } 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 { FileAccessDeniedError } from '@/app/api/files/authorization' 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,20 +16,13 @@ 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Teams chat write attempt: ${authResult.error}`) return NextResponse.json( { @@ -38,15 +33,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info( `[${requestId}] Authenticated Teams chat write request via ${authResult.authType}`, { - userId: authResult.userId, + userId, } ) - const body = await request.json() - const validatedData = TeamsWriteChatSchema.parse(body) + const parsed = await parseRequest(teamsWriteChatContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Sending Teams chat message`, { chatId: validatedData.chatId, @@ -59,6 +56,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { accessToken: validatedData.accessToken, requestId, logger, + userId, }) let messageContent = validatedData.content @@ -163,11 +161,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { + if (error instanceof FileAccessDeniedError) { + return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) + } logger.error(`[${requestId}] Error sending Teams chat message:`, error) return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/mistral/parse/route.ts b/apps/sim/app/api/tools/mistral/parse/route.ts index 984d74963ad..acfd066879a 100644 --- a/apps/sim/app/api/tools/mistral/parse/route.ts +++ b/apps/sim/app/api/tools/mistral/parse/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mistralParseContract } from '@/lib/api/contracts/tools/media/document-parse' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -8,29 +10,17 @@ 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, resolveInternalFileUrl, } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' 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() @@ -51,8 +41,28 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = authResult.userId - const body = await request.json() - const validatedData = MistralParseSchema.parse(body) + + const parsed = await parseRequest( + mistralParseContract, + 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 validatedData = parsed.data.body const fileData = validatedData.file || validatedData.fileData const filePath = typeof fileData === 'string' ? fileData : validatedData.filePath @@ -87,7 +97,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Failed to process file', + error: getErrorMessage(error, 'Failed to process file'), }, { status: 400 } ) @@ -112,6 +122,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } let base64 = userFile.base64 if (!base64) { + const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) + if (denied) return denied const buffer = await downloadFileFromStorage(userFile, requestId, logger) base64 = buffer.toString('base64') } @@ -253,24 +265,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) 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..423f5f5461f 100644 --- a/apps/sim/app/api/tools/mongodb/delete/route.ts +++ b/apps/sim/app/api/tools/mongodb/delete/route.ts @@ -1,43 +1,19 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mongodbDeleteContract } from '@/lib/api/contracts/tools/databases/mongodb' +import { parseToolRequest } 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 +25,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = DeleteSchema.parse(body) + const parsed = await parseToolRequest(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,15 +79,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] MongoDB delete failed:`, error) return NextResponse.json({ error: `MongoDB delete failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/mongodb/execute/route.ts b/apps/sim/app/api/tools/mongodb/execute/route.ts index 64c4a73484e..bb8f87abdde 100644 --- a/apps/sim/app/api/tools/mongodb/execute/route.ts +++ b/apps/sim/app/api/tools/mongodb/execute/route.ts @@ -1,35 +1,19 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mongodbExecuteContract } from '@/lib/api/contracts/tools/databases/mongodb' +import { parseToolRequest } 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 +25,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = ExecuteSchema.parse(body) + const parsed = await parseToolRequest(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,15 +72,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] MongoDB aggregation failed:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/mongodb/insert/route.ts b/apps/sim/app/api/tools/mongodb/insert/route.ts index e987ad50af7..a5cc1c3b21a 100644 --- a/apps/sim/app/api/tools/mongodb/insert/route.ts +++ b/apps/sim/app/api/tools/mongodb/insert/route.ts @@ -1,40 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mongodbInsertContract } from '@/lib/api/contracts/tools/databases/mongodb' +import { parseToolRequest } 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 +21,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = InsertSchema.parse(body) + const parsed = await parseToolRequest(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,15 +62,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] MongoDB insert failed:`, error) return NextResponse.json({ error: `MongoDB insert failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/mongodb/introspect/route.ts b/apps/sim/app/api/tools/mongodb/introspect/route.ts index 40cefb7aedb..61abc03375f 100644 --- a/apps/sim/app/api/tools/mongodb/introspect/route.ts +++ b/apps/sim/app/api/tools/mongodb/introspect/route.ts @@ -1,23 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mongodbIntrospectContract } from '@/lib/api/contracts/tools/databases/mongodb' +import { parseToolRequest } 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 +21,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = IntrospectSchema.parse(body) + const parsed = await parseToolRequest(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,15 +51,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] MongoDB introspect failed:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/mongodb/query/route.ts b/apps/sim/app/api/tools/mongodb/query/route.ts index 5a365472968..0b9cfd4ba45 100644 --- a/apps/sim/app/api/tools/mongodb/query/route.ts +++ b/apps/sim/app/api/tools/mongodb/query/route.ts @@ -1,52 +1,19 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mongodbQueryContract } from '@/lib/api/contracts/tools/databases/mongodb' +import { parseToolRequest } 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 +25,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = QuerySchema.parse(body) + const parsed = await parseToolRequest(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,15 +92,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] MongoDB query failed:`, error) return NextResponse.json({ error: `MongoDB query failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/mongodb/update/route.ts b/apps/sim/app/api/tools/mongodb/update/route.ts index c4038f5dc23..1163014207d 100644 --- a/apps/sim/app/api/tools/mongodb/update/route.ts +++ b/apps/sim/app/api/tools/mongodb/update/route.ts @@ -1,62 +1,19 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mongodbUpdateContract } from '@/lib/api/contracts/tools/databases/mongodb' +import { parseToolRequest } 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 +25,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = UpdateSchema.parse(body) + const parsed = await parseToolRequest(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,15 +89,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] MongoDB update failed:`, error) return NextResponse.json({ error: `MongoDB update failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/mongodb/utils.ts b/apps/sim/app/api/tools/mongodb/utils.ts index 33e6af90ae7..7fb17e17424 100644 --- a/apps/sim/app/api/tools/mongodb/utils.ts +++ b/apps/sim/app/api/tools/mongodb/utils.ts @@ -1,5 +1,8 @@ import { MongoClient } from 'mongodb' -import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' +import { + createPinnedLookup, + validateDatabaseHost, +} from '@/lib/core/security/input-validation.server' import type { MongoDBCollectionInfo, MongoDBConnectionConfig } from '@/tools/mongodb/types' export async function createMongoDBConnection(config: MongoDBConnectionConfig) { @@ -30,6 +33,7 @@ export async function createMongoDBConnection(config: MongoDBConnectionConfig) { connectTimeoutMS: 10000, socketTimeoutMS: 10000, maxPoolSize: 1, + lookup: createPinnedLookup(hostValidation.resolvedIP ?? config.host), }) await client.connect() diff --git a/apps/sim/app/api/tools/mysql/delete/route.ts b/apps/sim/app/api/tools/mysql/delete/route.ts index 4146bb49ba1..5fedac77ff9 100644 --- a/apps/sim/app/api/tools/mysql/delete/route.ts +++ b/apps/sim/app/api/tools/mysql/delete/route.ts @@ -1,24 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mysqlDeleteContract } from '@/lib/api/contracts/tools/databases/mysql' +import { parseToolRequest } 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 +20,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 parseToolRequest(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,15 +52,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] MySQL delete failed:`, error) return NextResponse.json({ error: `MySQL delete failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/mysql/execute/route.ts b/apps/sim/app/api/tools/mysql/execute/route.ts index 0f20e8bed9e..cdebfeb8107 100644 --- a/apps/sim/app/api/tools/mysql/execute/route.ts +++ b/apps/sim/app/api/tools/mysql/execute/route.ts @@ -1,23 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mysqlExecuteContract } from '@/lib/api/contracts/tools/databases/mysql' +import { parseToolRequest } 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 +20,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 parseToolRequest(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,15 +60,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] MySQL execute failed:`, error) return NextResponse.json({ error: `MySQL execute failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/mysql/insert/route.ts b/apps/sim/app/api/tools/mysql/insert/route.ts index 013e8cc650c..5c2e81f278b 100644 --- a/apps/sim/app/api/tools/mysql/insert/route.ts +++ b/apps/sim/app/api/tools/mysql/insert/route.ts @@ -1,45 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mysqlInsertContract } from '@/lib/api/contracts/tools/databases/mysql' +import { parseToolRequest } 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 +20,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 parseToolRequest(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,15 +52,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] MySQL insert failed:`, error) return NextResponse.json({ error: `MySQL insert failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/mysql/introspect/route.ts b/apps/sim/app/api/tools/mysql/introspect/route.ts index e22ecf5444d..6f4cad144a5 100644 --- a/apps/sim/app/api/tools/mysql/introspect/route.ts +++ b/apps/sim/app/api/tools/mysql/introspect/route.ts @@ -1,22 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mysqlIntrospectContract } from '@/lib/api/contracts/tools/databases/mysql' +import { parseToolRequest } 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 +20,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 parseToolRequest(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,15 +53,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] MySQL introspection failed:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/mysql/query/route.ts b/apps/sim/app/api/tools/mysql/query/route.ts index 0950b2be1d1..e1ff6c039ef 100644 --- a/apps/sim/app/api/tools/mysql/query/route.ts +++ b/apps/sim/app/api/tools/mysql/query/route.ts @@ -1,23 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mysqlQueryContract } from '@/lib/api/contracts/tools/databases/mysql' +import { parseToolRequest } 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 +20,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 parseToolRequest(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,15 +60,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] MySQL query failed:`, error) return NextResponse.json({ error: `MySQL query failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/mysql/update/route.ts b/apps/sim/app/api/tools/mysql/update/route.ts index bfcad56bbc8..5237c2a4b58 100644 --- a/apps/sim/app/api/tools/mysql/update/route.ts +++ b/apps/sim/app/api/tools/mysql/update/route.ts @@ -1,43 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { mysqlUpdateContract } from '@/lib/api/contracts/tools/databases/mysql' +import { parseToolRequest } 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 +20,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 parseToolRequest(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,15 +52,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] MySQL update failed:`, error) return NextResponse.json({ error: `MySQL update failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/mysql/utils.ts b/apps/sim/app/api/tools/mysql/utils.ts index 30883aa7f2a..971bc31ba21 100644 --- a/apps/sim/app/api/tools/mysql/utils.ts +++ b/apps/sim/app/api/tools/mysql/utils.ts @@ -1,3 +1,4 @@ +import net from 'node:net' import mysql from 'mysql2/promise' import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' @@ -16,12 +17,19 @@ export async function createMySQLConnection(config: MySQLConnectionConfig) { throw new Error(hostValidation.error) } + const resolvedIP = hostValidation.resolvedIP ?? config.host + const connectionConfig: mysql.ConnectionOptions = { host: config.host, port: config.port, database: config.database, user: config.username, password: config.password, + stream: () => { + const socket = net.connect({ host: resolvedIP, port: config.port, timeout: 10000 }) + socket.setNoDelay(true) + return socket + }, } if (config.ssl === 'disabled') { diff --git a/apps/sim/app/api/tools/neo4j/create/route.ts b/apps/sim/app/api/tools/neo4j/create/route.ts index 4ee7bbfd336..edd32617837 100644 --- a/apps/sim/app/api/tools/neo4j/create/route.ts +++ b/apps/sim/app/api/tools/neo4j/create/route.ts @@ -1,7 +1,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { neo4jCreateContract } from '@/lib/api/contracts/tools/databases/neo4j' +import { parseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -12,17 +14,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 +26,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = CreateSchema.parse(body) + const parsed = await parseToolRequest(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,15 +95,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] Neo4j create failed:`, error) return NextResponse.json({ error: `Neo4j create failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/neo4j/delete/route.ts b/apps/sim/app/api/tools/neo4j/delete/route.ts index 2338db5e843..449d0122e33 100644 --- a/apps/sim/app/api/tools/neo4j/delete/route.ts +++ b/apps/sim/app/api/tools/neo4j/delete/route.ts @@ -1,25 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { neo4jDeleteContract } from '@/lib/api/contracts/tools/databases/neo4j' +import { parseToolRequest } 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 +22,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = DeleteSchema.parse(body) + const parsed = await parseToolRequest(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,15 +79,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] Neo4j delete failed:`, error) return NextResponse.json({ error: `Neo4j delete failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/neo4j/execute/route.ts b/apps/sim/app/api/tools/neo4j/execute/route.ts index 9e74c736bc1..bf36ca56f08 100644 --- a/apps/sim/app/api/tools/neo4j/execute/route.ts +++ b/apps/sim/app/api/tools/neo4j/execute/route.ts @@ -1,7 +1,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { neo4jExecuteContract } from '@/lib/api/contracts/tools/databases/neo4j' +import { parseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -12,17 +14,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 +26,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = ExecuteSchema.parse(body) + const parsed = await parseToolRequest(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,15 +93,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] Neo4j execute failed:`, error) return NextResponse.json({ error: `Neo4j execute failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/neo4j/introspect/route.ts b/apps/sim/app/api/tools/neo4j/introspect/route.ts index 838d28377af..8d4fccc4569 100644 --- a/apps/sim/app/api/tools/neo4j/introspect/route.ts +++ b/apps/sim/app/api/tools/neo4j/introspect/route.ts @@ -1,7 +1,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { neo4jIntrospectContract } from '@/lib/api/contracts/tools/databases/neo4j' +import { parseToolRequest } 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 +11,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 +23,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = IntrospectSchema.parse(body) + const parsed = await parseToolRequest(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,15 +175,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] Neo4j introspection failed:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/neo4j/merge/route.ts b/apps/sim/app/api/tools/neo4j/merge/route.ts index 1c8163876f7..032d897f8ba 100644 --- a/apps/sim/app/api/tools/neo4j/merge/route.ts +++ b/apps/sim/app/api/tools/neo4j/merge/route.ts @@ -1,7 +1,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { neo4jMergeContract } from '@/lib/api/contracts/tools/databases/neo4j' +import { parseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -12,17 +14,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 +26,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = MergeSchema.parse(body) + const parsed = await parseToolRequest(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,15 +95,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] Neo4j merge failed:`, error) return NextResponse.json({ error: `Neo4j merge failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/neo4j/query/route.ts b/apps/sim/app/api/tools/neo4j/query/route.ts index f578ffdfa11..31550d4499b 100644 --- a/apps/sim/app/api/tools/neo4j/query/route.ts +++ b/apps/sim/app/api/tools/neo4j/query/route.ts @@ -1,7 +1,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { neo4jQueryContract } from '@/lib/api/contracts/tools/databases/neo4j' +import { parseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -12,17 +14,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 +26,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = QuerySchema.parse(body) + const parsed = await parseToolRequest(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,15 +93,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] Neo4j query failed:`, error) return NextResponse.json({ error: `Neo4j query failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/neo4j/update/route.ts b/apps/sim/app/api/tools/neo4j/update/route.ts index 8b910e887eb..f680eedbd8a 100644 --- a/apps/sim/app/api/tools/neo4j/update/route.ts +++ b/apps/sim/app/api/tools/neo4j/update/route.ts @@ -1,7 +1,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { neo4jUpdateContract } from '@/lib/api/contracts/tools/databases/neo4j' +import { parseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -12,17 +14,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 +26,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = UpdateSchema.parse(body) + const parsed = await parseToolRequest(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,15 +95,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] Neo4j update failed:`, error) return NextResponse.json({ error: `Neo4j update failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/neo4j/utils.ts b/apps/sim/app/api/tools/neo4j/utils.ts index f843d723a05..ac0bdf0eb0e 100644 --- a/apps/sim/app/api/tools/neo4j/utils.ts +++ b/apps/sim/app/api/tools/neo4j/utils.ts @@ -18,7 +18,14 @@ export async function createNeo4jDriver(config: Neo4jConnectionConfig) { protocol = config.encryption === 'enabled' ? 'bolt+s' : 'bolt' } - const uri = `${protocol}://${config.host}:${config.port}` + const useIPPinning = !protocol.endsWith('+s') + const resolvedIP = hostValidation.resolvedIP ?? config.host + const uriHost = useIPPinning + ? resolvedIP.includes(':') + ? `[${resolvedIP}]` + : resolvedIP + : config.host + const uri = `${protocol}://${uriHost}:${config.port}` const driverConfig: any = { maxConnectionPoolSize: 1, 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..d713208c494 100644 --- a/apps/sim/app/api/tools/onedrive/download/route.ts +++ b/apps/sim/app/api/tools/onedrive/download/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onedriveDownloadContract } from '@/lib/api/contracts/tools/microsoft' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -31,12 +33,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,10 +50,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = OneDriveDownloadSchema.parse(body) - - const { accessToken, fileId, fileName } = validatedData + const parsed = await parseRequest(onedriveDownloadContract, request, {}) + if (!parsed.success) return parsed.response + const { accessToken, fileId, fileName } = parsed.data.body const authHeader = `Bearer ${accessToken}` logger.info(`[${requestId}] Getting file metadata from OneDrive`, { fileId }) @@ -166,17 +161,11 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/onedrive/files/route.ts b/apps/sim/app/api/tools/onedrive/files/route.ts index 4dadb9d01d0..4b3b7273608 100644 --- a/apps/sim/app/api/tools/onedrive/files/route.ts +++ b/apps/sim/app/api/tools/onedrive/files/route.ts @@ -1,20 +1,18 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' +import { onedriveFilesQuerySchema } from '@/lib/api/contracts/selectors/microsoft' +import { getValidationErrorMessage } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } 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 */ @@ -23,20 +21,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] OneDrive files request received`) try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated request rejected`) - 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') || '' - - if (!credentialId) { - logger.warn(`[${requestId}] Missing credential ID`) - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + const validation = onedriveFilesQuerySchema.safeParse({ + 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) { @@ -46,38 +44,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Fetching credential`, { credentialId }) - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`, { credentialId }) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) + return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) } - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credentialId, + credAccess.credentialOwnerUserId, requestId ) if (!accessToken) { @@ -85,11 +63,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } - // Use search endpoint if query provided, otherwise list root children - // Microsoft Graph API doesn't support $filter on file/folder properties for /children endpoint + // $filter is unsupported on the /children endpoint; use search when a query is present let url: string if (query) { - // Use search endpoint with query const searchParams_new = new URLSearchParams() searchParams_new.append( '$select', @@ -98,7 +74,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { searchParams_new.append('$top', '50') url = `https://graph.microsoft.com/v1.0/me/drive/root/search(q='${encodeURIComponent(query)}')?${searchParams_new.toString()}` } else { - // List all children (files and folders) from root const searchParams_new = new URLSearchParams() searchParams_new.append( '$select', @@ -131,27 +106,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const data = await response.json() logger.info(`[${requestId}] Received ${data.value?.length || 0} items from Microsoft Graph`) - // Log what we received to debug filtering - const itemBreakdown = (data.value || []).reduce( - (acc: any, item: MicrosoftGraphDriveItem) => { - if (item.file) acc.files++ - if (item.folder) acc.folders++ - return acc - }, - { files: 0, folders: 0 } - ) - logger.info(`[${requestId}] Item breakdown`, itemBreakdown) - const files = (data.value || []) - .filter((item: MicrosoftGraphDriveItem) => { - const isFile = !!item.file && !item.folder - if (!isFile) { - logger.debug( - `[${requestId}] Filtering out item: ${item.name} (isFolder: ${!!item.folder})` - ) - } - return isFile - }) + .filter((item: MicrosoftGraphDriveItem) => !!item.file && !item.folder) .map((file: MicrosoftGraphDriveItem) => ({ id: file.id, name: file.name, @@ -172,16 +128,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { : [], })) - logger.info( - `[${requestId}] Returning ${files.length} files (filtered from ${data.value?.length || 0} items)` - ) - - // Log the file IDs we're returning - if (files.length > 0) { - logger.info(`[${requestId}] File IDs being returned:`, { - fileIds: files.slice(0, 5).map((f: any) => ({ id: f.id, name: f.name })), - }) - } + logger.info(`[${requestId}] Returning ${files.length} files`, { + totalItems: data.value?.length || 0, + }) return NextResponse.json({ files }, { status: 200 }) } catch (error) { diff --git a/apps/sim/app/api/tools/onedrive/folder/route.ts b/apps/sim/app/api/tools/onedrive/folder/route.ts index a499c06810f..17ff0b02d8e 100644 --- a/apps/sim/app/api/tools/onedrive/folder/route.ts +++ b/apps/sim/app/api/tools/onedrive/folder/route.ts @@ -1,13 +1,12 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' +import { onedriveFolderQuerySchema } from '@/lib/api/contracts/selectors/microsoft' +import { getValidationErrorMessage } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -17,55 +16,36 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - 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 = onedriveFolderQuerySchema.safeParse({ + 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) { return NextResponse.json({ error: fileIdValidation.error }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - if (!credentials.length) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) + return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) } - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credentialId, + credAccess.credentialOwnerUserId, requestId ) if (!accessToken) { diff --git a/apps/sim/app/api/tools/onedrive/folders/route.ts b/apps/sim/app/api/tools/onedrive/folders/route.ts index d95353de1e3..4c65c4190f6 100644 --- a/apps/sim/app/api/tools/onedrive/folders/route.ts +++ b/apps/sim/app/api/tools/onedrive/folders/route.ts @@ -1,20 +1,18 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' +import { onedriveFoldersQuerySchema } from '@/lib/api/contracts/selectors/microsoft' +import { getValidationErrorMessage } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } 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 */ @@ -22,18 +20,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { - const session = await getSession() - if (!session?.user?.id) { - 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') || '' - - if (!credentialId) { - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + const validation = onedriveFoldersQuerySchema.safeParse({ + 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) { @@ -41,37 +43,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - if (!credentials.length) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const authz = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credentialId, + authz.credentialOwnerUserId, requestId ) if (!accessToken) { diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts index 1af24e81e0e..f27cc9aa4bc 100644 --- a/apps/sim/app/api/tools/onedrive/upload/route.ts +++ b/apps/sim/app/api/tools/onedrive/upload/route.ts @@ -1,18 +1,20 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import * as XLSX from 'xlsx' -import { z } from 'zod' +import { onedriveUploadContract } from '@/lib/api/contracts/tools/microsoft' +import { parseRequest } 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, } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { normalizeExcelValues } from '@/tools/onedrive/utils' export const dynamic = 'force-dynamic' @@ -21,24 +23,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 @@ -65,7 +49,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized OneDrive upload attempt: ${authResult.error}`) return NextResponse.json( { @@ -80,8 +64,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = OneDriveUploadSchema.parse(body) + const parsed = await parseRequest(onedriveUploadContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const excelValues = normalizeExcelValues(validatedData.values) let fileBuffer: Buffer @@ -119,12 +104,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Failed to process file', + error: getErrorMessage(error, 'Failed to process file'), }, { status: 400 } ) } + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + try { fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) } catch (error) { @@ -132,7 +120,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: `Failed to download file: ${error instanceof Error ? error.message : 'Unknown error'}`, + error: `Failed to download file: ${getErrorMessage(error, 'Unknown error')}`, }, { status: 500 } ) @@ -393,7 +381,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.error(`[${requestId}] Exception during Excel content write`, err) excelWriteResult = { success: false, - error: err instanceof Error ? err.message : 'Unknown error during Excel write', + error: getErrorMessage(err, 'Unknown error during Excel write'), } } } @@ -416,24 +404,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) 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..7785a02f3e9 100644 --- a/apps/sim/app/api/tools/onepassword/create-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/create-item/route.ts @@ -1,8 +1,10 @@ import type { ItemCreateParams } from '@1password/sdk' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordCreateItemContract } from '@/lib/api/contracts/tools/onepassword' +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 +18,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 +28,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,13 +99,7 @@ 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' + const message = getErrorMessage(error, '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..716e7a97fc8 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,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordDeleteItemContract } from '@/lib/api/contracts/tools/onepassword' +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 +20,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,13 +59,7 @@ 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' + const message = getErrorMessage(error, '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..9693a65d6f9 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,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordGetItemContract } from '@/lib/api/contracts/tools/onepassword' +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 +15,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 +25,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,13 +64,7 @@ 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' + const message = getErrorMessage(error, '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..474f7fc2a78 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,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordGetVaultContract } from '@/lib/api/contracts/tools/onepassword' +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 +15,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 +25,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,13 +68,7 @@ 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' + const message = getErrorMessage(error, '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..daeb42e807d 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,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordListItemsContract } from '@/lib/api/contracts/tools/onepassword' +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 +15,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 +25,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,13 +76,7 @@ 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' + const message = getErrorMessage(error, '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..fa4011daa70 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,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordListVaultsContract } from '@/lib/api/contracts/tools/onepassword' +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 +15,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 +25,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,13 +75,7 @@ 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' + const message = getErrorMessage(error, '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..0f2ee44b76b 100644 --- a/apps/sim/app/api/tools/onepassword/replace-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/replace-item/route.ts @@ -1,8 +1,10 @@ import type { Item } from '@1password/sdk' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordReplaceItemContract } from '@/lib/api/contracts/tools/onepassword' +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 +18,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 +28,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,13 +105,7 @@ 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' + const message = getErrorMessage(error, '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..798b10b9795 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,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordResolveSecretContract } from '@/lib/api/contracts/tools/onepassword' +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 +20,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,13 +49,7 @@ 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' + const message = getErrorMessage(error, '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..f027e95c45d 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,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { onePasswordUpdateItemContract } from '@/lib/api/contracts/tools/onepassword' +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 +15,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 +25,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,13 +73,7 @@ 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' + const message = getErrorMessage(error, '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/onepassword/utils.ts b/apps/sim/app/api/tools/onepassword/utils.ts index 8fb53d59d50..94babba28f3 100644 --- a/apps/sim/app/api/tools/onepassword/utils.ts +++ b/apps/sim/app/api/tools/onepassword/utils.ts @@ -86,7 +86,7 @@ export interface NormalizedItemOverview { } /** Normalized field shape matching the Connect API response. */ -export interface NormalizedField { +interface NormalizedField { id: string label: string type: ConnectFieldType diff --git a/apps/sim/app/api/tools/outlook/copy/route.ts b/apps/sim/app/api/tools/outlook/copy/route.ts index 8bb47a0b5dc..406231899fb 100644 --- a/apps/sim/app/api/tools/outlook/copy/route.ts +++ b/apps/sim/app/api/tools/outlook/copy/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { outlookCopyContract } from '@/lib/api/contracts/tools/microsoft' +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' @@ -9,12 +11,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 +32,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = OutlookCopySchema.parse(body) + const parsed = await parseRequest(outlookCopyContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Copying Outlook email`, { messageId: validatedData.messageId, @@ -89,23 +86,11 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/outlook/delete/route.ts b/apps/sim/app/api/tools/outlook/delete/route.ts index f697c571280..1f3e4c1b5c5 100644 --- a/apps/sim/app/api/tools/outlook/delete/route.ts +++ b/apps/sim/app/api/tools/outlook/delete/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { outlookDeleteContract } from '@/lib/api/contracts/tools/microsoft' +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' @@ -9,11 +11,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 +32,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = OutlookDeleteSchema.parse(body) + const parsed = await parseRequest(outlookDeleteContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Deleting Outlook email`, { messageId: validatedData.messageId, @@ -78,23 +76,11 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/outlook/draft/route.ts b/apps/sim/app/api/tools/outlook/draft/route.ts index f58386af86e..693f0fa2ad5 100644 --- a/apps/sim/app/api/tools/outlook/draft/route.ts +++ b/apps/sim/app/api/tools/outlook/draft/route.ts @@ -1,35 +1,26 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { outlookDraftContract } from '@/lib/api/contracts/tools/microsoft' +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 { assertToolFileAccess } from '@/app/api/files/authorization' 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Outlook draft attempt: ${authResult.error}`) return NextResponse.json( { @@ -40,12 +31,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Outlook draft request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) - const body = await request.json() - const validatedData = OutlookDraftSchema.parse(body) + const parsed = await parseRequest(outlookDraftContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Creating Outlook draft`, { to: validatedData.to, @@ -108,32 +101,35 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const attachmentObjects = await Promise.all( + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( attachments.map(async (file) => { try { logger.info( `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` ) - - const buffer = await downloadFileFromStorage(file, requestId, logger) - - const base64Content = buffer.toString('base64') - - return { - '@odata.type': '#microsoft.graph.fileAttachment', - name: file.name, - contentType: file.type || 'application/octet-stream', - contentBytes: base64Content, - } + return await downloadFileFromStorage(file, requestId, logger) } catch (error) { logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) throw new Error( - `Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` + `Failed to download attachment "${file.name}": ${getErrorMessage(error, 'Unknown error')}` ) } }) ) + const attachmentObjects = attachments.map((file, i) => ({ + '@odata.type': '#microsoft.graph.fileAttachment', + name: file.name, + contentType: file.type || 'application/octet-stream', + contentBytes: buffers[i].toString('base64'), + })) + logger.info(`[${requestId}] Converted ${attachmentObjects.length} attachments to base64`) message.attachments = attachmentObjects } @@ -177,17 +173,11 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/outlook/folders/route.ts b/apps/sim/app/api/tools/outlook/folders/route.ts index 12ed5e067f2..2cd0addcd85 100644 --- a/apps/sim/app/api/tools/outlook/folders/route.ts +++ b/apps/sim/app/api/tools/outlook/folders/route.ts @@ -1,14 +1,13 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' -import { NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' +import { type NextRequest, NextResponse } from 'next/server' +import { outlookFoldersSelectorContract } from '@/lib/api/contracts/selectors/microsoft' +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' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -21,16 +20,11 @@ interface OutlookFolder { unreadItemCount?: number } -export const GET = withRouteHandler(async (request: Request) => { +export const GET = withRouteHandler(async (request: NextRequest) => { 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 parsed = await parseRequest(outlookFoldersSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credentialId } = parsed.data.query const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId') if (!credentialIdValidation.isValid) { @@ -39,49 +33,29 @@ export const GET = withRouteHandler(async (request: Request) => { } try { - const sessionUserId = session?.user?.id || '' - - if (!sessionUserId) { - logger.error('No user ID found in session') - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session!.user!.id, - 'workspace', - resolved.workspaceId + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.warn('Credential access denied', { error: credAccess.error }) + return NextResponse.json( + { error: credAccess.error || 'Authentication required' }, + { status: 401 } ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } } - const creds = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - if (!creds.length) { - logger.warn('Credential not found', { credentialId }) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - const credentialOwnerUserId = creds[0].userId - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - credentialOwnerUserId, + credentialId, + credAccess.credentialOwnerUserId, generateRequestId() ) if (!accessToken) { - logger.error('Failed to get access token', { credentialId, userId: credentialOwnerUserId }) + logger.error('Failed to get access token', { + credentialId, + userId: credAccess.credentialOwnerUserId, + }) return NextResponse.json( { error: 'Could not retrieve access token', 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..faf2ae20c59 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { outlookMarkReadContract } from '@/lib/api/contracts/tools/microsoft' +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' @@ -9,11 +11,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 +35,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = OutlookMarkReadSchema.parse(body) + const parsed = await parseRequest(outlookMarkReadContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Marking Outlook email as read`, { messageId: validatedData.messageId, @@ -88,23 +86,11 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) 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..e08caffa219 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { outlookMarkUnreadContract } from '@/lib/api/contracts/tools/microsoft' +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' @@ -9,11 +11,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 +35,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = OutlookMarkUnreadSchema.parse(body) + const parsed = await parseRequest(outlookMarkUnreadContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Marking Outlook email as unread`, { messageId: validatedData.messageId, @@ -88,23 +86,11 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/outlook/move/route.ts b/apps/sim/app/api/tools/outlook/move/route.ts index 24b04ed252e..d27440ffe80 100644 --- a/apps/sim/app/api/tools/outlook/move/route.ts +++ b/apps/sim/app/api/tools/outlook/move/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { outlookMoveContract } from '@/lib/api/contracts/tools/microsoft' +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' @@ -9,12 +11,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 +32,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = OutlookMoveSchema.parse(body) + const parsed = await parseRequest(outlookMoveContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Moving Outlook email`, { messageId: validatedData.messageId, @@ -87,23 +84,11 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/outlook/send/route.ts b/apps/sim/app/api/tools/outlook/send/route.ts index 6d8eac4cfcc..080d9db4871 100644 --- a/apps/sim/app/api/tools/outlook/send/route.ts +++ b/apps/sim/app/api/tools/outlook/send/route.ts @@ -1,37 +1,26 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { outlookSendContract } from '@/lib/api/contracts/tools/microsoft' +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 { assertToolFileAccess } from '@/app/api/files/authorization' 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Outlook send attempt: ${authResult.error}`) return NextResponse.json( { @@ -42,12 +31,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Outlook send request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) - const body = await request.json() - const validatedData = OutlookSendSchema.parse(body) + const parsed = await parseRequest(outlookSendContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Sending Outlook email`, { to: validatedData.to, @@ -110,32 +101,35 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const attachmentObjects = await Promise.all( + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( attachments.map(async (file) => { try { logger.info( `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` ) - - const buffer = await downloadFileFromStorage(file, requestId, logger) - - const base64Content = buffer.toString('base64') - - return { - '@odata.type': '#microsoft.graph.fileAttachment', - name: file.name, - contentType: file.type || 'application/octet-stream', - contentBytes: base64Content, - } + return await downloadFileFromStorage(file, requestId, logger) } catch (error) { logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) throw new Error( - `Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` + `Failed to download attachment "${file.name}": ${getErrorMessage(error, 'Unknown error')}` ) } }) ) + const attachmentObjects = attachments.map((file, i) => ({ + '@odata.type': '#microsoft.graph.fileAttachment', + name: file.name, + contentType: file.type || 'application/octet-stream', + contentBytes: buffers[i].toString('base64'), + })) + logger.info(`[${requestId}] Converted ${attachmentObjects.length} attachments to base64`) message.attachments = attachmentObjects } @@ -190,17 +184,11 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) 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..bb6a05cea88 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +34,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 +51,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() @@ -166,7 +160,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) 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..bdf9fcb605e 100644 --- a/apps/sim/app/api/tools/postgresql/delete/route.ts +++ b/apps/sim/app/api/tools/postgresql/delete/route.ts @@ -1,24 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { postgresqlDeleteContract } from '@/lib/api/contracts/tools/databases/postgresql' +import { parseToolRequest } 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 +20,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 parseToolRequest(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,15 +51,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] PostgreSQL delete failed:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/postgresql/execute/route.ts b/apps/sim/app/api/tools/postgresql/execute/route.ts index 373c749be45..3a5bbd5388b 100644 --- a/apps/sim/app/api/tools/postgresql/execute/route.ts +++ b/apps/sim/app/api/tools/postgresql/execute/route.ts @@ -1,7 +1,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { postgresqlExecuteContract } from '@/lib/api/contracts/tools/databases/postgresql' +import { parseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -12,16 +14,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 +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 = ExecuteSchema.parse(body) + const parsed = await parseToolRequest(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,15 +64,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] PostgreSQL execute failed:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/postgresql/insert/route.ts b/apps/sim/app/api/tools/postgresql/insert/route.ts index 447b098895c..760d26e1be2 100644 --- a/apps/sim/app/api/tools/postgresql/insert/route.ts +++ b/apps/sim/app/api/tools/postgresql/insert/route.ts @@ -1,45 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { postgresqlInsertContract } from '@/lib/api/contracts/tools/databases/postgresql' +import { parseToolRequest } 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 +20,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 parseToolRequest(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,15 +51,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] PostgreSQL insert failed:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/postgresql/introspect/route.ts b/apps/sim/app/api/tools/postgresql/introspect/route.ts index 462ae3f88fc..0a295f93b9b 100644 --- a/apps/sim/app/api/tools/postgresql/introspect/route.ts +++ b/apps/sim/app/api/tools/postgresql/introspect/route.ts @@ -1,23 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { postgresqlIntrospectContract } from '@/lib/api/contracts/tools/databases/postgresql' +import { parseToolRequest } 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 +20,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 parseToolRequest(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,15 +53,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] PostgreSQL introspection failed:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/postgresql/query/route.ts b/apps/sim/app/api/tools/postgresql/query/route.ts index 7726a8d5742..d47ad84189f 100644 --- a/apps/sim/app/api/tools/postgresql/query/route.ts +++ b/apps/sim/app/api/tools/postgresql/query/route.ts @@ -1,23 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { postgresqlQueryContract } from '@/lib/api/contracts/tools/databases/postgresql' +import { parseToolRequest } 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 +20,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 parseToolRequest(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,15 +51,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] PostgreSQL query failed:`, error) return NextResponse.json({ error: `PostgreSQL query failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/postgresql/update/route.ts b/apps/sim/app/api/tools/postgresql/update/route.ts index 2e0dcf4feb3..6533b47f5f4 100644 --- a/apps/sim/app/api/tools/postgresql/update/route.ts +++ b/apps/sim/app/api/tools/postgresql/update/route.ts @@ -1,43 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { postgresqlUpdateContract } from '@/lib/api/contracts/tools/databases/postgresql' +import { parseToolRequest } 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 +20,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 parseToolRequest(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,15 +51,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] PostgreSQL update failed:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/postgresql/utils.ts b/apps/sim/app/api/tools/postgresql/utils.ts index 55f0bbe9304..dfeeab9eadb 100644 --- a/apps/sim/app/api/tools/postgresql/utils.ts +++ b/apps/sim/app/api/tools/postgresql/utils.ts @@ -8,17 +8,18 @@ export async function createPostgresConnection(config: PostgresConnectionConfig) throw new Error(hostValidation.error) } - const sslConfig = + const resolvedHost = hostValidation.resolvedIP ?? config.host + const pinIP = config.ssl !== 'preferred' + + const sslConfig: boolean | 'prefer' | { rejectUnauthorized: boolean; servername?: string } = config.ssl === 'disabled' ? false - : config.ssl === 'required' - ? 'require' - : config.ssl === 'preferred' - ? 'prefer' - : 'require' + : config.ssl === 'preferred' + ? 'prefer' + : { rejectUnauthorized: false, servername: config.host } const sql = postgres({ - host: config.host, + host: pinIP ? resolvedHost : config.host, port: config.port, database: config.database, username: config.username, diff --git a/apps/sim/app/api/tools/pulse/parse/route.ts b/apps/sim/app/api/tools/pulse/parse/route.ts index 30b5a198803..ae8c550d29c 100644 --- a/apps/sim/app/api/tools/pulse/parse/route.ts +++ b/apps/sim/app/api/tools/pulse/parse/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { pulseParseContract } from '@/lib/api/contracts/tools/media/document-parse' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -8,7 +10,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 +17,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() @@ -48,8 +37,28 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = authResult.userId - const body = await request.json() - const validatedData = PulseParseSchema.parse(body) + + const parsed = await parseRequest( + pulseParseContract, + 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 validatedData = parsed.data.body logger.info(`[${requestId}] Pulse parse request`, { fileName: validatedData.file?.name, @@ -152,24 +161,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) 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..199dc8b0e7c 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,38 +1,45 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { quiverImageToSvgContract } from '@/lib/api/contracts/tools/quiver' +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' +import { assertToolFileAccess } from '@/app/api/files/authorization' 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() const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } 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 } @@ -42,6 +49,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (parsed && typeof parsed === 'object') { const userFiles = processFilesToUserFiles([parsed as RawFileInput], requestId, logger) if (userFiles.length > 0) { + const denied = await assertToolFileAccess( + userFiles[0].key, + authResult.userId, + requestId, + logger + ) + if (denied) return denied const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger) apiImage = { base64: buffer.toString('base64') } } else { @@ -59,6 +73,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } else if (typeof data.image === 'object' && data.image !== null) { const userFiles = processFilesToUserFiles([data.image as RawFileInput], requestId, logger) if (userFiles.length > 0) { + const denied = await assertToolFileAccess( + userFiles[0].key, + authResult.userId, + requestId, + logger + ) + if (denied) return denied const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger) apiImage = { base64: buffer.toString('base64') } } else { @@ -136,7 +157,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } catch (error) { logger.error(`[${requestId}] Error in Quiver image-to-svg:`, error) - const message = error instanceof Error ? error.message : 'Unknown error' + const message = getErrorMessage(error, 'Unknown error') return NextResponse.json({ success: false, error: message }, { status: 500 }) } }) 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..5b12aaa0679 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,42 +1,46 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { quiverTextToSvgContract } from '@/lib/api/contracts/tools/quiver' +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' +import { assertToolFileAccess } from '@/app/api/files/authorization' 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() const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } + const userId = authResult.userId 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 }> = [] @@ -50,6 +54,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (parsed && typeof parsed === 'object') { const userFiles = processFilesToUserFiles([parsed as RawFileInput], requestId, logger) if (userFiles.length > 0) { + const denied = await assertToolFileAccess( + userFiles[0].key, + userId, + requestId, + logger + ) + if (denied) return denied const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger) apiReferences.push({ base64: buffer.toString('base64') }) } @@ -60,6 +71,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } else if (typeof ref === 'object' && ref !== null) { const userFiles = processFilesToUserFiles([ref as RawFileInput], requestId, logger) if (userFiles.length > 0) { + const denied = await assertToolFileAccess(userFiles[0].key, userId, requestId, logger) + if (denied) return denied const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger) apiReferences.push({ base64: buffer.toString('base64') }) } @@ -137,7 +150,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } catch (error) { logger.error(`[${requestId}] Error in Quiver text-to-svg:`, error) - const message = error instanceof Error ? error.message : 'Unknown error' + const message = getErrorMessage(error, 'Unknown error') return NextResponse.json({ success: false, error: message }, { status: 500 }) } }) diff --git a/apps/sim/app/api/tools/rds/delete/route.ts b/apps/sim/app/api/tools/rds/delete/route.ts index a1921d5a186..a83e5959c1d 100644 --- a/apps/sim/app/api/tools/rds/delete/route.ts +++ b/apps/sim/app/api/tools/rds/delete/route.ts @@ -1,26 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { rdsDeleteContract } from '@/lib/api/contracts/tools/databases/rds' +import { parseToolRequest } 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 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = DeleteSchema.parse(body) + const parsed = await parseToolRequest(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,15 +55,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] RDS delete failed:`, error) return NextResponse.json({ error: `RDS delete failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/rds/execute/route.ts b/apps/sim/app/api/tools/rds/execute/route.ts index 3e3dfdac2fe..408c5bfe29f 100644 --- a/apps/sim/app/api/tools/rds/execute/route.ts +++ b/apps/sim/app/api/tools/rds/execute/route.ts @@ -1,23 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { rdsExecuteContract } from '@/lib/api/contracts/tools/databases/rds' +import { parseToolRequest } 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 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = ExecuteSchema.parse(body) + const parsed = await parseToolRequest(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,15 +54,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] RDS execute failed:`, error) return NextResponse.json({ error: `RDS execute failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/rds/insert/route.ts b/apps/sim/app/api/tools/rds/insert/route.ts index 899a657937c..ae7751adf67 100644 --- a/apps/sim/app/api/tools/rds/insert/route.ts +++ b/apps/sim/app/api/tools/rds/insert/route.ts @@ -1,26 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { rdsInsertContract } from '@/lib/api/contracts/tools/databases/rds' +import { parseToolRequest } 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 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = InsertSchema.parse(body) + const parsed = await parseToolRequest(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,15 +55,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] RDS insert failed:`, error) return NextResponse.json({ error: `RDS insert failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/rds/introspect/route.ts b/apps/sim/app/api/tools/rds/introspect/route.ts index 8f983033fac..32e3df10595 100644 --- a/apps/sim/app/api/tools/rds/introspect/route.ts +++ b/apps/sim/app/api/tools/rds/introspect/route.ts @@ -1,24 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { rdsIntrospectContract } from '@/lib/api/contracts/tools/databases/rds' +import { parseToolRequest } 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 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = IntrospectSchema.parse(body) + const parsed = await parseToolRequest(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,15 +60,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] RDS introspection failed:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/rds/query/route.ts b/apps/sim/app/api/tools/rds/query/route.ts index 7fcc4bf8d30..cc511122847 100644 --- a/apps/sim/app/api/tools/rds/query/route.ts +++ b/apps/sim/app/api/tools/rds/query/route.ts @@ -1,23 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { rdsQueryContract } from '@/lib/api/contracts/tools/databases/rds' +import { parseToolRequest } 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 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = QuerySchema.parse(body) + const parsed = await parseToolRequest(rdsQueryContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Executing RDS query on ${params.database}`) @@ -67,15 +60,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] RDS query failed:`, error) return NextResponse.json({ error: `RDS query failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/rds/update/route.ts b/apps/sim/app/api/tools/rds/update/route.ts index fcdcb67c94e..6ea2df5c10e 100644 --- a/apps/sim/app/api/tools/rds/update/route.ts +++ b/apps/sim/app/api/tools/rds/update/route.ts @@ -1,29 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { rdsUpdateContract } from '@/lib/api/contracts/tools/databases/rds' +import { parseToolRequest } 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 +19,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = UpdateSchema.parse(body) + const parsed = await parseToolRequest(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,15 +56,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] RDS update failed:`, error) return NextResponse.json({ error: `RDS update failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/rds/utils.ts b/apps/sim/app/api/tools/rds/utils.ts index ac78b83d87a..ea76642a380 100644 --- a/apps/sim/app/api/tools/rds/utils.ts +++ b/apps/sim/app/api/tools/rds/utils.ts @@ -279,7 +279,7 @@ export interface RdsIntrospectionResult { /** * Detects the database engine by querying SELECT VERSION() */ -export async function detectEngine( +async function detectEngine( client: RDSDataClient, resourceArn: string, secretArn: string, diff --git a/apps/sim/app/api/tools/redis/execute/route.ts b/apps/sim/app/api/tools/redis/execute/route.ts index 5482d0896d2..7a38c676b95 100644 --- a/apps/sim/app/api/tools/redis/execute/route.ts +++ b/apps/sim/app/api/tools/redis/execute/route.ts @@ -1,19 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import Redis from 'ioredis' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { redisExecuteContract } from '@/lib/api/contracts/tools/databases/redis' +import { parseToolRequest } 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 +19,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 parseToolRequest(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 = @@ -36,7 +36,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: hostValidation.error }, { status: 400 }) } - client = new Redis(url, { + const resolvedIP = hostValidation.resolvedIP ?? hostname + const tlsEnabled = parsedUrl.protocol === 'rediss:' + const port = parsedUrl.port ? Number(parsedUrl.port) : 6379 + const username = parsedUrl.username ? decodeURIComponent(parsedUrl.username) : undefined + const password = parsedUrl.password ? decodeURIComponent(parsedUrl.password) : undefined + + let db = 0 + if (parsedUrl.pathname && parsedUrl.pathname.length > 1) { + const dbSegment = parsedUrl.pathname.slice(1) + const parsedDb = Number.parseInt(dbSegment, 10) + if (!Number.isFinite(parsedDb) || String(parsedDb) !== dbSegment) { + return NextResponse.json( + { error: `Invalid Redis database index in URL path: '${dbSegment}'` }, + { status: 400 } + ) + } + db = parsedDb + } + + client = new Redis({ + host: resolvedIP, + port, + username, + password, + db, + family: resolvedIP.includes(':') ? 6 : 4, + tls: tlsEnabled ? { servername: hostname } : undefined, connectTimeout: 10000, commandTimeout: 10000, maxRetriesPerRequest: 1, @@ -55,7 +81,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ result }) } catch (error) { logger.error('Redis command failed', { error }) - const errorMessage = error instanceof Error ? error.message : 'Redis command failed' + const errorMessage = getErrorMessage(error, 'Redis command failed') return NextResponse.json({ error: errorMessage }, { status: 500 }) } finally { if (client) { diff --git a/apps/sim/app/api/tools/reducto/parse/route.ts b/apps/sim/app/api/tools/reducto/parse/route.ts index dc92994a48f..91a1ea440f6 100644 --- a/apps/sim/app/api/tools/reducto/parse/route.ts +++ b/apps/sim/app/api/tools/reducto/parse/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { reductoParseContract } from '@/lib/api/contracts/tools/media/document-parse' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -8,7 +10,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 +17,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() @@ -44,8 +37,28 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = authResult.userId - const body = await request.json() - const validatedData = ReductoParseSchema.parse(body) + + const parsed = await parseRequest( + reductoParseContract, + 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 validatedData = parsed.data.body logger.info(`[${requestId}] Reducto parse request`, { fileName: validatedData.file?.name, @@ -145,24 +158,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) 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..28a71cf12d6 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,9 @@ import { CopyObjectCommand, type ObjectCannedACL, S3Client } from '@aws-sdk/client-s3' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsS3CopyObjectContract } from '@/lib/api/contracts/tools/aws/s3-copy-object' +import { parseToolRequest } 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 +12,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 +33,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = S3CopyObjectSchema.parse(body) + const parsed = await parseToolRequest(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,24 +88,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) 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..29a5778b6e4 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,9 @@ import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsS3DeleteObjectContract } from '@/lib/api/contracts/tools/aws/s3-delete-object' +import { parseToolRequest } 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 +12,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 +36,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = S3DeleteObjectSchema.parse(body) + const parsed = await parseToolRequest(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,24 +80,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) 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..0f2ad914679 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,9 @@ import { ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsS3ListObjectsContract } from '@/lib/api/contracts/tools/aws/s3-list-objects' +import { parseToolRequest } 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 +12,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 +33,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = S3ListObjectsSchema.parse(body) + const parsed = await parseToolRequest(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,24 +88,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) 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..13d543aca0a 100644 --- a/apps/sim/app/api/tools/s3/put-object/route.ts +++ b/apps/sim/app/api/tools/s3/put-object/route.ts @@ -1,37 +1,27 @@ import { type ObjectCannedACL, PutObjectCommand, S3Client } from '@aws-sdk/client-s3' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { awsS3PutObjectContract } from '@/lib/api/contracts/tools/aws/s3-put-object' +import { parseToolRequest } 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 { assertToolFileAccess } from '@/app/api/files/authorization' 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized S3 put object attempt: ${authResult.error}`) return NextResponse.json( { @@ -46,8 +36,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = S3PutObjectSchema.parse(body) + const parsed = await parseToolRequest(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, @@ -78,12 +72,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Failed to process file', + error: getErrorMessage(error, 'Failed to process file'), }, { status: 400 } ) } + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + const buffer = await downloadFileFromStorage(userFile, requestId, logger) uploadBody = buffer @@ -133,24 +130,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/sap_concur/proxy/route.ts b/apps/sim/app/api/tools/sap_concur/proxy/route.ts new file mode 100644 index 00000000000..802d83be266 --- /dev/null +++ b/apps/sim/app/api/tools/sap_concur/proxy/route.ts @@ -0,0 +1,133 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { getValidationErrorMessage, isZodError } 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 { + assertSafeExternalUrl, + extractSapConcurError, + fetchSapConcurAccessToken, + SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS, + type SapConcurProxyRequest, + SapConcurProxyRequestSchema, +} from '@/app/api/tools/sap_concur/shared' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('SapConcurProxyAPI') + +type ProxyRequest = SapConcurProxyRequest + +function buildApiUrl(geolocation: string, req: ProxyRequest): string { + const base = geolocation.replace(/\/+$/, '') + const subPath = req.path.startsWith('/') ? req.path : `/${req.path}` + const url = `${base}${subPath}` + + if (!req.query || Object.keys(req.query).length === 0) { + return url + } + const search = new URLSearchParams() + for (const [key, value] of Object.entries(req.query)) { + if (value === undefined || value === null) continue + search.append(key, String(value)) + } + const queryString = search.toString() + if (!queryString) return url + return url.includes('?') ? `${url}&${queryString}` : `${url}?${queryString}` +} + +interface Invocation { + status: number + body: unknown + raw: string +} + +async function callConcur( + req: ProxyRequest, + accessToken: string, + geolocation: string +): Promise { + const url = assertSafeExternalUrl(buildApiUrl(geolocation, req), 'apiUrl').toString() + const hasBody = req.body !== undefined && req.body !== null + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + } + if (hasBody) headers['Content-Type'] = req.contentType ?? 'application/json' + if (req.companyUuid) headers['concur-correlationid'] = req.companyUuid + + const response = await secureFetchWithValidation( + url, + { + method: req.method, + headers, + body: hasBody + ? typeof req.body === 'string' + ? req.body + : JSON.stringify(req.body) + : undefined, + timeout: SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS, + }, + 'apiUrl' + ) + + const raw = await response.text() + let parsed: unknown = null + if (raw.length > 0) { + try { + parsed = JSON.parse(raw) + } catch { + parsed = raw + } + } + return { status: response.status, body: parsed, raw } +} + +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 Concur proxy request: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + // boundary-raw-json: internal proxy envelope validated by SapConcurProxyRequestSchema below; not a public boundary + const json = await request.json() + const proxyReq = SapConcurProxyRequestSchema.parse(json) + + const { accessToken, geolocation } = await fetchSapConcurAccessToken(proxyReq, requestId) + const invocation = await callConcur(proxyReq, accessToken, geolocation) + + if (invocation.status >= 200 && invocation.status < 300) { + const data = invocation.status === 204 ? null : invocation.body + return NextResponse.json({ success: true, output: { status: invocation.status, data } }) + } + + const message = extractSapConcurError(invocation.body, invocation.status) + logger.warn( + `[${requestId}] Concur API error (${invocation.status}) ${proxyReq.path}: ${message}` + ) + return NextResponse.json( + { success: false, error: message, status: invocation.status }, + { status: invocation.status } + ) + } catch (error) { + if (isZodError(error)) { + logger.warn(`[${requestId}] Validation error:`, error.issues) + return NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Validation failed') }, + { status: 400 } + ) + } + logger.error(`[${requestId}] Unexpected Concur proxy error:`, error) + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/sap_concur/shared.ts b/apps/sim/app/api/tools/sap_concur/shared.ts new file mode 100644 index 00000000000..e395b4123af --- /dev/null +++ b/apps/sim/app/api/tools/sap_concur/shared.ts @@ -0,0 +1,305 @@ +import { createHash } from 'node:crypto' +import { createLogger } from '@sim/logger' +import { z } from 'zod' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' +import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' + +const logger = createLogger('SapConcurShared') + +export const SAP_CONCUR_ALLOWED_DATACENTERS = new Set([ + 'us.api.concursolutions.com', + 'us2.api.concursolutions.com', + 'eu.api.concursolutions.com', + 'eu2.api.concursolutions.com', + 'cn.api.concursolutions.com', + 'emea.api.concursolutions.com', +]) + +export const SapConcurDatacenterSchema = z + .string() + .min(1) + .refine((d) => SAP_CONCUR_ALLOWED_DATACENTERS.has(d), { + message: `datacenter must be one of: ${Array.from(SAP_CONCUR_ALLOWED_DATACENTERS).join(', ')}`, + }) + +export const SapConcurGrantTypeSchema = z.enum(['client_credentials', 'password']) + +export const SapConcurAuthSchema = z.object({ + datacenter: SapConcurDatacenterSchema.default('us.api.concursolutions.com'), + grantType: SapConcurGrantTypeSchema.default('client_credentials'), + clientId: z.string().min(1, 'clientId is required'), + clientSecret: z.string().min(1, 'clientSecret is required'), + username: z.string().optional(), + password: z.string().optional(), + companyUuid: z.string().optional(), +}) + +export type SapConcurAuth = z.infer + +export const SapConcurHttpMethod = z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) + +export const SapConcurProxyPath = z + .string() + .min(1, 'path is required') + .refine( + (p) => + !p.split(/[/\\]/).some((seg) => seg === '..' || seg === '.') && + !p.includes('#') && + !/%(?:2[eEfF]|5[cC]|23)/.test(p), + { + message: + 'path must not contain ".." or "." segments, "#", or percent-encoded path/fragment characters', + } + ) + +export const SapConcurProxyRequestSchema = SapConcurAuthSchema.extend({ + path: SapConcurProxyPath, + method: SapConcurHttpMethod.default('GET'), + query: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(), + body: z.unknown().optional(), + contentType: z.string().optional(), +}).superRefine((req, ctx) => { + if (req.grantType === 'password') { + if (!req.username) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['username'], + message: 'username is required for password grant', + }) + } + if (!req.password) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['password'], + message: 'password is required for password grant', + }) + } + } +}) + +export type SapConcurProxyRequest = z.infer + +export const SapConcurUploadOperation = z.enum([ + 'upload_receipt_image', + 'create_quick_expense_with_image', +]) + +export const SapConcurUploadRequestSchema = SapConcurAuthSchema.extend({ + operation: SapConcurUploadOperation, + userId: z.string().min(1, 'userId is required'), + contextType: z.string().optional(), + receipt: FileInputSchema, + forwardId: z.string().max(40).optional(), + body: z.union([z.record(z.string(), z.unknown()), z.string()]).optional(), +}) + +export type SapConcurUploadRequest = z.infer + +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 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 +} + +/** Validate a URL is https and not pointing to a private/loopback host. */ +export function assertSafeExternalUrl(rawUrl: string, label: string): URL { + let parsed: URL + try { + parsed = new URL(rawUrl) + } catch { + throw new Error(`${label} must be a valid URL`) + } + if (parsed.protocol !== 'https:') { + throw new Error(`${label} must use https://`) + } + const host = parsed.hostname.toLowerCase() + if (FORBIDDEN_HOSTS.has(host) || FORBIDDEN_HOSTS.has(`[${host}]`)) { + throw new Error(`${label} host is not allowed`) + } + if (isPrivateIPv4(host)) { + throw new Error(`${label} host is not allowed (private/loopback range)`) + } + if (isPrivateOrLoopbackIPv6(host)) { + throw new Error(`${label} host is not allowed (IPv6 private/loopback)`) + } + return parsed +} + +interface CachedToken { + accessToken: string + geolocation: string + expiresAt: number +} + +const TOKEN_CACHE = new Map() +const TOKEN_CACHE_MAX_ENTRIES = 500 +const TOKEN_SAFETY_WINDOW_MS = 60_000 +export const SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS = 30_000 + +function tokenCacheKey(req: SapConcurAuth): string { + const secretHash = createHash('sha256').update(req.clientSecret).digest('hex').slice(0, 16) + const userHash = req.username + ? createHash('sha256').update(req.username).digest('hex').slice(0, 12) + : '' + return `${req.datacenter}::${req.grantType}::${req.clientId}::${secretHash}::${userHash}` +} + +function rememberToken(key: string, token: CachedToken): void { + if (TOKEN_CACHE.has(key)) TOKEN_CACHE.delete(key) + TOKEN_CACHE.set(key, token) + while (TOKEN_CACHE.size > TOKEN_CACHE_MAX_ENTRIES) { + const oldestKey = TOKEN_CACHE.keys().next().value + if (oldestKey === undefined) break + TOKEN_CACHE.delete(oldestKey) + } +} + +function normalizeGeolocation(raw: string | undefined, fallback: string): string { + if (!raw) return `https://${fallback}` + const trimmed = raw.replace(/\/+$/, '') + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed + return `https://${trimmed}` +} + +/** + * Acquire a Concur access token, sharing a cache with the proxy route. + * Validates that the geolocation returned by Concur is a safe external URL. + */ +export async function fetchSapConcurAccessToken( + auth: SapConcurAuth, + requestId: string +): Promise<{ accessToken: string; geolocation: string }> { + if (auth.grantType === 'password') { + if (!auth.username) throw new Error('username is required for password grant') + if (!auth.password) throw new Error('password is required for password grant') + } + + const cacheKey = tokenCacheKey(auth) + const cached = TOKEN_CACHE.get(cacheKey) + if (cached && cached.expiresAt - TOKEN_SAFETY_WINDOW_MS > Date.now()) { + return { accessToken: cached.accessToken, geolocation: cached.geolocation } + } + + const tokenUrl = assertSafeExternalUrl( + `https://${auth.datacenter}/oauth2/v0/token`, + 'tokenUrl' + ).toString() + + const params = new URLSearchParams() + params.set('client_id', auth.clientId) + params.set('client_secret', auth.clientSecret) + params.set('grant_type', auth.grantType) + if (auth.grantType === 'password') { + params.set('username', auth.username ?? '') + params.set('password', auth.password ?? '') + if (auth.companyUuid) params.set('credtype', 'authtoken') + } + + const response = await secureFetchWithValidation( + tokenUrl, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: params.toString(), + timeout: SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS, + }, + 'tokenUrl' + ) + + if (!response.ok) { + const text = await response.text().catch(() => '') + logger.warn(`[${requestId}] Concur token fetch failed (${response.status}): ${text}`) + throw new Error(`Concur token request failed: HTTP ${response.status}`) + } + + const data = (await response.json()) as { + access_token?: string + expires_in?: number + geolocation?: string + } + + if (!data.access_token) { + throw new Error('Concur token response missing access_token') + } + + const geolocation = normalizeGeolocation(data.geolocation, auth.datacenter) + const geolocationUrl = assertSafeExternalUrl(geolocation, 'geolocation') + if (!SAP_CONCUR_ALLOWED_DATACENTERS.has(geolocationUrl.hostname.toLowerCase())) { + throw new Error( + `Concur geolocation host is not in the allowed datacenter list: ${geolocationUrl.hostname}` + ) + } + + const expiresInMs = (data.expires_in ?? 3600) * 1000 + rememberToken(cacheKey, { + accessToken: data.access_token, + geolocation, + expiresAt: Date.now() + expiresInMs, + }) + return { accessToken: data.access_token, geolocation } +} + +/** Extract a meaningful error message from a Concur error response body. */ +export function extractSapConcurError(body: unknown, status: number): string { + if (body && typeof body === 'object') { + const obj = body as Record + if (typeof obj.error === 'string' && obj.error.length > 0) { + const desc = typeof obj.error_description === 'string' ? `: ${obj.error_description}` : '' + return `${obj.error}${desc}` + } + if (typeof obj.message === 'string' && obj.message.length > 0) { + return obj.message + } + const errors = obj.errors + if (Array.isArray(errors) && errors.length > 0) { + return errors + .map((e) => { + if (e && typeof e === 'object') { + const eo = e as Record + const code = typeof eo.errorCode === 'string' ? `[${eo.errorCode}] ` : '' + const msg = typeof eo.errorMessage === 'string' ? eo.errorMessage : '' + return `${code}${msg}`.trim() + } + return String(e) + }) + .filter(Boolean) + .join('; ') + } + } + if (typeof body === 'string' && body.length > 0) return body + return `Concur request failed with HTTP ${status}` +} diff --git a/apps/sim/app/api/tools/sap_concur/upload/route.ts b/apps/sim/app/api/tools/sap_concur/upload/route.ts new file mode 100644 index 00000000000..74f8fb093de --- /dev/null +++ b/apps/sim/app/api/tools/sap_concur/upload/route.ts @@ -0,0 +1,283 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { getValidationErrorMessage, isZodError } 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 { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' +import { + assertSafeExternalUrl, + extractSapConcurError, + fetchSapConcurAccessToken, + SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS, + type SapConcurUploadRequest, + SapConcurUploadRequestSchema, +} from '@/app/api/tools/sap_concur/shared' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('SapConcurUploadAPI') + +type UploadRequest = SapConcurUploadRequest + +const RECEIPT_ALLOWED_MIME_TYPES = new Set([ + 'application/pdf', + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/tiff', +]) + +const QUICK_EXPENSE_ALLOWED_MIME_TYPES = new Set([ + 'application/pdf', + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/tiff', +]) + +const ALLOWED_MIME_TYPES = RECEIPT_ALLOWED_MIME_TYPES + +function inferMimeType(name: string, declared?: string): string { + if (declared && ALLOWED_MIME_TYPES.has(declared.toLowerCase())) { + return declared.toLowerCase() === 'image/jpg' ? 'image/jpeg' : declared.toLowerCase() + } + const lower = name.toLowerCase() + if (lower.endsWith('.pdf')) return 'application/pdf' + if (lower.endsWith('.png')) return 'image/png' + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg' + if (lower.endsWith('.gif')) return 'image/gif' + if (lower.endsWith('.tif') || lower.endsWith('.tiff')) return 'image/tiff' + return 'application/octet-stream' +} + +function stringifyMaybeJson(value: unknown): string { + if (typeof value === 'string') return value + return JSON.stringify(value ?? {}) +} + +interface UploadInvocation { + status: number + body: unknown +} + +async function postMultipart( + url: string, + accessToken: string, + formData: FormData, + companyUuid: string | undefined, + extraHeaders?: Record +): Promise { + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + ...(extraHeaders ?? {}), + } + if (companyUuid) headers['concur-correlationid'] = companyUuid + + // Serialize FormData (with auto-generated multipart boundary) to a Buffer so we can + // route through secureFetchWithValidation (which doesn't support FormData bodies directly). + const serialized = new Request('http://localhost/internal-multipart-serializer', { + method: 'POST', + body: formData, + }) + const contentType = serialized.headers.get('content-type') + if (contentType) headers['Content-Type'] = contentType + const bodyBuffer = Buffer.from(await serialized.arrayBuffer()) + + const response = await secureFetchWithValidation( + url, + { + method: 'POST', + headers, + body: bodyBuffer, + timeout: SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS, + }, + 'apiUrl' + ) + + const raw = await response.text() + let parsed: unknown = null + if (raw.length > 0) { + try { + parsed = JSON.parse(raw) + } catch { + parsed = raw + } + } + // Surface Location/Link headers for receipt endpoints that return 202 with no body. + if ( + parsed === null || + (typeof parsed === 'object' && parsed !== null && Object.keys(parsed).length === 0) + ) { + const location = response.headers.get('Location') + const link = response.headers.get('Link') + if (location || link) { + parsed = { location, link } + } + } + return { status: response.status, body: parsed } +} + +async function handleUploadReceiptImage( + req: UploadRequest, + fileBuffer: Buffer, + fileName: string, + mimeType: string, + accessToken: string, + geolocation: string +): Promise { + const url = assertSafeExternalUrl( + `${geolocation.replace(/\/+$/, '')}/receipts/v4/users/${encodeURIComponent(req.userId)}/image-only-receipts`, + 'apiUrl' + ).toString() + + const formData = new FormData() + formData.append('image', new Blob([new Uint8Array(fileBuffer)], { type: mimeType }), fileName) + + const extraHeaders: Record | undefined = req.forwardId + ? { 'concur-forwardid': req.forwardId } + : undefined + + return postMultipart(url, accessToken, formData, req.companyUuid, extraHeaders) +} + +async function handleCreateQuickExpenseWithImage( + req: UploadRequest, + fileBuffer: Buffer, + fileName: string, + mimeType: string, + accessToken: string, + geolocation: string +): Promise { + const contextType = req.contextType?.trim() || 'TRAVELER' + const url = assertSafeExternalUrl( + `${geolocation.replace(/\/+$/, '')}/quickexpense/v4/users/${encodeURIComponent( + req.userId + )}/context/${encodeURIComponent(contextType)}/quickexpenses/image`, + 'apiUrl' + ).toString() + + const quickExpenseRequest = stringifyMaybeJson(req.body ?? {}) + + const formData = new FormData() + formData.append('quickExpenseRequest', quickExpenseRequest) + formData.append( + 'fileContent', + new Blob([new Uint8Array(fileBuffer)], { type: mimeType }), + fileName + ) + + return postMultipart(url, accessToken, formData, req.companyUuid) +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Concur upload request: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + const userId = authResult.userId + + // boundary-raw-json: internal upload envelope validated by SapConcurUploadRequestSchema below; not a public boundary + const json = await request.json() + const uploadReq = SapConcurUploadRequestSchema.parse(json) + + const userFiles = processFilesToUserFiles( + [uploadReq.receipt as RawFileInput], + requestId, + logger + ) + if (userFiles.length === 0) { + return NextResponse.json( + { success: false, error: 'Invalid receipt file input' }, + { status: 400 } + ) + } + const userFile = userFiles[0] + const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) + if (denied) return denied + const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) + const fileName = userFile.name + const mimeType = inferMimeType(fileName, userFile.type) + + const allowedForOperation = + uploadReq.operation === 'create_quick_expense_with_image' + ? QUICK_EXPENSE_ALLOWED_MIME_TYPES + : RECEIPT_ALLOWED_MIME_TYPES + if (!allowedForOperation.has(mimeType)) { + const allowedLabel = + uploadReq.operation === 'create_quick_expense_with_image' + ? 'pdf, png, jpeg, tiff' + : 'pdf, png, jpeg, gif, tiff' + return NextResponse.json( + { + success: false, + error: `Unsupported receipt mime type: ${mimeType}. Allowed: ${allowedLabel}`, + }, + { status: 400 } + ) + } + + const { accessToken, geolocation } = await fetchSapConcurAccessToken(uploadReq, requestId) + + let invocation: UploadInvocation + if (uploadReq.operation === 'upload_receipt_image') { + invocation = await handleUploadReceiptImage( + uploadReq, + fileBuffer, + fileName, + mimeType, + accessToken, + geolocation + ) + } else { + invocation = await handleCreateQuickExpenseWithImage( + uploadReq, + fileBuffer, + fileName, + mimeType, + accessToken, + geolocation + ) + } + + if (invocation.status >= 200 && invocation.status < 300) { + const data = invocation.status === 204 ? null : invocation.body + logger.info( + `[${requestId}] Concur ${uploadReq.operation} succeeded: HTTP ${invocation.status}` + ) + return NextResponse.json({ success: true, output: { status: invocation.status, data } }) + } + + const message = extractSapConcurError(invocation.body, invocation.status) + logger.warn( + `[${requestId}] Concur upload error (${invocation.status}) ${uploadReq.operation}: ${message}` + ) + return NextResponse.json( + { success: false, error: message, status: invocation.status }, + { status: invocation.status } + ) + } catch (error) { + if (isZodError(error)) { + logger.warn(`[${requestId}] Validation error:`, error.issues) + return NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Validation failed') }, + { status: 400 } + ) + } + logger.error(`[${requestId}] Unexpected Concur upload error:`, error) + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/sap_s4hana/proxy/route.ts b/apps/sim/app/api/tools/sap_s4hana/proxy/route.ts new file mode 100644 index 00000000000..bee4a8b84aa --- /dev/null +++ b/apps/sim/app/api/tools/sap_s4hana/proxy/route.ts @@ -0,0 +1,380 @@ +import { createHash } from 'node:crypto' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + assertSafeSapExternalUrl, + type SapS4HanaProxyRequest, + sapS4HanaProxyContract, +} from '@/lib/api/contracts/tools/sap' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + type SecureFetchResponse, + secureFetchWithValidation, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('SapS4HanaProxyAPI') + +type ProxyRequest = SapS4HanaProxyRequest + +interface CachedToken { + accessToken: string + expiresAt: number +} + +const TOKEN_CACHE = new Map() +const TOKEN_CACHE_MAX_ENTRIES = 500 +const TOKEN_SAFETY_WINDOW_MS = 60_000 +const OUTBOUND_FETCH_TIMEOUT_MS = 30_000 + +function resolveTokenUrl(req: ProxyRequest): string { + if (req.deploymentType === 'cloud_public') { + return `https://${req.subdomain}.authentication.${req.region}.hana.ondemand.com/oauth/token` + } + if (!req.tokenUrl) { + throw new Error('tokenUrl is required for OAuth on cloud_private/on_premise') + } + return req.tokenUrl +} + +function tokenCacheKey(req: ProxyRequest): string { + const secretHash = req.clientSecret + ? createHash('sha256').update(req.clientSecret).digest('hex').slice(0, 16) + : '' + return `${resolveTokenUrl(req)}::${req.clientId ?? ''}::${secretHash}` +} + +function rememberToken(key: string, token: CachedToken): void { + if (TOKEN_CACHE.has(key)) TOKEN_CACHE.delete(key) + TOKEN_CACHE.set(key, token) + while (TOKEN_CACHE.size > TOKEN_CACHE_MAX_ENTRIES) { + const oldestKey = TOKEN_CACHE.keys().next().value + if (oldestKey === undefined) break + TOKEN_CACHE.delete(oldestKey) + } +} + +async function fetchAccessToken(req: ProxyRequest, requestId: string): Promise { + const cacheKey = tokenCacheKey(req) + const cached = TOKEN_CACHE.get(cacheKey) + if (cached && cached.expiresAt - TOKEN_SAFETY_WINDOW_MS > Date.now()) { + return cached.accessToken + } + + const tokenUrl = assertSafeSapExternalUrl(resolveTokenUrl(req), 'tokenUrl').toString() + const basic = Buffer.from(`${req.clientId}:${req.clientSecret}`).toString('base64') + + const response = await secureFetchWithValidation( + tokenUrl, + { + method: 'POST', + headers: { + Authorization: `Basic ${basic}`, + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: 'grant_type=client_credentials', + timeout: OUTBOUND_FETCH_TIMEOUT_MS, + }, + 'tokenUrl' + ) + + if (!response.ok) { + const text = await response.text().catch(() => '') + logger.warn(`[${requestId}] Token fetch failed (${response.status}): ${text}`) + throw new Error(`SAP token request failed: HTTP ${response.status}`) + } + + const data = (await response.json()) as { + access_token?: string + expires_in?: number + } + + if (!data.access_token) { + throw new Error('SAP token response missing access_token') + } + + const expiresInMs = (data.expires_in ?? 3600) * 1000 + rememberToken(cacheKey, { + accessToken: data.access_token, + expiresAt: Date.now() + expiresInMs, + }) + return data.access_token +} + +interface CsrfBundle { + token: string + cookie: string +} + +function joinSetCookies(response: SecureFetchResponse): string { + return response.headers + .getSetCookie() + .map((c) => c.split(';')[0]?.trim()) + .filter(Boolean) + .join('; ') +} + +function buildAuthHeader(req: ProxyRequest, accessToken: string | null): string { + if (req.authType === 'basic') { + const basic = Buffer.from(`${req.username}:${req.password}`).toString('base64') + return `Basic ${basic}` + } + return `Bearer ${accessToken}` +} + +async function fetchCsrf( + req: ProxyRequest, + accessToken: string | null, + requestId: string +): Promise { + const url = buildOdataUrl(req, '/$metadata') + const response = await secureFetchWithValidation( + url, + { + method: 'GET', + headers: { + Authorization: buildAuthHeader(req, accessToken), + Accept: 'application/xml', + 'X-CSRF-Token': 'Fetch', + }, + timeout: OUTBOUND_FETCH_TIMEOUT_MS, + }, + 'baseUrl' + ) + + if (!response.ok) { + const text = await response.text().catch(() => '') + logger.warn(`[${requestId}] CSRF fetch failed (${response.status}): ${text}`) + return null + } + + const token = response.headers.get('x-csrf-token') + const cookie = joinSetCookies(response) + if (!token) return null + return { token, cookie } +} + +function resolveHost(req: ProxyRequest): string { + if (req.deploymentType === 'cloud_public') { + const constructed = `https://${req.subdomain}-api.s4hana.ondemand.com` + return assertSafeSapExternalUrl(constructed, 'subdomain').toString().replace(/\/+$/, '') + } + if (!req.baseUrl) { + throw new Error('baseUrl is required for cloud_private and on_premise deployments') + } + const trimmed = req.baseUrl.replace(/\/+$/, '') + return assertSafeSapExternalUrl(trimmed, 'baseUrl').toString().replace(/\/+$/, '') +} + +function buildOdataUrl(req: ProxyRequest, pathOverride?: string): string { + const host = resolveHost(req) + const servicePath = `/sap/opu/odata/sap/${req.service}` + const subPath = pathOverride ?? req.path + const normalized = subPath.startsWith('/') ? subPath : `/${subPath}` + const base = `${host}${servicePath}${normalized}` + + if (pathOverride !== undefined) { + return base + } + if (!req.query || Object.keys(req.query).length === 0) { + return base + } + const encode = (s: string) => encodeURIComponent(s).replace(/%24/g, '$') + const parts: string[] = [] + for (const [key, value] of Object.entries(req.query)) { + if (value === undefined || value === null) continue + parts.push(`${encode(key)}=${encode(String(value))}`) + } + const queryString = parts.join('&') + if (!queryString) return base + return base.includes('?') ? `${base}&${queryString}` : `${base}?${queryString}` +} + +const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE', 'MERGE']) + +interface OdataInvocation { + status: number + body: unknown + raw: string + csrfHeader: string +} + +async function callOdata( + req: ProxyRequest, + accessToken: string | null, + csrf: CsrfBundle | null +): Promise { + const url = buildOdataUrl(req) + const headers: Record = { + Authorization: buildAuthHeader(req, accessToken), + Accept: 'application/json', + } + + const isWrite = WRITE_METHODS.has(req.method) + const hasBody = req.body !== undefined && req.body !== null + if (hasBody) headers['Content-Type'] = 'application/json' + if (req.ifMatch) headers['If-Match'] = req.ifMatch + + if (isWrite && csrf) { + headers['X-CSRF-Token'] = csrf.token + if (csrf.cookie) headers.Cookie = csrf.cookie + } + + const response = await secureFetchWithValidation( + url, + { + method: req.method, + headers, + body: hasBody ? JSON.stringify(req.body) : undefined, + timeout: OUTBOUND_FETCH_TIMEOUT_MS, + }, + 'baseUrl' + ) + + const raw = await response.text() + let parsed: unknown = null + if (raw.length > 0) { + try { + parsed = JSON.parse(raw) + } catch { + parsed = raw + } + } + + const csrfHeader = response.headers.get('x-csrf-token')?.toLowerCase() ?? '' + return { status: response.status, body: parsed, raw, csrfHeader } +} + +function isCsrfRequired(invocation: OdataInvocation): boolean { + if (invocation.status !== 403) return false + if (invocation.csrfHeader === 'required') return true + if (typeof invocation.body !== 'object' || invocation.body === null) return false + const errorObj = (invocation.body as { error?: { message?: { value?: string } | string } }).error + const messageField = errorObj?.message + const message = typeof messageField === 'string' ? messageField : (messageField?.value ?? '') + return message.toLowerCase().includes('csrf') +} + +function extractOdataError(body: unknown, status: number): string { + if (body && typeof body === 'object') { + const err = ( + body as { + error?: { + message?: { value?: string } | string + code?: string + innererror?: { + errordetails?: Array<{ code?: string; message?: string; severity?: string }> + } + } + } + ).error + if (err) { + const messageField = err.message + const base = + typeof messageField === 'string' ? messageField : (messageField?.value ?? err.code ?? '') + const prefix = err.code ? `[${err.code}] ` : '' + const details = err.innererror?.errordetails + ?.filter((d) => d.message && (!d.severity || d.severity.toLowerCase() !== 'info')) + .map((d) => { + const tag = d.code ? `[${d.code}] ` : '' + return `${tag}${d.message}` + }) + .filter((m): m is string => Boolean(m)) + if (details && details.length > 0) { + const extras = details.filter((d) => !d.endsWith(base)) + return extras.length > 0 ? `${prefix}${base} (${extras.join('; ')})` : `${prefix}${base}` + } + if (base) return `${prefix}${base}` + } + } + if (typeof body === 'string' && body.length > 0) return body + return `SAP request failed with HTTP ${status}` +} + +function unwrapOdata(body: unknown): unknown { + if (!body || typeof body !== 'object') return body + const root = (body as { d?: unknown }).d + if (root === undefined) return body + if (root && typeof root === 'object' && 'results' in (root as Record)) { + const rootObj = root as { results: unknown; __count?: string; __next?: string } + if (rootObj.__count !== undefined || rootObj.__next !== undefined) { + return { + results: rootObj.results, + ...(rootObj.__count !== undefined && { __count: rootObj.__count }), + ...(rootObj.__next !== undefined && { __next: rootObj.__next }), + } + } + return rootObj.results + } + return root +} + +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 SAP proxy request: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + 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 = + proxyReq.authType === 'oauth_client_credentials' + ? await fetchAccessToken(proxyReq, requestId) + : null + const csrf = isWrite ? await fetchCsrf(proxyReq, accessToken, requestId) : null + + let invocation = await callOdata(proxyReq, accessToken, csrf) + + if (isWrite && isCsrfRequired(invocation)) { + logger.info(`[${requestId}] CSRF token rejected, refetching and retrying`) + const refreshed = await fetchCsrf(proxyReq, accessToken, requestId) + if (refreshed) { + invocation = await callOdata(proxyReq, accessToken, refreshed) + } + } + + if (invocation.status >= 200 && invocation.status < 300) { + const data = invocation.status === 204 ? null : unwrapOdata(invocation.body) + return NextResponse.json({ success: true, output: { status: invocation.status, data } }) + } + + const message = extractOdataError(invocation.body, invocation.status) + logger.warn( + `[${requestId}] SAP API error (${invocation.status}) ${proxyReq.service}${proxyReq.path}: ${message}` + ) + return NextResponse.json( + { success: false, error: message, status: invocation.status }, + { status: invocation.status } + ) + } catch (error) { + 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..cf38ee596b0 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,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 { parseToolRequest } 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 +19,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = CreateSecretSchema.parse(body) + const parsed = await parseToolRequest(awsSecretsManagerCreateSecretContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Creating secret ${params.name}`) @@ -50,15 +47,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] Failed to create secret:`, error) return NextResponse.json({ error: `Failed to create secret: ${errorMessage}` }, { status: 500 }) 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..62cdb9ffd58 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,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 { parseToolRequest } 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 +19,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = DeleteSecretSchema.parse(body) + const parsed = await parseToolRequest(awsSecretsManagerDeleteSecretContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Deleting secret ${params.secretId}`) @@ -56,15 +53,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] Failed to delete secret:`, error) return NextResponse.json({ error: `Failed to delete secret: ${errorMessage}` }, { status: 500 }) 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..31b0ba266c7 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,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 { parseToolRequest } 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 +19,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = GetSecretSchema.parse(body) + const parsed = await parseToolRequest(awsSecretsManagerGetSecretContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Retrieving secret ${params.secretId}`) @@ -52,15 +49,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] Failed to retrieve secret:`, error) return NextResponse.json( 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..6345b07b12f 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,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 { parseToolRequest } 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 +19,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = ListSecretsSchema.parse(body) + const parsed = await parseToolRequest(awsSecretsManagerListSecretsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Listing secrets`) @@ -46,15 +44,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] Failed to list secrets:`, error) return NextResponse.json({ error: `Failed to list secrets: ${errorMessage}` }, { status: 500 }) 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..1bb386bf564 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,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 { parseToolRequest } 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 +19,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = UpdateSecretSchema.parse(body) + const parsed = await parseToolRequest(awsSecretsManagerUpdateSecretContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body logger.info(`[${requestId}] Updating secret ${params.secretId}`) @@ -55,15 +52,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] Failed to update secret:`, error) return NextResponse.json({ error: `Failed to update secret: ${errorMessage}` }, { status: 500 }) 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..b70144b1956 100644 --- a/apps/sim/app/api/tools/sendgrid/send-mail/route.ts +++ b/apps/sim/app/api/tools/sendgrid/send-mail/route.ts @@ -1,42 +1,26 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { sendGridSendMailContract } from '@/lib/api/contracts/tools/communication/email' +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 { assertToolFileAccess } from '@/app/api/files/authorization' 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized SendGrid send attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, @@ -44,10 +28,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated SendGrid send request via ${authResult.authType}`) - const body = await request.json() - const validatedData = SendGridSendMailSchema.parse(body) + const parsed = await parseRequest(sendGridSendMailContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Sending SendGrid email`, { to: validatedData.to, @@ -114,29 +100,35 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userFiles = processFilesToUserFiles(rawAttachments, requestId, logger) if (userFiles.length > 0) { - const sendGridAttachments = await Promise.all( + const accessResults = await Promise.all( + userFiles.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( userFiles.map(async (file) => { try { logger.info( `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` ) - const buffer = await downloadFileFromStorage(file, requestId, logger) - - return { - content: buffer.toString('base64'), - filename: file.name, - type: file.type || 'application/octet-stream', - disposition: 'attachment', - } + return await downloadFileFromStorage(file, requestId, logger) } catch (error) { logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) throw new Error( - `Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` + `Failed to download attachment "${file.name}": ${getErrorMessage(error, 'Unknown error')}` ) } }) ) + const sendGridAttachments = userFiles.map((file, i) => ({ + content: buffers[i].toString('base64'), + filename: file.name, + type: file.type || 'application/octet-stream', + disposition: 'attachment', + })) + mailBody.attachments = sendGridAttachments } } @@ -172,17 +164,9 @@ 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' }, + { success: false, error: getErrorMessage(error, 'Unknown error') }, { status: 500 } ) } 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..d8741f0a624 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 { parseToolRequest } 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 parseToolRequest(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..6a6b9e343a0 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 { parseToolRequest } 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 parseToolRequest(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..4316901af58 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 { parseToolRequest } 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 parseToolRequest(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..04cec4dc688 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 { parseToolRequest } 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 parseToolRequest(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..ec761ac1d15 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 { parseToolRequest } 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 parseToolRequest(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..f0c570cdf44 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 { parseToolRequest } 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 parseToolRequest(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..8799b20e303 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 { parseToolRequest } 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 parseToolRequest(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..35f3cc7840b 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 { parseToolRequest } 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 parseToolRequest(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..7a9e9f39f70 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 { parseToolRequest } 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 parseToolRequest(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..42e260dfe2e 100644 --- a/apps/sim/app/api/tools/sftp/delete/route.ts +++ b/apps/sim/app/api/tools/sftp/delete/route.ts @@ -1,7 +1,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +20,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 +78,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,15 +158,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SFTP delete failed:`, error) return NextResponse.json({ error: `SFTP delete failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/sftp/download/route.ts b/apps/sim/app/api/tools/sftp/download/route.ts index a430fd679c3..5ba713c1065 100644 --- a/apps/sim/app/api/tools/sftp/download/route.ts +++ b/apps/sim/app/api/tools/sftp/download/route.ts @@ -1,7 +1,9 @@ import path from 'path' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +14,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 +32,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,15 +128,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SFTP download failed:`, error) return NextResponse.json({ error: `SFTP download failed: ${errorMessage}` }, { status: 500 }) 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..122568e7918 100644 --- a/apps/sim/app/api/tools/sftp/mkdir/route.ts +++ b/apps/sim/app/api/tools/sftp/mkdir/route.ts @@ -1,7 +1,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +19,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 +66,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,15 +138,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SFTP mkdir failed:`, error) return NextResponse.json({ error: `SFTP mkdir failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/sftp/upload/route.ts b/apps/sim/app/api/tools/sftp/upload/route.ts index c915ec22e9b..6b686d57c2b 100644 --- a/apps/sim/app/api/tools/sftp/upload/route.ts +++ b/apps/sim/app/api/tools/sftp/upload/route.ts @@ -1,12 +1,14 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 { assertToolFileAccess } from '@/app/api/files/authorization' import { createSftpConnection, getSftp, @@ -20,28 +22,13 @@ 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized SFTP upload attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, @@ -53,15 +40,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 @@ -116,6 +97,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { for (const file of userFiles) { try { + const denied = await assertToolFileAccess( + file.key, + authResult.userId, + requestId, + logger + ) + if (denied) return denied logger.info( `[${requestId}] Downloading file for upload: ${file.name} (${file.size} bytes)` ) @@ -156,7 +144,7 @@ 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}": ${getErrorMessage(error, 'Unknown error')}` ) } } @@ -221,15 +209,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SFTP upload failed:`, error) return NextResponse.json({ error: `SFTP upload failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/sharepoint/lists/route.ts b/apps/sim/app/api/tools/sharepoint/lists/route.ts index 9265d8aff61..109b4106784 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 { type NextRequest, NextResponse } from 'next/server' +import { sharepointListsSelectorContract } from '@/lib/api/contracts/selectors' +import { parseRequest } 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' @@ -20,17 +22,16 @@ interface SharePointList { } } -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, siteId } = body - - if (!credential) { - logger.error(`[${requestId}] Missing credential in request`) - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + const parsed = await parseRequest(sharepointListsSelectorContract, request, {}) + if (!parsed.success) { + logger.warn(`[${requestId}] Invalid lists request data`) + return parsed.response } + const { credential, workflowId, siteId } = parsed.data.body const siteIdValidation = validateSharePointSiteId(siteId) if (!siteIdValidation.isValid) { @@ -38,7 +39,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, }) diff --git a/apps/sim/app/api/tools/sharepoint/site/route.ts b/apps/sim/app/api/tools/sharepoint/site/route.ts index be3da5174bb..ce7bcefcbc9 100644 --- a/apps/sim/app/api/tools/sharepoint/site/route.ts +++ b/apps/sim/app/api/tools/sharepoint/site/route.ts @@ -1,13 +1,12 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' +import { sharepointSiteQuerySchema } from '@/lib/api/contracts/selectors/sharepoint' +import { getValidationErrorMessage } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -17,55 +16,32 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - 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 = sharepointSiteQuerySchema.safeParse({ + 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) { return NextResponse.json({ error: siteIdValidation.error }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - if (!credentials.length) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const authz = await authorizeCredentialUse(request, { credentialId }) + if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + authz.resolvedCredentialId, + authz.credentialOwnerUserId, requestId ) if (!accessToken) { diff --git a/apps/sim/app/api/tools/sharepoint/sites/route.ts b/apps/sim/app/api/tools/sharepoint/sites/route.ts index 14fd022fe42..4ca0c58cdeb 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 { type NextRequest, NextResponse } from 'next/server' +import { sharepointSitesSelectorContract } 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,19 +12,18 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SharePointSitesAPI') -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, query } = body - - if (!credential) { - logger.error(`[${requestId}] Missing credential in request`) - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + const parsed = await parseRequest(sharepointSitesSelectorContract, request, {}) + if (!parsed.success) { + logger.warn(`[${requestId}] Invalid sites request data`) + return parsed.response } + const { credential, workflowId, query } = 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/sharepoint/upload/route.ts b/apps/sim/app/api/tools/sharepoint/upload/route.ts index b7c08dd7a32..af975058eb1 100644 --- a/apps/sim/app/api/tools/sharepoint/upload/route.ts +++ b/apps/sim/app/api/tools/sharepoint/upload/route.ts @@ -1,27 +1,22 @@ import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { sharepointUploadContract } from '@/lib/api/contracts/tools/microsoft' +import { parseRequest } 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 { assertToolFileAccess } from '@/app/api/files/authorization' import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' +import type { SharepointSkippedFile, SharepointUploadError } from '@/tools/sharepoint/types' 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(), -}) +const MAX_SHAREPOINT_UPLOAD_BYTES = 250 * 1024 * 1024 export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -29,7 +24,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized SharePoint upload attempt: ${authResult.error}`) return NextResponse.json( { @@ -47,8 +42,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = SharepointUploadSchema.parse(body) + const parsed = await parseRequest(sharepointUploadContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Uploading files to SharePoint`, { siteId: validatedData.siteId, @@ -80,44 +76,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - let effectiveDriveId = validatedData.driveId - if (!effectiveDriveId) { - logger.info(`[${requestId}] No driveId provided, fetching default drive for site`) - const driveUrl = `https://graph.microsoft.com/v1.0/sites/${validatedData.siteId}/drive` - const driveResponse = await secureFetchWithValidation( - driveUrl, - { - method: 'GET', - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - Accept: 'application/json', - }, - }, - 'driveUrl' - ) - - if (!driveResponse.ok) { - const errorData = (await driveResponse.json().catch(() => ({}))) as { - error?: { message?: string } - } - logger.error(`[${requestId}] Failed to get default drive:`, errorData) - return NextResponse.json( - { - success: false, - error: errorData.error?.message || 'Failed to get default document library', - }, - { status: driveResponse.status } - ) - } - - const driveData = (await driveResponse.json()) as { id: string } - effectiveDriveId = driveData.id - logger.info(`[${requestId}] Using default drive: ${effectiveDriveId}`) - } - - const uploadedFiles: any[] = [] + const siteId = validatedData.siteId.trim() || 'root' + const driveId = validatedData.driveId?.trim() || null + const uploadedFiles: MicrosoftGraphDriveItem[] = [] + const skippedFiles: SharepointSkippedFile[] = [] + const errors: SharepointUploadError[] = [] for (const userFile of userFiles) { + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied logger.info(`[${requestId}] Uploading file: ${userFile.name}`) const buffer = await downloadFileFromStorage(userFile, requestId, logger) @@ -127,10 +94,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const fileSizeMB = buffer.length / (1024 * 1024) - if (fileSizeMB > 250) { + if (buffer.length > MAX_SHAREPOINT_UPLOAD_BYTES) { logger.warn( `[${requestId}] File ${fileName} is ${fileSizeMB.toFixed(2)}MB, exceeds 250MB limit` ) + skippedFiles.push({ + name: fileName, + size: buffer.length, + limit: MAX_SHAREPOINT_UPLOAD_BYTES, + reason: 'File exceeds the 250 MB Microsoft Graph small upload limit', + }) continue } @@ -150,7 +123,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { .map((segment) => (segment ? encodeURIComponent(segment) : '')) .join('/') - const uploadUrl = `https://graph.microsoft.com/v1.0/sites/${validatedData.siteId}/drives/${effectiveDriveId}/root:${encodedPath}:/content` + const uploadUrl = driveId + ? `https://graph.microsoft.com/v1.0/drives/${driveId}/root:${encodedPath}:/content` + : `https://graph.microsoft.com/v1.0/sites/${siteId}/drive/root:${encodedPath}:/content` logger.info(`[${requestId}] Uploading to: ${uploadUrl}`) @@ -193,13 +168,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { error?: { message?: string } } logger.error(`[${requestId}] Failed to replace file ${fileName}:`, replaceErrorData) - return NextResponse.json( - { - success: false, - error: replaceErrorData.error?.message || `Failed to replace file: ${fileName}`, - }, - { status: replaceResponse.status } - ) + errors.push({ + name: fileName, + status: replaceResponse.status, + error: replaceErrorData.error?.message || `Failed to replace file: ${fileName}`, + }) + continue } const replaceData = (await replaceResponse.json()) as { @@ -223,15 +197,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { continue } - return NextResponse.json( - { - success: false, - error: - (errorData as { error?: { message?: string } }).error?.message || - `Failed to upload file: ${fileName}`, - }, - { status: uploadResponse.status } - ) + errors.push({ + name: fileName, + status: uploadResponse.status, + error: + (errorData as { error?: { message?: string } }).error?.message || + `Failed to upload file: ${fileName}`, + }) + continue } const uploadData = (await uploadResponse.json()) as MicrosoftGraphDriveItem @@ -248,22 +221,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } if (uploadedFiles.length === 0) { - return NextResponse.json( - { - success: false, - error: 'No files were uploaded successfully', + return NextResponse.json({ + success: false, + error: 'No files were uploaded successfully', + output: { + uploadedFiles, + fileCount: 0, + skippedFiles, + skippedCount: skippedFiles.length, + errors, }, - { status: 400 } - ) + }) } - logger.info(`[${requestId}] Successfully uploaded ${uploadedFiles.length} file(s)`) + logger.info(`[${requestId}] Completed SharePoint upload`, { + uploadedCount: uploadedFiles.length, + skippedCount: skippedFiles.length, + errorCount: errors.length, + }) return NextResponse.json({ success: true, output: { uploadedFiles, fileCount: uploadedFiles.length, + skippedFiles, + skippedCount: skippedFiles.length, + errors, }, }) } catch (error) { @@ -271,7 +255,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: toError(error).message, }, { status: 500 } ) 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..a266f890b79 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,12 @@ +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { slackAddReactionContract } from '@/lib/api/contracts/tools/communication/slack' +import { parseRequest } 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 +21,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = SlackAddReactionSchema.parse(body) + const parsed = await parseRequest(slackAddReactionContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const slackResponse = await fetch('https://slack.com/api/reactions.add', { method: 'POST', @@ -66,21 +62,10 @@ 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, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/slack/channels/route.ts b/apps/sim/app/api/tools/slack/channels/route.ts index 7d37f4197d2..6603c36d867 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 { type NextRequest, NextResponse } from 'next/server' +import { slackChannelsSelectorContract } from '@/lib/api/contracts/selectors/slack' +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' @@ -18,16 +20,15 @@ interface SlackChannel { is_member: boolean } -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 - - if (!credential) { + const parsed = await parseRequest(slackChannelsSelectorContract, request, {}) + if (!parsed.success) { logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + return parsed.response } + const { credential, workflowId } = parsed.data.body let accessToken: string let isBotToken = false @@ -37,9 +38,9 @@ export const POST = withRouteHandler(async (request: Request) => { isBotToken = true logger.info('Using direct bot token for Slack API') } else { - 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/slack/delete-message/route.ts b/apps/sim/app/api/tools/slack/delete-message/route.ts index dc6ecb4b071..4634a3da073 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,12 @@ +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { slackDeleteMessageContract } from '@/lib/api/contracts/tools/communication/slack' +import { parseRequest } 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 +21,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = SlackDeleteMessageSchema.parse(body) + const parsed = await parseRequest(slackDeleteMessageContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const slackResponse = await fetch('https://slack.com/api/chat.delete', { method: 'POST', @@ -63,21 +60,10 @@ 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, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/slack/download/route.ts b/apps/sim/app/api/tools/slack/download/route.ts index 5885206bd2a..2cc356bef6d 100644 --- a/apps/sim/app/api/tools/slack/download/route.ts +++ b/apps/sim/app/api/tools/slack/download/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { slackDownloadContract } from '@/lib/api/contracts/tools/communication/slack' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -13,12 +15,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,10 +36,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = SlackDownloadSchema.parse(body) - - const { accessToken, fileId, fileName } = validatedData + const parsed = await parseRequest(slackDownloadContract, request, {}) + if (!parsed.success) return parsed.response + const { accessToken, fileId, fileName } = parsed.data.body logger.info(`[${requestId}] Getting file info from Slack`, { fileId }) @@ -159,17 +154,11 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) 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..5960b6f20db 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { slackReadMessagesContract } from '@/lib/api/contracts/tools/communication/slack' +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,24 +12,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 +36,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = SlackReadMessagesSchema.parse(body) + const parsed = await parseRequest(slackReadMessagesContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body let channel = validatedData.channel if (!channel && validatedData.userId) { @@ -189,23 +174,11 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) 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..108d08b52e6 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,12 @@ +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { slackRemoveReactionContract } from '@/lib/api/contracts/tools/communication/slack' +import { parseRequest } 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 +21,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = SlackRemoveReactionSchema.parse(body) + const parsed = await parseRequest(slackRemoveReactionContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const slackResponse = await fetch('https://slack.com/api/reactions.remove', { method: 'POST', @@ -66,21 +62,10 @@ 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, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) 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..058ce45d0d3 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { slackSendEphemeralContract } from '@/lib/api/contracts/tools/communication/slack' +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' @@ -9,15 +11,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 +33,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { userId: authResult.userId } ) - const body = await request.json() - const validatedData = SlackSendEphemeralSchema.parse(body) + const parsed = await parseRequest(slackSendEphemeralContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Sending ephemeral message`, { channel: validatedData.channel, @@ -85,17 +79,11 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) 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..a407df3bbb6 100644 --- a/apps/sim/app/api/tools/slack/send-message/route.ts +++ b/apps/sim/app/api/tools/slack/send-message/route.ts @@ -1,37 +1,25 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { slackSendMessageContract } from '@/lib/api/contracts/tools/communication/slack' +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 { sendSlackMessage } from '../utils' +import { FileAccessDeniedError } from '@/app/api/files/authorization' +import { sendSlackMessage } from '@/app/api/tools/slack/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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Slack send attempt: ${authResult.error}`) return NextResponse.json( { @@ -42,12 +30,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Slack send request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) - const body = await request.json() - const validatedData = SlackSendMessageSchema.parse(body) + const parsed = await parseRequest(slackSendMessageContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const isDM = !!validatedData.userId logger.info(`[${requestId}] Sending Slack message`, { @@ -63,6 +53,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { accessToken: validatedData.accessToken, channel: validatedData.channel ?? undefined, userId: validatedData.userId ?? undefined, + ownerUserId: userId, text: validatedData.text, threadTs: validatedData.thread_ts ?? undefined, blocks: validatedData.blocks ?? undefined, @@ -78,17 +69,14 @@ 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 } - ) + if (error instanceof FileAccessDeniedError) { + return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) } logger.error(`[${requestId}] Error sending Slack message:`, error) return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) 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..fdfd675cae7 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { slackUpdateMessageContract } from '@/lib/api/contracts/tools/communication/slack' +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' @@ -9,14 +11,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 +35,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = SlackUpdateMessageSchema.parse(body) + const parsed = await parseRequest(slackUpdateMessageContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Updating Slack message`, { channel: validatedData.channel, @@ -102,23 +97,11 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/slack/users/route.ts b/apps/sim/app/api/tools/slack/users/route.ts index 6accc49d91f..d9360d9b7c4 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 { type NextRequest, NextResponse } from 'next/server' +import { slackUsersListOrDetailContract } from '@/lib/api/contracts/selectors/slack' +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' @@ -18,16 +20,15 @@ interface SlackUser { is_bot: boolean } -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, userId } = body - - if (!credential) { + const parsed = await parseRequest(slackUsersListOrDetailContract, request, {}) + if (!parsed.success) { logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + return parsed.response } + const { credential, workflowId, userId } = parsed.data.body if (userId !== undefined && userId !== null) { const validation = validateAlphanumericId(userId, 'userId', 100) @@ -44,7 +45,7 @@ export const POST = withRouteHandler(async (request: Request) => { accessToken = credential logger.info('Using direct bot token for Slack API') } else { - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) diff --git a/apps/sim/app/api/tools/slack/utils.ts b/apps/sim/app/api/tools/slack/utils.ts index 4049a3fe0db..91b6fc14534 100644 --- a/apps/sim/app/api/tools/slack/utils.ts +++ b/apps/sim/app/api/tools/slack/utils.ts @@ -2,12 +2,13 @@ import type { Logger } from '@sim/logger' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { FileAccessDeniedError, verifyFileAccess } from '@/app/api/files/authorization' import type { ToolFileData } from '@/tools/types' /** * Sends a message to a Slack channel using chat.postMessage */ -export async function postSlackMessage( +async function postSlackMessage( accessToken: string, channel: string, text: string, @@ -69,11 +70,12 @@ export function formatMessageSuccessResponse( /** * Uploads files to Slack and returns the uploaded file IDs */ -export async function uploadFilesToSlack( +async function uploadFilesToSlack( files: any[], accessToken: string, requestId: string, - logger: Logger + logger: Logger, + ownerUserId: string ): Promise<{ fileIds: string[]; files: ToolFileData[] }> { const userFiles = processFilesToUserFiles(files, requestId, logger) const uploadedFileIds: string[] = [] @@ -82,6 +84,11 @@ export async function uploadFilesToSlack( for (const userFile of userFiles) { logger.info(`[${requestId}] Uploading file: ${userFile.name}`) + const hasAccess = await verifyFileAccess(userFile.key, ownerUserId) + if (!hasAccess) { + throw new FileAccessDeniedError() + } + const buffer = await downloadFileFromStorage(userFile, requestId, logger) const getUrlResponse = await fetch('https://slack.com/api/files.getUploadURLExternal', { @@ -136,12 +143,13 @@ export async function uploadFilesToSlack( /** * Completes the file upload process by associating files with a channel */ -export async function completeSlackFileUpload( +async function completeSlackFileUpload( uploadedFileIds: string[], channel: string, text: string, accessToken: string, - threadTs?: string | null + threadTs?: string | null, + blocks?: unknown[] | null ): Promise<{ ok: boolean; files?: any[]; error?: string }> { const response = await fetch('https://slack.com/api/files.completeUploadExternal', { method: 'POST', @@ -152,7 +160,10 @@ export async function completeSlackFileUpload( body: JSON.stringify({ files: uploadedFileIds.map((id) => ({ id })), channel_id: channel, - initial_comment: text, + // Per Slack docs for files.completeUploadExternal: if `initial_comment` + // is provided, `blocks` is silently ignored. So when blocks are present + // we omit initial_comment and let blocks render instead. + ...(blocks && blocks.length > 0 ? { blocks } : { initial_comment: text }), ...(threadTs && { thread_ts: threadTs }), }), }) @@ -220,6 +231,7 @@ export interface SlackMessageParams { accessToken: string channel?: string userId?: string + ownerUserId: string text: string threadTs?: string | null blocks?: unknown[] | null @@ -245,7 +257,7 @@ export async function sendSlackMessage( } error?: string }> { - const { accessToken, text, threadTs, blocks, files } = params + const { accessToken, text, threadTs, blocks, files, ownerUserId } = params let { channel } = params if (!channel && params.userId) { @@ -278,7 +290,8 @@ export async function sendSlackMessage( files, accessToken, requestId, - logger + logger, + ownerUserId ) // No valid files uploaded - send text-only @@ -295,7 +308,14 @@ export async function sendSlackMessage( } // Complete file upload with thread support - const completeData = await completeSlackFileUpload(fileIds, channel, text, accessToken, threadTs) + const completeData = await completeSlackFileUpload( + fileIds, + channel, + text, + accessToken, + threadTs, + blocks + ) if (!completeData.ok) { logger.error(`[${requestId}] Failed to complete upload:`, completeData.error) diff --git a/apps/sim/app/api/tools/sms/send/route.ts b/apps/sim/app/api/tools/sms/send/route.ts index 5a1c6701b60..e19840c2a04 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 { smsSendContract } from '@/lib/api/contracts/tools/communication/messaging' +import { parseRequest } 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,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = SMSSendSchema.parse(body) + const parsed = await parseRequest(smsSendContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const fromNumber = env.TWILIO_PHONE_NUMBER @@ -74,18 +71,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..e08101c8596 100644 --- a/apps/sim/app/api/tools/smtp/send/route.ts +++ b/apps/sim/app/api/tools/smtp/send/route.ts @@ -1,47 +1,28 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' +import { getErrorMessage, toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import nodemailer from 'nodemailer' -import { z } from 'zod' +import { smtpSendContract } from '@/lib/api/contracts/tools/communication/email' +import { parseRequest } 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' +import { assertToolFileAccess } from '@/app/api/files/authorization' 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized SMTP send attempt: ${authResult.error}`) return NextResponse.json( { @@ -52,12 +33,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated SMTP request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) - const body = await request.json() - const validatedData = SmtpSendSchema.parse(body) + const parsed = await parseRequest(smtpSendContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const hostValidation = await validateDatabaseHost(validatedData.smtpHost, 'smtpHost') if (!hostValidation.isValid) { @@ -138,29 +121,34 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const attachmentBuffers = await Promise.all( + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( attachments.map(async (file) => { try { logger.info( `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` ) - - const buffer = await downloadFileFromStorage(file, requestId, logger) - - return { - filename: file.name, - content: buffer, - contentType: file.type || 'application/octet-stream', - } + return await downloadFileFromStorage(file, requestId, logger) } catch (error) { logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) throw new Error( - `Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` + `Failed to download attachment "${file.name}": ${getErrorMessage(error, 'Unknown error')}` ) } }) ) + const attachmentBuffers = attachments.map((file, i) => ({ + filename: file.name, + content: buffers[i], + contentType: file.type || 'application/octet-stream', + })) + logger.info(`[${requestId}] Processed ${attachmentBuffers.length} attachment(s)`) mailOptions.attachments = attachmentBuffers } @@ -180,18 +168,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..497cac1d99a 100644 --- a/apps/sim/app/api/tools/sqs/send/route.ts +++ b/apps/sim/app/api/tools/sqs/send/route.ts @@ -1,25 +1,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 { parseToolRequest } 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 +19,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const params = SendMessageSchema.parse(body) + const parsed = await parseToolRequest(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,17 +53,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SQS send message failed:`, error) return NextResponse.json({ error: `SQS send message failed: ${errorMessage}` }, { status: 500 }) 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..ea0238b01f3 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,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +20,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,15 +79,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SSH check command exists failed:`, error) return NextResponse.json( 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..b5aaf72a62b 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 @@ -1,8 +1,10 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +16,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 +38,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,15 +107,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SSH check file exists failed:`, error) return NextResponse.json( 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..abd3214d7f1 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,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +15,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 +25,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,15 +80,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SSH create directory failed:`, error) return NextResponse.json( 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..52b1c714c82 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,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +15,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 +25,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,15 +76,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SSH delete file failed:`, error) return NextResponse.json({ error: `SSH delete file failed: ${errorMessage}` }, { status: 500 }) 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..8bd41d9f253 100644 --- a/apps/sim/app/api/tools/ssh/download-file/route.ts +++ b/apps/sim/app/api/tools/ssh/download-file/route.ts @@ -1,9 +1,11 @@ import path from 'path' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +13,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 +35,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,15 +120,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SSH file download failed:`, error) return NextResponse.json( 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..3b192957d97 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,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +15,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 +25,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,15 +62,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SSH command execution failed:`, error) return NextResponse.json( 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..ce6cf309c71 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,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +20,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,15 +74,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SSH script execution failed:`, error) return NextResponse.json( 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..9dc315afeba 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,15 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +20,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,15 +100,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SSH get system info failed:`, error) return NextResponse.json( 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..b8d4b795619 100644 --- a/apps/sim/app/api/tools/ssh/list-directory/route.ts +++ b/apps/sim/app/api/tools/ssh/list-directory/route.ts @@ -1,8 +1,10 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +16,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 +58,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,15 +104,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SSH list directory failed:`, error) return NextResponse.json( 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..2433bdd08f4 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,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +15,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 +25,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,15 +93,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SSH move/rename failed:`, error) return NextResponse.json({ error: `SSH move/rename failed: ${errorMessage}` }, { status: 500 }) 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..2558f1fcf62 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 @@ -1,26 +1,16 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +33,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}` @@ -90,9 +74,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const content = await new Promise((resolve, reject) => { const chunks: Buffer[] = [] + let totalBytes = 0 const readStream = sftp.createReadStream(filePath) readStream.on('data', (chunk: Buffer) => { + totalBytes += chunk.length + if (totalBytes > maxBytes) { + readStream.destroy() + reject(new Error(`File exceeds maximum allowed size of ${params.maxSize}MB`)) + return + } chunks.push(chunk) }) @@ -121,15 +112,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SSH read file content failed:`, error) return NextResponse.json( 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..106aeff7c79 100644 --- a/apps/sim/app/api/tools/ssh/upload-file/route.ts +++ b/apps/sim/app/api/tools/ssh/upload-file/route.ts @@ -1,28 +1,16 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +33,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,15 +103,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SSH file upload failed:`, error) return NextResponse.json({ error: `SSH file upload failed: ${errorMessage}` }, { status: 500 }) diff --git a/apps/sim/app/api/tools/ssh/utils.ts b/apps/sim/app/api/tools/ssh/utils.ts index ed3dae88328..3d64440e22d 100644 --- a/apps/sim/app/api/tools/ssh/utils.ts +++ b/apps/sim/app/api/tools/ssh/utils.ts @@ -174,6 +174,8 @@ export async function createSSHConnection(config: SSHConnectionConfig): Promise< }) } +const MAX_OUTPUT_BYTES = 16 * 1024 * 1024 + /** * Execute a command on the SSH connection */ @@ -187,21 +189,45 @@ export function executeSSHCommand(client: Client, command: string): Promise { resolve({ - stdout: stdout.trim(), - stderr: stderr.trim(), - exitCode: code ?? 0, + stdout: stdoutTruncated + ? `${stdout.trim()}\n[output truncated: exceeded 16MB limit]` + : stdout.trim(), + stderr: stderrTruncated + ? `${stderr.trim()}\n[stderr truncated: exceeded 16MB limit]` + : stderr.trim(), + exitCode: code ?? -1, }) }) stream.on('data', (data: Buffer) => { - stdout += data.toString() + const remaining = MAX_OUTPUT_BYTES - stdoutBytes + if (remaining <= 0) { + stdoutTruncated = true + return + } + const chunk = data.subarray(0, remaining) + stdout += chunk.toString() + stdoutBytes += chunk.length + if (data.length > remaining) stdoutTruncated = true }) stream.stderr.on('data', (data: Buffer) => { - stderr += data.toString() + const remaining = MAX_OUTPUT_BYTES - stderrBytes + if (remaining <= 0) { + stderrTruncated = true + return + } + const chunk = data.subarray(0, remaining) + stderr += chunk.toString() + stderrBytes += chunk.length + if (data.length > remaining) stderrTruncated = true }) }) }) 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..9ce1f92b5e2 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 @@ -1,27 +1,16 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +33,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,15 +123,7 @@ 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' + const errorMessage = getErrorMessage(error, 'Unknown error occurred') logger.error(`[${requestId}] SSH write file content failed:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/stagehand/agent/route.ts b/apps/sim/app/api/tools/stagehand/agent/route.ts index afc32d5bc6a..a1fa2ce7706 100644 --- a/apps/sim/app/api/tools/stagehand/agent/route.ts +++ b/apps/sim/app/api/tools/stagehand/agent/route.ts @@ -1,6 +1,9 @@ +import type { Stagehand as StagehandType } from '@browserbasehq/stagehand' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { stagehandAgentContract } from '@/lib/api/contracts/tools/stagehand' +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' @@ -10,20 +13,9 @@ import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils' const logger = createLogger('StagehandAgentAPI') -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(), -}) - /** * Extracts the inner schema object from a potentially nested schema structure */ @@ -102,26 +94,34 @@ 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 } = params + const { task, startUrl: rawStartUrl, outputSchema, provider, apiKey, mode, maxSteps } = params const variablesObject = processVariables(params.variables) const startUrl = normalizeUrl(rawStartUrl) @@ -165,8 +165,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Invalid Anthropic API key format' }, { status: 400 }) } - const modelName = - provider === 'anthropic' ? 'anthropic/claude-sonnet-4-5-20250929' : 'openai/gpt-5' + const modelName = provider === 'anthropic' ? 'anthropic/claude-sonnet-4-6' : 'openai/gpt-5' + + let sessionId: string | null = null + let liveViewUrl: string | null = null try { logger.info('Initializing Stagehand with Browserbase (v3)', { provider, modelName }) @@ -190,6 +192,35 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await stagehand.init() logger.info('Stagehand initialized successfully') + sessionId = stagehand.browserbaseSessionID ?? null + if (sessionId) { + try { + const debugResponse = await fetch( + `https://api.browserbase.com/v1/sessions/${sessionId}/debug`, + { + method: 'GET', + headers: { + 'X-BB-API-Key': BROWSERBASE_API_KEY, + }, + } + ) + if (debugResponse.ok) { + const debugData = (await debugResponse.json()) as { + debuggerFullscreenUrl?: string + debuggerUrl?: string + } + liveViewUrl = debugData.debuggerFullscreenUrl ?? debugData.debuggerUrl ?? null + if (liveViewUrl) { + logger.info(`Browserbase live view URL: ${liveViewUrl}`) + } + } else { + logger.warn(`Failed to fetch Browserbase debug URL: ${debugResponse.statusText}`) + } + } catch (debugError) { + logger.warn('Error fetching Browserbase debug URL', { error: debugError }) + } + } + const page = stagehand.context.pages()[0] logger.info(`Navigating to ${startUrl}`) await page.goto(startUrl, { waitUntil: 'networkidle' }) @@ -223,13 +254,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { apiKey: apiKey, }, systemPrompt: agentInstructions, + mode, }) - logger.info('Executing agent task', { task: taskWithVariables }) + logger.info('Executing agent task', { task: taskWithVariables, mode, maxSteps }) const agentExecutionResult = await agent.execute({ instruction: taskWithVariables, - maxSteps: 20, + maxSteps, }) const agentResult = { @@ -293,11 +325,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ agentResult, structuredOutput, + liveViewUrl, + sessionId, }) } catch (error) { logger.error('Stagehand agent execution error', { error, - message: error instanceof Error ? error.message : 'Unknown error', + message: getErrorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : undefined, }) @@ -327,6 +361,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { error: errorMessage, details: errorDetails, + liveViewUrl, + sessionId, }, { status: 500 } ) @@ -334,13 +370,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error('Unexpected error in agent API route', { error, - message: error instanceof Error ? error.message : 'Unknown error', + message: getErrorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : undefined, }) return NextResponse.json( { error: 'Internal server error', - details: error instanceof Error ? error.message : 'Unknown error', + details: getErrorMessage(error, 'Unknown error'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/stagehand/extract/route.ts b/apps/sim/app/api/tools/stagehand/extract/route.ts index c39f5c78534..c72f96c4e7c 100644 --- a/apps/sim/app/api/tools/stagehand/extract/route.ts +++ b/apps/sim/app/api/tools/stagehand/extract/route.ts @@ -1,6 +1,9 @@ +import type { Stagehand as StagehandType } from '@browserbasehq/stagehand' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { stagehandExtractContract } from '@/lib/api/contracts/tools/stagehand' +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' @@ -9,21 +12,9 @@ import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils' const logger = createLogger('StagehandExtractAPI') -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()), - useTextExtract: z.boolean().optional().default(false), - selector: z.string().nullable().optional(), - 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) { @@ -33,25 +24,33 @@ 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, selector, provider, apiKey, schema } = params + const { url: rawUrl, instruction, provider, apiKey, schema } = params const url = normalizeUrl(rawUrl) const urlValidation = await validateUrlWithDNS(url, 'url') if (!urlValidation.isValid) { @@ -101,8 +100,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const modelName = - provider === 'anthropic' ? 'anthropic/claude-sonnet-4-5-20250929' : 'openai/gpt-5' + const modelName = provider === 'anthropic' ? 'anthropic/claude-sonnet-4-6' : 'openai/gpt-5' logger.info('Initializing Stagehand with Browserbase (v3)', { provider, modelName }) @@ -152,7 +150,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (schemaError) { logger.error('Failed to convert JSON schema to Zod schema', { error: schemaError, - message: schemaError instanceof Error ? schemaError.message : 'Unknown schema error', + message: getErrorMessage(schemaError, 'Unknown schema error'), }) logger.info('Falling back to simple extraction without schema') @@ -162,14 +160,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info('Calling stagehand.extract with options', { hasInstruction: !!instruction, hasSchema: !!zodSchema, - hasSelector: !!selector, }) let extractedData if (zodSchema) { - extractedData = await stagehand.extract(instruction, zodSchema, { - selector: selector || undefined, - }) + extractedData = await stagehand.extract(instruction, zodSchema) } else { extractedData = await stagehand.extract(instruction) } @@ -187,15 +182,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (extractError) { logger.error('Error during extraction operation', { error: extractError, - message: - extractError instanceof Error ? extractError.message : 'Unknown extraction error', + message: getErrorMessage(extractError, 'Unknown extraction error'), }) throw extractError } } catch (error) { logger.error('Stagehand extraction error', { error, - message: error instanceof Error ? error.message : 'Unknown error', + message: getErrorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : undefined, }) @@ -232,13 +226,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error('Unexpected error in extraction API route', { error, - message: error instanceof Error ? error.message : 'Unknown error', + message: getErrorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : undefined, }) return NextResponse.json( { error: 'Internal server error', - details: error instanceof Error ? error.message : 'Unknown error', + details: getErrorMessage(error, 'Unknown error'), }, { status: 500 } ) 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..442250b02ea 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 { parseToolRequest } 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 parseToolRequest(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..381f33b2057 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 { parseToolRequest } 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 parseToolRequest(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..4b24f2fb9ff 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 { parseToolRequest } 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 parseToolRequest(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..2ebcbf2c627 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 { parseToolRequest } 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 parseToolRequest(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..a320bcce008 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -1,7 +1,10 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' +import { sttToolContract } from '@/lib/api/contracts/tools/media/stt' +import { getValidationErrorMessage, parseRequest, 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,50 +18,44 @@ import { downloadFileFromStorage, resolveInternalFileUrl, } from '@/lib/uploads/utils/file-utils.server' -import type { UserFile } from '@/executor/types' +import { assertToolFileAccess } from '@/app/api/files/authorization' import type { TranscriptSegment } from '@/tools/stt/types' const logger = createLogger('SttProxyAPI') +const ELEVENLABS_STT_MODEL = 'scribe_v2' 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`) try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const userId = authResult.userId - const body: SttRequestBody = await request.json() + + const parsed = await parseRequest( + sttToolContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid STT request:`, error.issues) + return validationErrorResponse( + error, + getValidationErrorMessage(error, 'Invalid request data') + ) + }, + } + ) + if (!parsed.success) return parsed.response + + const body = parsed.data.body const { provider, apiKey, @@ -73,13 +70,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 @@ -91,6 +81,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const file = Array.isArray(body.audioFile) ? body.audioFile[0] : body.audioFile logger.info(`[${requestId}] Processing uploaded file: ${file.name}`) + const deniedAudio = await assertToolFileAccess(file.key, userId, requestId, logger) + if (deniedAudio) return deniedAudio audioBuffer = await downloadFileFromStorage(file, requestId, logger) audioFileName = file.name // file.type may be missing if the file came from a block that doesn't preserve it @@ -109,6 +101,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { : body.audioFileReference logger.info(`[${requestId}] Processing referenced file: ${file.name}`) + const deniedRef = await assertToolFileAccess(file.key, userId, requestId, logger) + if (deniedRef) return deniedRef audioBuffer = await downloadFileFromStorage(file, requestId, logger) audioFileName = file.name @@ -183,7 +177,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.error(`[${requestId}] Video extraction failed:`, error) return NextResponse.json( { - error: `Failed to extract audio from video: ${error instanceof Error ? error.message : 'Unknown error'}`, + error: `Failed to extract audio from video: ${getErrorMessage(error, 'Unknown error')}`, }, { status: 500 } ) @@ -235,13 +229,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { duration = result.duration confidence = result.confidence } else if (provider === 'elevenlabs') { - const result = await transcribeWithElevenLabs( - audioBuffer, - apiKey, - language, - timestamps, - model - ) + const result = await transcribeWithElevenLabs(audioBuffer, apiKey, language, timestamps) transcript = result.transcript segments = result.segments detectedLanguage = result.language @@ -286,7 +274,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } catch (error) { logger.error(`[${requestId}] Transcription failed:`, error) - const errorMessage = error instanceof Error ? error.message : 'Transcription failed' + const errorMessage = getErrorMessage(error, 'Transcription failed') return NextResponse.json({ error: errorMessage }, { status: 500 }) } @@ -304,7 +292,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(response) } catch (error) { logger.error(`[${requestId}] STT proxy error:`, error) - const errorMessage = error instanceof Error ? error.message : 'Unknown error' + const errorMessage = getErrorMessage(error, 'Unknown error') return NextResponse.json({ error: errorMessage }, { status: 500 }) } }) @@ -483,8 +471,7 @@ async function transcribeWithElevenLabs( audioBuffer: Buffer, apiKey: string, language?: string, - timestamps?: 'none' | 'sentence' | 'word', - model?: string + timestamps?: 'none' | 'sentence' | 'word' ): Promise<{ transcript: string segments?: TranscriptSegment[] @@ -494,7 +481,7 @@ async function transcribeWithElevenLabs( const formData = new FormData() const blob = new Blob([new Uint8Array(audioBuffer)], { type: 'audio/mpeg' }) formData.append('file', blob, 'audio.mp3') - formData.append('model_id', model || 'scribe_v1') + formData.append('model_id', ELEVENLABS_STT_MODEL) if (language && language !== 'auto') { formData.append('language_code', language) 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..e36f88c501a 100644 --- a/apps/sim/app/api/tools/supabase/storage-upload/route.ts +++ b/apps/sim/app/api/tools/supabase/storage-upload/route.ts @@ -1,39 +1,27 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { supabaseStorageUploadContract } from '@/lib/api/contracts/tools/databases/supabase' +import { parseToolRequest } 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' +import { assertToolFileAccess } from '@/app/api/files/authorization' 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn( `[${requestId}] Unauthorized Supabase storage upload attempt: ${authResult.error}` ) @@ -53,8 +41,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) - const body = await request.json() - const validatedData = SupabaseStorageUploadSchema.parse(body) + const parsed = await parseToolRequest(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' @@ -147,12 +139,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Failed to process file', + error: getErrorMessage(error, 'Failed to process file'), }, { status: 400 } ) } + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied const buffer = await downloadFileFromStorage(userFile, requestId, logger) uploadBody = buffer @@ -243,24 +237,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) 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..2d685c501fd 100644 --- a/apps/sim/app/api/tools/telegram/send-document/route.ts +++ b/apps/sim/app/api/tools/telegram/send-document/route.ts @@ -1,25 +1,20 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { telegramSendDocumentContract } from '@/lib/api/contracts/tools/communication/messaging' +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 { assertToolFileAccess } from '@/app/api/files/authorization' import { convertMarkdownToHTML } from '@/tools/telegram/utils' 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() @@ -28,7 +23,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { requireWorkflowId: false, }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Telegram send attempt: ${authResult.error}`) return NextResponse.json( { @@ -43,8 +38,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, }) - const body = await request.json() - const validatedData = TelegramSendDocumentSchema.parse(body) + const parsed = await parseRequest(telegramSendDocumentContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body logger.info(`[${requestId}] Sending Telegram document`, { chatId: validatedData.chatId, @@ -94,6 +90,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userFile = userFiles[0] logger.info(`[${requestId}] Uploading document: ${userFile.name}`) + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + const buffer = await downloadFileFromStorage(userFile, requestId, logger) const filesOutput = [ { @@ -153,7 +152,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts index 323b18568c5..48e6f07899f 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -1,74 +1,31 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { textractParseContract } from '@/lib/api/contracts/tools/media/document-parse' +import { getValidationErrorMessage, parseRequest } 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, resolveInternalFileUrl, } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' 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, @@ -329,8 +286,28 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = authResult.userId - const body = await request.json() - const validatedData = TextractParseSchema.parse(body) + + const parsed = await parseRequest( + textractParseContract, + 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 validatedData = parsed.data.body const processingMode = validatedData.processingMode || 'sync' const featureTypes = validatedData.featureTypes ?? [] @@ -447,12 +424,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Failed to process file', + error: getErrorMessage(error, 'Failed to process file'), }, { status: 400 } ) } + const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) + if (denied) return denied const buffer = await downloadFileFromStorage(userFile, requestId, logger) bytes = buffer.toString('base64') contentType = userFile.type || 'application/octet-stream' @@ -632,24 +611,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/thinking/route.ts b/apps/sim/app/api/tools/thinking/route.ts index b9396b93621..75510e49c89 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/tools/thinking' +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..366d2ee03ee 100644 --- a/apps/sim/app/api/tools/tts/route.ts +++ b/apps/sim/app/api/tools/tts/route.ts @@ -1,14 +1,22 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { ttsToolContract } from '@/lib/api/contracts/tools/media/tts' +import { getValidationErrorMessage, parseRequest } 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' +import { + isPayloadSizeLimitError, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { StorageService } from '@/lib/uploads' const logger = createLogger('ProxyTTSAPI') +const MAX_TTS_AUDIO_BYTES = 25 * 1024 * 1024 export const POST = withRouteHandler(async (request: NextRequest) => { try { @@ -18,20 +26,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() + const parsed = await parseRequest( + ttsToolContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { error: getValidationErrorMessage(error, 'Missing required parameters') }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + const { text, voiceId, apiKey, - modelId = 'eleven_monolingual_v1', + modelId, + stability, + similarityBoost, workspaceId, workflowId, executionId, - } = body - - if (!text || !voiceId || !apiKey) { - return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }) - } + } = parsed.data.body const voiceIdValidation = validateAlphanumericId(voiceId, 'voiceId', 255) if (!voiceIdValidation.isValid) { @@ -40,10 +59,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, @@ -51,6 +71,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const endpoint = `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}` + const hasVoiceSetting = stability !== undefined || similarityBoost !== undefined + const voiceSettings = hasVoiceSetting + ? { + stability: stability ?? 0.5, + similarity_boost: similarityBoost ?? 0.75, + } + : undefined + const response = await fetch(endpoint, { method: 'POST', headers: { @@ -61,6 +89,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { body: JSON.stringify({ text, model_id: modelId, + ...(voiceSettings ? { voice_settings: voiceSettings } : {}), }), signal: AbortSignal.timeout(DEFAULT_EXECUTION_TIMEOUT_MS), }) @@ -74,27 +103,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const audioBlob = await response.blob() + const audioBuffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_TTS_AUDIO_BYTES, + label: 'TTS audio response', + signal: request.signal, + }) - if (audioBlob.size === 0) { + if (audioBuffer.length === 0) { logger.error('Empty audio received from ElevenLabs') return NextResponse.json({ error: 'Empty audio received' }, { status: 422 }) } - const audioBuffer = Buffer.from(await audioBlob.arrayBuffer()) 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', @@ -138,9 +166,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { - error: `Internal Server Error: ${error instanceof Error ? error.message : 'Unknown error'}`, + error: `Internal Server Error: ${getErrorMessage(error, 'Unknown error')}`, }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } }) diff --git a/apps/sim/app/api/tools/tts/unified/route.ts b/apps/sim/app/api/tools/tts/unified/route.ts index a6478e3894d..80cc10db05b 100644 --- a/apps/sim/app/api/tools/tts/unified/route.ts +++ b/apps/sim/app/api/tools/tts/unified/route.ts @@ -1,9 +1,22 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { + playHtOutputFormatSchema, + ttsUnifiedToolContract, +} from '@/lib/api/contracts/tools/media/tts' +import { getValidationErrorMessage, parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { + assertKnownSizeWithinLimit, + isPayloadSizeLimitError, + readResponseJsonWithLimit, + readResponseTextWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { StorageService } from '@/lib/uploads' @@ -15,75 +28,45 @@ import type { GoogleTtsParams, OpenAiTtsParams, PlayHtTtsParams, - TtsProvider, TtsResponse, } from '@/tools/tts/types' import { getFileExtension, getMimeType } from '@/tools/tts/types' const logger = createLogger('TtsUnifiedProxyAPI') +const MAX_TTS_AUDIO_BYTES = 25 * 1024 * 1024 +const MAX_TTS_ERROR_BYTES = 64 * 1024 +const MAX_TTS_JSON_BYTES = Math.ceil((MAX_TTS_AUDIO_BYTES * 4) / 3) + 256 * 1024 + +async function readTtsErrorJson( + response: Response, + label: string +): Promise> { + return readResponseJsonWithLimit>(response, { + maxBytes: MAX_TTS_ERROR_BYTES, + label, + }).catch(() => ({})) +} + +function getTtsErrorMessage(error: Record, fallback: string): string { + const nested = error.error + if (typeof nested === 'object' && nested !== null && 'message' in nested) { + const message = (nested as { message?: unknown }).message + if (typeof message === 'string') return message + } + for (const key of ['message', 'err_msg', 'error_message', 'error', 'detail']) { + const value = error[key] + if (typeof value === 'string') return value + if (typeof value === 'object' && value !== null && 'message' in value) { + const message = (value as { message?: unknown }).message + if (typeof message === 'string') return message + } + } + return fallback +} 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 +78,29 @@ 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 + const parsed = await parseRequest( + ttsUnifiedToolContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid TTS unified request:`, error.issues) + return validationErrorResponse( + error, + getValidationErrorMessage(error, 'Invalid request data') + ) + }, + } + ) + if (!parsed.success) return parsed.response - if (!provider || !text || !apiKey) { - return NextResponse.json( - { error: 'Missing required fields: provider, text, and apiKey' }, - { status: 400 } - ) - } + const body = parsed.data.body + 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 +167,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 +188,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 +202,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 +222,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, @@ -242,19 +244,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } catch (error) { logger.error(`[${requestId}] TTS synthesis failed:`, error) - const errorMessage = error instanceof Error ? error.message : 'TTS synthesis failed' - return NextResponse.json({ error: errorMessage }, { status: 500 }) + const errorMessage = getErrorMessage(error, 'TTS synthesis failed') + return NextResponse.json( + { error: errorMessage }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) } const timestamp = Date.now() 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, @@ -311,8 +316,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(response) } catch (error) { logger.error(`[${requestId}] TTS unified proxy error:`, error) - const errorMessage = error instanceof Error ? error.message : 'Unknown error' - return NextResponse.json({ error: errorMessage }, { status: 500 }) + const errorMessage = getErrorMessage(error, 'Unknown error') + return NextResponse.json( + { error: errorMessage }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) } }) @@ -338,13 +346,15 @@ async function synthesizeWithOpenAi( }) if (!response.ok) { - const error = await response.json().catch(() => ({})) - const errorMessage = error.error?.message || error.message || response.statusText + const error = await readTtsErrorJson(response, 'OpenAI TTS error response') + const errorMessage = getTtsErrorMessage(error, response.statusText) throw new Error(`OpenAI TTS API error: ${errorMessage}`) } - const arrayBuffer = await response.arrayBuffer() - const audioBuffer = Buffer.from(arrayBuffer) + const audioBuffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_TTS_AUDIO_BYTES, + label: 'OpenAI TTS audio response', + }) const mimeType = getMimeType(responseFormat) return { @@ -394,13 +404,15 @@ async function synthesizeWithDeepgram( }) if (!response.ok) { - const error = await response.json().catch(() => ({})) - const errorMessage = error.err_msg || error.message || response.statusText + const error = await readTtsErrorJson(response, 'Deepgram TTS error response') + const errorMessage = getTtsErrorMessage(error, response.statusText) throw new Error(`Deepgram TTS API error: ${errorMessage}`) } - const arrayBuffer = await response.arrayBuffer() - const audioBuffer = Buffer.from(arrayBuffer) + const audioBuffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_TTS_AUDIO_BYTES, + label: 'Deepgram TTS audio response', + }) let finalFormat: string = encoding if (container === 'wav') { @@ -457,16 +469,15 @@ async function synthesizeWithElevenLabs( }) if (!response.ok) { - const error = await response.json().catch(() => ({})) - const errorMessage = - typeof error.detail === 'string' - ? error.detail - : error.detail?.message || error.message || response.statusText + const error = await readTtsErrorJson(response, 'ElevenLabs TTS error response') + const errorMessage = getTtsErrorMessage(error, response.statusText) throw new Error(`ElevenLabs TTS API error: ${errorMessage}`) } - const arrayBuffer = await response.arrayBuffer() - const audioBuffer = Buffer.from(arrayBuffer) + const audioBuffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_TTS_AUDIO_BYTES, + label: 'ElevenLabs TTS audio response', + }) return { audioBuffer, @@ -544,9 +555,9 @@ async function synthesizeWithCartesia( }) if (!response.ok) { - const error = await response.json().catch(() => ({})) - const errorMessage = error.error || error.message || response.statusText - const errorDetail = error.detail || '' + const error = await readTtsErrorJson(response, 'Cartesia TTS error response') + const errorMessage = getTtsErrorMessage(error, response.statusText) + const errorDetail = typeof error.detail === 'string' ? error.detail : '' logger.error('Cartesia API error details:', { status: response.status, error: errorMessage, @@ -558,8 +569,10 @@ async function synthesizeWithCartesia( ) } - const arrayBuffer = await response.arrayBuffer() - const audioBuffer = Buffer.from(arrayBuffer) + const audioBuffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_TTS_AUDIO_BYTES, + label: 'Cartesia TTS audio response', + }) const format = outputFormat && typeof outputFormat === 'object' && 'container' in outputFormat @@ -651,12 +664,15 @@ async function synthesizeWithGoogle( ) if (!response.ok) { - const error = await response.json().catch(() => ({})) - const errorMessage = error.error?.message || error.message || response.statusText + const error = await readTtsErrorJson(response, 'Google TTS error response') + const errorMessage = getTtsErrorMessage(error, response.statusText) throw new Error(`Google Cloud TTS API error: ${errorMessage}`) } - const data = await response.json() + const data = await readResponseJsonWithLimit<{ audioContent?: string }>(response, { + maxBytes: MAX_TTS_JSON_BYTES, + label: 'Google TTS JSON response', + }) const audioContent = data.audioContent if (!audioContent) { @@ -664,6 +680,7 @@ async function synthesizeWithGoogle( } const audioBuffer = Buffer.from(audioContent, 'base64') + assertKnownSizeWithinLimit(audioBuffer.length, MAX_TTS_AUDIO_BYTES, 'Google TTS audio response') const format = audioEncoding.toLowerCase().replace('_', '') const mimeType = getMimeType(format) @@ -695,6 +712,13 @@ async function synthesizeWithAzure( throw new Error('text and apiKey are required for Azure TTS') } + const AZURE_REGION_RE = /^[a-z][a-z0-9-]{1,30}[a-z0-9]$/ + if (!AZURE_REGION_RE.test(region)) { + throw new Error( + 'Invalid Azure region: must match /^[a-z][a-z0-9-]{1,30}[a-z0-9]$/ (e.g. eastus, westeurope)' + ) + } + let ssml = `` if (style) { @@ -734,12 +758,17 @@ async function synthesizeWithAzure( }) if (!response.ok) { - const error = await response.text() + const error = await readResponseTextWithLimit(response, { + maxBytes: MAX_TTS_ERROR_BYTES, + label: 'Azure TTS error response', + }) throw new Error(`Azure TTS API error: ${error || response.statusText}`) } - const arrayBuffer = await response.arrayBuffer() - const audioBuffer = Buffer.from(arrayBuffer) + const audioBuffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_TTS_AUDIO_BYTES, + label: 'Azure TTS audio response', + }) const format = outputFormat.includes('mp3') ? 'mp3' : 'wav' const mimeType = getMimeType(format) @@ -796,13 +825,15 @@ async function synthesizeWithPlayHT( }) if (!response.ok) { - const error = await response.json().catch(() => ({})) - const errorMessage = error.error_message || error.message || response.statusText + const error = await readTtsErrorJson(response, 'PlayHT TTS error response') + const errorMessage = getTtsErrorMessage(error, response.statusText) throw new Error(`PlayHT TTS API error: ${errorMessage}`) } - const arrayBuffer = await response.arrayBuffer() - const audioBuffer = Buffer.from(arrayBuffer) + const audioBuffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_TTS_AUDIO_BYTES, + label: 'PlayHT TTS audio response', + }) const format = outputFormat || 'mp3' const mimeType = getMimeType(format) 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..ddd0fc9350c 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { twilioGetRecordingContract } from '@/lib/api/contracts/tools/communication/messaging' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -44,12 +46,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,10 +63,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const body = await request.json() - const validatedData = TwilioGetRecordingSchema.parse(body) - - const { accountSid, authToken, recordingSid } = validatedData + const parsed = await parseRequest(twilioGetRecordingContract, request, {}) + if (!parsed.success) return parsed.response + const { accountSid, authToken, recordingSid } = parsed.data.body if (!accountSid.startsWith('AC')) { return NextResponse.json( @@ -243,7 +238,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/typeform/files/route.ts b/apps/sim/app/api/tools/typeform/files/route.ts new file mode 100644 index 00000000000..f4ded92ff92 --- /dev/null +++ b/apps/sim/app/api/tools/typeform/files/route.ts @@ -0,0 +1,168 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { typeformFilesContract } from '@/lib/api/contracts/tools/typeform' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + isPayloadSizeLimitError, + readResponseTextWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot' +import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' + +const logger = createLogger('TypeformFilesAPI') +const MAX_TYPEFORM_FILE_BYTES = 10 * 1024 * 1024 + +export const dynamic = 'force-dynamic' + +function buildTypeformFileUrl({ + formId, + responseId, + fieldId, + filename, + inline, +}: { + formId: string + responseId: string + fieldId: string + filename: string + inline?: boolean +}): string { + const encodedFormId = encodeURIComponent(formId) + const encodedResponseId = encodeURIComponent(responseId) + const encodedFieldId = encodeURIComponent(fieldId) + const encodedFilename = encodeURIComponent(filename) + const url = new URL( + `https://api.typeform.com/forms/${encodedFormId}/responses/${encodedResponseId}/fields/${encodedFieldId}/files/${encodedFilename}` + ) + if (inline !== undefined) { + url.searchParams.set('inline', String(inline)) + } + return url.toString() +} + +function getFilename( + response: { headers: { get(name: string): string | null } }, + fallback: string +): string { + const contentDisposition = response.headers.get('content-disposition') || '' + const filenameMatch = contentDisposition.match(/filename="(.+?)"/) + return filenameMatch?.[1] || fallback || 'typeform-file' +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest( + typeformFilesContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Invalid request data') }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + + try { + const body = parsed.data.body + const fileUrl = buildTypeformFileUrl(body) + const urlValidation = await validateUrlWithDNS(fileUrl, 'typeformFileUrl') + if (!urlValidation.isValid) { + return NextResponse.json( + { success: false, error: urlValidation.error || 'Invalid Typeform file URL' }, + { status: 400 } + ) + } + + const response = await secureFetchWithPinnedIP(fileUrl, urlValidation.resolvedIP!, { + headers: { Authorization: `Bearer ${body.apiKey}` }, + maxResponseBytes: MAX_TYPEFORM_FILE_BYTES, + }) + + if (!response.ok) { + const errorText = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Typeform file error response', + }).catch(() => '') + return NextResponse.json( + { + success: false, + error: `Failed to download Typeform file: ${response.status} ${errorText}`, + }, + { status: response.status } + ) + } + + const buffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_TYPEFORM_FILE_BYTES, + label: 'Typeform file download', + }) + const contentType = response.headers.get('content-type') || 'application/octet-stream' + const filename = getFilename(response, body.filename) + const executionContext = + body.workspaceId && body.workflowId && body.executionId + ? { + workspaceId: body.workspaceId, + workflowId: body.workflowId, + executionId: body.executionId, + } + : undefined + + if (executionContext) { + const file = await uploadExecutionFile( + executionContext, + buffer, + filename, + contentType, + authResult.userId + ) + return NextResponse.json({ + success: true, + output: { + fileUrl: file.url, + file: { ...file, mimeType: contentType }, + contentType, + filename, + }, + }) + } + + const file = await uploadCopilotFile({ + buffer, + fileName: filename, + contentType, + userId: authResult.userId, + }) + + return NextResponse.json({ + success: true, + output: { + fileUrl: file.url || fileUrl, + file, + contentType, + filename, + }, + }) + } catch (error) { + logger.error('Typeform file download failed', { error }) + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Failed to download Typeform file') }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/video/route.ts b/apps/sim/app/api/tools/video/route.ts index 2a91b29473a..1110432a473 100644 --- a/apps/sim/app/api/tools/video/route.ts +++ b/apps/sim/app/api/tools/video/route.ts @@ -1,41 +1,90 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' +import { videoProviders, videoToolContract } from '@/lib/api/contracts/tools/media/video' +import { getValidationErrorMessage, parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { + assertKnownSizeWithinLimit, + DEFAULT_MAX_ERROR_BODY_BYTES, + isPayloadSizeLimitError, + PayloadSizeLimitError, + readResponseJsonWithLimit, + readResponseTextWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' -import type { VideoRequestBody } from '@/tools/video/types' const logger = createLogger('VideoProxyAPI') +const MAX_VIDEO_OUTPUT_BYTES = 250 * 1024 * 1024 +const MAX_VIDEO_REFERENCE_IMAGE_BYTES = 25 * 1024 * 1024 +const MAX_VIDEO_JSON_BYTES = 2 * 1024 * 1024 export const dynamic = 'force-dynamic' export const maxDuration = 600 // 10 minutes for video generation +async function readVideoResponseBuffer(response: Response, label: string): Promise { + return readResponseToBufferWithLimit(response, { + maxBytes: MAX_VIDEO_OUTPUT_BYTES, + label, + }) +} + +async function readVideoJson>( + response: Response, + label: string +): Promise { + return readResponseJsonWithLimit(response, { + maxBytes: MAX_VIDEO_JSON_BYTES, + label, + }) +} + +async function readVideoErrorText(response: Response, label: string): Promise { + return readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label, + }).catch(() => '') +} + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId() logger.info(`[${requestId}] Video generation request started`) try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body: VideoRequestBody = await request.json() - const { provider, apiKey, model, prompt, duration, aspectRatio, resolution } = body + const parsed = await parseRequest( + videoToolContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid video request:`, error.issues) + return validationErrorResponse( + error, + getValidationErrorMessage(error, 'Invalid request data') + ) + }, + } + ) + if (!parsed.success) return parsed.response - if (!provider || !apiKey || !prompt) { - return NextResponse.json( - { error: 'Missing required fields: provider, apiKey, and prompt' }, - { status: 400 } - ) - } + const body = parsed.data.body + 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 } @@ -72,13 +121,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - // Validate aspect ratio (Veo only supports 16:9 and 9:16) - const validAspectRatios = provider === 'veo' ? ['16:9', '9:16'] : ['16:9', '9:16', '1:1'] - if (aspectRatio && !validAspectRatios.includes(aspectRatio)) { - return NextResponse.json( - { error: `Aspect ratio must be ${validAspectRatios.join(', ')}` }, - { status: 400 } - ) + if (provider !== 'falai') { + const validAspectRatios = provider === 'veo' ? ['16:9', '9:16'] : ['16:9', '9:16', '1:1'] + if (aspectRatio && !validAspectRatios.includes(aspectRatio)) { + return NextResponse.json( + { error: `Aspect ratio must be ${validAspectRatios.join(', ')}` }, + { status: 400 } + ) + } } logger.info(`[${requestId}] Generating video with ${provider}, model: ${model || 'default'}`) @@ -89,6 +139,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let height: number | undefined let jobId: string | undefined let actualDuration: number | undefined + let falaiCost: FalAICostMetadata | undefined + + if (body.visualReference) { + const denied = await assertToolFileAccess( + body.visualReference.key, + authResult.userId, + requestId, + logger + ) + if (denied) return denied + } try { if (provider === 'runway') { @@ -144,10 +205,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } else if (provider === 'minimax') { const result = await generateWithMiniMax( apiKey, - model || 'hailuo-02', + model || 'hailuo-2.3', prompt, duration || 6, - body.promptOptimizer !== false, // Default true + body.promptOptimizer !== false, + body.endpoint, requestId, logger ) @@ -163,6 +225,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } + const validationError = getFalAIValidationError(model, duration, aspectRatio, resolution) + if (validationError) { + return NextResponse.json({ error: validationError }, { status: 400 }) + } const result = await generateWithFalAI( apiKey, model, @@ -171,6 +237,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { aspectRatio, resolution, body.promptOptimizer, + body.generateAudio, + body.useHostedCostTracking === true, requestId, logger ) @@ -179,20 +247,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { height = result.height jobId = result.jobId actualDuration = result.duration + falaiCost = result.falaiCost } else { return NextResponse.json({ error: `Unknown provider: ${provider}` }, { status: 400 }) } } catch (error) { logger.error(`[${requestId}] Video generation failed:`, error) - const errorMessage = error instanceof Error ? error.message : 'Video generation failed' - return NextResponse.json({ error: errorMessage }, { status: 500 }) + const errorMessage = getErrorMessage(error, 'Video generation failed') + return NextResponse.json( + { error: errorMessage }, + { status: isPayloadSizeLimitError(error) ? 413 : 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 +279,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', @@ -218,9 +293,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } catch (error) { logger.error(`[${requestId}] Failed to upload video file:`, error) - throw new Error( - `Failed to store video: ${error instanceof Error ? error.message : 'Unknown error'}` - ) + throw new Error(`Failed to store video: ${getErrorMessage(error, 'Unknown error')}`) } return NextResponse.json({ @@ -232,6 +305,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { provider, model: model || 'default', jobId, + __falaiCostDollars: falaiCost?.costDollars, + __falaiBilling: falaiCost, }) } @@ -251,9 +326,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { videoUrl = `${getBaseUrl()}${fileInfo.path}` } catch (error) { logger.error(`[${requestId}] Failed to upload video file (fallback):`, error) - throw new Error( - `Failed to store video: ${error instanceof Error ? error.message : 'Unknown error'}` - ) + throw new Error(`Failed to store video: ${getErrorMessage(error, 'Unknown error')}`) } logger.info(`[${requestId}] Video generation completed successfully`) @@ -266,11 +339,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { provider, model: model || 'default', jobId, + __falaiCostDollars: falaiCost?.costDollars, + __falaiBilling: falaiCost, }) } catch (error) { logger.error(`[${requestId}] Video proxy error:`, error) - const errorMessage = error instanceof Error ? error.message : 'Unknown error' - return NextResponse.json({ error: errorMessage }, { status: 500 }) + const errorMessage = getErrorMessage(error, 'Unknown error') + return NextResponse.json( + { error: errorMessage }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) } }) @@ -305,7 +383,21 @@ async function generateWithRunway( } if (visualReference) { - const refBuffer = await downloadFileFromStorage(visualReference, requestId, logger) + if (visualReference.size > MAX_VIDEO_REFERENCE_IMAGE_BYTES) { + throw new PayloadSizeLimitError({ + label: 'video visual reference', + maxBytes: MAX_VIDEO_REFERENCE_IMAGE_BYTES, + observedBytes: visualReference.size, + }) + } + const refBuffer = await downloadFileFromStorage(visualReference, requestId, logger, { + maxBytes: MAX_VIDEO_REFERENCE_IMAGE_BYTES, + }) + assertKnownSizeWithinLimit( + refBuffer.length, + MAX_VIDEO_REFERENCE_IMAGE_BYTES, + 'video visual reference' + ) const refBase64 = refBuffer.toString('base64') createPayload.promptImage = `data:${visualReference.type};base64,${refBase64}` // Use promptImage } @@ -321,11 +413,11 @@ async function generateWithRunway( }) if (!createResponse.ok) { - const error = await createResponse.text() + const error = await readVideoErrorText(createResponse, 'Runway create error response') throw new Error(`Runway API error: ${createResponse.status} - ${error}`) } - const createData = await createResponse.json() + const createData = await readVideoJson<{ id: string }>(createResponse, 'Runway create response') const taskId = createData.id logger.info(`[${requestId}] Runway task created: ${taskId}`) @@ -345,24 +437,32 @@ async function generateWithRunway( }) if (!statusResponse.ok) { - await statusResponse.text().catch(() => {}) + await readVideoErrorText(statusResponse, 'Runway status error response') throw new Error(`Runway status check failed: ${statusResponse.status}`) } - const statusData = await statusResponse.json() + const statusData = await readVideoJson<{ + status?: string + output?: string[] + failure?: string + }>(statusResponse, 'Runway status response') if (statusData.status === 'SUCCEEDED') { logger.info(`[${requestId}] Runway generation completed after ${attempts * 5}s`) - const videoResponse = await fetch(statusData.output[0]) + const videoUrl = statusData.output?.[0] + if (!videoUrl) { + throw new Error('No video URL in response') + } + + const videoResponse = await fetch(videoUrl) if (!videoResponse.ok) { - await videoResponse.text().catch(() => {}) + await readVideoErrorText(videoResponse, 'Runway video error response') throw new Error(`Failed to download video: ${videoResponse.status}`) } - const arrayBuffer = await videoResponse.arrayBuffer() return { - buffer: Buffer.from(arrayBuffer), + buffer: await readVideoResponseBuffer(videoResponse, 'Runway video response'), width: dimensions.width, height: dimensions.height, jobId: taskId, @@ -427,11 +527,11 @@ async function generateWithVeo( ) if (!createResponse.ok) { - const error = await createResponse.text() + const error = await readVideoErrorText(createResponse, 'Veo create error response') throw new Error(`Veo API error: ${createResponse.status} - ${error}`) } - const createData = await createResponse.json() + const createData = await readVideoJson<{ name: string }>(createResponse, 'Veo create response') const operationName = createData.name logger.info(`[${requestId}] Veo operation created: ${operationName}`) @@ -453,11 +553,17 @@ async function generateWithVeo( ) if (!statusResponse.ok) { - await statusResponse.text().catch(() => {}) + await readVideoErrorText(statusResponse, 'Veo status error response') throw new Error(`Veo status check failed: ${statusResponse.status}`) } - const statusData = await statusResponse.json() + const statusData = await readVideoJson<{ + done?: boolean + error?: { message?: string } + response?: { + generateVideoResponse?: { generatedSamples?: Array<{ video?: { uri?: string } }> } + } + }>(statusResponse, 'Veo status response') if (statusData.done) { if (statusData.error) { @@ -478,13 +584,12 @@ async function generateWithVeo( }) if (!videoResponse.ok) { - await videoResponse.text().catch(() => {}) + await readVideoErrorText(videoResponse, 'Veo video error response') throw new Error(`Failed to download video: ${videoResponse.status}`) } - const arrayBuffer = await videoResponse.arrayBuffer() return { - buffer: Buffer.from(arrayBuffer), + buffer: await readVideoResponseBuffer(videoResponse, 'Veo video response'), width: dimensions.width, height: dimensions.height, jobId: operationName, @@ -542,11 +647,11 @@ async function generateWithLuma( }) if (!createResponse.ok) { - const error = await createResponse.text() + const error = await readVideoErrorText(createResponse, 'Luma create error response') throw new Error(`Luma API error: ${createResponse.status} - ${error}`) } - const createData = await createResponse.json() + const createData = await readVideoJson<{ id: string }>(createResponse, 'Luma create response') const generationId = createData.id logger.info(`[${requestId}] Luma generation created: ${generationId}`) @@ -568,11 +673,15 @@ async function generateWithLuma( ) if (!statusResponse.ok) { - await statusResponse.text().catch(() => {}) + await readVideoErrorText(statusResponse, 'Luma status error response') throw new Error(`Luma status check failed: ${statusResponse.status}`) } - const statusData = await statusResponse.json() + const statusData = await readVideoJson<{ + state?: string + failure_reason?: string + assets?: { video?: string } + }>(statusResponse, 'Luma status response') if (statusData.state === 'completed') { logger.info(`[${requestId}] Luma generation completed after ${attempts * 5}s`) @@ -584,13 +693,12 @@ async function generateWithLuma( const videoResponse = await fetch(videoUrl) if (!videoResponse.ok) { - await videoResponse.text().catch(() => {}) + await readVideoErrorText(videoResponse, 'Luma video error response') throw new Error(`Failed to download video: ${videoResponse.status}`) } - const arrayBuffer = await videoResponse.arrayBuffer() return { - buffer: Buffer.from(arrayBuffer), + buffer: await readVideoResponseBuffer(videoResponse, 'Luma video response'), width: dimensions.width, height: dimensions.height, jobId: generationId, @@ -614,27 +722,25 @@ async function generateWithMiniMax( prompt: string, duration: number, promptOptimizer: boolean, + endpoint: string | undefined, requestId: string, logger: ReturnType ): Promise<{ buffer: Buffer; width: number; height: number; jobId: string; duration: number }> { logger.info(`[${requestId}] Starting MiniMax Hailuo generation via MiniMax Platform API`) logger.info( - `[${requestId}] Request params - model: ${model}, duration: ${duration}, promptOptimizer: ${promptOptimizer}` + `[${requestId}] Request params - model: ${model}, duration: ${duration}, endpoint: ${endpoint || 'standard'}, promptOptimizer: ${promptOptimizer}` ) - // Determine resolution and dimensions based on duration - // MiniMax-Hailuo-02 supports 768P (6s) or 1080P (10s) - const resolution = duration === 10 ? '1080P' : '768P' - const dimensions = duration === 10 ? { width: 1920, height: 1080 } : { width: 1360, height: 768 } + const useProResolution = endpoint === 'pro' && duration === 6 + const resolution = useProResolution ? '1080P' : '768P' + const dimensions = useProResolution ? { width: 1920, height: 1080 } : { width: 1360, height: 768 } logger.info( `[${requestId}] Using resolution: ${resolution}, dimensions: ${dimensions.width}x${dimensions.height}` ) - // Map our model ID to MiniMax model name const minimaxModel = model === 'hailuo-02' ? 'MiniMax-Hailuo-02' : 'MiniMax-Hailuo-2.3' - // Create video generation request via MiniMax Platform API const createResponse = await fetch('https://api.minimax.io/v1/video_generation', { method: 'POST', headers: { @@ -651,7 +757,7 @@ async function generateWithMiniMax( }) if (!createResponse.ok) { - const errorText = await createResponse.text() + const errorText = await readVideoErrorText(createResponse, 'MiniMax create error response') if (createResponse.status === 401 || createResponse.status === 1004) { throw new Error( `MiniMax API authentication failed (${createResponse.status}). Please ensure you're using a valid MiniMax API key from platform.minimax.io. Error: ${errorText}` @@ -660,7 +766,10 @@ async function generateWithMiniMax( throw new Error(`MiniMax API error: ${createResponse.status} - ${errorText}`) } - const createData = await createResponse.json() + const createData = await readVideoJson<{ + base_resp?: { status_code?: number; status_msg?: string } + task_id?: string + }>(createResponse, 'MiniMax create response') // Check for error in response if (createData.base_resp?.status_code !== 0) { @@ -668,6 +777,9 @@ async function generateWithMiniMax( } const taskId = createData.task_id + if (!taskId) { + throw new Error('MiniMax response missing task_id') + } logger.info(`[${requestId}] MiniMax task created: ${taskId}`) @@ -688,11 +800,16 @@ async function generateWithMiniMax( ) if (!statusResponse.ok) { - await statusResponse.text().catch(() => {}) + await readVideoErrorText(statusResponse, 'MiniMax status error response') throw new Error(`MiniMax status check failed: ${statusResponse.status}`) } - const statusData = await statusResponse.json() + const statusData = await readVideoJson<{ + base_resp?: { status_code?: number; status_msg?: string } + status?: string + file_id?: string + error?: string + }>(statusResponse, 'MiniMax status response') if ( statusData.base_resp?.status_code !== 0 && @@ -722,11 +839,14 @@ async function generateWithMiniMax( ) if (!fileResponse.ok) { - await fileResponse.text().catch(() => {}) + await readVideoErrorText(fileResponse, 'MiniMax file error response') throw new Error(`Failed to download video: ${fileResponse.status}`) } - const fileData = await fileResponse.json() + const fileData = await readVideoJson<{ file?: { download_url?: string } }>( + fileResponse, + 'MiniMax file response' + ) const videoUrl = fileData.file?.download_url if (!videoUrl) { @@ -736,13 +856,12 @@ async function generateWithMiniMax( // Download the actual video file const videoResponse = await fetch(videoUrl) if (!videoResponse.ok) { - await videoResponse.text().catch(() => {}) + await readVideoErrorText(videoResponse, 'MiniMax video error response') throw new Error(`Failed to download video from URL: ${videoResponse.status}`) } - const arrayBuffer = await videoResponse.arrayBuffer() return { - buffer: Buffer.from(arrayBuffer), + buffer: await readVideoResponseBuffer(videoResponse, 'MiniMax video response'), width: dimensions.width, height: dimensions.height, jobId: taskId, @@ -761,32 +880,290 @@ async function generateWithMiniMax( throw new Error('MiniMax generation timed out') } -// Helper function to strip subpaths from Fal.ai model IDs for status/result endpoints -function getBaseModelId(fullModelId: string): string { - const parts = fullModelId.split('/') - // Keep only the first two parts (e.g., "fal-ai/sora-2" from "fal-ai/sora-2/text-to-video") - if (parts.length > 2) { - return parts.slice(0, 2).join('/') - } - return fullModelId +type FalAIDurationFormat = 'number' | 'seconds' | 'string' + +interface FalAIModelConfig { + endpoint: string + durationFormat?: FalAIDurationFormat + durationOptions?: readonly number[] + supportsAspectRatio?: boolean + aspectRatioOptions?: readonly string[] + supportsResolution?: boolean + resolutionOptions?: readonly string[] + supportsPromptOptimizer?: boolean + supportsGenerateAudio?: boolean +} + +interface FalAIRequestBody { + prompt: string + duration?: number | string + aspect_ratio?: string + resolution?: string + prompt_optimizer?: boolean + generate_audio?: boolean +} + +const FALAI_MODEL_CONFIGS: Record = { + 'veo-3.1': { + endpoint: 'fal-ai/veo3.1', + durationFormat: 'seconds', + durationOptions: [4, 6, 8], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16'], + supportsResolution: true, + resolutionOptions: ['720p', '1080p', '4k'], + supportsGenerateAudio: true, + }, + 'veo-3.1-fast': { + endpoint: 'fal-ai/veo3.1/fast', + durationFormat: 'seconds', + durationOptions: [4, 6, 8], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16'], + supportsResolution: true, + resolutionOptions: ['720p', '1080p', '4k'], + supportsGenerateAudio: true, + }, + 'sora-2': { + endpoint: 'fal-ai/sora-2/text-to-video', + durationFormat: 'number', + durationOptions: [4, 8, 12, 16, 20], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16'], + supportsResolution: true, + resolutionOptions: ['720p'], + }, + 'sora-2-pro': { + endpoint: 'fal-ai/sora-2/text-to-video/pro', + durationFormat: 'number', + durationOptions: [4, 8, 12, 16, 20], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16'], + supportsResolution: true, + resolutionOptions: ['720p', '1080p', 'true_1080p'], + }, + 'seedance-2.0': { + endpoint: 'bytedance/seedance-2.0/text-to-video', + durationFormat: 'string', + durationOptions: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + supportsAspectRatio: true, + aspectRatioOptions: ['auto', '21:9', '16:9', '4:3', '1:1', '3:4', '9:16'], + supportsResolution: true, + resolutionOptions: ['480p', '720p', '1080p'], + supportsGenerateAudio: true, + }, + 'seedance-2.0-fast': { + endpoint: 'bytedance/seedance-2.0/fast/text-to-video', + durationFormat: 'string', + durationOptions: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + supportsAspectRatio: true, + aspectRatioOptions: ['auto', '21:9', '16:9', '4:3', '1:1', '3:4', '9:16'], + supportsResolution: true, + resolutionOptions: ['480p', '720p'], + supportsGenerateAudio: true, + }, + 'kling-v3-pro': { + endpoint: 'fal-ai/kling-video/v3/pro/text-to-video', + durationFormat: 'string', + durationOptions: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16', '1:1'], + supportsGenerateAudio: true, + }, + 'kling-v3-4k': { + endpoint: 'fal-ai/kling-video/v3/4k/text-to-video', + durationFormat: 'string', + durationOptions: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16', '1:1'], + supportsGenerateAudio: true, + }, + 'kling-o3-pro': { + endpoint: 'fal-ai/kling-video/o3/pro/text-to-video', + durationFormat: 'string', + durationOptions: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16', '1:1'], + supportsGenerateAudio: true, + }, + 'kling-o3-4k': { + endpoint: 'fal-ai/kling-video/o3/4k/text-to-video', + durationFormat: 'string', + durationOptions: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16', '1:1'], + supportsGenerateAudio: true, + }, + 'kling-2.5-turbo-pro': { + endpoint: 'fal-ai/kling-video/v2.5-turbo/pro/text-to-video', + durationFormat: 'string', + supportsAspectRatio: true, + supportsResolution: true, + }, + 'kling-2.1-pro': { + endpoint: 'fal-ai/kling-video/v2.1/master/text-to-video', + durationFormat: 'string', + supportsAspectRatio: true, + supportsResolution: true, + }, + 'minimax-hailuo-2.3-pro': { + endpoint: 'fal-ai/minimax/hailuo-2.3/pro/text-to-video', + supportsPromptOptimizer: true, + }, + 'minimax-hailuo-2.3-standard': { + endpoint: 'fal-ai/minimax/hailuo-2.3/standard/text-to-video', + durationFormat: 'string', + durationOptions: [6, 10], + supportsPromptOptimizer: true, + }, + 'minimax-hailuo-02-pro': { + endpoint: 'fal-ai/minimax/hailuo-02/pro/text-to-video', + durationFormat: 'string', + supportsAspectRatio: true, + supportsResolution: true, + supportsPromptOptimizer: true, + }, + 'minimax-hailuo-02-standard': { + endpoint: 'fal-ai/minimax/hailuo-02/standard/text-to-video', + durationFormat: 'string', + supportsAspectRatio: true, + supportsResolution: true, + supportsPromptOptimizer: true, + }, + 'wan-2.2-a14b-turbo': { + endpoint: 'fal-ai/wan/v2.2-a14b/text-to-video/turbo', + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16', '1:1'], + supportsResolution: true, + resolutionOptions: ['480p', '580p', '720p'], + }, + 'wan-2.1': { + endpoint: 'fal-ai/wan-t2v', + }, + 'ltx-2.3': { + endpoint: 'fal-ai/ltx-2.3/text-to-video', + durationFormat: 'number', + durationOptions: [6, 8, 10], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16'], + supportsResolution: true, + resolutionOptions: ['1080p', '1440p', '2160p'], + supportsGenerateAudio: true, + }, + 'ltx-2.3-fast': { + endpoint: 'fal-ai/ltx-2.3/text-to-video/fast', + durationFormat: 'number', + durationOptions: [6, 8, 10, 12, 14, 16, 18, 20], + supportsAspectRatio: true, + aspectRatioOptions: ['16:9', '9:16'], + supportsResolution: true, + resolutionOptions: ['1080p', '1440p', '2160p'], + supportsGenerateAudio: true, + }, + 'ltxv-0.9.8': { + endpoint: 'fal-ai/ltxv-13b-098-distilled', + }, +} + +function formatFalAIDuration( + format: FalAIDurationFormat | undefined, + duration: number | undefined +): string | number | undefined { + if (!format || duration === undefined) return undefined + + if (format === 'number') return duration + if (format === 'seconds') return `${duration}s` + return String(duration) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function getStringProperty( + record: Record | undefined, + key: string +): string | undefined { + const value = record?.[key] + return typeof value === 'string' ? value : undefined +} + +function getNumberProperty( + record: Record | undefined, + key: string +): number | undefined { + const value = record?.[key] + return typeof value === 'number' ? value : undefined } -// Helper function to format duration based on model requirements -function formatDuration(model: string, duration: number | undefined): string | number | undefined { - if (duration === undefined) return undefined +function formatAllowedValues(allowed: readonly (number | string)[]): string { + return allowed.map(String).join(', ') +} - // Veo 3.1 requires duration with "s" suffix (e.g., "8s") - if (model === 'veo-3.1') { - return `${duration}s` +function getFalAIValidationError( + model: string, + duration: number | undefined, + aspectRatio: string | undefined, + resolution: string | undefined +): string | undefined { + const modelConfig = FALAI_MODEL_CONFIGS[model] + if (!modelConfig) { + return `Unknown Fal.ai model: ${model}` } - // Sora 2 requires numeric duration - if (model === 'sora-2') { - return duration + if ( + duration !== undefined && + modelConfig.durationOptions && + !modelConfig.durationOptions.includes(duration) + ) { + return `Invalid duration for Fal.ai model ${model}. Supported durations: ${formatAllowedValues(modelConfig.durationOptions)}` } - // Other models use string format - return String(duration) + if (aspectRatio) { + if (!modelConfig.supportsAspectRatio) { + return `Fal.ai model ${model} does not support aspect ratio` + } + + if (modelConfig.aspectRatioOptions && !modelConfig.aspectRatioOptions.includes(aspectRatio)) { + return `Invalid aspect ratio for Fal.ai model ${model}. Supported aspect ratios: ${formatAllowedValues(modelConfig.aspectRatioOptions)}` + } + } + + if (resolution) { + if (!modelConfig.supportsResolution) { + return `Fal.ai model ${model} does not support resolution` + } + + if (modelConfig.resolutionOptions && !modelConfig.resolutionOptions.includes(resolution)) { + return `Invalid resolution for Fal.ai model ${model}. Supported resolutions: ${formatAllowedValues(modelConfig.resolutionOptions)}` + } + } + + if ( + model === 'ltx-2.3-fast' && + duration !== undefined && + duration > 10 && + resolution && + resolution !== '1080p' + ) { + return 'Fal.ai model ltx-2.3-fast only supports durations over 10 seconds with 1080p resolution' + } + + return undefined +} + +function getFalAIErrorMessage(error: unknown): string { + if (typeof error === 'string') return error + if (isRecord(error)) return getStringProperty(error, 'message') || JSON.stringify(error) + return 'Unknown error' +} + +function buildFalAIQueueUrl( + endpoint: string, + requestId: string, + path: 'response' | 'status' +): string { + return `https://queue.fal.run/${endpoint}/requests/${requestId}/${path}` } async function generateWithFalAI( @@ -797,64 +1174,49 @@ async function generateWithFalAI( aspectRatio: string | undefined, resolution: string | undefined, promptOptimizer: boolean | undefined, + generateAudio: boolean | undefined, + useHostedCostTracking: boolean, requestId: string, logger: ReturnType -): Promise<{ buffer: Buffer; width: number; height: number; jobId: string; duration: number }> { +): Promise<{ + buffer: Buffer + width: number + height: number + jobId: string + duration: number + falaiCost?: FalAICostMetadata +}> { logger.info(`[${requestId}] Starting Fal.ai generation with model: ${model}`) - // Map our model IDs to Fal.ai model paths - const modelMap: { [key: string]: string } = { - 'veo-3.1': 'fal-ai/veo3.1', - 'sora-2': 'fal-ai/sora-2/text-to-video', - 'kling-2.5-turbo-pro': 'fal-ai/kling-video/v2.5-turbo/pro/text-to-video', - 'kling-2.1-pro': 'fal-ai/kling-video/v2.1/master/text-to-video', - 'minimax-hailuo-2.3-pro': 'fal-ai/minimax/hailuo-02/pro/text-to-video', - 'minimax-hailuo-2.3-standard': 'fal-ai/minimax/hailuo-02/standard/text-to-video', - 'wan-2.1': 'fal-ai/wan-t2v', - 'ltxv-0.9.8': 'fal-ai/ltxv-13b-098-distilled', - } - - const falModelId = modelMap[model] - if (!falModelId) { + const modelConfig = FALAI_MODEL_CONFIGS[model] + if (!modelConfig) { throw new Error(`Unknown Fal.ai model: ${model}`) } - // Build request body based on model requirements - const requestBody: any = { prompt } - - // Models that support duration and aspect_ratio parameters - const supportsStandardParams = [ - 'kling-2.5-turbo-pro', - 'kling-2.1-pro', - 'minimax-hailuo-2.3-pro', - 'minimax-hailuo-2.3-standard', - ] + const requestBody: FalAIRequestBody = { prompt } + const formattedDuration = formatFalAIDuration(modelConfig.durationFormat, duration) - // Models that only need prompt (minimal params) - const minimalParamModels = ['ltxv-0.9.8', 'wan-2.1', 'veo-3.1', 'sora-2'] - - if (supportsStandardParams.includes(model)) { - // Kling and MiniMax models support duration and aspect_ratio - const formattedDuration = formatDuration(model, duration) - if (formattedDuration !== undefined) { - requestBody.duration = formattedDuration - } + if (formattedDuration !== undefined) { + requestBody.duration = formattedDuration + } - if (aspectRatio) { - requestBody.aspect_ratio = aspectRatio - } + if (modelConfig.supportsAspectRatio && aspectRatio) { + requestBody.aspect_ratio = aspectRatio + } - if (resolution) { - requestBody.resolution = resolution - } + if (modelConfig.supportsResolution && resolution) { + requestBody.resolution = resolution } - // MiniMax models support prompt optimizer - if (model.startsWith('minimax-hailuo') && promptOptimizer !== undefined) { + if (modelConfig.supportsPromptOptimizer && promptOptimizer !== undefined) { requestBody.prompt_optimizer = promptOptimizer } - const createResponse = await fetch(`https://queue.fal.run/${falModelId}`, { + if (modelConfig.supportsGenerateAudio && generateAudio !== undefined) { + requestBody.generate_audio = generateAudio + } + + const createResponse = await fetch(`https://queue.fal.run/${modelConfig.endpoint}`, { method: 'POST', headers: { Authorization: `Key ${apiKey}`, @@ -864,17 +1226,28 @@ async function generateWithFalAI( }) if (!createResponse.ok) { - const error = await createResponse.text() + const error = await readVideoErrorText(createResponse, 'Fal.ai create error response') throw new Error(`Fal.ai API error: ${createResponse.status} - ${error}`) } - const createData = await createResponse.json() - const requestIdFal = createData.request_id + const createData = await readVideoJson(createResponse, 'Fal.ai queue response') + if (!isRecord(createData)) { + throw new Error('Invalid Fal.ai queue response') + } - logger.info(`[${requestId}] Fal.ai request created: ${requestIdFal}`) + const requestIdFal = getStringProperty(createData, 'request_id') + if (!requestIdFal) { + throw new Error('Fal.ai queue response missing request_id') + } - // Get base model ID (without subpath) for status and result endpoints - const baseModelId = getBaseModelId(falModelId) + const statusUrl = + getStringProperty(createData, 'status_url') || + buildFalAIQueueUrl(modelConfig.endpoint, requestIdFal, 'status') + const responseUrl = + getStringProperty(createData, 'response_url') || + buildFalAIQueueUrl(modelConfig.endpoint, requestIdFal, 'response') + + logger.info(`[${requestId}] Fal.ai request created: ${requestIdFal}`) const pollIntervalMs = 5000 const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs) @@ -883,27 +1256,32 @@ async function generateWithFalAI( while (attempts < maxAttempts) { await sleep(pollIntervalMs) - const statusResponse = await fetch( - `https://queue.fal.run/${baseModelId}/requests/${requestIdFal}/status`, - { - headers: { - Authorization: `Key ${apiKey}`, - }, - } - ) + const statusResponse = await fetch(statusUrl, { + headers: { + Authorization: `Key ${apiKey}`, + }, + }) if (!statusResponse.ok) { - await statusResponse.text().catch(() => {}) + await readVideoErrorText(statusResponse, 'Fal.ai status error response') throw new Error(`Fal.ai status check failed: ${statusResponse.status}`) } - const statusData = await statusResponse.json() + const statusData = await readVideoJson(statusResponse, 'Fal.ai status response') + if (!isRecord(statusData)) { + throw new Error('Invalid Fal.ai status response') + } + + if (getStringProperty(statusData, 'status') === 'COMPLETED') { + const statusError = statusData.error + if (statusError) { + throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusError)}`) + } - if (statusData.status === 'COMPLETED') { logger.info(`[${requestId}] Fal.ai generation completed after ${attempts * 5}s`) const resultResponse = await fetch( - `https://queue.fal.run/${baseModelId}/requests/${requestIdFal}`, + getStringProperty(statusData, 'response_url') || responseUrl, { headers: { Authorization: `Key ${apiKey}`, @@ -912,46 +1290,56 @@ async function generateWithFalAI( ) if (!resultResponse.ok) { - await resultResponse.text().catch(() => {}) + await readVideoErrorText(resultResponse, 'Fal.ai result error response') throw new Error(`Failed to fetch result: ${resultResponse.status}`) } - const resultData = await resultResponse.json() + const resultData = await readVideoJson(resultResponse, 'Fal.ai result response') + if (!isRecord(resultData)) { + throw new Error('Invalid Fal.ai result response') + } - const videoUrl = resultData.video?.url || resultData.output?.url + const videoOutput = isRecord(resultData.video) ? resultData.video : undefined + const fallbackOutput = isRecord(resultData.output) ? resultData.output : undefined + const videoUrl = + getStringProperty(videoOutput, 'url') || getStringProperty(fallbackOutput, 'url') if (!videoUrl) { throw new Error('No video URL in response') } const videoResponse = await fetch(videoUrl) if (!videoResponse.ok) { - await videoResponse.text().catch(() => {}) + await readVideoErrorText(videoResponse, 'Fal.ai video error response') throw new Error(`Failed to download video: ${videoResponse.status}`) } - const arrayBuffer = await videoResponse.arrayBuffer() - - // Try to get dimensions from response, or calculate from aspect ratio - let width = resultData.video?.width || 1920 - let height = resultData.video?.height || 1080 + let width = getNumberProperty(videoOutput, 'width') || 1920 + let height = getNumberProperty(videoOutput, 'height') || 1080 - if (!resultData.video?.width && aspectRatio) { + if (!getNumberProperty(videoOutput, 'width') && aspectRatio?.includes(':')) { const dims = getVideoDimensions(aspectRatio, resolution || '1080p') width = dims.width height = dims.height } return { - buffer: Buffer.from(arrayBuffer), + buffer: await readVideoResponseBuffer(videoResponse, 'Fal.ai video response'), width, height, jobId: requestIdFal, - duration: duration || 5, + duration: getNumberProperty(videoOutput, 'duration') || duration || 5, + falaiCost: useHostedCostTracking + ? await getFalAICostMetadata({ + apiKey, + endpointId: modelConfig.endpoint, + requestId: requestIdFal, + }) + : undefined, } } - if (statusData.status === 'FAILED') { - throw new Error(`Fal.ai generation failed: ${statusData.error || 'Unknown error'}`) + if (['ERROR', 'FAILED', 'CANCELLED'].includes(getStringProperty(statusData, 'status') || '')) { + throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusData.error)}`) } attempts++ @@ -965,13 +1353,20 @@ function getVideoDimensions( resolution: string ): { width: number; height: number } { let height: number - if (resolution === '4k') { + if (resolution === '4k' || resolution === '2160p') { height = 2160 + } else if (resolution === 'true_1080p') { + height = 1080 } else { - height = Number.parseInt(resolution.replace('p', '')) + const parsedHeight = Number.parseInt(resolution.replace('p', '')) + height = Number.isFinite(parsedHeight) ? parsedHeight : 1080 } const [ratioW, ratioH] = aspectRatio.split(':').map(Number) + if (!Number.isFinite(ratioW) || !Number.isFinite(ratioH) || ratioH === 0) { + return { width: Math.round((height * 16) / 9), height } + } + const width = Math.round((height * ratioW) / ratioH) return { width, height } diff --git a/apps/sim/app/api/tools/vision/analyze/route.ts b/apps/sim/app/api/tools/vision/analyze/route.ts index 7890669d540..70b106e3c6c 100644 --- a/apps/sim/app/api/tools/vision/analyze/route.ts +++ b/apps/sim/app/api/tools/vision/analyze/route.ts @@ -1,7 +1,9 @@ import { GoogleGenAI } from '@google/genai' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { visionAnalyzeContract } from '@/lib/api/contracts/tools/media/vision' +import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP, @@ -9,33 +11,25 @@ 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, resolveInternalFileUrl, } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { convertUsageMetadata, extractTextContent } from '@/providers/google/utils' 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Vision analyze attempt: ${authResult.error}`) return NextResponse.json( { @@ -51,8 +45,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) const userId = authResult.userId - const body = await request.json() - const validatedData = VisionAnalyzeSchema.parse(body) + + const parsed = await parseRequest(visionAnalyzeContract, request, {}) + if (!parsed.success) return parsed.response + + const validatedData = parsed.data.body if (!validatedData.imageUrl && !validatedData.imageFile) { return NextResponse.json( @@ -83,7 +80,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Failed to process image file', + error: getErrorMessage(error, 'Failed to process image file'), }, { status: 400 } ) @@ -92,6 +89,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let base64 = userFile.base64 let bufferLength = 0 if (!base64) { + const denied = await assertToolFileAccess( + userFile.key, + authResult.userId, + requestId, + logger + ) + if (denied) return denied const buffer = await downloadFileFromStorage(userFile, requestId, logger) base64 = buffer.toString('base64') bufferLength = buffer.length @@ -357,7 +361,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/wealthbox/item/route.ts b/apps/sim/app/api/tools/wealthbox/item/route.ts index d25bf495bc0..066cfb6fcbe 100644 --- a/apps/sim/app/api/tools/wealthbox/item/route.ts +++ b/apps/sim/app/api/tools/wealthbox/item/route.ts @@ -1,13 +1,12 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation' +import { wealthboxItemContract } from '@/lib/api/contracts/selectors/wealthbox' +import { parseRequest } 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' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -17,29 +16,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated request rejected`) - 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', @@ -65,39 +44,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`, { credentialId }) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) + return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) } - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credentialId, + credAccess.credentialOwnerUserId, requestId ) @@ -143,16 +101,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..00ce1aab98a 100644 --- a/apps/sim/app/api/tools/wealthbox/items/route.ts +++ b/apps/sim/app/api/tools/wealthbox/items/route.ts @@ -1,13 +1,12 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation' +import { wealthboxItemsSelectorContract } from '@/lib/api/contracts/selectors/wealthbox' +import { parseRequest } 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' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -29,22 +28,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated request rejected`) - 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,46 +45,17 @@ 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 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`, { credentialId }) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const authz = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credentialId, + authz.credentialOwnerUserId, requestId ) @@ -142,7 +100,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 +125,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..a62632caea6 100644 --- a/apps/sim/app/api/tools/wordpress/upload/route.ts +++ b/apps/sim/app/api/tools/wordpress/upload/route.ts @@ -1,16 +1,18 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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, processSingleFileToUserFile, } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -18,24 +20,13 @@ 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() try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized WordPress upload attempt: ${authResult.error}`) return NextResponse.json( { @@ -53,8 +44,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, @@ -72,7 +64,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - // Process file - convert to UserFile format if needed const fileData = validatedData.file let userFile @@ -82,12 +73,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Failed to process file', + error: getErrorMessage(error, 'Failed to process file'), }, { status: 400 } ) } + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + logger.info(`[${requestId}] Downloading file from storage`, { fileName: userFile.name, key: userFile.key, @@ -103,13 +97,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: `Failed to download file: ${error instanceof Error ? error.message : 'Unknown error'}`, + error: `Failed to download file: ${getErrorMessage(error, 'Unknown error')}`, }, { status: 500 } ) } - // Use provided filename or fall back to the original file name const filename = validatedData.filename || userFile.name const mimeType = userFile.type || getMimeTypeFromExtension(getFileExtension(filename)) @@ -120,14 +113,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { size: fileBuffer.length, }) - // Upload to WordPress using multipart form data const formData = new FormData() - // Convert Buffer to Uint8Array for Blob compatibility const uint8Array = new Uint8Array(fileBuffer) const blob = new Blob([uint8Array], { type: mimeType }) formData.append('file', blob, filename) - // Add optional metadata if (validatedData.title) { formData.append('title', validatedData.title) } @@ -201,24 +191,12 @@ 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( { success: false, - error: error instanceof Error ? error.message : 'Internal server error', + error: getErrorMessage(error, 'Internal server error'), }, { status: 500 } ) 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..96879dcc14c 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +12,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 +21,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, @@ -44,7 +37,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { Onboarding_Plan_Assignment_Data: { Onboarding_Plan_Reference: wdRef('Onboarding_Plan_ID', data.onboardingPlanId), Person_Reference: wdRef('WID', data.workerId), - Action_Event_Reference: wdRef('Background_Check_ID', data.actionEventId), + Action_Event_Reference: wdRef('WID', data.actionEventId), Assignment_Effective_Moment: new Date().toISOString(), Active: true, }, @@ -61,7 +54,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] Workday assign onboarding failed`, { error }) return NextResponse.json( - { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { success: false, error: getErrorMessage(error, 'Unknown error') }, { status: 500 } ) } 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..8cbba58fe1f 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +12,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 +21,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), @@ -88,7 +77,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] Workday change job failed`, { error }) return NextResponse.json( - { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { success: false, error: getErrorMessage(error, 'Unknown error') }, { status: 500 } ) } 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..7ed61b992f8 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +12,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 +21,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( @@ -58,7 +49,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const client = await createWorkdaySoapClient( data.tenantUrl, data.tenant, - 'staffing', + 'recruiting', data.username, data.password ) @@ -128,7 +119,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] Workday create prehire failed`, { error }) return NextResponse.json( - { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { success: false, error: getErrorMessage(error, 'Unknown error') }, { status: 500 } ) } 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..eb57a04418e 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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' @@ -8,6 +10,7 @@ import { createWorkdaySoapClient, extractRefId, normalizeSoapArray, + parseSoapNumber, type WorkdayCompensationDataSoap, type WorkdayCompensationPlanSoap, type WorkdayWorkerSoap, @@ -17,14 +20,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 +29,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, @@ -66,7 +62,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const mapPlan = (p: WorkdayCompensationPlanSoap) => ({ id: extractRefId(p.Compensation_Plan_Reference) ?? null, planName: p.Compensation_Plan_Reference?.attributes?.Descriptor ?? null, - amount: p.Amount ?? p.Per_Unit_Amount ?? p.Individual_Target_Amount ?? null, + amount: + parseSoapNumber(p.Amount) ?? + parseSoapNumber(p.Per_Unit_Amount) ?? + parseSoapNumber(p.Individual_Target_Amount) ?? + null, currency: extractRefId(p.Currency_Reference) ?? null, frequency: extractRefId(p.Frequency_Reference) ?? null, }) @@ -95,7 +95,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] Workday get compensation failed`, { error }) return NextResponse.json( - { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { success: false, error: getErrorMessage(error, 'Unknown error') }, { status: 500 } ) } 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..7758ec415bd 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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' @@ -8,6 +10,8 @@ import { createWorkdaySoapClient, extractRefId, normalizeSoapArray, + parseSoapBoolean, + parseSoapNumber, type WorkdayOrganizationSoap, } from '@/tools/workday/soap' @@ -15,16 +19,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 +28,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, @@ -71,15 +66,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { | undefined ) - const organizations = orgsArray.map((o) => ({ - id: extractRefId(o.Organization_Reference) ?? null, - descriptor: o.Organization_Descriptor ?? null, - type: extractRefId(o.Organization_Data?.Organization_Type_Reference) ?? null, - subtype: extractRefId(o.Organization_Data?.Organization_Subtype_Reference) ?? null, - isActive: o.Organization_Data?.Inactive != null ? !o.Organization_Data.Inactive : null, - })) + const organizations = orgsArray.map((o) => { + const inactive = parseSoapBoolean(o.Organization_Data?.Inactive) + return { + id: extractRefId(o.Organization_Reference) ?? null, + descriptor: o.Organization_Descriptor ?? null, + type: extractRefId(o.Organization_Data?.Organization_Type_Reference) ?? null, + subtype: extractRefId(o.Organization_Data?.Organization_Subtype_Reference) ?? null, + isActive: inactive == null ? null : !inactive, + } + }) - const total = result?.Response_Results?.Total_Results ?? organizations.length + const total = parseSoapNumber(result?.Response_Results?.Total_Results) ?? organizations.length return NextResponse.json({ success: true, @@ -88,7 +86,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] Workday get organizations failed`, { error }) return NextResponse.json( - { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { success: false, error: getErrorMessage(error, 'Unknown error') }, { status: 500 } ) } 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..96fe04b884b 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +17,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 +26,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, @@ -81,7 +76,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] Workday get worker failed`, { error }) return NextResponse.json( - { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { success: false, error: getErrorMessage(error, 'Unknown error') }, { status: 500 } ) } diff --git a/apps/sim/app/api/tools/workday/hire/route.ts b/apps/sim/app/api/tools/workday/hire/route.ts index 393998d996a..9bc24a6b745 100644 --- a/apps/sim/app/api/tools/workday/hire/route.ts +++ b/apps/sim/app/api/tools/workday/hire/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +12,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 +21,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, @@ -72,7 +64,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] Workday hire employee failed`, { error }) return NextResponse.json( - { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { success: false, error: getErrorMessage(error, 'Unknown error') }, { status: 500 } ) } 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..878a4048f15 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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' @@ -8,6 +10,7 @@ import { createWorkdaySoapClient, extractRefId, normalizeSoapArray, + parseSoapNumber, type WorkdayWorkerSoap, } from '@/tools/workday/soap' @@ -15,15 +18,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 +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(workdayListWorkersContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body const client = await createWorkdaySoapClient( data.tenantUrl, @@ -68,7 +63,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { employmentData: w.Worker_Data?.Employment_Data ?? null, })) - const total = result?.Response_Results?.Total_Results ?? workers.length + const total = parseSoapNumber(result?.Response_Results?.Total_Results) ?? workers.length return NextResponse.json({ success: true, @@ -77,7 +72,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] Workday list workers failed`, { error }) return NextResponse.json( - { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { success: false, error: getErrorMessage(error, 'Unknown error') }, { status: 500 } ) } diff --git a/apps/sim/app/api/tools/workday/terminate/route.ts b/apps/sim/app/api/tools/workday/terminate/route.ts index 92ccf22ae29..9548af4467b 100644 --- a/apps/sim/app/api/tools/workday/terminate/route.ts +++ b/apps/sim/app/api/tools/workday/terminate/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +12,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 +21,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, @@ -71,7 +62,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] Workday terminate employee failed`, { error }) return NextResponse.json( - { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { success: false, error: getErrorMessage(error, 'Unknown error') }, { status: 500 } ) } 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..e3681a2567e 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +12,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 +21,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, @@ -60,7 +54,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] Workday update worker failed`, { error }) return NextResponse.json( - { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { success: false, error: getErrorMessage(error, 'Unknown error') }, { status: 500 } ) } 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..12da2faa429 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 +50,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 +67,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() @@ -209,7 +203,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: getErrorMessage(error, 'Unknown error occurred'), }, { status: 500 } ) 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..5ee3f0a5401 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 { getUsageLimitContract, updateUsageLimitContract } from '@/lib/api/contracts/subscription' +import { getValidationErrorMessage, parseRequest } 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,21 @@ 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') + const parsed = await parseRequest( + getUsageLimitContract, + request, + {}, + { + validationErrorResponse: () => + NextResponse.json( + { error: 'Invalid context. Must be "user" or "organization"' }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response - if (!['user', 'organization'].includes(context)) { - return NextResponse.json( - { error: 'Invalid context. Must be "user" or "organization"' }, - { status: 400 } - ) - } + const { context, userId = session.user.id, organizationId } = parsed.data.query if (context === 'user' && userId !== session.user.id) { return NextResponse.json( @@ -85,7 +78,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { success: true, context, userId, - organizationId, + organizationId: organizationId ?? null, data: usageLimitInfo, }) } catch (error) { @@ -106,16 +99,22 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const validation = usageUpdateSchema.safeParse(body) + const parsed = await parseRequest( + updateUsageLimitContract, + request, + {}, + { + validationErrorResponse: (error) => { + const message = getValidationErrorMessage(error) + logger.error('Validation error:', message) + return NextResponse.json({ error: message }, { status: 400 }) + }, + } + ) - if (!validation.success) { - const firstError = validation.error.errors[0] - logger.error('Validation error:', firstError) - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } + if (!parsed.success) return parsed.response - const { limit, context, organizationId } = validation.data + const { limit, context, organizationId } = parsed.data.body const userId = session.user.id if (context === 'user') { @@ -147,7 +146,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..e14a00a8118 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 { createPersonalApiKeyContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' import { hashApiKey } from '@/lib/api-key/crypto' import { getSession } from '@/lib/auth' @@ -62,17 +64,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = session.user.id - const body = await request.json() + const parsed = await parseRequest(createPersonalApiKeyContract, request, {}) + if (!parsed.success) return parsed.response - const { name: rawName } = body - if (!rawName || typeof rawName !== 'string') { - return NextResponse.json({ error: 'Invalid request. Name is required.' }, { status: 400 }) - } - - const name = rawName.trim() - if (!name) { - return NextResponse.json({ error: 'Name cannot be empty.' }, { status: 400 }) - } + const { name } = parsed.data.body 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..81336d60dec 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 { updateUserProfileContract } from '@/lib/api/contracts' +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' 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 @@ -47,9 +31,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest) => { } const userId = session.user.id - const body = await request.json() - const validatedData = UpdateProfileSchema.parse(body) + const parsed = await parseRequest(updateUserProfileContract, request, {}) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body const updateData: UpdateData = { updatedAt: new Date() } if (validatedData.name !== undefined) updateData.name = validatedData.name @@ -80,16 +65,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..47e0ac452eb 100644 --- a/apps/sim/app/api/users/me/settings/route.ts +++ b/apps/sim/app/api/users/me/settings/route.ts @@ -3,35 +3,15 @@ import { settings } from '@sim/db/schema' 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 { type NextRequest, NextResponse } from 'next/server' +import { updateUserSettingsContract } from '@/lib/api/contracts' +import { parseRequest, 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, @@ -40,6 +20,7 @@ const defaultSettings = { billingUsageNotificationsEnabled: true, showTrainingControls: false, superUserModeEnabled: false, + mothershipEnvironment: 'default', errorNotificationsEnabled: true, snapToGridSize: 0, showActionBar: true, @@ -58,7 +39,24 @@ export const GET = withRouteHandler(async () => { } const userId = session.user.id - const result = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1) + const result = await db + .select({ + theme: settings.theme, + autoConnect: settings.autoConnect, + telemetryEnabled: settings.telemetryEnabled, + emailPreferences: settings.emailPreferences, + billingUsageNotificationsEnabled: settings.billingUsageNotificationsEnabled, + showTrainingControls: settings.showTrainingControls, + superUserModeEnabled: settings.superUserModeEnabled, + mothershipEnvironment: settings.mothershipEnvironment, + errorNotificationsEnabled: settings.errorNotificationsEnabled, + snapToGridSize: settings.snapToGridSize, + showActionBar: settings.showActionBar, + lastActiveWorkspaceId: settings.lastActiveWorkspaceId, + }) + .from(settings) + .where(eq(settings.userId, userId)) + .limit(1) if (!result.length) { return NextResponse.json({ data: defaultSettings }, { status: 200 }) @@ -76,6 +74,7 @@ export const GET = withRouteHandler(async () => { billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true, showTrainingControls: userSettings.showTrainingControls ?? false, superUserModeEnabled: userSettings.superUserModeEnabled ?? false, + mothershipEnvironment: userSettings.mothershipEnvironment ?? 'default', errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true, snapToGridSize: userSettings.snapToGridSize ?? 0, showActionBar: userSettings.showActionBar ?? true, @@ -90,7 +89,7 @@ export const GET = withRouteHandler(async () => { } }) -export const PATCH = withRouteHandler(async (request: Request) => { +export const PATCH = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -104,40 +103,39 @@ export const PATCH = withRouteHandler(async (request: Request) => { } const userId = session.user.id - const body = await request.json() - - try { - const validatedData = SettingsSchema.parse(body) - await db - .insert(settings) - .values({ - id: generateShortId(), - userId, + const parsed = await parseRequest( + updateUserSettingsContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid settings data`, { errors: error.issues }) + return validationErrorResponse(error, 'Invalid settings data') + }, + } + ) + if (!parsed.success) return parsed.response + + const validatedData = parsed.data.body + + await db + .insert(settings) + .values({ + id: generateShortId(), + userId, + ...validatedData, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [settings.userId], + set: { ...validatedData, updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [settings.userId], - set: { - ...validatedData, - updatedAt: new Date(), - }, - }) + }, + }) - return NextResponse.json({ success: true }, { status: 200 }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid settings data`, { - errors: validationError.errors, - }) - return NextResponse.json( - { error: 'Invalid settings data', details: validationError.errors }, - { status: 400 } - ) - } - throw validationError - } + return NextResponse.json({ success: true }, { status: 200 }) } catch (error: any) { logger.error(`[${requestId}] Settings update error`, error) return NextResponse.json({ success: true }, { status: 200 }) 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..654324e85d4 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,12 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + unsubscribeFormContract, + unsubscribeGetContract, + unsubscribePostContract, +} from '@/lib/api/contracts/user' +import { parseRequest } from '@/lib/api/server' +import { enforceIpRateLimit } from '@/lib/core/rate-limiter' 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 +20,33 @@ 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'), -}) +const UNSUBSCRIBE_RATE_LIMIT = { + maxTokens: 10, + refillRate: 10, + refillIntervalMs: 60_000, +} 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 rateLimited = await enforceIpRateLimit('unsubscribe', req, UNSUBSCRIBE_RATE_LIMIT) + if (rateLimited) return rateLimited - if (!email || !token) { + try { + const parsed = await parseRequest( + unsubscribeGetContract, + req, + {}, + { + validationErrorResponse: () => + NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 }), + } + ) + if (!parsed.success) { logger.warn(`[${requestId}] Missing email or token in GET request`) - return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 }) + return parsed.response } + const { email, token } = parsed.data.query const tokenVerification = verifyUnsubscribeToken(email, token) if (!tokenVerification.valid) { @@ -65,8 +80,10 @@ export const GET = withRouteHandler(async (req: NextRequest) => { export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() + const rateLimited = await enforceIpRateLimit('unsubscribe', req, UNSUBSCRIBE_RATE_LIMIT) + if (rateLimited) return rateLimited + try { - const { searchParams } = new URL(req.url) const contentType = req.headers.get('content-type') || '' let email: string @@ -74,32 +91,45 @@ 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') || '' - - if (!email || !token) { + const parsed = await parseRequest( + unsubscribeFormContract, + req, + {}, + { + validationErrorResponse: () => + NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 }), + } + ) + if (!parsed.success) { logger.warn(`[${requestId}] One-click unsubscribe missing email or token in URL`) - return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 }) + return parsed.response } + email = parsed.data.query.email + token = parsed.data.query.token + logger.info(`[${requestId}] Processing one-click unsubscribe for: ${email}`) } else { - const body = await req.json() - const result = unsubscribeSchema.safeParse(body) - - if (!result.success) { - logger.warn(`[${requestId}] Invalid unsubscribe POST data`, { - errors: result.error.format(), - }) - return NextResponse.json( - { error: 'Invalid request data', details: result.error.format() }, - { status: 400 } - ) + const parsed = await parseRequest( + unsubscribePostContract, + req, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { error: 'Invalid request data', details: error.issues }, + { status: 400 } + ), + } + ) + if (!parsed.success) { + logger.warn(`[${requestId}] Invalid unsubscribe POST data`) + return parsed.response } - email = result.data.email - token = result.data.token - type = result.data.type + email = parsed.data.body.email + token = parsed.data.body.token + type = parsed.data.body.type } const tokenVerification = verifyUnsubscribeToken(email, token) diff --git a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts index d9c464661b4..cee604950e5 100644 --- a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts +++ b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts @@ -4,6 +4,7 @@ import { authMock, authMockFns, + createMockRequest, createSession, dbChainMock, dbChainMockFns, @@ -27,11 +28,12 @@ import { POST } from '@/app/api/users/me/subscription/[id]/transfer/route' function makeRequest(body: unknown, id = 'sub-1') { return POST( - new Request(`http://localhost/api/users/me/subscription/${id}/transfer`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) as any, + createMockRequest( + 'POST', + body, + {}, + `http://localhost/api/users/me/subscription/${id}/transfer` + ), { params: Promise.resolve({ id }) } ) } 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..c1a64e93175 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,8 @@ 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 { subscriptionTransferContract } from '@/lib/api/contracts/user' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { isOrgPlan } from '@/lib/billing/plan-helpers' import { @@ -15,19 +16,14 @@ 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 } | { kind: 'success'; message: string } export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { try { - const subscriptionId = (await params).id const session = await getSession() if (!session?.user?.id) { @@ -35,30 +31,11 @@ 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) - if (!validationResult.success) { - return NextResponse.json( - { - error: 'Invalid request parameters', - details: validationResult.error.format(), - }, - { status: 400 } - ) - } + const parsed = await parseRequest(subscriptionTransferContract, request, context) + if (!parsed.success) return parsed.response - const { organizationId } = validationResult.data + const subscriptionId = parsed.data.params.id + const { organizationId } = parsed.data.body const userId = session.user.id logger.info('Processing subscription transfer', { subscriptionId, organizationId }) 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..4dfd60b1a0a 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,7 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { usageLimitsRequestSchema } from '@/lib/api/contracts/usage-limits' import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid' import { checkServerSideUsageLimits } from '@/lib/billing' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' @@ -12,6 +14,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 +80,8 @@ 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(getErrorMessage(error, '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..969fe21531e 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,33 @@ 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, + adminV1DeleteAccessControlContract, + adminV1ListAccessControlContract, +} from '@/lib/api/contracts/v1/admin' +import { parseRequest } 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 parsed = await parseRequest( + adminV1ListAccessControlContract, + request, + {}, + { validationErrorResponse: adminValidationErrorResponse } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, organizationId } = parsed.data.query try { const baseQuery = db @@ -100,7 +99,7 @@ export const GET = withRouteHandler( createdAt: group.createdAt.toISOString(), createdByUserId: group.createdByUserId, createdByEmail: group.createdByEmail, - } as AdminPermissionGroup + } as AdminV1PermissionGroup }) ) @@ -132,14 +131,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..ac35a3d374b 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/v1/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..78d4f62067d 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 { v1AdminListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs' +import { parseRequest } 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,25 @@ 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 - - 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.') - } + const parsed = await parseRequest( + v1AdminListAuditLogsContract, + request, + {}, + { validationErrorResponse: adminValidationErrorResponse } + ) + if (!parsed.success) return parsed.response try { + const query = parsed.data.query 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/auth.ts b/apps/sim/app/api/v1/admin/auth.ts index 813d3f8c5b0..3d79a8db265 100644 --- a/apps/sim/app/api/v1/admin/auth.ts +++ b/apps/sim/app/api/v1/admin/auth.ts @@ -15,11 +15,11 @@ import { env } from '@/lib/core/config/env' const logger = createLogger('AdminAuth') -export interface AdminAuthSuccess { +interface AdminAuthSuccess { authenticated: true } -export interface AdminAuthFailure { +interface AdminAuthFailure { authenticated: false error: string notConfigured?: boolean diff --git a/apps/sim/app/api/v1/admin/credits/route.ts b/apps/sim/app/api/v1/admin/credits/route.ts index 1fc0d5f658c..756f1efc304 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/v1/admin' +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..fb597ca7c16 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,9 +16,12 @@ 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/v1/admin' +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' +import { encodeFilenameForHeader } from '@/app/api/files/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, @@ -97,9 +100,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 @@ -238,7 +243,7 @@ export const GET = withRouteHandler( status: 200, headers: { 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Disposition': `attachment; ${encodeFilenameForHeader(filename)}`, 'Content-Length': arrayBuffer.byteLength.toString(), }, }) diff --git a/apps/sim/app/api/v1/admin/index.ts b/apps/sim/app/api/v1/admin/index.ts deleted file mode 100644 index 97e9a519db5..00000000000 --- a/apps/sim/app/api/v1/admin/index.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Admin API v1 - * - * A RESTful API for administrative operations on Sim. - * - * Authentication: - * Set ADMIN_API_KEY environment variable and use x-admin-key header. - * - * Endpoints: - * - * Users: - * GET /api/v1/admin/users - List all users - * GET /api/v1/admin/users/:id - Get user details - * GET /api/v1/admin/users/:id/billing - Get user billing info - * PATCH /api/v1/admin/users/:id/billing - Update user billing (limit, blocked) - * - * Workspaces: - * GET /api/v1/admin/workspaces - List all workspaces - * GET /api/v1/admin/workspaces/:id - Get workspace details - * GET /api/v1/admin/workspaces/:id/members - List workspace members - * POST /api/v1/admin/workspaces/:id/members - Add/update workspace member - * DELETE /api/v1/admin/workspaces/:id/members?userId=X - Remove workspace member - * GET /api/v1/admin/workspaces/:id/members/:mid - Get workspace member details - * PATCH /api/v1/admin/workspaces/:id/members/:mid - Update workspace member permissions - * DELETE /api/v1/admin/workspaces/:id/members/:mid - Remove workspace member by ID - * GET /api/v1/admin/workspaces/:id/workflows - List workspace workflows - * DELETE /api/v1/admin/workspaces/:id/workflows - Delete all workspace workflows - * GET /api/v1/admin/workspaces/:id/folders - List workspace folders - * GET /api/v1/admin/workspaces/:id/export - Export workspace (ZIP/JSON) - * POST /api/v1/admin/workspaces/:id/import - Import into workspace - * - * Workflows: - * GET /api/v1/admin/workflows - List all workflows - * GET /api/v1/admin/workflows/:id - Get workflow details - * DELETE /api/v1/admin/workflows/:id - Delete workflow - * GET /api/v1/admin/workflows/:id/export - Export workflow (JSON) - * POST /api/v1/admin/workflows/export - Export multiple workflows (ZIP/JSON) - * POST /api/v1/admin/workflows/import - Import single workflow - * POST /api/v1/admin/workflows/:id/deploy - Deploy workflow - * DELETE /api/v1/admin/workflows/:id/deploy - Undeploy workflow - * GET /api/v1/admin/workflows/:id/versions - List deployment versions - * POST /api/v1/admin/workflows/:id/versions/:vid/activate - Activate specific version - * - * Folders: - * GET /api/v1/admin/folders/:id/export - Export folder with contents (ZIP/JSON) - * - * Organizations: - * GET /api/v1/admin/organizations - List all organizations - * POST /api/v1/admin/organizations - Create organization (requires ownerId) - * GET /api/v1/admin/organizations/:id - Get organization details - * PATCH /api/v1/admin/organizations/:id - Update organization - * GET /api/v1/admin/organizations/:id/members - List organization members - * POST /api/v1/admin/organizations/:id/members - Add/update member (validates seat availability) - * GET /api/v1/admin/organizations/:id/members/:mid - Get member details - * PATCH /api/v1/admin/organizations/:id/members/:mid - Update member role - * DELETE /api/v1/admin/organizations/:id/members/:mid - Remove member - * GET /api/v1/admin/organizations/:id/billing - Get org billing summary - * PATCH /api/v1/admin/organizations/:id/billing - Update org usage limit - * GET /api/v1/admin/organizations/:id/seats - Get seat analytics - * - * Subscriptions: - * GET /api/v1/admin/subscriptions - List all subscriptions - * GET /api/v1/admin/subscriptions/:id - Get subscription details - * DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled) - * - * Credits: - * POST /api/v1/admin/credits - Issue credits to user (by userId or email) - * - * Referral Campaigns: - * GET /api/v1/admin/referral-campaigns - List campaigns (?active=true/false) - * POST /api/v1/admin/referral-campaigns - Create campaign - * GET /api/v1/admin/referral-campaigns/:id - Get campaign details - * PATCH /api/v1/admin/referral-campaigns/:id - Update campaign fields - * - * Access Control (Permission Groups): - * GET /api/v1/admin/access-control - List permission groups (?organizationId=X) - * DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X) - */ - -export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth' -export { authenticateAdminRequest } from '@/app/api/v1/admin/auth' -export type { AdminRouteHandler, AdminRouteHandlerWithParams } from '@/app/api/v1/admin/middleware' -export { withAdminAuth, withAdminAuthParams } from '@/app/api/v1/admin/middleware' -export { - badRequestResponse, - errorResponse, - forbiddenResponse, - internalErrorResponse, - listResponse, - notConfiguredResponse, - notFoundResponse, - singleResponse, - unauthorizedResponse, -} from '@/app/api/v1/admin/responses' -export type { - AdminDeploymentVersion, - AdminDeployResult, - AdminErrorResponse, - AdminFolder, - AdminListResponse, - AdminMember, - AdminMemberDetail, - AdminOrganization, - AdminOrganizationBillingSummary, - AdminOrganizationDetail, - AdminSeatAnalytics, - AdminSingleResponse, - AdminSubscription, - AdminUndeployResult, - AdminUser, - AdminUserBilling, - AdminUserBillingWithSubscription, - AdminWorkflow, - AdminWorkflowDetail, - AdminWorkspace, - AdminWorkspaceDetail, - AdminWorkspaceMember, - DbMember, - DbOrganization, - DbSubscription, - DbUser, - DbUserStats, - DbWorkflow, - DbWorkflowFolder, - DbWorkspace, - FolderExportPayload, - ImportResult, - PaginationMeta, - PaginationParams, - VariableType, - WorkflowExportPayload, - WorkflowExportState, - WorkflowImportRequest, - WorkflowVariable, - WorkspaceExportPayload, - WorkspaceImportRequest, - WorkspaceImportResponse, -} from '@/app/api/v1/admin/types' -export { - createPaginationMeta, - DEFAULT_LIMIT, - extractWorkflowMetadata, - MAX_LIMIT, - parsePaginationParams, - parseWorkflowVariables, - toAdminFolder, - toAdminOrganization, - toAdminSubscription, - toAdminUser, - toAdminWorkflow, - toAdminWorkspace, -} from '@/app/api/v1/admin/types' 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..9dd083a30a2 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/v1/admin' +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,13 +44,22 @@ 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) { const [[orgData], [memberCount]] = await Promise.all([ - db.select().from(organization).where(eq(organization.id, organizationId)).limit(1), + db + .select({ id: organization.id, name: organization.name }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1), db .select({ count: count() }) .from(member) @@ -127,10 +142,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 +167,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..4dadf2cf93e 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/v1/admin' +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..0c5b50fefd6 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/v1/admin' +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..5f8881dc739 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/v1/admin' +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..6c015190408 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/v1/admin' +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..9da30b930d2 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/v1/admin' +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..07ee15890e8 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/v1/admin' +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,13 +56,37 @@ 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([ db.select({ total: count() }).from(organization), - db.select().from(organization).orderBy(organization.name).limit(limit).offset(offset), + db + .select({ + id: organization.id, + name: organization.name, + slug: organization.slug, + logo: organization.logo, + orgUsageLimit: organization.orgUsageLimit, + storageUsedBytes: organization.storageUsedBytes, + departedMemberUsage: organization.departedMemberUsage, + createdAt: organization.createdAt, + updatedAt: organization.updatedAt, + }) + .from(organization) + .orderBy(organization.name) + .limit(limit) + .offset(offset), ]) const total = countResult[0].total @@ -75,21 +105,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 +132,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 +141,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 +163,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..2b00537059a 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/v1/admin' +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..f88ac55536c 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/v1/admin' +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..b7f7c162118 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/v1/admin' +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..592a5b0a0c0 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, serializeZodIssues } from '@/lib/api/server' import type { AdminErrorResponse, AdminListResponse, @@ -69,6 +71,17 @@ 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'), + serializeZodIssues(error) + ) +} + +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..f462ec0b5af 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/v1/admin' +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..80074fcb5fd 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/v1/admin' +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..3cdbfc46d27 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 { @@ -199,7 +198,23 @@ export interface AdminWorkflowDetail extends AdminWorkflow { edgeCount: number } -export function toAdminWorkflow(dbWorkflow: DbWorkflow): AdminWorkflow { +export type AdminWorkflowSource = Pick< + DbWorkflow, + | 'id' + | 'name' + | 'description' + | 'color' + | 'workspaceId' + | 'folderId' + | 'isDeployed' + | 'deployedAt' + | 'runCount' + | 'lastRunAt' + | 'createdAt' + | 'updatedAt' +> + +export function toAdminWorkflow(dbWorkflow: AdminWorkflowSource): AdminWorkflow { return { id: dbWorkflow.id, name: dbWorkflow.name, @@ -444,7 +459,20 @@ export interface AdminOrganizationDetail extends AdminOrganization { subscription: AdminSubscription | null } -export function toAdminOrganization(dbOrg: DbOrganization): AdminOrganization { +export type AdminOrganizationSource = Pick< + DbOrganization, + | 'id' + | 'name' + | 'slug' + | 'logo' + | 'orgUsageLimit' + | 'storageUsedBytes' + | 'departedMemberUsage' + | 'createdAt' + | 'updatedAt' +> + +export function toAdminOrganization(dbOrg: AdminOrganizationSource): AdminOrganization { return { id: dbOrg.id, name: dbOrg.name, @@ -539,7 +567,7 @@ export interface AdminWorkspaceMember { // User Billing Types // ============================================================================= -export interface AdminUserBilling { +interface AdminUserBilling { userId: string // User info userName: string @@ -646,6 +674,7 @@ export interface AdminDeployResult { export interface AdminUndeployResult { isDeployed: boolean + warnings?: string[] } // ============================================================================= 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..7c080a4e6e5 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/v1/admin' +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..1bb29384e7d 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/v1/admin' +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..8ae454eb7a6 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/v1/admin' +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..0f088980489 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,10 @@ import { createLogger } from '@sim/logger' import { getActiveWorkflowRecord } from '@sim/workflow-authz' +import { + adminV1DeployWorkflowContract, + adminV1UndeployWorkflowContract, +} from '@/lib/api/contracts/v1/admin' +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' @@ -13,6 +18,7 @@ import { import type { AdminDeployResult, AdminUndeployResult } from '@/app/api/v1/admin/types' const logger = createLogger('AdminWorkflowDeployAPI') +export const maxDuration = 120 interface RouteParams { id: string @@ -29,7 +35,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 +81,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 { @@ -98,6 +110,7 @@ export const DELETE = withRouteHandler( const response: AdminUndeployResult = { isDeployed: false, + warnings: result.warnings, } return singleResponse(response) 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..fa1775723f1 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/v1/admin' +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..a72a86dfb0c 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,11 @@ 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/v1/admin' +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 +41,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 +81,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..686cbc71211 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/v1/admin' +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' @@ -12,6 +14,7 @@ import { } from '@/app/api/v1/admin/responses' const logger = createLogger('AdminWorkflowActivateVersionAPI') +export const maxDuration = 120 interface RouteParams { id: string @@ -21,7 +24,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 +36,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..fb5789356dd 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/v1/admin' +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..0072b3573c9 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/v1/admin' +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..5089e121157 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/v1/admin' +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' @@ -32,6 +34,7 @@ import { } from '@/app/api/v1/admin/responses' import { extractWorkflowMetadata, + type VariableType, type WorkflowImportRequest, type WorkflowVariable, } from '@/app/api/v1/admin/types' @@ -47,16 +50,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 @@ -122,14 +119,38 @@ export const POST = withRouteHandler( return internalErrorResponse(`Failed to save workflow state: ${saveResult.error}`) } - if (workflowData.variables && Array.isArray(workflowData.variables)) { + if ( + workflowData.variables && + typeof workflowData.variables === 'object' && + !Array.isArray(workflowData.variables) + ) { + const variablesRecord: Record = {} + const vars = workflowData.variables as Record< + string, + { id?: string; name: string; type?: VariableType; value: unknown } + > + Object.entries(vars).forEach(([key, v]) => { + const varId = v.id || key + variablesRecord[varId] = { + id: varId, + name: v.name, + type: v.type ?? 'string', + value: v.value, + } + }) + + await db + .update(workflow) + .set({ variables: variablesRecord, updatedAt: new Date() }) + .where(eq(workflow.id, workflowId)) + } else if (workflowData.variables && Array.isArray(workflowData.variables)) { const variablesRecord: Record = {} workflowData.variables.forEach((v) => { const varId = v.id || generateId() variablesRecord[varId] = { id: varId, name: v.name, - type: v.type || 'string', + type: (v.type as VariableType) ?? 'string', value: v.value, } }) diff --git a/apps/sim/app/api/v1/admin/workflows/route.ts b/apps/sim/app/api/v1/admin/workflows/route.ts index 9a13531d1f2..0a85dd78345 100644 --- a/apps/sim/app/api/v1/admin/workflows/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/route.ts @@ -14,27 +14,44 @@ 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/v1/admin' +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([ db.select({ total: count() }).from(workflow), - db.select().from(workflow).orderBy(workflow.name).limit(limit).offset(offset), + db + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + color: workflow.color, + workspaceId: workflow.workspaceId, + folderId: workflow.folderId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + }) + .from(workflow) + .orderBy(workflow.name) + .limit(limit) + .offset(offset), ]) const total = countResult[0].total 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..41b00f8377a 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,9 +16,12 @@ 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/v1/admin' +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' +import { encodeFilenameForHeader } from '@/app/api/files/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, @@ -40,9 +43,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 @@ -158,7 +163,7 @@ export const GET = withRouteHandler( status: 200, headers: { 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Disposition': `attachment; ${encodeFilenameForHeader(filename)}`, 'Content-Length': arrayBuffer.byteLength.toString(), }, }) 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..1b6269efeec 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/v1/admin' +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..82bdd359d5d 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 @@ -26,9 +26,15 @@ import { db } from '@sim/db' import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { + adminV1ImportWorkspaceContract, + adminV1WorkspaceImportBodySchema, +} from '@/lib/api/contracts/v1/admin' +import { parseJsonBody, parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { extractWorkflowName, @@ -66,10 +72,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 +89,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 = adminV1WorkspaceImportBodySchema.safeParse(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', @@ -298,7 +311,7 @@ async function importSingleWorkflow( workflowId: '', name: wf.name, success: false, - error: error instanceof Error ? error.message : 'Unknown error', + error: getErrorMessage(error, 'Unknown error'), } } } 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..72ed9bf961d 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/v1/admin' +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..f7ab35c47d2 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/v1/admin' +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..b2ebdd5a82f 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/v1/admin' +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..8a841ee6ba9 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/v1/admin' +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 @@ -61,7 +63,20 @@ export const GET = withRouteHandler( .from(workflow) .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))), db - .select() + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + color: workflow.color, + workspaceId: workflow.workspaceId, + folderId: workflow.folderId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + }) .from(workflow) .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))) .orderBy(workflow.name) @@ -87,7 +102,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..04e92caf857 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/v1/admin' +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..3c7208cc841 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.ts @@ -13,9 +13,12 @@ import { db } from '@sim/db' import { auditLog, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { v1GetAuditLogContract } from '@/lib/api/contracts/v1/audit-logs' +import { parseRequest } from '@/lib/api/server' import { 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 +30,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 +40,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) { @@ -74,7 +83,7 @@ export const GET = withRouteHandler( return NextResponse.json(response.body, { headers: response.headers }) } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error' + const message = getErrorMessage(error, 'Unknown error') logger.error(`[${requestId}] Audit log detail fetch error`, { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts index b382e284be2..b01f6af9736 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -15,7 +15,7 @@ import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' const logger = createLogger('V1AuditLogsAuth') -export interface EnterpriseAuditContext { +interface EnterpriseAuditContext { organizationId: string orgMemberIds: string[] } diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts index 2c31a0c18bb..0ac8b9ab43f 100644 --- a/apps/sim/app/api/v1/audit-logs/route.ts +++ b/apps/sim/app/api/v1/audit-logs/route.ts @@ -20,9 +20,11 @@ */ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { v1ListAuditLogsContract } from '@/lib/api/contracts/v1/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 +41,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 +59,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( @@ -122,7 +109,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(response.body, { headers: response.headers }) } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error' + const message = getErrorMessage(error, 'Unknown error') logger.error(`[${requestId}] Audit logs fetch error`, { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/v1/copilot/chat/route.ts b/apps/sim/app/api/v1/copilot/chat/route.ts index 0a5cf8adb7b..6eaa1424b77 100644 --- a/apps/sim/app/api/v1/copilot/chat/route.ts +++ b/apps/sim/app/api/v1/copilot/chat/route.ts @@ -2,29 +2,18 @@ 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/v1/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' -import { authenticateV1Request } from '@/app/api/v1/auth' +import { authenticateRequest } from '@/app/api/v1/middleware' 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. @@ -36,17 +25,40 @@ const RequestSchema = z.object({ */ export const POST = withRouteHandler(async (req: NextRequest) => { let messageId: string | undefined - const auth = await authenticateV1Request(req) - if (!auth.authenticated || !auth.userId) { - return NextResponse.json( - { success: false, error: auth.error || 'Unauthorized' }, - { status: 401 } - ) + const authorized = await authenticateRequest(req, 'copilot-chat') + if (authorized instanceof NextResponse) { + return authorized + } + const { userId, rateLimit } = authorized + const auth = { + authenticated: true as const, + userId, + keyType: rateLimit.keyType, + workspaceId: rateLimit.workspaceId, } 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 +138,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..5482a4d378f 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -1,19 +1,15 @@ -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/v1/files' +import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - deleteWorkspaceFile, - downloadWorkspaceFile, - getWorkspaceFile, -} from '@/lib/uploads/contexts/workspace' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' import { checkRateLimit, - checkWorkspaceScope, createRateLimitResponse, + validateWorkspaceAccess, } from '@/app/api/v1/middleware' const logger = createLogger('V1FileDetailAPI') @@ -21,16 +17,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,35 +32,21 @@ 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 parsed = await parseRequest(v1DownloadFileContract, request, context) + if (!parsed.success) return parsed.response - const { workspaceId } = validation.data + const { fileId } = parsed.data.params + 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 fileRecord = await getWorkspaceFile(workspaceId, fileId) if (!fileRecord) { return NextResponse.json({ error: 'File not found' }, { status: 404 }) } - const buffer = await downloadWorkspaceFile(fileRecord) + const buffer = await fetchWorkspaceFileBuffer(fileRecord) return new Response(new Uint8Array(buffer), { status: 200, @@ -91,72 +69,51 @@ 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 }) + } + + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId, + fileIds: [fileId], + }) + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 500 }) } + + logger.info( + `[${requestId}] Archived file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}` + ) + + 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..06d965ac028 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -1,8 +1,15 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { v1ListFilesContract, v1UploadFileFormFieldsSchema } from '@/lib/api/contracts/v1/files' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' +import { + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FileConflictError, @@ -10,11 +17,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') @@ -23,10 +30,7 @@ export const dynamic = 'force-dynamic' 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'), -}) +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 /** GET /api/v1/files — List all files in a workspace. */ export const GET = withRouteHandler(async (request: NextRequest) => { @@ -39,27 +43,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const userId = rateLimit.userId! - const { searchParams } = new URL(request.url) + const parsed = await parseRequest(v1ListFilesContract, request, {}) + if (!parsed.success) return parsed.response - const validation = ListFilesSchema.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 { 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 files = await listWorkspaceFiles(workspaceId) @@ -99,8 +89,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let formData: FormData try { - formData = await request.formData() - } catch { + formData = await readFormDataWithLimit(request, { + maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'workspace file upload body', + }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return NextResponse.json({ error: error.message }, { status: 413 }) + } return NextResponse.json( { error: 'Request body must be valid multipart form data' }, { status: 400 } @@ -109,11 +105,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 = v1UploadFileFormFieldsSchema.safeParse({ + 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 @@ -127,16 +129,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { error: `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)`, }, - { status: 400 } + { status: 413 } ) } - 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()) + const buffer = await readFileToBufferWithLimit(file, { + maxBytes: MAX_FILE_SIZE, + label: 'workspace upload file', + }) const userFile = await uploadWorkspaceFile( workspaceId, @@ -184,7 +187,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to upload file' + if (isPayloadSizeLimitError(error)) { + return NextResponse.json({ error: error.message }, { status: 413 }) + } + + const errorMessage = getErrorMessage(error, 'Failed to upload file') const isDuplicate = error instanceof FileConflictError || errorMessage.includes('already exists') 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..ae67288f2ab 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/v1/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..27e1035558b 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/v1/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..b47241f0199 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/v1/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..04e9d5f5800 100644 --- a/apps/sim/app/api/v1/knowledge/route.ts +++ b/apps/sim/app/api/v1/knowledge/route.ts @@ -1,41 +1,19 @@ 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/v1/knowledge' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' -import { - 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 +21,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 +50,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 @@ -92,8 +64,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { description, workspaceId, userId, - embeddingModel: 'text-embedding-3-small', - embeddingDimension: 1536, + embeddingModel: getConfiguredEmbeddingModel(), + embeddingDimension: EMBEDDING_DIMENSIONS, chunkingConfig: chunkingConfig ?? { maxSize: 1024, minSize: 100, overlap: 200 }, }, requestId diff --git a/apps/sim/app/api/v1/knowledge/search/route.test.ts b/apps/sim/app/api/v1/knowledge/search/route.test.ts new file mode 100644 index 00000000000..3c854e4e5b1 --- /dev/null +++ b/apps/sim/app/api/v1/knowledge/search/route.test.ts @@ -0,0 +1,188 @@ +/** + * Tests for v1 knowledge search API route. + * Specifically guards the per-KB embedding model resolution and the + * multi-model rejection so the v1 endpoint stays in lockstep with the + * internal route. + * + * @vitest-environment node + */ + +import { createMockRequest, knowledgeApiUtilsMock, knowledgeApiUtilsMockFns } from '@sim/testing' +import { getErrorMessage } from '@sim/utils/errors' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockHandleVectorOnlySearch, + mockHandleTagOnlySearch, + mockHandleTagAndVectorSearch, + mockGetQueryStrategy, + mockGenerateSearchEmbedding, + mockGetDocumentMetadataByIds, + mockAuthenticateRequest, + mockValidateWorkspaceAccess, +} = vi.hoisted(() => ({ + mockHandleVectorOnlySearch: vi.fn(), + mockHandleTagOnlySearch: vi.fn(), + mockHandleTagAndVectorSearch: vi.fn(), + mockGetQueryStrategy: vi.fn(), + mockGenerateSearchEmbedding: vi.fn(), + mockGetDocumentMetadataByIds: vi.fn(), + mockAuthenticateRequest: vi.fn(), + mockValidateWorkspaceAccess: vi.fn(), +})) + +vi.mock('@/app/api/knowledge/search/utils', () => ({ + handleVectorOnlySearch: mockHandleVectorOnlySearch, + handleTagOnlySearch: mockHandleTagOnlySearch, + handleTagAndVectorSearch: mockHandleTagAndVectorSearch, + getQueryStrategy: mockGetQueryStrategy, + generateSearchEmbedding: mockGenerateSearchEmbedding, + getDocumentMetadataByIds: mockGetDocumentMetadataByIds, +})) + +vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) + +vi.mock('@/app/api/v1/middleware', () => ({ + authenticateRequest: mockAuthenticateRequest, + validateWorkspaceAccess: mockValidateWorkspaceAccess, +})) + +vi.mock('@/app/api/v1/knowledge/utils', () => ({ + handleError: (e: unknown) => + new Response(JSON.stringify({ error: getErrorMessage(e, 'error') }), { + status: 500, + }), +})) + +vi.mock('@/lib/knowledge/tags/service', () => ({ + getDocumentTagDefinitions: vi.fn().mockResolvedValue([]), +})) + +import { POST } from '@/app/api/v1/knowledge/search/route' + +const mockCheckKnowledgeBaseAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess + +const baseKb = (id: string, embeddingModel: string) => ({ + id, + userId: 'user-1', + name: `KB ${id}`, + workspaceId: 'ws-1', + embeddingModel, + deletedAt: null, +}) + +describe('v1 knowledge search route — per-KB embedding model', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthenticateRequest.mockResolvedValue({ + requestId: 'req-1', + userId: 'user-1', + rateLimit: {}, + }) + mockValidateWorkspaceAccess.mockResolvedValue(null) + mockGetQueryStrategy.mockReturnValue({ distanceThreshold: 0.5 }) + mockGenerateSearchEmbedding.mockResolvedValue([0.1, 0.2, 0.3]) + mockHandleVectorOnlySearch.mockResolvedValue([]) + mockGetDocumentMetadataByIds.mockResolvedValue({}) + }) + + it('passes the KB embedding model into generateSearchEmbedding', async () => { + mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({ + hasAccess: true, + knowledgeBase: baseKb('kb-gemini', 'gemini-embedding-001'), + }) + + const req = createMockRequest('POST', { + workspaceId: 'ws-1', + knowledgeBaseIds: 'kb-gemini', + query: 'hello', + }) + const res = await POST(req) + + expect(res.status).toBe(200) + expect(mockGenerateSearchEmbedding).toHaveBeenCalledWith( + 'hello', + 'gemini-embedding-001', + 'ws-1' + ) + }) + + it('rejects cross-KB queries with mixed embedding models', async () => { + mockCheckKnowledgeBaseAccess + .mockResolvedValueOnce({ + hasAccess: true, + knowledgeBase: baseKb('kb-openai', 'text-embedding-3-small'), + }) + .mockResolvedValueOnce({ + hasAccess: true, + knowledgeBase: baseKb('kb-gemini', 'gemini-embedding-001'), + }) + + const req = createMockRequest('POST', { + workspaceId: 'ws-1', + knowledgeBaseIds: ['kb-openai', 'kb-gemini'], + query: 'hello', + }) + const res = await POST(req) + + expect(res.status).toBe(400) + expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled() + }) + + it('surfaces sourceUrl from document metadata in search results', async () => { + mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({ + hasAccess: true, + knowledgeBase: baseKb('kb-confluence', 'text-embedding-3-small'), + }) + mockHandleVectorOnlySearch.mockResolvedValue([ + { + documentId: 'doc-confluence', + knowledgeBaseId: 'kb-confluence', + content: 'page content', + chunkIndex: 0, + distance: 0.1, + }, + ]) + mockGetDocumentMetadataByIds.mockResolvedValue({ + 'doc-confluence': { + filename: 'Runbook.md', + sourceUrl: 'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345', + }, + }) + + const req = createMockRequest('POST', { + workspaceId: 'ws-1', + knowledgeBaseIds: 'kb-confluence', + query: 'runbook', + }) + const res = await POST(req) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.results[0].sourceUrl).toBe( + 'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345' + ) + expect(body.data.results[0].documentName).toBe('Runbook.md') + }) + + it('allows tag-only search across mixed embedding models', async () => { + mockHandleTagOnlySearch.mockResolvedValue([]) + mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({ + hasAccess: true, + knowledgeBase: baseKb('kb-mixed', 'text-embedding-3-small'), + }) + + const req = createMockRequest('POST', { + workspaceId: 'ws-1', + knowledgeBaseIds: 'kb-mixed', + tagFilters: [{ tagName: 'category', operator: 'eq', value: 'docs' }], + }) + const res = await POST(req) + + expect(res.status).toBe(400) + // tagName "category" is undefined in our empty getDocumentTagDefinitions mock, + // so the route returns 400 before reaching the search handlers — but crucially + // it never tries to generate an embedding. + expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v1/knowledge/search/route.ts b/apps/sim/app/api/v1/knowledge/search/route.ts index 4a622ff0bcf..32679d24b6b 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/v1/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' @@ -7,58 +8,20 @@ import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/ import type { StructuredFilter } from '@/lib/knowledge/types' import { generateSearchEmbedding, - getDocumentNamesByIds, + getDocumentMetadataByIds, getQueryStrategy, handleTagAndVectorSearch, handleTagOnlySearch, handleVectorOnlySearch, 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 { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/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,29 +29,28 @@ 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)) ) - const accessibleKbIds = knowledgeBaseIds.filter( - (_, idx) => - accessChecks[idx]?.hasAccess && - accessChecks[idx]?.knowledgeBase?.workspaceId === workspaceId - ) + const accessibleKbs = accessChecks + .filter( + (ac): ac is KnowledgeBaseAccessResult => + ac.hasAccess === true && ac.knowledgeBase.workspaceId === workspaceId + ) + .map((ac) => ac.knowledgeBase) + const accessibleKbIds = accessibleKbs.map((kb) => kb.id) if (accessibleKbIds.length === 0) { return NextResponse.json( @@ -173,6 +135,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const hasQuery = query && query.trim().length > 0 const hasFilters = structuredFilters.length > 0 + const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel))) + if (hasQuery && embeddingModels.length > 1) { + return NextResponse.json( + { + error: + 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.', + }, + { status: 400 } + ) + } + const queryEmbeddingModel = embeddingModels[0] + let results: SearchResult[] if (!hasQuery && hasFilters) { @@ -184,7 +158,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } else if (hasQuery && hasFilters) { const strategy = getQueryStrategy(accessibleKbIds.length, topK) const queryVector = JSON.stringify( - await generateSearchEmbedding(query!, undefined, workspaceId) + await generateSearchEmbedding(query!, queryEmbeddingModel, workspaceId) ) results = await handleTagAndVectorSearch({ knowledgeBaseIds: accessibleKbIds, @@ -196,7 +170,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } else if (hasQuery) { const strategy = getQueryStrategy(accessibleKbIds.length, topK) const queryVector = JSON.stringify( - await generateSearchEmbedding(query!, undefined, workspaceId) + await generateSearchEmbedding(query!, queryEmbeddingModel, workspaceId) ) results = await handleVectorOnlySearch({ knowledgeBaseIds: accessibleKbIds, @@ -231,7 +205,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) const documentIds = results.map((r) => r.documentId) - const documentNameMap = await getDocumentNamesByIds(documentIds) + const documentMetadataMap = await getDocumentMetadataByIds(documentIds) return NextResponse.json({ success: true, @@ -248,9 +222,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } }) + const docMeta = documentMetadataMap[result.documentId] return { documentId: result.documentId, - documentName: documentNameMap[result.documentId] || undefined, + documentName: docMeta?.filename || undefined, + sourceUrl: docMeta?.sourceUrl ?? null, content: result.content, chunkIndex: result.chunkIndex, metadata: tags, 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..c32acfd444c 100644 --- a/apps/sim/app/api/v1/logs/[id]/route.ts +++ b/apps/sim/app/api/v1/logs/[id]/route.ts @@ -1,19 +1,25 @@ import { db } from '@sim/db' -import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { v1GetLogContract } from '@/lib/api/contracts/v1/logs' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' -import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' +import { + checkRateLimit, + createRateLimitResponse, + validateWorkspaceAccess, +} from '@/app/api/v1/middleware' 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,12 +29,19 @@ 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({ id: workflowExecutionLogs.id, workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, executionId: workflowExecutionLogs.executionId, stateSnapshotId: workflowExecutionLogs.stateSnapshotId, level: workflowExecutionLogs.level, @@ -51,14 +64,6 @@ export const GET = withRouteHandler( }) .from(workflowExecutionLogs) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) .where(eq(workflowExecutionLogs.id, id)) .limit(1) @@ -67,6 +72,11 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Log not found' }, { status: 404 }) } + const accessError = await validateWorkspaceAccess(rateLimit, userId, log.workspaceId) + if (accessError) { + return NextResponse.json({ error: 'Log not found' }, { status: 404 }) + } + const workflowSummary = { id: log.workflowId, name: log.workflowName || 'Deleted Workflow', 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..e7503ecb071 100644 --- a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts +++ b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts @@ -1,16 +1,22 @@ import { db } from '@sim/db' -import { permissions, workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema' +import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { v1GetExecutionContract } from '@/lib/api/contracts/v1/logs' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' -import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' +import { + checkRateLimit, + createRateLimitResponse, + validateWorkspaceAccess, +} 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,23 +24,19 @@ 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}`) const rows = await db - .select({ - log: workflowExecutionLogs, - }) + .select() .from(workflowExecutionLogs) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) .where(eq(workflowExecutionLogs.executionId, executionId)) .limit(1) @@ -42,7 +44,12 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) } - const { log: workflowLog } = rows[0] + const workflowLog = rows[0] + + const accessError = await validateWorkspaceAccess(rateLimit, userId, workflowLog.workspaceId) + if (accessError) { + return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) + } const [snapshot] = await db .select() diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index d51448826de..0f8f7b31b82 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -4,39 +4,22 @@ 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/v1/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' -import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' +import { + checkRateLimit, + checkWorkspaceScope, + createRateLimitResponse, +} from '@/app/api/v1/middleware' 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 +47,27 @@ 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 = parsed.data.query - const params = validationResult.data + const scopeError = checkWorkspaceScope(rateLimit, params.workspaceId) + if (scopeError) return scopeError 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..92aa72eb344 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -3,11 +3,31 @@ 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' + | 'copilot-chat' + export interface RateLimitResult { allowed: boolean remaining: number @@ -20,24 +40,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 +105,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 +169,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..657b4487200 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/v1/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..bf9c38b406d 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/v1/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..4724b39b247 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/v1/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 @@ -148,6 +139,14 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R table, requestId ) + // No `cancellationGuard` is passed here, so `updateRow` can't return null + // from this caller. Defensive narrowing for TypeScript. + if (!updatedRow) { + return NextResponse.json({ error: 'Row not found' }, { status: 404 }) + } + // Auto-dispatch for user edits is handled inside `updateRow` (mode: 'new'). + // Firing a second mode: 'incomplete' dispatch here would race with it AND + // bulk-clear sibling-group outputs. return NextResponse.json({ success: true, @@ -169,12 +168,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 +193,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 +203,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 @@ -238,7 +230,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, { params }: eq(userTableRows.workspaceId, workspaceId) ) ) - .returning() + .returning({ id: userTableRows.id }) if (!deletedRow) { return NextResponse.json({ error: 'Row not found' }, { status: 404 }) 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 d2a8f837cec..e736a859eaa 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -4,23 +4,33 @@ 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/v1/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, validateRowData, validateRowSize, } from '@/lib/table' -import { buildFilterClause, buildSortClause } from '@/lib/table/sql' +import { buildFilterClause, buildSortClause, TableQueryValidationError } from '@/lib/table/sql' import { accessError, checkAccess } from '@/app/api/table/utils' import { checkRateLimit, @@ -33,84 +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), -}) - -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 }> } @@ -118,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') @@ -183,102 +115,94 @@ 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 }) - } + return validationErrorResponse(error) + }, + }) + if (!parsed.success) return parsed.response - const validated = QueryRowsSchema.parse({ - workspaceId: searchParams.get('workspaceId'), - filter, - sort, - limit: searchParams.get('limit'), - offset: searchParams.get('offset'), - }) + const { tableId } = parsed.data.params + const validated = parsed.data.query + const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return scopeError - 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), - ] + const schema = table.schema as TableSchema - 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, + schema.columns + ) + 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)) + 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 - } + if (validated.sort) { + 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, - query.limit(validated.limit).offset(validated.offset), - ]) - const totalCount = countResult[0].count - + const [countResult, rows] = await Promise.all([countQuery, rowsPromise]) + totalCount = Number(countResult[0].count) return NextResponse.json({ success: true, data: { @@ -292,28 +216,47 @@ export const GET = withRouteHandler( r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), })), rowCount: rows.length, - totalCount: Number(totalCount), + totalCount, limit: validated.limit, offset: validated.offset, }, }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) - } + } + + 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 }) + if (error instanceof TableQueryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }) } + + 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 { @@ -323,28 +266,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 @@ -392,12 +325,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 @@ -418,108 +347,98 @@ 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( + table, + { + filter: validated.filter as Filter, + data: validated.data as RowData, + limit: validated.limit, + }, + 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 }) - } + if (error instanceof TableQueryValidationError) { + return NextResponse.json({ error: error.message }, { 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 { @@ -529,16 +448,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 @@ -552,7 +465,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 @@ -574,11 +487,10 @@ export const DELETE = withRouteHandler( } const result = await deleteRowsByFilter( + table, { - tableId, filter: validated.filter as Filter, limit: validated.limit, - workspaceId: validated.workspaceId, }, requestId ) @@ -595,11 +507,11 @@ 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 TableQueryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }) } 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..c1129883547 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/v1/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..b90245a8df0 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/v1/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..7532772fb41 100644 --- a/apps/sim/app/api/v1/workflows/[id]/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/route.ts @@ -1,22 +1,28 @@ import { db } from '@sim/db' import { workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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/v1/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' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' -import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' +import { + checkRateLimit, + createRateLimitResponse, + validateWorkspaceAccess, +} from '@/app/api/v1/middleware' 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 +32,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 }) @@ -35,13 +47,13 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } - const permission = await getUserEntityPermissions( + const accessError = await validateWorkspaceAccess( + rateLimit, userId, - 'workspace', workflowData.workspaceId! ) - if (!permission) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + if (accessError) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } const blockRows = await db @@ -81,7 +93,7 @@ export const GET = withRouteHandler( return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error' + const message = getErrorMessage(error, 'Unknown error') logger.error(`[${requestId}] Workflow details fetch error`, { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/v1/workflows/route.ts b/apps/sim/app/api/v1/workflows/route.ts index 29be1a1b708..4b1bed44ef9 100644 --- a/apps/sim/app/api/v1/workflows/route.ts +++ b/apps/sim/app/api/v1/workflows/route.ts @@ -1,28 +1,25 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, asc, eq, gt, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { v1ListWorkflowsContract } from '@/lib/api/contracts/v1/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' -import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' +import { + checkRateLimit, + createRateLimitResponse, + validateWorkspaceAccess, +} from '@/app/api/v1/middleware' 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 +48,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, @@ -72,10 +75,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }, }) - const permission = await getUserEntityPermissions(userId, 'workspace', params.workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } + const accessError = await validateWorkspaceAccess(rateLimit, userId, params.workspaceId) + if (accessError) return accessError const conditions = [eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt)] @@ -172,7 +173,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(response.body, { headers: response.headers }) } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error' + const message = getErrorMessage(error, 'Unknown error') logger.error(`[${requestId}] Workflows fetch error`, { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts index 7b692368bd6..8f94d13d58d 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' @@ -12,6 +14,7 @@ import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-f import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { enrichTableSchema } from '@/lib/table/llm/wand' +import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { extractResponseText, parseResponsesUsage } from '@/providers/openai/utils' import { getModelPricing } from '@/providers/utils' @@ -43,16 +46,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) @@ -94,7 +87,8 @@ Use this context to calculate relative dates like "yesterday", "last week", "beg } async function updateUserStatsForWand( - userId: string, + billingUserId: string, + workspaceId: string | null, usage: { prompt_tokens?: number completion_tokens?: number @@ -136,7 +130,8 @@ async function updateUserStatsForWand( } await recordUsage({ - userId, + userId: billingUserId, + workspaceId: workspaceId ?? undefined, entries: [ { category: 'model', @@ -151,7 +146,7 @@ async function updateUserStatsForWand( }, }) - await checkAndBillOverageThreshold(userId) + await checkAndBillOverageThreshold(billingUserId) } catch (error) { logger.error(`[${requestId}] Failed to update user stats for wand usage`, error) } @@ -168,7 +163,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, @@ -229,6 +226,21 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } } + let billingUserId = session.user.id + if (workspaceId) { + const workspaceBilledAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId) + if (!workspaceBilledAccountUserId) { + logger.error(`[${requestId}] Unable to resolve billed account for workspace`, { + workspaceId, + }) + return NextResponse.json( + { success: false, error: 'Unable to resolve billing account for this workspace' }, + { status: 500 } + ) + } + billingUserId = workspaceBilledAccountUserId + } + let isBYOK = false let activeOpenAIKey = openaiApiKey @@ -345,7 +357,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } usageRecorded = true - await updateUserStatsForWand(session.user.id, finalUsage, requestId, isBYOK) + await updateUserStatsForWand( + billingUserId, + workspaceId, + finalUsage, + requestId, + isBYOK + ) } try { @@ -562,7 +580,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const usage = parseResponsesUsage(completion.usage) if (usage) { await updateUserStatsForWand( - session.user.id, + billingUserId, + workspaceId, { prompt_tokens: usage.promptTokens, completion_tokens: usage.completionTokens, diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 5c2a5cd51ad..ea79ee38f69 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -2,11 +2,20 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { webhook, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { + assertWorkflowMutable, + authorizeWorkflowByWorkspacePermission, + WorkflowLockedError, +} from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { + deleteWebhookContract, + getWebhookContract, + updateWebhookContract, +} from '@/lib/api/contracts/webhooks' +import { parseRequest } 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' @@ -19,11 +28,13 @@ export const dynamic = 'force-dynamic' // Get a specific webhook export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() try { - const { id } = await params + const parsed = await parseRequest(getWebhookContract, request, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { @@ -76,11 +87,13 @@ export const GET = withRouteHandler( ) export const PATCH = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() try { - const { id } = await params + const parsed = await parseRequest(updateWebhookContract, request, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { @@ -89,16 +102,7 @@ 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 { isActive, failedCount } = parsed.data.body const webhooks = await db .select({ @@ -131,20 +135,35 @@ export const PATCH = withRouteHandler( logger.warn(`[${requestId}] User ${userId} denied permission to modify webhook: ${id}`) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + await assertWorkflowMutable(webhookData.workflow.id) + const setClause: Partial = {} + if (isActive !== undefined && isActive !== webhooks[0].webhook.isActive) { + setClause.isActive = isActive + } + if (failedCount !== undefined && failedCount !== webhooks[0].webhook.failedCount) { + setClause.failedCount = failedCount + } + + if (Object.keys(setClause).length === 0) { + logger.info(`[${requestId}] No-op webhook PATCH (no field changes): ${id}`) + return NextResponse.json({ webhook: webhooks[0].webhook }, { status: 200 }) + } + + setClause.updatedAt = new Date() const updatedWebhook = await db .update(webhook) - .set({ - isActive: isActive !== undefined ? isActive : webhooks[0].webhook.isActive, - failedCount: failedCount !== undefined ? failedCount : webhooks[0].webhook.failedCount, - updatedAt: new Date(), - }) + .set(setClause) .where(eq(webhook.id, id)) .returning() logger.info(`[${requestId}] Successfully updated webhook: ${id}`) return NextResponse.json({ webhook: updatedWebhook[0] }, { status: 200 }) } catch (error) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error(`[${requestId}] Error updating webhook`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } @@ -153,11 +172,13 @@ export const PATCH = withRouteHandler( // Delete a webhook export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() try { - const { id } = await params + const parsed = await parseRequest(deleteWebhookContract, request, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { @@ -199,6 +220,7 @@ export const DELETE = withRouteHandler( logger.warn(`[${requestId}] User ${userId} denied permission to delete webhook: ${id}`) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + await assertWorkflowMutable(webhookData.workflow.id) const foundWebhook = webhookData.webhook const credentialSetId = foundWebhook.credentialSetId as string | undefined @@ -299,6 +321,10 @@ export const DELETE = withRouteHandler( return NextResponse.json({ success: true }, { status: 200 }) } catch (error: any) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error(`[${requestId}] Error deleting webhook`, { error: error.message, stack: error.stack, diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index a603078b00e..9c4333c4fec 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -8,11 +8,17 @@ import { workspace, } from '@sim/db' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' 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 { 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 = webhookSvixHeadersSchema.safeParse({ + '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,62 @@ 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 = agentMailEnvelopeSchema.safeParse(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( + { error: 'Invalid envelope payload', details: envelopeResult.error.issues }, + { status: 400 } + ) + } + + if (envelopeResult.data.event_type !== 'message.received') { + return NextResponse.json({ ok: true }) + } + + const messageResult = agentMailMessageSchema.safeParse(envelopeResult.data.message) + if (!messageResult.success) { + logger.warn('Invalid AgentMail message payload', { + workspaceId: result.id, + issues: messageResult.error.issues, + }) + return NextResponse.json( + { error: 'Invalid message payload', details: messageResult.error.issues }, + { status: 400 } + ) + } + + 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 }) } @@ -182,7 +215,7 @@ export const POST = withRouteHandler(async (req: Request) => { executeInboxTask(taskId).catch((err) => { logger.error('Local inbox task execution failed', { taskId, - error: err instanceof Error ? err.message : 'Unknown error', + error: getErrorMessage(err, 'Unknown error'), }) }) } @@ -191,7 +224,7 @@ export const POST = withRouteHandler(async (req: Request) => { executeInboxTask(taskId).catch((err) => { logger.error('Local inbox task execution failed', { taskId, - error: err instanceof Error ? err.message : 'Unknown error', + error: getErrorMessage(err, 'Unknown error'), }) }) } @@ -199,7 +232,7 @@ export const POST = withRouteHandler(async (req: Request) => { return NextResponse.json({ ok: true }) } catch (error) { logger.error('AgentMail webhook error', { - error: error instanceof Error ? error.message : 'Unknown error', + error: getErrorMessage(error, 'Unknown error'), }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts b/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts index 2d4312b54be..4f4098953ed 100644 --- a/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts +++ b/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { cleanupExpiredIdempotencyKeys, getIdempotencyKeyStats } from '@/lib/core/idempotency' @@ -56,7 +57,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { { success: false, message: 'Idempotency cleanup failed', - error: error instanceof Error ? error.message : 'Unknown error', + error: getErrorMessage(error, 'Unknown error'), requestId, }, { status: 500 } diff --git a/apps/sim/app/api/webhooks/outbox/process/route.ts b/apps/sim/app/api/webhooks/outbox/process/route.ts index 6a5f2b385ee..4ac098c3a2d 100644 --- a/apps/sim/app/api/webhooks/outbox/process/route.ts +++ b/apps/sim/app/api/webhooks/outbox/process/route.ts @@ -6,6 +6,7 @@ import { billingOutboxHandlers } from '@/lib/billing/webhooks/outbox-handlers' import { processOutboxEvents } from '@/lib/core/outbox/service' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox' const logger = createLogger('OutboxProcessorAPI') @@ -14,6 +15,7 @@ export const maxDuration = 120 const handlers = { ...billingOutboxHandlers, + ...workflowDeploymentOutboxHandlers, } as const export const GET = withRouteHandler(async (request: NextRequest) => { @@ -25,7 +27,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return authError } - const result = await processOutboxEvents(handlers, { batchSize: 20 }) + const result = await processOutboxEvents(handlers, { + batchSize: 20, + maxRuntimeMs: 110_000, + minRemainingMs: 95_000, + }) logger.info('Outbox processing completed', { requestId, ...result }) diff --git a/apps/sim/app/api/webhooks/poll/[provider]/route.ts b/apps/sim/app/api/webhooks/poll/[provider]/route.ts index 06e3837e49c..3c8415ea343 100644 --- a/apps/sim/app/api/webhooks/poll/[provider]/route.ts +++ b/apps/sim/app/api/webhooks/poll/[provider]/route.ts @@ -1,6 +1,9 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' +import { webhookPollingContract } from '@/lib/api/contracts/webhooks' +import { parseRequest } 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' @@ -15,14 +18,18 @@ export const dynamic = 'force-dynamic' export const maxDuration = 180 export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ provider: string }> }) => { - const { provider } = await params + async (request: NextRequest, context: { params: Promise<{ provider: string }> }) => { const requestId = generateShortId() + let provider: string | undefined try { - const authError = verifyCronAuth(request, `${provider} webhook polling`) + const authError = verifyCronAuth(request, 'webhook polling') if (authError) return authError + const parsed = await parseRequest(webhookPollingContract, request, context) + if (!parsed.success) return parsed.response + provider = parsed.data.params.provider + if (!VALID_POLLING_PROVIDERS.has(provider)) { return NextResponse.json( { error: `Unknown polling provider: ${provider}` }, @@ -63,12 +70,13 @@ export const GET = withRouteHandler( } } } catch (error) { - logger.error(`Error during ${provider} polling (${requestId}):`, error) + const providerLabel = provider ?? 'webhook' + logger.error(`Error during ${providerLabel} polling (${requestId}):`, error) return NextResponse.json( { success: false, - message: `${provider} polling failed`, - error: error instanceof Error ? error.message : 'Unknown error', + message: `${providerLabel} polling failed`, + error: getErrorMessage(error, 'Unknown error'), requestId, }, { status: 500 } diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index e1121b1caf0..4740909c2f5 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -2,10 +2,17 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { permissions, webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId, generateShortId } from '@sim/utils/id' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { + assertWorkflowMutable, + authorizeWorkflowByWorkspacePermission, + WorkflowLockedError, +} from '@sim/workflow-authz' import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { listWebhooksContract, upsertWebhookContract } from '@/lib/api/contracts/webhooks' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' @@ -67,9 +74,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const workflowId = searchParams.get('workflowId') - const blockId = searchParams.get('blockId') + const parsed = await parseRequest(listWebhooksContract, request, {}) + if (!parsed.success) return parsed.response + const { workflowId, blockId } = parsed.data.query if (workflowId && blockId) { // Collaborative-aware path: allow collaborators with read access to view webhooks @@ -183,8 +190,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const body = await request.json() - const { workflowId, path, provider, providerConfig, blockId } = body + const parsed = await parseRequest(upsertWebhookContract, request, {}) + if (!parsed.success) return parsed.response + + const body = parsed.data.body + const { workflowId, path, providerConfig, blockId } = body + const provider = body.provider || '' // Validate input if (!workflowId) { @@ -287,6 +298,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + await assertWorkflowMutable(workflowId) // Determine existing webhook to update (prefer by workflow+block for credential-based providers) let targetWebhookId: string | null = null @@ -376,7 +388,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const syncResult = await syncWebhooksForCredentialSet({ workflowId, - blockId, + blockId: blockId || '', provider, basePath: finalPath, credentialSetId, @@ -476,7 +488,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { error: `Failed to configure ${provider} webhook`, - details: err instanceof Error ? err.message : 'Unknown error', + details: getErrorMessage(err, 'Unknown error'), }, { status: 500 } ) @@ -531,7 +543,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { error: 'Failed to create external webhook subscription', - details: err instanceof Error ? err.message : 'Unknown error', + details: getErrorMessage(err, 'Unknown error'), }, { status: 500 } ) @@ -658,7 +670,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { error: `Failed to configure ${provider} webhook`, - details: err instanceof Error ? err.message : 'Unknown error', + details: getErrorMessage(err, 'Unknown error'), }, { status: 500 } ) @@ -714,6 +726,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const status = targetWebhookId ? 200 : 201 return NextResponse.json({ webhook: savedWebhook }, { status }) } catch (error: any) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error(`[${requestId}] Error creating/updating webhook`, { message: error.message, stack: error.stack, diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 6a6b509155e..73e71b1f3dc 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 { webhookTriggerGetContract, webhookTriggerPostContract } from '@/lib/api/contracts/webhooks' +import { parseRequest } 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' @@ -24,9 +26,11 @@ export const runtime = 'nodejs' export const maxDuration = 60 export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ path: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ path: string }> }) => { const requestId = generateRequestId() - const { path } = await params + const parsed = await parseRequest(webhookTriggerGetContract, request, context) + if (!parsed.success) return parsed.response + const { path } = parsed.data.params // Handle provider-specific GET verifications (Microsoft Graph, WhatsApp, etc.) const challengeResponse = await handleProviderChallenges({}, request, requestId, path) @@ -42,14 +46,14 @@ export const GET = withRouteHandler( ) export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ path: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ path: string }> }) => { const ticket = tryAdmit() if (!ticket) { return admissionRejectedResponse() } try { - return await handleWebhookPost(request, params) + return await handleWebhookPost(request, context) } finally { ticket.release() } @@ -58,10 +62,12 @@ export const POST = withRouteHandler( async function handleWebhookPost( request: NextRequest, - params: Promise<{ path: string }> + context: { params: Promise<{ path: string }> } ): Promise { const requestId = generateRequestId() - const { path } = await params + const parsed = await parseRequest(webhookTriggerPostContract, request, context) + if (!parsed.success) return parsed.response + const { path } = parsed.data.params 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..18d1864daef 100644 --- a/apps/sim/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/sim/app/api/workflows/[id]/autolayout/route.ts @@ -1,7 +1,13 @@ import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { getErrorMessage } from '@sim/utils/errors' +import { + assertWorkflowMutable, + authorizeWorkflowByWorkspacePermission, + WorkflowLockedError, +} from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { workflowAutoLayoutContract } from '@/lib/api/contracts/workflows' +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' @@ -20,38 +26,15 @@ 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 */ export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() const startTime = Date.now() - const { id: workflowId } = await params + const { id: workflowId } = await context.params try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -62,8 +45,9 @@ export const POST = withRouteHandler( const userId = auth.userId - const body = await request.json() - const layoutOptions = AutoLayoutRequestSchema.parse(body) + const parsed = await parseRequest(workflowAutoLayoutContract, request, context) + if (!parsed.success) return parsed.response + const layoutOptions = parsed.data.body logger.info(`[${requestId}] Processing autolayout request for workflow ${workflowId}`, { userId, @@ -93,6 +77,8 @@ export const POST = withRouteHandler( ) } + await assertWorkflowMutable(workflowId) + let currentWorkflowData: NormalizedWorkflowData | null if (layoutOptions.blocks && layoutOptions.edges) { @@ -162,21 +148,17 @@ 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 } - ) + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) } + const elapsed = Date.now() - startTime + logger.error(`[${requestId}] Autolayout failed after ${elapsed}ms:`, error) return NextResponse.json( { error: 'Autolayout failed', - details: error instanceof Error ? error.message : 'Unknown error', + details: getErrorMessage(error, 'Unknown error'), }, { status: 500 } ) 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..c8202be91dc 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 { getChatDeploymentStatusContract } from '@/lib/api/contracts/deployments' +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,8 +17,10 @@ const logger = createLogger('ChatStatusAPI') * GET endpoint to check if a workflow has an active chat deployment */ export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id } = await params + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const parsed = await parseRequest(getChatDeploymentStatusContract, request, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params 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..23d574efc41 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -1,7 +1,11 @@ import { db, workflow } from '@sim/db' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { updatePublicApiContract } from '@/lib/api/contracts/deployments' +import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' @@ -21,6 +25,7 @@ const logger = createLogger('WorkflowDeployAPI') export const dynamic = 'force-dynamic' export const runtime = 'nodejs' +export const maxDuration = 120 export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { @@ -90,6 +95,7 @@ export const POST = withRouteHandler( logger.warn(`[${requestId}] Unable to resolve actor user for workflow deployment: ${id}`) return createErrorResponse('Unable to determine deploying user', 400) } + await assertWorkflowMutable(id) const result = await performFullDeploy({ workflowId: id, @@ -128,7 +134,10 @@ export const POST = withRouteHandler( warnings: result.warnings, }) } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Failed to deploy workflow' + if (error instanceof WorkflowLockedError) { + return createErrorResponse(error.message, error.status) + } + const message = getErrorMessage(error, 'Failed to deploy workflow') logger.error(`[${requestId}] Error deploying workflow: ${id}`, { error }) return createErrorResponse(message, 500) } @@ -136,11 +145,19 @@ export const POST = withRouteHandler( ) export const PATCH = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params try { + const parsed = await parseRequest(updatePublicApiContract, request, context, { + validationErrorResponse: () => + createErrorResponse('Invalid request body: isPublicApi must be a boolean', 400), + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { isPublicApi } = parsed.data.body + const { error, session, @@ -149,13 +166,7 @@ export const PATCH = withRouteHandler( if (error) { return createErrorResponse(error.message, error.status) } - - const body = await request.json() - const { isPublicApi } = body - - if (typeof isPublicApi !== 'boolean') { - return createErrorResponse('Invalid request body: isPublicApi must be a boolean', 400) - } + await assertWorkflowMutable(id) if (isPublicApi) { try { @@ -182,9 +193,11 @@ export const PATCH = withRouteHandler( return createSuccessResponse({ isPublicApi }) } catch (error: unknown) { - const message = - error instanceof Error ? error.message : 'Failed to update deployment settings' - logger.error(`[${requestId}] Error updating deployment settings: ${id}`, { error }) + if (error instanceof WorkflowLockedError) { + return createErrorResponse(error.message, error.status) + } + const message = getErrorMessage(error, 'Failed to update deployment settings') + logger.error(`[${requestId}] Error updating deployment settings`, { error }) return createErrorResponse(message, 500) } } @@ -204,6 +217,7 @@ export const DELETE = withRouteHandler( if (error) { return createErrorResponse(error.message, error.status) } + await assertWorkflowMutable(id) const result = await performFullUndeploy({ workflowId: id, @@ -227,9 +241,13 @@ export const DELETE = withRouteHandler( isDeployed: false, deployedAt: null, apiKey: null, + warnings: result.warnings, }) } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Failed to undeploy workflow' + if (error instanceof WorkflowLockedError) { + return createErrorResponse(error.message, error.status) + } + const message = getErrorMessage(error, 'Failed to undeploy workflow') logger.error(`[${requestId}] Error undeploying workflow: ${id}`, { error }) return createErrorResponse(message, 500) } diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.ts b/apps/sim/app/api/workflows/[id]/deployed/route.ts index 48b33f5e816..d68f2b6d6ed 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 { getDeployedWorkflowStateContract } from '@/lib/api/contracts/deployments' +import { parseRequest } from '@/lib/api/server' import { verifyInternalToken } from '@/lib/auth/internal' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,9 +20,11 @@ function addNoCacheHeaders(response: NextResponse): NextResponse { } export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const parsed = await parseRequest(getDeployedWorkflowStateContract, request, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params try { const authHeader = request.headers.get('authorization') 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..e7a95618bcd 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,7 @@ import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import type { NextRequest } from 'next/server' +import { workflowDeploymentVersionParamSchema } from '@/lib/api/contracts/workflows' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performRevertToVersion } from '@/lib/workflows/orchestration' @@ -28,15 +30,16 @@ export const POST = withRouteHandler( if (error) { return createErrorResponse(error.message, error.status) } + await assertWorkflowMutable(id) - const versionSelector = version === 'active' ? null : Number(version) - if (version !== 'active' && !Number.isFinite(versionSelector)) { + const versionValidation = workflowDeploymentVersionParamSchema.safeParse(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, @@ -56,6 +59,10 @@ export const POST = withRouteHandler( lastSaved: result.lastSaved, }) } catch (error: any) { + if (error instanceof WorkflowLockedError) { + return createErrorResponse(error.message, error.status) + } + logger.error('Error reverting to deployment version', error) return createErrorResponse(error.message || 'Failed to revert', 500) } 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..f8a4113021a 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,8 @@ 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 { updateDeploymentVersionMetadataContract } from '@/lib/api/contracts/deployments' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' @@ -12,31 +13,9 @@ 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' +export const maxDuration = 120 export const GET = withRouteHandler( async ( @@ -84,25 +63,18 @@ export const GET = withRouteHandler( ) export const PATCH = withRouteHandler( - async ( - request: NextRequest, - { params }: { params: Promise<{ id: string; version: string }> } - ) => { + async (request: NextRequest, context: { params: Promise<{ id: string; version: string }> }) => { const requestId = generateRequestId() - const { id, version } = await params try { - const body = await request.json() - const validation = patchBodySchema.safeParse(body) - - if (!validation.success) { - return createErrorResponse( - validation.error.errors[0]?.message || 'Invalid request body', - 400 - ) - } + const parsed = await parseRequest(updateDeploymentVersionMetadataContract, request, context, { + validationErrorResponse: (error) => + createErrorResponse(getValidationErrorMessage(error, 'Invalid request body'), 400), + }) + if (!parsed.success) return parsed.response - const { name, description, isActive } = validation.data + const { id, version } = parsed.data.params + const { name, description, isActive } = parsed.data.body // Activation requires admin permission, other updates require write const requiredPermission = isActive ? 'admin' : 'write' @@ -115,10 +87,7 @@ export const PATCH = withRouteHandler( return createErrorResponse(error.message, error.status) } - const versionNum = Number(version) - if (!Number.isFinite(versionNum)) { - return createErrorResponse('Invalid version', 400) - } + const versionNum = version // Handle activation if (isActive) { @@ -239,10 +208,7 @@ export const PATCH = withRouteHandler( return createSuccessResponse({ name: updated.name, description: updated.description }) } catch (error: any) { - logger.error( - `[${requestId}] Error updating deployment version ${version} for workflow ${id}`, - error - ) + logger.error(`[${requestId}] Error updating deployment version`, error) return createErrorResponse(error.message || 'Failed to update deployment version', 500) } } diff --git a/apps/sim/app/api/workflows/[id]/deployments/route.ts b/apps/sim/app/api/workflows/[id]/deployments/route.ts index 1bc72ae66b1..905908daecb 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 { listDeploymentVersionsContract } from '@/lib/api/contracts/deployments' +import { parseRequest } 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' @@ -13,9 +15,11 @@ export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const parsed = await parseRequest(listDeploymentVersionsContract, request, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params try { const { error } = await validateWorkflowPermissions(id, requestId, 'read') diff --git a/apps/sim/app/api/workflows/[id]/duplicate/route.ts b/apps/sim/app/api/workflows/[id]/duplicate/route.ts index a1fdc2de0d4..044dcb86970 100644 --- a/apps/sim/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workflows/[id]/duplicate/route.ts @@ -1,7 +1,9 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { FolderLockedError } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { duplicateWorkflowContract } from '@/lib/api/contracts/workflows' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' @@ -11,19 +13,10 @@ 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 }> }) => { - const { id: sourceWorkflowId } = await params + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const { id: sourceWorkflowId } = await context.params const requestId = generateRequestId() const startTime = Date.now() @@ -37,9 +30,9 @@ export const POST = withRouteHandler( const userId = auth.userId try { - const body = await req.json() - const { name, description, color, workspaceId, folderId, newId } = - DuplicateRequestSchema.parse(body) + const parsed = await parseRequest(duplicateWorkflowContract, req, context) + if (!parsed.success) return parsed.response + const { name, description, color, workspaceId, folderId, newId } = parsed.data.body logger.info(`[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${userId}`) @@ -102,6 +95,10 @@ export const POST = withRouteHandler( return NextResponse.json(result, { status: 201 }) } catch (error) { if (error instanceof Error) { + if (error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + if (error.message === 'Source workflow not found') { logger.warn(`[${requestId}] Source workflow ${sourceWorkflowId} not found`) return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 }) @@ -113,14 +110,21 @@ export const POST = withRouteHandler( ) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - } - 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 } - ) + if (error.message === 'Cross-workspace workflow duplication is not supported') { + logger.warn( + `[${requestId}] User ${userId} attempted cross-workspace workflow duplication for ${sourceWorkflowId}` + ) + return NextResponse.json({ error: error.message }, { status: 400 }) + } + + if (error.message === 'Folder is locked') { + return NextResponse.json({ error: error.message }, { status: 423 }) + } + + if (error.message === 'Target folder not found') { + return NextResponse.json({ error: error.message }, { status: 400 }) + } } const elapsed = Date.now() - startTime diff --git a/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts b/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts index 16808cca6f9..c23bba7d666 100644 --- a/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts @@ -5,11 +5,49 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { AuthType } from '@/lib/auth/hybrid' +import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache' +import { createLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest' +import { compactExecutionPayload } from '@/lib/execution/payloads/serializer' +import { storeLargeValue } from '@/lib/execution/payloads/store' +import { EXECUTION_RESOURCE_LIMIT_CODE } from '@/lib/execution/resource-errors' import type { ExecutionResult } from '@/lib/workflows/types' import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils' +const { + mockAddLargeValueReference, + mockDownloadFile, + mockRegisterLargeValueOwner, + mockUploadFile, + uploadedFiles, +} = vi.hoisted(() => ({ + mockAddLargeValueReference: vi.fn(), + mockDownloadFile: vi.fn(), + mockRegisterLargeValueOwner: vi.fn(), + mockUploadFile: vi.fn(), + uploadedFiles: new Map(), +})) + +const MATERIALIZATION_CONTEXT = { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + userId: 'user-1', +} + +vi.mock('@/lib/uploads', () => ({ + StorageService: { + downloadFile: mockDownloadFile, + uploadFile: mockUploadFile, + }, +})) + +vi.mock('@/lib/execution/payloads/large-value-metadata', () => ({ + addLargeValueReference: mockAddLargeValueReference, + registerLargeValueOwner: mockRegisterLargeValueOwner, +})) + function buildExecutionResult(overrides: Partial = {}): ExecutionResult { return { success: true, @@ -38,6 +76,18 @@ describe('Response block gating by auth type', () => { let resultWithResponseBlock: ExecutionResult beforeEach(() => { + vi.clearAllMocks() + clearLargeValueCacheForTests() + uploadedFiles.clear() + mockAddLargeValueReference.mockResolvedValue(undefined) + mockRegisterLargeValueOwner.mockResolvedValue(true) + mockUploadFile.mockImplementation(async ({ customKey, file }) => { + uploadedFiles.set(customKey, file) + return { key: customKey } + }) + mockDownloadFile.mockImplementation( + async ({ key }) => uploadedFiles.get(key) ?? Buffer.from('{}') + ) resultWithResponseBlock = buildExecutionResult() }) @@ -75,14 +125,14 @@ describe('Response block gating by auth type', () => { expect(shouldFormatAsResponseBlock).toBe(false) }) - it('should apply Response block formatting for API key callers', () => { + it('should apply Response block formatting for API key callers', async () => { const authType = AuthType.API_KEY const hasResponseBlock = workflowHasResponseBlock(resultWithResponseBlock) const shouldFormatAsResponseBlock = authType !== AuthType.INTERNAL_JWT && hasResponseBlock expect(shouldFormatAsResponseBlock).toBe(true) - const response = createHttpResponseFromBlock(resultWithResponseBlock) + const response = await createHttpResponseFromBlock(resultWithResponseBlock) expect(response.status).toBe(200) }) @@ -95,7 +145,7 @@ describe('Response block gating by auth type', () => { }) it('should return raw user data via createHttpResponseFromBlock', async () => { - const response = createHttpResponseFromBlock(resultWithResponseBlock) + const response = await createHttpResponseFromBlock(resultWithResponseBlock) const body = await response.json() // Response block returns the user-defined data directly (no success/executionId wrapper) @@ -104,12 +154,293 @@ describe('Response block gating by auth type', () => { expect(body.executionId).toBeUndefined() }) - it('should respect custom status codes from Response block', () => { + it('should respect custom status codes from Response block', async () => { const result = buildExecutionResult({ output: { data: { error: 'Not found' }, status: 404, headers: {} }, }) - const response = createHttpResponseFromBlock(result) + const response = await createHttpResponseFromBlock(result) expect(response.status).toBe(404) }) + + it('should materialize manifest data for Response block HTTP output', async () => { + const rows = Array.from({ length: 100 }, (_, index) => ({ + key: `SIM-${index}`, + payload: 'x'.repeat(100), + })) + const output = await compactExecutionPayload( + { + data: { rows }, + status: 200, + headers: {}, + }, + { + ...MATERIALIZATION_CONTEXT, + requireDurable: true, + preserveRoot: true, + thresholdBytes: 1024, + } + ) + const response = await createHttpResponseFromBlock( + buildExecutionResult({ output }), + MATERIALIZATION_CONTEXT + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.rows).toEqual(rows) + expect(body.success).toBeUndefined() + }) + + it('should materialize Response block manifests from an allowed source execution', async () => { + const rows = [{ key: 'SIM-1' }, { key: 'SIM-2' }] + const manifest = await createLargeArrayManifest(rows, { + ...MATERIALIZATION_CONTEXT, + executionId: 'source-execution-1', + }) + + const response = await createHttpResponseFromBlock( + buildExecutionResult({ + output: { + data: { rows: manifest }, + status: 200, + headers: {}, + }, + }), + { + ...MATERIALIZATION_CONTEXT, + largeValueExecutionIds: ['source-execution-1'], + } + ) + const body = await response.json() + + expect(body.rows).toEqual(rows) + }) + + it('should reject Response block manifests from non-source same-workflow executions', async () => { + const manifest = await createLargeArrayManifest([{ key: 'SIM-stale' }], { + ...MATERIALIZATION_CONTEXT, + executionId: 'stale-execution-1', + }) + + await expect( + createHttpResponseFromBlock( + buildExecutionResult({ + output: { + data: { rows: manifest }, + status: 200, + headers: {}, + }, + }), + { + ...MATERIALIZATION_CONTEXT, + largeValueExecutionIds: ['source-execution-1'], + } + ) + ).rejects.toThrow('Large execution value is not available in this execution') + }) + + it('should materialize Response block manifests inherited by the source snapshot', async () => { + const rows = [{ key: 'SIM-inherited' }] + const manifest = await createLargeArrayManifest(rows, { + ...MATERIALIZATION_CONTEXT, + executionId: 'original-execution-1', + }) + + const response = await createHttpResponseFromBlock( + buildExecutionResult({ + output: { + data: { rows: manifest }, + status: 200, + headers: {}, + }, + }), + { + ...MATERIALIZATION_CONTEXT, + largeValueExecutionIds: ['source-execution-1', 'original-execution-1'], + } + ) + + const body = await response.json() + + expect(body.rows).toEqual(rows) + }) + + it('should recursively materialize refs inside Response block manifest rows', async () => { + const text = 'nested'.repeat(2 * 1024 * 1024) + const nestedOutput = await compactExecutionPayload( + { text }, + { + ...MATERIALIZATION_CONTEXT, + executionId: 'original-execution-1', + requireDurable: true, + preserveRoot: true, + } + ) + const nestedRef = (nestedOutput as unknown as { text: unknown }).text + const manifest = await createLargeArrayManifest([{ nested: nestedRef }], { + ...MATERIALIZATION_CONTEXT, + executionId: 'source-execution-1', + }) + const response = await createHttpResponseFromBlock( + buildExecutionResult({ + output: { + data: { rows: manifest }, + status: 200, + headers: {}, + }, + }), + { + ...MATERIALIZATION_CONTEXT, + largeValueExecutionIds: ['source-execution-1'], + } + ) + + const body = await response.json() + + expect(body.rows).toEqual([{ nested: text }]) + }) + + it('should recursively materialize refs inside stored Response block objects', async () => { + const text = 'nested'.repeat(2 * 1024 * 1024) + const nestedOutput = await compactExecutionPayload( + { text }, + { + ...MATERIALIZATION_CONTEXT, + executionId: 'original-execution-1', + requireDurable: true, + preserveRoot: true, + } + ) + const nestedRef = (nestedOutput as unknown as { text: unknown }).text + const storedValue = { + wrapper: { + nested: nestedRef, + padding: 'x'.repeat(2048), + }, + } + const storedJson = JSON.stringify(storedValue) + const storedOutput = await storeLargeValue( + storedValue, + storedJson, + Buffer.byteLength(storedJson), + { + ...MATERIALIZATION_CONTEXT, + executionId: 'source-execution-1', + requireDurable: true, + } + ) + + const response = await createHttpResponseFromBlock( + buildExecutionResult({ + output: { + data: storedOutput, + status: 200, + headers: {}, + }, + }), + { + ...MATERIALIZATION_CONTEXT, + largeValueExecutionIds: ['source-execution-1'], + } + ) + + const body = await response.json() + + expect(body.wrapper.nested).toEqual(text) + }) + + it('should memoize repeated materialized objects while resolving nested refs', async () => { + const text = 'nested'.repeat(2 * 1024 * 1024) + const nestedOutput = await compactExecutionPayload( + { text }, + { + ...MATERIALIZATION_CONTEXT, + executionId: 'original-execution-1', + requireDurable: true, + preserveRoot: true, + } + ) + const nestedRef = (nestedOutput as unknown as { text: unknown }).text + const sourceValue = { nested: nestedRef } + const sourceJson = JSON.stringify(sourceValue) + const sourceRef = await storeLargeValue( + sourceValue, + sourceJson, + Buffer.byteLength(sourceJson), + { + ...MATERIALIZATION_CONTEXT, + executionId: 'source-execution-1', + requireDurable: true, + } + ) + + const response = await createHttpResponseFromBlock( + buildExecutionResult({ + output: { + data: { first: sourceRef, second: sourceRef }, + status: 200, + headers: {}, + }, + }), + { + ...MATERIALIZATION_CONTEXT, + largeValueKeys: sourceRef.key ? [sourceRef.key] : [], + } + ) + + const body = await response.json() + + expect(body).toEqual({ + first: { nested: text }, + second: { nested: text }, + }) + }) + + it('should materialize large string refs for Response block HTTP output', async () => { + const text = 'x'.repeat(9 * 1024 * 1024) + const output = await compactExecutionPayload( + { + data: { text }, + status: 200, + headers: {}, + }, + { + ...MATERIALIZATION_CONTEXT, + requireDurable: true, + preserveRoot: true, + } + ) + const response = await createHttpResponseFromBlock( + buildExecutionResult({ output }), + MATERIALIZATION_CONTEXT + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.text).toBe(text) + }) + + it('should reject Response block HTTP output that is too large to inline', async () => { + const output = await compactExecutionPayload( + { + data: { + text: 'x'.repeat(17 * 1024 * 1024), + }, + status: 200, + headers: {}, + }, + { + ...MATERIALIZATION_CONTEXT, + requireDurable: true, + preserveRoot: true, + } + ) + + await expect( + createHttpResponseFromBlock(buildExecutionResult({ output }), MATERIALIZATION_CONTEXT) + ).rejects.toMatchObject({ + code: EXECUTION_RESOURCE_LIMIT_CODE, + }) + }) }) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index b6e1aeab7b7..dc35f7b6a93 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -1,12 +1,12 @@ import { db } from '@sim/db' import { workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' +import { getErrorMessage, toError } from '@sim/utils/errors' 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 { AuthType, checkHybridAuth, hasExternalApiCredentials } from '@/lib/auth/hybrid' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' @@ -25,12 +25,18 @@ import { SIM_VIA_HEADER, validateCallChain, } from '@/lib/execution/call-chain' -import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer' +import { + createExecutionEventWriter, + flushExecutionStreamReplayBuffer, + initializeExecutionStreamMeta, + type TerminalExecutionStreamStatus, +} from '@/lib/execution/event-buffer' import { processInputFileFields } from '@/lib/execution/files' import { registerManualExecutionAborter, unregisterManualExecutionAborter, } from '@/lib/execution/manual-cancellation' +import { compactBlockLogs, compactExecutionPayload } from '@/lib/execution/payloads/serializer' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { @@ -60,59 +66,30 @@ import type { IterationContext, SerializableExecutionState, } from '@/executor/execution/types' -import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' -import { hasExecutionResult } from '@/executor/utils/errors' +import type { BlockLog, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' +import { getExecutionErrorStatus, hasExecutionResult } from '@/executor/utils/errors' import { Serializer } from '@/serializer' import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types' 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' +async function compactRoutePayload( + value: T, + context: { + workspaceId?: string + workflowId?: string + executionId?: string + userId?: string + preserveUserFileBase64?: boolean + preserveRoot?: boolean + } +): Promise { + return compactExecutionPayload(value, { ...context, requireDurable: true }) +} + function resolveOutputIds( selectedOutputs: string[] | undefined, blocks: Record @@ -254,10 +231,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise ({ + details: validation.error.issues.map((e) => ({ path: e.path.join('.'), message: e.message, })), @@ -384,10 +359,13 @@ async function handleExecutePost( includeFileBase64, base64MaxBytes, workflowStateOverride, - triggerBlockId, + executionId: requestedExecutionId, + triggerBlockId: parsedTriggerBlockId, + startBlockId, stopAfterBlockId, runFromBlock: rawRunFromBlock, } = validation.data + const triggerBlockId = parsedTriggerBlockId ?? startBlockId if (isPublicApiAccess && isClientSession) { return NextResponse.json( @@ -421,7 +399,11 @@ async function handleExecutePost( // Resolve runFromBlock snapshot from executionId if needed let resolvedRunFromBlock: - | { startBlockId: string; sourceSnapshot: SerializableExecutionState } + | { + startBlockId: string + sourceSnapshot: SerializableExecutionState + sourceExecutionId?: string + } | undefined if (rawRunFromBlock) { if (rawRunFromBlock.sourceSnapshot && auth.authType === 'api_key') { @@ -446,13 +428,16 @@ async function handleExecutePost( sourceSnapshot: rawRunFromBlock.sourceSnapshot as SerializableExecutionState, } } else if (rawRunFromBlock.executionId) { - const { getExecutionStateForWorkflow, getLatestExecutionState } = await import( - '@/lib/workflows/executor/execution-state' - ) - const snapshot = + const { getExecutionStateForWorkflow, getLatestExecutionStateWithExecutionId } = + await import('@/lib/workflows/executor/execution-state') + const sourceExecution = rawRunFromBlock.executionId === 'latest' - ? await getLatestExecutionState(workflowId) - : await getExecutionStateForWorkflow(rawRunFromBlock.executionId, workflowId) + ? await getLatestExecutionStateWithExecutionId(workflowId) + : { + executionId: rawRunFromBlock.executionId, + state: await getExecutionStateForWorkflow(rawRunFromBlock.executionId, workflowId), + } + const snapshot = sourceExecution?.state if (!snapshot) { return NextResponse.json( { @@ -464,6 +449,7 @@ async function handleExecutePost( resolvedRunFromBlock = { startBlockId: rawRunFromBlock.startBlockId, sourceSnapshot: snapshot, + sourceExecutionId: sourceExecution.executionId, } } else { return NextResponse.json( @@ -531,7 +517,8 @@ async function handleExecutePost( ) } - const executionId = generateId() + const executionId = + isClientSession && requestedExecutionId ? requestedExecutionId : generateId() reqLogger = reqLogger.withMetadata({ userId, executionId }) reqLogger.info('Starting server-side execution', { @@ -692,7 +679,7 @@ async function handleExecutePost( await loggingSession.safeCompleteWithError({ error: { - message: `File processing failed: ${fileError instanceof Error ? fileError.message : 'Unable to process input files'}`, + message: `File processing failed: ${getErrorMessage(fileError, 'Unable to process input files')}`, stackTrace: fileError instanceof Error ? fileError.stack : undefined, }, traceSpans: [], @@ -700,7 +687,7 @@ async function handleExecutePost( return NextResponse.json( { - error: `File processing failed: ${fileError instanceof Error ? fileError.message : 'Unable to process input files'}`, + error: `File processing failed: ${getErrorMessage(fileError, 'Unable to process input files')}`, }, { status: 400 } ) @@ -708,6 +695,12 @@ async function handleExecutePost( const effectiveWorkflowStateOverride = sanitizedWorkflowStateOverride || cachedWorkflowData || undefined + const largeValueExecutionIds = [executionId] + const largeValueKeys: string[] = [] + const fileKeys: string[] = [] + const allowLargeValueWorkflowScope = Boolean( + resolvedRunFromBlock?.sourceSnapshot && !resolvedRunFromBlock.sourceExecutionId + ) if (!enableSSE) { reqLogger.info('Using non-SSE execution (direct JSON response)') @@ -726,6 +719,10 @@ async function handleExecutePost( isClientSession, enforceCredentialAccess: useAuthenticatedUserAsActor, workflowStateOverride: effectiveWorkflowStateOverride, + largeValueExecutionIds, + largeValueKeys, + fileKeys, + allowLargeValueWorkflowScope, callChain, executionMode: 'sync', } @@ -757,6 +754,14 @@ async function handleExecutePost( }) await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession }) + const compactResultOutput = await compactRoutePayload(result.output, { + workspaceId, + workflowId, + executionId, + userId: actorUserId, + preserveUserFileBase64: true, + preserveRoot: true, + }) if ( result.status === 'cancelled' && @@ -772,7 +777,7 @@ async function handleExecutePost( return NextResponse.json( { success: false, - output: result.output, + output: compactResultOutput, error: timeoutErrorMessage, metadata: result.metadata ? { @@ -786,24 +791,62 @@ async function handleExecutePost( ) } + const outputLargeValueKeys = result.metadata?.largeValueKeys ?? largeValueKeys + const outputFileKeys = result.metadata?.fileKeys ?? fileKeys + const outputWithBase64 = includeFileBase64 ? ((await hydrateUserFilesWithBase64(result.output, { requestId, + workspaceId, + workflowId, executionId, + largeValueExecutionIds, + largeValueKeys: outputLargeValueKeys, + fileKeys: outputFileKeys, + allowLargeValueWorkflowScope, + userId: actorUserId, maxBytes: base64MaxBytes, + preserveLargeValueMetadata: true, })) as NormalizedBlockOutput) : result.output - const resultWithBase64 = { ...result, output: outputWithBase64 } - - if (auth.authType !== AuthType.INTERNAL_JWT && workflowHasResponseBlock(resultWithBase64)) { - return createHttpResponseFromBlock(resultWithBase64) + if (auth.authType !== AuthType.INTERNAL_JWT && workflowHasResponseBlock(result)) { + const compactResponseBlockOutput = await compactRoutePayload(outputWithBase64, { + workspaceId, + workflowId, + executionId, + userId: actorUserId, + preserveUserFileBase64: true, + preserveRoot: true, + }) + return await createHttpResponseFromBlock( + { ...result, output: compactResponseBlockOutput }, + { + workspaceId, + workflowId, + executionId, + largeValueExecutionIds, + largeValueKeys: outputLargeValueKeys, + fileKeys: outputFileKeys, + userId: actorUserId, + allowLargeValueWorkflowScope, + } + ) } + const compactOutput = await compactRoutePayload(outputWithBase64, { + workspaceId, + workflowId, + executionId, + userId: actorUserId, + preserveUserFileBase64: true, + preserveRoot: true, + }) + const filteredResult = { success: result.success, executionId, - output: outputWithBase64, + output: compactOutput, error: result.error, metadata: result.metadata ? { @@ -816,16 +859,27 @@ async function handleExecutePost( return NextResponse.json(filteredResult) } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error' + const errorMessage = getErrorMessage(error, 'Unknown error') reqLogger.error(`Non-SSE execution failed: ${errorMessage}`) const executionResult = hasExecutionResult(error) ? error.executionResult : undefined + const status = getExecutionErrorStatus(error) + const compactErrorOutput = executionResult?.output + ? await compactRoutePayload(executionResult.output, { + workspaceId, + workflowId, + executionId, + userId: actorUserId, + preserveUserFileBase64: true, + preserveRoot: true, + }) + : undefined return NextResponse.json( { success: false, - output: executionResult?.output, + output: compactErrorOutput, error: executionResult?.error || errorMessage || 'Execution failed', metadata: executionResult?.metadata ? { @@ -835,7 +889,7 @@ async function handleExecutePost( } : undefined, }, - { status: 500 } + { status } ) } finally { timeoutController.cleanup() @@ -875,6 +929,13 @@ async function handleExecutePost( timeoutMs: preprocessResult.executionTimeout?.sync, }, executionId, + largeValueExecutionIds, + largeValueKeys, + fileKeys, + workspaceId, + workflowId, + userId: actorUserId, + allowLargeValueWorkflowScope, executeFn: async ({ onStream, onBlockComplete, abortSignal }) => executeWorkflow( streamWorkflow, @@ -893,6 +954,10 @@ async function handleExecutePost( base64MaxBytes, abortSignal, executionMode: 'stream', + largeValueKeys, + fileKeys, + stopAfterBlockId, + runFromBlock: resolvedRunFromBlock, }, executionId ), @@ -909,12 +974,23 @@ async function handleExecutePost( let isStreamClosed = false let isManualAbortRegistered = false - const eventWriter = createExecutionEventWriter(executionId) - setExecutionMeta(executionId, { - status: 'active', + const eventWriter = createExecutionEventWriter(executionId, { + workspaceId, + workflowId, + userId: actorUserId, + preserveUserFileBase64: includeFileBase64, + }) + const metaInitialized = await initializeExecutionStreamMeta(executionId, { userId: actorUserId, workflowId, - }).catch(() => {}) + }) + if (!metaInitialized) { + timeoutController.cleanup() + return NextResponse.json( + { error: 'Run buffer temporarily unavailable' }, + { status: 503, headers: { 'X-Execution-Id': executionId } } + ) + } const stream = new ReadableStream({ async start(controller) { @@ -923,29 +999,34 @@ async function handleExecutePost( registerManualExecutionAborter(executionId, timeoutController.abort) isManualAbortRegistered = true - let localEventSeq = 0 - const sendEvent = (event: ExecutionEvent) => { + let terminalEventPublished = false + const sendEvent = async ( + event: ExecutionEvent, + terminalStatus?: TerminalExecutionStreamStatus + ) => { const isBuffered = event.type !== 'stream:chunk' && event.type !== 'stream:done' + let eventToSend = event if (isBuffered) { - localEventSeq++ - event.eventId = localEventSeq + const entry = terminalStatus + ? await eventWriter.writeTerminal(event, terminalStatus) + : await eventWriter.write(event) + eventToSend = entry.event + eventToSend.eventId = entry.eventId + terminalEventPublished ||= Boolean(terminalStatus) } if (!isStreamClosed) { try { - controller.enqueue(encodeSSEEvent(event)) + controller.enqueue(encodeSSEEvent(eventToSend)) } catch { isStreamClosed = true } } - if (isBuffered) { - eventWriter.write(event).catch(() => {}) - } } try { const startTime = new Date() - sendEvent({ + await sendEvent({ type: 'execution:started', timestamp: startTime.toISOString(), executionId, @@ -964,7 +1045,7 @@ async function handleExecutePost( childWorkflowContext?: ChildWorkflowContext ) => { reqLogger.info('onBlockStart called', { blockId, blockName, blockType }) - sendEvent({ + await sendEvent({ type: 'block:started', timestamp: new Date().toISOString(), executionId, @@ -999,7 +1080,26 @@ async function handleExecutePost( iterationContext?: IterationContext, childWorkflowContext?: ChildWorkflowContext ) => { - const hasError = callbackData.output?.error + const compactCallbackData = { + ...callbackData, + input: await compactRoutePayload(callbackData.input, { + workspaceId, + workflowId, + executionId, + userId: actorUserId, + preserveUserFileBase64: includeFileBase64, + preserveRoot: true, + }), + output: await compactRoutePayload(callbackData.output, { + workspaceId, + workflowId, + executionId, + userId: actorUserId, + preserveUserFileBase64: includeFileBase64, + preserveRoot: true, + }), + } + const hasError = compactCallbackData.output?.error const childWorkflowData = childWorkflowContext ? { childWorkflowBlockId: childWorkflowContext.parentBlockId, @@ -1016,9 +1116,9 @@ async function handleExecutePost( blockId, blockName, blockType, - error: callbackData.output.error, + error: compactCallbackData.output.error, }) - sendEvent({ + await sendEvent({ type: 'block:error', timestamp: new Date().toISOString(), executionId, @@ -1027,12 +1127,12 @@ async function handleExecutePost( blockId, blockName, blockType, - input: callbackData.input, - error: callbackData.output.error, - durationMs: callbackData.executionTime || 0, - startedAt: callbackData.startedAt, - executionOrder: callbackData.executionOrder, - endedAt: callbackData.endedAt, + input: compactCallbackData.input, + error: compactCallbackData.output.error, + durationMs: compactCallbackData.executionTime || 0, + startedAt: compactCallbackData.startedAt, + executionOrder: compactCallbackData.executionOrder, + endedAt: compactCallbackData.endedAt, ...(iterationContext && { iterationCurrent: iterationContext.iterationCurrent, iterationTotal: iterationContext.iterationTotal, @@ -1052,7 +1152,7 @@ async function handleExecutePost( blockName, blockType, }) - sendEvent({ + await sendEvent({ type: 'block:completed', timestamp: new Date().toISOString(), executionId, @@ -1061,12 +1161,12 @@ async function handleExecutePost( blockId, blockName, blockType, - input: callbackData.input, - output: callbackData.output, - durationMs: callbackData.executionTime || 0, - startedAt: callbackData.startedAt, - executionOrder: callbackData.executionOrder, - endedAt: callbackData.endedAt, + input: compactCallbackData.input, + output: compactCallbackData.output, + durationMs: compactCallbackData.executionTime || 0, + startedAt: compactCallbackData.startedAt, + executionOrder: compactCallbackData.executionOrder, + endedAt: compactCallbackData.endedAt, ...(iterationContext && { iterationCurrent: iterationContext.iterationCurrent, iterationTotal: iterationContext.iterationTotal, @@ -1095,7 +1195,7 @@ async function handleExecutePost( if (done) break const chunk = decoder.decode(value, { stream: true }) - sendEvent({ + await sendEvent({ type: 'stream:chunk', timestamp: new Date().toISOString(), executionId, @@ -1104,7 +1204,7 @@ async function handleExecutePost( }) } - sendEvent({ + await sendEvent({ type: 'stream:done', timestamp: new Date().toISOString(), executionId, @@ -1135,6 +1235,10 @@ async function handleExecutePost( isClientSession, enforceCredentialAccess: useAuthenticatedUserAsActor, workflowStateOverride: effectiveWorkflowStateOverride, + largeValueExecutionIds, + largeValueKeys, + fileKeys, + allowLargeValueWorkflowScope, callChain, executionMode: 'sync', } @@ -1149,13 +1253,14 @@ async function handleExecutePost( selectedOutputs ) - const onChildWorkflowInstanceReady = ( + const onChildWorkflowInstanceReady = async ( blockId: string, childWorkflowInstanceId: string, iterationContext?: IterationContext, - executionOrder?: number + executionOrder?: number, + childWorkflowContext?: ChildWorkflowContext ) => { - sendEvent({ + await sendEvent({ type: 'block:childWorkflowStarted', timestamp: new Date().toISOString(), executionId, @@ -1165,7 +1270,16 @@ async function handleExecutePost( childWorkflowInstanceId, ...(iterationContext && { iterationCurrent: iterationContext.iterationCurrent, + iterationTotal: iterationContext.iterationTotal, + iterationType: iterationContext.iterationType, iterationContainerId: iterationContext.iterationContainerId, + ...(iterationContext.parentIterations?.length && { + parentIterations: iterationContext.parentIterations, + }), + }), + ...(childWorkflowContext && { + childWorkflowBlockId: childWorkflowContext.parentBlockId, + childWorkflowName: childWorkflowContext.workflowName, }), ...(executionOrder !== undefined && { executionOrder }), }, @@ -1190,6 +1304,20 @@ async function handleExecutePost( await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession }) + /** + * Compact block logs once and reuse across cancelled/timeout/paused/complete + * SSE events. Walks all block logs and durably serializes large values to + * object storage, so doing it twice would double the latency and storage + * load on the happy path. + */ + const compactedBlockLogs = await compactBlockLogs(result.logs, { + workspaceId, + workflowId, + executionId, + userId: actorUserId, + requireDurable: true, + }) + if (result.status === 'cancelled') { if (timeoutController.isTimedOut() && timeoutController.timeoutMs) { const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) @@ -1199,108 +1327,182 @@ async function handleExecutePost( await loggingSession.markAsFailed(timeoutErrorMessage) - sendEvent({ - type: 'execution:error', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { - error: timeoutErrorMessage, - duration: result.metadata?.duration || 0, - }, - }) finalMetaStatus = 'error' + await sendEvent( + { + type: 'execution:error', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + error: timeoutErrorMessage, + duration: result.metadata?.duration || 0, + finalBlockLogs: compactedBlockLogs, + }, + }, + 'error' + ) } else { reqLogger.info('Workflow execution was cancelled') - sendEvent({ - type: 'execution:cancelled', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { - duration: result.metadata?.duration || 0, - }, - }) finalMetaStatus = 'cancelled' + await sendEvent( + { + type: 'execution:cancelled', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + duration: result.metadata?.duration || 0, + finalBlockLogs: compactedBlockLogs, + }, + }, + 'cancelled' + ) } return } + const outputLargeValueKeys = result.metadata?.largeValueKeys ?? largeValueKeys + const outputFileKeys = result.metadata?.fileKeys ?? fileKeys + const sseOutput = includeFileBase64 ? await hydrateUserFilesWithBase64(result.output, { requestId, + workspaceId, + workflowId, executionId, + largeValueExecutionIds, + largeValueKeys: outputLargeValueKeys, + fileKeys: outputFileKeys, + allowLargeValueWorkflowScope, + userId: actorUserId, maxBytes: base64MaxBytes, + preserveLargeValueMetadata: true, }) : result.output + const compactSseOutput = await compactRoutePayload(sseOutput, { + workspaceId, + workflowId, + executionId, + userId: actorUserId, + preserveUserFileBase64: true, + preserveRoot: true, + }) if (result.status === 'paused') { - sendEvent({ - type: 'execution:paused', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { - output: sseOutput, - duration: result.metadata?.duration || 0, - startTime: result.metadata?.startTime || startTime.toISOString(), - endTime: result.metadata?.endTime || new Date().toISOString(), + finalMetaStatus = 'complete' + await sendEvent( + { + type: 'execution:paused', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + output: compactSseOutput, + duration: result.metadata?.duration || 0, + startTime: result.metadata?.startTime || startTime.toISOString(), + endTime: result.metadata?.endTime || new Date().toISOString(), + finalBlockLogs: compactedBlockLogs, + }, }, - }) + 'complete' + ) } else { - sendEvent({ - type: 'execution:completed', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { - success: result.success, - output: sseOutput, - duration: result.metadata?.duration || 0, - startTime: result.metadata?.startTime || startTime.toISOString(), - endTime: result.metadata?.endTime || new Date().toISOString(), + finalMetaStatus = 'complete' + await sendEvent( + { + type: 'execution:completed', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + success: result.success, + output: compactSseOutput, + duration: result.metadata?.duration || 0, + startTime: result.metadata?.startTime || startTime.toISOString(), + endTime: result.metadata?.endTime || new Date().toISOString(), + finalBlockLogs: compactedBlockLogs, + }, }, - }) + 'complete' + ) } - finalMetaStatus = 'complete' } catch (error: unknown) { const isTimeout = isTimeoutError(error) || timeoutController.isTimedOut() const errorMessage = isTimeout ? getTimeoutErrorMessage(error, timeoutController.timeoutMs) - : error instanceof Error - ? error.message - : 'Unknown error' + : getErrorMessage(error, 'Unknown error') reqLogger.error(`SSE execution failed: ${errorMessage}`, { isTimeout }) const executionResult = hasExecutionResult(error) ? error.executionResult : undefined + let compactErrorLogs: BlockLog[] | undefined + try { + compactErrorLogs = executionResult?.logs + ? await compactBlockLogs(executionResult.logs, { + workspaceId, + workflowId, + executionId, + userId: actorUserId, + requireDurable: true, + }) + : undefined + } catch (compactionError) { + reqLogger.warn('Failed to compact SSE error logs, omitting oversized error details', { + error: toError(compactionError).message, + }) + } - sendEvent({ - type: 'execution:error', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { - error: executionResult?.error || errorMessage, - duration: executionResult?.metadata?.duration || 0, - }, - }) finalMetaStatus = 'error' + await sendEvent( + { + type: 'execution:error', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + error: executionResult?.error || errorMessage, + duration: executionResult?.metadata?.duration || 0, + finalBlockLogs: compactErrorLogs, + }, + }, + 'error' + ) } finally { if (isManualAbortRegistered) { unregisterManualExecutionAborter(executionId) isManualAbortRegistered = false } - try { - await eventWriter.close() - } catch (closeError) { - reqLogger.warn('Failed to close event writer', { - error: toError(closeError).message, + if (finalMetaStatus && !terminalEventPublished) { + const replayBufferFlushed = await flushExecutionStreamReplayBuffer( + executionId, + eventWriter + ) + reqLogger.error('Failed to publish terminal execution event durably', { + executionId, + status: finalMetaStatus, + replayBufferFlushed, }) - } - if (finalMetaStatus) { - setExecutionMeta(executionId, { status: finalMetaStatus }).catch(() => {}) + if (!isStreamClosed) { + controller.error(new Error('Run buffer terminal event publish failed')) + isStreamClosed = true + } + } else if (terminalEventPublished) { + await eventWriter.close().catch((closeError) => { + reqLogger.warn('Failed to close execution event writer after terminal publish', { + executionId, + error: getErrorMessage(closeError), + }) + }) + } else { + try { + await eventWriter.close() + } catch (closeError) { + reqLogger.warn('Failed to close event writer', { + error: toError(closeError).message, + }) + } } timeoutController.cleanup() if (executionId) { diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts index 6a9808f1b0e..6ee6c71aa7d 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts @@ -5,6 +5,7 @@ import { databaseMock, hybridAuthMockFns, + posthogServerMock, workflowAuthzMockFns, workflowsUtilsMock, } from '@sim/testing' @@ -14,17 +15,27 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockMarkExecutionCancelled, mockAbortManualExecution, - mockCancelPausedExecution, - mockSetExecutionMeta, + mockBeginPausedCancellation, + mockBlockQueuedResumesForCancellation, + mockClearPausedCancellationIntent, + mockCompletePausedCancellation, + mockGetPausedCancellationStatus, + mockFinalizeExecutionStream, + mockReadExecutionMetaState, mockWriteEvent, - mockCloseWriter, + mockWriteTerminalEvent, } = vi.hoisted(() => ({ mockMarkExecutionCancelled: vi.fn(), mockAbortManualExecution: vi.fn(), - mockCancelPausedExecution: vi.fn(), - mockSetExecutionMeta: vi.fn(), + mockBeginPausedCancellation: vi.fn(), + mockBlockQueuedResumesForCancellation: vi.fn(), + mockClearPausedCancellationIntent: vi.fn(), + mockCompletePausedCancellation: vi.fn(), + mockGetPausedCancellationStatus: vi.fn(), + mockFinalizeExecutionStream: vi.fn(), + mockReadExecutionMetaState: vi.fn(), mockWriteEvent: vi.fn(), - mockCloseWriter: vi.fn(), + mockWriteTerminalEvent: vi.fn(), })) vi.mock('@/lib/execution/cancellation', () => ({ @@ -37,21 +48,27 @@ vi.mock('@/lib/execution/manual-cancellation', () => ({ vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({ PauseResumeManager: { - cancelPausedExecution: (...args: unknown[]) => mockCancelPausedExecution(...args), + beginPausedCancellation: (...args: unknown[]) => mockBeginPausedCancellation(...args), + blockQueuedResumesForCancellation: (...args: unknown[]) => + mockBlockQueuedResumesForCancellation(...args), + clearPausedCancellationIntent: (...args: unknown[]) => + mockClearPausedCancellationIntent(...args), + completePausedCancellation: (...args: unknown[]) => mockCompletePausedCancellation(...args), + getPausedCancellationStatus: (...args: unknown[]) => mockGetPausedCancellationStatus(...args), }, })) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -vi.mock('@/lib/posthog/server', () => ({ - captureServerEvent: vi.fn(), -})) +vi.mock('@/lib/posthog/server', () => posthogServerMock) vi.mock('@/lib/execution/event-buffer', () => ({ - setExecutionMeta: (...args: unknown[]) => mockSetExecutionMeta(...args), + finalizeExecutionStream: (...args: unknown[]) => mockFinalizeExecutionStream(...args), + readExecutionMetaState: (...args: unknown[]) => mockReadExecutionMetaState(...args), createExecutionEventWriter: () => ({ write: (...args: unknown[]) => mockWriteEvent(...args), - close: () => mockCloseWriter(), + writeTerminal: (...args: unknown[]) => mockWriteTerminalEvent(...args), + close: vi.fn().mockResolvedValue(undefined), }), })) @@ -72,10 +89,15 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { allowed: true, }) mockAbortManualExecution.mockReturnValue(false) - mockCancelPausedExecution.mockResolvedValue(false) - mockSetExecutionMeta.mockResolvedValue(undefined) + mockBeginPausedCancellation.mockResolvedValue(false) + mockBlockQueuedResumesForCancellation.mockResolvedValue(false) + mockClearPausedCancellationIntent.mockResolvedValue(undefined) + mockCompletePausedCancellation.mockResolvedValue(false) + mockGetPausedCancellationStatus.mockResolvedValue(null) + mockFinalizeExecutionStream.mockResolvedValue(true) + mockReadExecutionMetaState.mockResolvedValue({ status: 'missing' }) mockWriteEvent.mockResolvedValue({ eventId: 1 }) - mockCloseWriter.mockResolvedValue(undefined) + mockWriteTerminalEvent.mockResolvedValue({ eventId: 1 }) }) it('returns success when cancellation was durably recorded', async () => { @@ -160,11 +182,8 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { }) it('returns success when a paused HITL execution is cancelled directly in the database', async () => { - mockMarkExecutionCancelled.mockResolvedValue({ - durablyRecorded: false, - reason: 'redis_unavailable', - }) - mockCancelPausedExecution.mockResolvedValue(true) + mockBeginPausedCancellation.mockResolvedValue(true) + mockCompletePausedCancellation.mockResolvedValue(true) const response = await POST(makeRequest(), makeParams()) @@ -172,12 +191,77 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { await expect(response.json()).resolves.toEqual({ success: true, executionId: 'ex-1', + redisAvailable: true, + durablyRecorded: true, + locallyAborted: false, + pausedCancelled: true, + reason: 'recorded', + }) + expect(mockMarkExecutionCancelled).not.toHaveBeenCalled() + expect(mockWriteTerminalEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'execution:cancelled', + executionId: 'ex-1', + workflowId: 'wf-1', + }), + 'cancelled' + ) + expect(mockFinalizeExecutionStream).not.toHaveBeenCalled() + }) + + it('publishes paused cancellation event even when Redis cancellation is recorded', async () => { + mockBeginPausedCancellation.mockResolvedValue(true) + mockCompletePausedCancellation.mockResolvedValue(true) + + const response = await POST(makeRequest(), makeParams()) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + success: true, + executionId: 'ex-1', + durablyRecorded: true, + pausedCancelled: true, + }) + expect(mockMarkExecutionCancelled).not.toHaveBeenCalled() + expect(mockWriteTerminalEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'execution:cancelled', + executionId: 'ex-1', + workflowId: 'wf-1', + }), + 'cancelled' + ) + expect(mockFinalizeExecutionStream).not.toHaveBeenCalled() + }) + + it('does not confirm paused cancellation when terminal event publication fails', async () => { + mockBeginPausedCancellation.mockResolvedValue(true) + mockCompletePausedCancellation.mockResolvedValue(true) + mockWriteTerminalEvent.mockRejectedValue(new Error('Redis unavailable')) + + const response = await POST(makeRequest(), makeParams()) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: false, + executionId: 'ex-1', redisAvailable: false, durablyRecorded: false, locallyAborted: false, - pausedCancelled: true, - reason: 'redis_unavailable', + pausedCancelled: false, + reason: 'paused_event_publish_failed', }) + expect(mockMarkExecutionCancelled).not.toHaveBeenCalled() + expect(mockCompletePausedCancellation).not.toHaveBeenCalled() + expect(mockWriteTerminalEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'execution:cancelled', + executionId: 'ex-1', + workflowId: 'wf-1', + }), + 'cancelled' + ) + expect(mockFinalizeExecutionStream).not.toHaveBeenCalled() }) it('returns 401 when auth fails', async () => { @@ -242,11 +326,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { }) it('does not update execution log status in DB when only paused execution was cancelled', async () => { - mockMarkExecutionCancelled.mockResolvedValue({ - durablyRecorded: false, - reason: 'redis_unavailable', - }) - mockCancelPausedExecution.mockResolvedValue(true) + mockBeginPausedCancellation.mockResolvedValue(true) await POST(makeRequest(), makeParams()) 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..92f32a26f7d 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -1,28 +1,109 @@ import { db } from '@sim/db' import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows' +import { parseRequest } from '@/lib/api/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { markExecutionCancelled } from '@/lib/execution/cancellation' -import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer' +import { + type ExecutionCancellationRecordResult, + markExecutionCancelled, +} from '@/lib/execution/cancellation' +import { createExecutionEventWriter, readExecutionMetaState } from '@/lib/execution/event-buffer' import { abortManualExecution } from '@/lib/execution/manual-cancellation' import { captureServerEvent } from '@/lib/posthog/server' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' const logger = createLogger('CancelExecutionAPI') +const PAUSED_CANCELLATION_DB_ATTEMPTS = 3 +const PAUSED_CANCELLATION_DB_RETRY_MS = 200 + +async function completePausedCancellationWithRetry( + executionId: string, + workflowId: string +): Promise { + for (let attempt = 1; attempt <= PAUSED_CANCELLATION_DB_ATTEMPTS; attempt++) { + try { + const cancelled = await PauseResumeManager.completePausedCancellation(executionId, workflowId) + if (cancelled) { + logger.info('Paused execution cancelled in database', { executionId, attempt }) + return true + } + logger.warn('Paused execution cancellation could not be completed in database', { + executionId, + attempt, + }) + return false + } catch (error) { + logger.warn('Failed to complete paused execution cancellation in database', { + executionId, + attempt, + error, + }) + if (attempt < PAUSED_CANCELLATION_DB_ATTEMPTS) { + await sleep(PAUSED_CANCELLATION_DB_RETRY_MS) + } + } + } + return false +} + +async function ensurePausedCancellationEventPublished( + executionId: string, + workflowId: string, + context: { workspaceId?: string; userId?: string } = {} +): Promise { + const metaState = await readExecutionMetaState(executionId) + if (metaState.status === 'found' && metaState.meta.status === 'cancelled') { + return true + } + + const writer = createExecutionEventWriter(executionId, { + workspaceId: context.workspaceId, + workflowId, + userId: context.userId, + }) + try { + await writer.writeTerminal( + { + type: 'execution:cancelled', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { duration: 0 }, + }, + 'cancelled' + ) + return true + } catch (error) { + logger.warn('Failed to publish paused execution cancellation event', { + executionId, + error, + }) + return false + } finally { + await writer.close().catch((error) => { + logger.warn('Failed to close paused cancellation event writer', { + executionId, + error, + }) + }) + } +} export const runtime = 'nodejs' export const dynamic = 'force-dynamic' export const POST = withRouteHandler( - async ( - req: NextRequest, - { params }: { params: Promise<{ id: string; executionId: string }> } - ) => { - const { id: workflowId, executionId } = await params + async (req: NextRequest, context: { params: Promise<{ id: string; executionId: string }> }) => { + const parsed = await parseRequest(cancelWorkflowExecutionContract, req, context) + if (!parsed.success) return parsed.response + const { id: workflowId, executionId } = parsed.data.params try { const auth = await checkHybridAuth(req, { requireWorkflowId: false }) @@ -54,40 +135,123 @@ export const POST = withRouteHandler( logger.info('Cancel execution requested', { workflowId, executionId, userId: auth.userId }) - const cancellation = await markExecutionCancelled(executionId) - const locallyAborted = abortManualExecution(executionId) + let pausedCancellationStarted = false let pausedCancelled = false try { - pausedCancelled = await PauseResumeManager.cancelPausedExecution(executionId) + pausedCancellationStarted = await PauseResumeManager.beginPausedCancellation( + executionId, + workflowId + ) } catch (error) { - logger.warn('Failed to cancel paused execution in database', { executionId, error }) + logger.warn('Failed to begin paused execution cancellation in database', { + executionId, + error, + }) } + const pendingPausedCancellation = pausedCancellationStarted + ? null + : await PauseResumeManager.getPausedCancellationStatus(executionId, workflowId) + const isPausedCancellationPath = + pausedCancellationStarted || pendingPausedCancellation !== null + + const cancellation: ExecutionCancellationRecordResult = isPausedCancellationPath + ? { durablyRecorded: false, reason: 'redis_unavailable' } + : await markExecutionCancelled(executionId) + const locallyAborted = isPausedCancellationPath ? false : abortManualExecution(executionId) - if (cancellation.durablyRecorded) { + if (pausedCancellationStarted) { + logger.info('Paused execution cancellation reserved in database', { executionId }) + } else if (cancellation.durablyRecorded) { logger.info('Execution marked as cancelled in Redis', { executionId }) } else if (locallyAborted) { logger.info('Execution cancelled via local in-process fallback', { executionId }) - } else if (pausedCancelled) { - logger.info('Paused execution cancelled directly in database', { executionId }) - void setExecutionMeta(executionId, { status: 'cancelled', workflowId }).catch(() => {}) - const writer = createExecutionEventWriter(executionId) - void writer - .write({ - type: 'execution:cancelled', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { duration: 0 }, - }) - .then(() => writer.close()) - .catch(() => {}) - } else { + } else if (!pausedCancellationStarted) { logger.warn('Execution cancellation was not durably recorded', { executionId, reason: cancellation.reason, }) } + if (!isPausedCancellationPath && (cancellation.durablyRecorded || locallyAborted)) { + await PauseResumeManager.blockQueuedResumesForCancellation(executionId, workflowId).catch( + (error) => { + logger.warn('Failed to block queued paused resumes after cancellation', { + executionId, + error, + }) + } + ) + } else if (!isPausedCancellationPath) { + await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch( + (error) => { + logger.warn( + 'Failed to clear paused cancellation intent after unsuccessful cancellation', + { + executionId, + error, + } + ) + } + ) + } + + let pausedCancellationPublished = false + let pausedCancellationPublishFailed = false + if (pausedCancellationStarted) { + pausedCancellationPublished = await ensurePausedCancellationEventPublished( + executionId, + workflowId, + { + workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined, + userId: auth.userId, + } + ) + pausedCancellationPublishFailed = !pausedCancellationPublished + if (pausedCancellationPublished) { + pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId) + } + } else { + if (pendingPausedCancellation === 'cancelled') { + pausedCancellationPublished = await ensurePausedCancellationEventPublished( + executionId, + workflowId, + { + workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined, + userId: auth.userId, + } + ) + pausedCancellationPublishFailed = !pausedCancellationPublished + pausedCancelled = pausedCancellationPublished + } else if (pendingPausedCancellation === 'cancelling') { + pausedCancellationPublished = await ensurePausedCancellationEventPublished( + executionId, + workflowId, + { + workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined, + userId: auth.userId, + } + ) + pausedCancellationPublishFailed = !pausedCancellationPublished + if (pausedCancellationPublished) { + pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId) + } + } + } + + if ( + pausedCancellationPublishFailed && + (pausedCancellationStarted || pendingPausedCancellation === 'cancelling') + ) { + await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch( + (error) => { + logger.warn('Failed to clear paused cancellation intent after publish failure', { + executionId, + error, + }) + } + ) + } + if ((cancellation.durablyRecorded || locallyAborted) && !pausedCancelled) { try { await db @@ -107,7 +271,10 @@ export const POST = withRouteHandler( } } - const success = cancellation.durablyRecorded || locallyAborted || pausedCancelled + const success = + (isPausedCancellationPath + ? pausedCancelled && pausedCancellationPublished + : cancellation.durablyRecorded) || locallyAborted if (success) { const workspaceId = workflowAuthorization.workflow?.workspaceId @@ -119,19 +286,39 @@ export const POST = withRouteHandler( ) } + const durablyRecorded = isPausedCancellationPath + ? pausedCancellationPublished + : pausedCancelled || cancellation.durablyRecorded + const reason = pausedCancellationPublishFailed + ? 'paused_event_publish_failed' + : !pausedCancelled && isPausedCancellationPath + ? 'paused_database_cancel_failed' + : pausedCancelled && !pausedCancellationPublished + ? 'paused_event_publish_failed' + : pausedCancelled || isPausedCancellationPath + ? 'recorded' + : cancellation.reason + return NextResponse.json({ success, executionId, - redisAvailable: cancellation.reason !== 'redis_unavailable', - durablyRecorded: cancellation.durablyRecorded, + redisAvailable: + isPausedCancellationPath || pausedCancelled + ? pausedCancellationPublished + : cancellation.reason !== 'redis_unavailable', + durablyRecorded, locallyAborted, pausedCancelled, - reason: cancellation.reason, + reason, + }) + } catch (error) { + logger.error('Failed to cancel execution', { + workflowId, + executionId, + error: toError(error).message, }) - } catch (error: any) { - logger.error('Failed to cancel execution', { workflowId, executionId, error: error.message }) return NextResponse.json( - { error: error.message || 'Failed to cancel execution' }, + { error: toError(error).message || 'Failed to cancel execution' }, { status: 500 } ) } diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts new file mode 100644 index 00000000000..6efff82a7cc --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts @@ -0,0 +1,223 @@ +import { db } from '@sim/db' +import { pausedExecutions, workflowExecutionLogs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { + getWorkflowExecutionContract, + type WorkflowExecutionStatusResponse, +} from '@/lib/api/contracts/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { validateWorkflowAccess } from '@/app/api/workflows/middleware' +import type { PausePoint } from '@/executor/types' + +const logger = createLogger('WorkflowExecutionStatusAPI') + +type LogStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + +interface TraceSpanShape { + blockId?: string + output?: Record + children?: TraceSpanShape[] +} + +interface ExecutionDataShape { + finalOutput?: { error?: string } & Record + error?: { message?: string } | string + completionFailure?: string + traceSpans?: TraceSpanShape[] +} + +function collectBlockOutputs(spans: TraceSpanShape[] | undefined): Map { + const map = new Map() + const visit = (list?: TraceSpanShape[]): void => { + if (!list) return + for (const span of list) { + if (span.blockId && span.output !== undefined && !map.has(span.blockId)) { + map.set(span.blockId, span.output) + } + if (span.children) visit(span.children) + } + } + visit(spans) + return map +} + +function resolvePath(value: unknown, path: string[]): unknown { + let current: unknown = value + for (const segment of path) { + if (current == null || typeof current !== 'object') return undefined + current = (current as Record)[segment] + } + return current +} + +function pickSelectedOutputs( + selectedOutputs: string[], + blockOutputs: Map +): Record { + const out: Record = {} + for (const selector of selectedOutputs) { + const [head, ...rest] = selector.split('.') + if (!head) continue + if (!blockOutputs.has(head)) continue + const blockValue = blockOutputs.get(head) + out[selector] = rest.length === 0 ? blockValue : resolvePath(blockValue, rest) + } + return out +} + +function pickEarliestPausePoint(points: PausePoint[]): PausePoint | null { + const active = points.filter((p) => p.resumeStatus === 'paused') + if (active.length === 0) return null + return active.reduce((best, current) => { + if (!best) return current + if (!current.resumeAt) return best + if (!best.resumeAt) return current + return current.resumeAt < best.resumeAt ? current : best + }, null) +} + +function normalizePausePoints(raw: unknown): PausePoint[] { + if (!raw) return [] + if (Array.isArray(raw)) return raw as PausePoint[] + if (typeof raw === 'object') return Object.values(raw as Record) + return [] +} + +function extractError(executionData: unknown): string | null { + if (!executionData || typeof executionData !== 'object') return null + const data = executionData as ExecutionDataShape + if (typeof data.error === 'string') return data.error + if (data.error && typeof data.error === 'object' && typeof data.error.message === 'string') { + return data.error.message + } + if (typeof data.finalOutput?.error === 'string') return data.finalOutput.error + if (typeof data.completionFailure === 'string') return data.completionFailure + return null +} + +export const GET = withRouteHandler( + async ( + request: NextRequest, + context: { params: Promise<{ id: string; executionId: string }> } + ) => { + const parsed = await parseRequest(getWorkflowExecutionContract, request, context) + if (!parsed.success) return parsed.response + const { id: workflowId, executionId } = parsed.data.params + const { includeOutput, selectedOutputs } = parsed.data.query + + const access = await validateWorkflowAccess(request, workflowId, false) + if (access.error) { + return NextResponse.json({ error: access.error.message }, { status: access.error.status }) + } + + const [logRow] = await db + .select({ + executionId: workflowExecutionLogs.executionId, + workflowId: workflowExecutionLogs.workflowId, + status: workflowExecutionLogs.status, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + cost: workflowExecutionLogs.cost, + }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.executionId, executionId), + eq(workflowExecutionLogs.workflowId, workflowId) + ) + ) + .limit(1) + + if (!logRow) { + return NextResponse.json({ error: 'Execution not found' }, { status: 404 }) + } + + const [pausedRow] = await db + .select({ + id: pausedExecutions.id, + status: pausedExecutions.status, + pausePoints: pausedExecutions.pausePoints, + resumedCount: pausedExecutions.resumedCount, + pausedAt: pausedExecutions.pausedAt, + nextResumeAt: pausedExecutions.nextResumeAt, + }) + .from(pausedExecutions) + .where(eq(pausedExecutions.executionId, executionId)) + .limit(1) + + const isCurrentlyPaused = + !!pausedRow && (pausedRow.status === 'paused' || pausedRow.status === 'partially_resumed') + + let status: WorkflowExecutionStatusResponse['status'] + if (isCurrentlyPaused) { + status = 'paused' + } else { + status = logRow.status as LogStatus + } + + let paused: WorkflowExecutionStatusResponse['paused'] = null + if (isCurrentlyPaused && pausedRow) { + const points = normalizePausePoints(pausedRow.pausePoints) + const earliest = pickEarliestPausePoint(points) + paused = { + pausedAt: pausedRow.pausedAt.toISOString(), + resumeAt: pausedRow.nextResumeAt?.toISOString() ?? earliest?.resumeAt ?? null, + pauseKind: earliest?.pauseKind ?? null, + blockedOnBlockId: earliest?.blockId ?? null, + pausedExecutionId: pausedRow.id, + pausePointCount: points.length, + resumedCount: pausedRow.resumedCount, + } + } + + const cost = logRow.cost + ? { total: Number((logRow.cost as { total?: number }).total ?? 0) } + : null + + const error = status === 'failed' ? extractError(logRow.executionData) : null + + const executionData = logRow.executionData as ExecutionDataShape | undefined + + const finalOutput = + includeOutput && status === 'completed' && executionData + ? (executionData.finalOutput ?? null) + : null + + const blockOutputs = + selectedOutputs.length > 0 + ? pickSelectedOutputs(selectedOutputs, collectBlockOutputs(executionData?.traceSpans)) + : null + + const response: WorkflowExecutionStatusResponse = { + executionId: logRow.executionId, + workflowId: logRow.workflowId ?? workflowId, + status, + trigger: logRow.trigger, + level: logRow.level, + startedAt: logRow.startedAt.toISOString(), + endedAt: logRow.endedAt?.toISOString() ?? null, + totalDurationMs: logRow.totalDurationMs ?? null, + paused, + cost, + error, + finalOutput, + blockOutputs, + } + + logger.debug('Fetched execution status', { + workflowId, + executionId, + status, + paused: !!paused, + }) + + return NextResponse.json(response) + } +) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts new file mode 100644 index 00000000000..5e41a225e9e --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts @@ -0,0 +1,266 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionEventEntry } from '@/lib/execution/event-buffer' + +const { + mockAuthorizeWorkflowByWorkspacePermission, + mockGetSession, + mockReadExecutionEventsState, + mockReadExecutionMetaState, +} = vi.hoisted(() => ({ + mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), + mockGetSession: vi.fn(), + mockReadExecutionEventsState: vi.fn(), + mockReadExecutionMetaState: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@sim/workflow-authz', () => ({ + authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, +})) + +vi.mock('@/lib/execution/event-buffer', () => ({ + readExecutionEventsState: mockReadExecutionEventsState, + readExecutionMetaState: mockReadExecutionMetaState, +})) + +import { GET } from './route' + +function completedEntry(eventId: number): ExecutionEventEntry { + return { + eventId, + executionId: 'exec-1', + event: { + type: 'execution:completed', + timestamp: new Date().toISOString(), + executionId: 'exec-1', + workflowId: 'wf-1', + data: { + success: true, + output: {}, + duration: 10, + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + finalBlockLogs: [], + }, + }, + } +} + +describe('execution stream reconnect route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true }) + mockReadExecutionMetaState.mockResolvedValue({ + status: 'found', + meta: { status: 'active', workflowId: 'wf-1' }, + }) + mockReadExecutionEventsState.mockResolvedValue({ status: 'ok', events: [] }) + }) + + it('drains final events after terminal meta before sending DONE', async () => { + mockReadExecutionMetaState + .mockResolvedValueOnce({ + status: 'found', + meta: { status: 'active', workflowId: 'wf-1' }, + }) + .mockResolvedValueOnce({ + status: 'found', + meta: { status: 'complete', workflowId: 'wf-1' }, + }) + mockReadExecutionEventsState + .mockResolvedValueOnce({ status: 'ok', events: [] }) + .mockResolvedValueOnce({ status: 'ok', events: [completedEntry(4)] }) + + const req = createMockRequest( + 'GET', + undefined, + undefined, + 'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3' + ) + const response = await GET(req, { + params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }), + }) + + expect(response.status).toBe(200) + const body = await response.text() + const completedIndex = body.indexOf('"type":"execution:completed"') + const doneIndex = body.indexOf('data: [DONE]') + + expect(completedIndex).toBeGreaterThanOrEqual(0) + expect(doneIndex).toBeGreaterThan(completedIndex) + expect(mockReadExecutionEventsState).toHaveBeenNthCalledWith(1, 'exec-1', 3) + expect(mockReadExecutionEventsState).toHaveBeenNthCalledWith(2, 'exec-1', 3) + }) + + it('errors when terminal metadata has no terminal event to replay', async () => { + mockReadExecutionMetaState + .mockResolvedValueOnce({ + status: 'found', + meta: { status: 'active', workflowId: 'wf-1' }, + }) + .mockResolvedValueOnce({ + status: 'found', + meta: { status: 'complete', workflowId: 'wf-1' }, + }) + mockReadExecutionEventsState + .mockResolvedValueOnce({ status: 'ok', events: [] }) + .mockResolvedValueOnce({ status: 'ok', events: [] }) + + const req = createMockRequest( + 'GET', + undefined, + undefined, + 'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3' + ) + const response = await GET(req, { + params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }), + }) + + expect(response.status).toBe(200) + await expect(response.text()).rejects.toThrow( + 'Execution reached terminal metadata without a terminal event' + ) + }) + + it('allows replay event id gaps from reserved but unused writer ids', async () => { + mockReadExecutionEventsState.mockResolvedValueOnce({ + status: 'ok', + events: [completedEntry(101)], + }) + + const req = createMockRequest( + 'GET', + undefined, + undefined, + 'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3' + ) + const response = await GET(req, { + params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }), + }) + + expect(response.status).toBe(200) + const body = await response.text() + + expect(body).toContain('"eventId":101') + expect(body).toContain('data: [DONE]') + }) + + it('errors when replay events are not strictly increasing', async () => { + mockReadExecutionEventsState.mockResolvedValueOnce({ + status: 'ok', + events: [completedEntry(3)], + }) + + const req = createMockRequest( + 'GET', + undefined, + undefined, + 'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3' + ) + const response = await GET(req, { + params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }), + }) + + expect(response.status).toBe(200) + await expect(response.text()).rejects.toThrow( + 'Execution event replay order violation: previous 3, received 3' + ) + }) + + it('returns unavailable when metadata cannot be read', async () => { + mockReadExecutionMetaState.mockResolvedValueOnce({ + status: 'unavailable', + error: 'redis unavailable', + }) + + const req = createMockRequest( + 'GET', + undefined, + undefined, + 'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3' + ) + const response = await GET(req, { + params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }), + }) + + expect(response.status).toBe(503) + await expect(response.json()).resolves.toEqual({ + error: 'Run buffer temporarily unavailable', + }) + }) + + it('stops after replaying a terminal event even when metadata is still active', async () => { + mockReadExecutionEventsState.mockResolvedValueOnce({ + status: 'ok', + events: [completedEntry(4)], + }) + + const req = createMockRequest( + 'GET', + undefined, + undefined, + 'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3' + ) + const response = await GET(req, { + params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }), + }) + + expect(response.status).toBe(200) + const body = await response.text() + + expect(body).toContain('"type":"execution:completed"') + expect(body).toContain('data: [DONE]') + expect(mockReadExecutionEventsState).toHaveBeenCalledTimes(1) + expect(mockReadExecutionMetaState).toHaveBeenCalledTimes(1) + }) + + it('errors the stream when replay events cannot be read', async () => { + mockReadExecutionEventsState.mockResolvedValueOnce({ + status: 'unavailable', + error: 'redis read failed', + }) + + const req = createMockRequest( + 'GET', + undefined, + undefined, + 'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3' + ) + const response = await GET(req, { + params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }), + }) + + expect(response.status).toBe(200) + await expect(response.text()).rejects.toThrow('Execution events unavailable: redis read failed') + }) + + it('errors the stream when requested events were pruned', async () => { + mockReadExecutionEventsState.mockResolvedValueOnce({ + status: 'pruned', + earliestEventId: 10, + }) + + const req = createMockRequest( + 'GET', + undefined, + undefined, + 'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3' + ) + const response = await GET(req, { + params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }), + }) + + expect(response.status).toBe(200) + await expect(response.text()).rejects.toThrow( + 'Execution events pruned before requested event id' + ) + }) +}) 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..6915a8dcbc1 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,14 +3,18 @@ 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 { streamWorkflowExecutionContract } from '@/lib/api/contracts/workflows' +import { parseRequest } 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' import { + type ExecutionEventEntry, type ExecutionStreamStatus, - getExecutionMeta, - readExecutionEvents, + readExecutionEventsState, + readExecutionMetaState, } from '@/lib/execution/event-buffer' +import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events' import { formatSSEEvent } from '@/lib/workflows/executor/execution-events' const logger = createLogger('ExecutionStreamReconnectAPI') @@ -22,15 +26,24 @@ function isTerminalStatus(status: ExecutionStreamStatus): boolean { return status === 'complete' || status === 'error' || status === 'cancelled' } +function isTerminalEvent(event: ExecutionEvent): boolean { + return ( + event.type === 'execution:completed' || + event.type === 'execution:error' || + event.type === 'execution:cancelled' || + event.type === 'execution:paused' + ) +} + export const runtime = 'nodejs' export const dynamic = 'force-dynamic' export const GET = withRouteHandler( - async ( - req: NextRequest, - { params }: { params: Promise<{ id: string; executionId: string }> } - ) => { - const { id: workflowId, executionId } = await params + async (req: NextRequest, context: { params: Promise<{ id: string; executionId: string }> }) => { + const parsed = await parseRequest(streamWorkflowExecutionContract, req, context) + if (!parsed.success) return parsed.response + const { id: workflowId, executionId } = parsed.data.params + const { from: fromEventId } = parsed.data.query try { const session = await getSession() @@ -50,19 +63,19 @@ export const GET = withRouteHandler( ) } - const meta = await getExecutionMeta(executionId) - if (!meta) { + const metaResult = await readExecutionMetaState(executionId) + if (metaResult.status === 'unavailable') { + return NextResponse.json({ error: 'Run buffer temporarily unavailable' }, { status: 503 }) + } + if (metaResult.status === 'missing') { return NextResponse.json({ error: 'Run buffer not found or expired' }, { status: 404 }) } + const { meta } = metaResult if (meta.workflowId && meta.workflowId !== workflowId) { 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 - logger.info('Reconnection stream requested', { workflowId, executionId, @@ -88,19 +101,68 @@ export const GET = withRouteHandler( } } - try { - const events = await readExecutionEvents(executionId, lastEventId) + const readEventsOrThrow = async ( + afterEventId: number + ): Promise => { + const result = await readExecutionEventsState(executionId, afterEventId) + if (result.status === 'unavailable') { + throw new Error(`Execution events unavailable: ${result.error}`) + } + if (result.status === 'pruned') { + throw new Error( + `Execution events pruned before requested event id: earliest retained event is ${result.earliestEventId}` + ) + } + let previousEventId = afterEventId + for (const entry of result.events) { + if (entry.eventId <= previousEventId) { + throw new Error( + `Execution event replay order violation: previous ${previousEventId}, received ${entry.eventId}` + ) + } + previousEventId = entry.eventId + } + return result.events + } + + const enqueueEvents = (events: ExecutionEventEntry[]) => { + let sawTerminalEvent = false for (const entry of events) { - if (closed) return + if (closed) break entry.event.eventId = entry.eventId enqueue(formatSSEEvent(entry.event)) lastEventId = entry.eventId + sawTerminalEvent ||= isTerminalEvent(entry.event) } + return sawTerminalEvent + } - const currentMeta = await getExecutionMeta(executionId) - if (!currentMeta || isTerminalStatus(currentMeta.status)) { - enqueue('data: [DONE]\n\n') - if (!closed) controller.close() + const closeWithDone = () => { + enqueue('data: [DONE]\n\n') + if (!closed) controller.close() + } + + const closeAfterTerminalEvent = (events: ExecutionEventEntry[]) => { + if (!enqueueEvents(events)) { + throw new Error('Execution reached terminal metadata without a terminal event') + } + closeWithDone() + } + + try { + const events = await readEventsOrThrow(lastEventId) + if (enqueueEvents(events)) { + closeWithDone() + return + } + + const currentMeta = await readExecutionMetaState(executionId) + if (currentMeta.status === 'unavailable') { + throw new Error(`Execution metadata unavailable: ${currentMeta.error}`) + } + if (currentMeta.status === 'missing' || isTerminalStatus(currentMeta.meta.status)) { + const finalEvents = await readEventsOrThrow(lastEventId) + closeAfterTerminalEvent(finalEvents) return } @@ -108,33 +170,26 @@ export const GET = withRouteHandler( await sleep(POLL_INTERVAL_MS) if (closed) return - const newEvents = await readExecutionEvents(executionId, lastEventId) - for (const entry of newEvents) { - if (closed) return - entry.event.eventId = entry.eventId - enqueue(formatSSEEvent(entry.event)) - lastEventId = entry.eventId + const newEvents = await readEventsOrThrow(lastEventId) + if (enqueueEvents(newEvents)) { + closeWithDone() + return } - const polledMeta = await getExecutionMeta(executionId) - if (!polledMeta || isTerminalStatus(polledMeta.status)) { - const finalEvents = await readExecutionEvents(executionId, lastEventId) - for (const entry of finalEvents) { - if (closed) return - entry.event.eventId = entry.eventId - enqueue(formatSSEEvent(entry.event)) - lastEventId = entry.eventId - } - enqueue('data: [DONE]\n\n') - if (!closed) controller.close() + const polledMeta = await readExecutionMetaState(executionId) + if (polledMeta.status === 'unavailable') { + throw new Error(`Execution metadata unavailable: ${polledMeta.error}`) + } + if (polledMeta.status === 'missing' || isTerminalStatus(polledMeta.meta.status)) { + const finalEvents = await readEventsOrThrow(lastEventId) + closeAfterTerminalEvent(finalEvents) return } } if (!closed) { logger.warn('Reconnection stream poll deadline reached', { executionId }) - enqueue('data: [DONE]\n\n') - controller.close() + throw new Error('Execution stream ended before a terminal event was available') } } catch (error) { logger.error('Error in reconnection stream', { @@ -143,7 +198,7 @@ export const GET = withRouteHandler( }) if (!closed) { try { - controller.close() + controller.error(error) } catch {} } } 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..cb77489a39e 100644 --- a/apps/sim/app/api/workflows/[id]/form/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/form/status/route.ts @@ -1,9 +1,12 @@ import { db } from '@sim/db' import { form } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { getFormStatusContract } from '@/lib/api/contracts/forms' +import { parseRequest } 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' @@ -11,14 +14,16 @@ import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/ const logger = createLogger('FormStatusAPI') export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { return createErrorResponse('Unauthorized', 401) } - const { id: workflowId } = await params + const parsed = await parseRequest(getFormStatusContract, request, context) + if (!parsed.success) return parsed.response + const { id: workflowId } = parsed.data.params const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId, userId: auth.userId, @@ -53,9 +58,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.test.ts b/apps/sim/app/api/workflows/[id]/log/route.test.ts new file mode 100644 index 00000000000..f607ba3d0f0 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/log/route.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + */ +import { authMockFns, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Override global db mock with the configurable chain mock +vi.mock('@sim/db', () => dbChainMock) + +const { mockValidateWorkflowAccess, mockGetWorkspaceBilledAccountUserId } = vi.hoisted(() => ({ + mockValidateWorkflowAccess: vi.fn(), + mockGetWorkspaceBilledAccountUserId: vi.fn(), +})) + +vi.mock('@/app/api/workflows/middleware', () => ({ + validateWorkflowAccess: mockValidateWorkflowAccess, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId, +})) + +vi.mock('@/lib/logs/execution/logging-session', () => ({ + LoggingSession: vi.fn().mockImplementation(() => ({ + start: vi.fn().mockResolvedValue(undefined), + markAsFailed: vi.fn().mockResolvedValue(undefined), + safeCompleteWithError: vi.fn().mockResolvedValue(undefined), + safeComplete: vi.fn().mockResolvedValue(undefined), + })), +})) + +vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({ + buildTraceSpans: vi.fn().mockReturnValue([]), +})) + +import { POST } from './route' + +const makeRequest = (workflowId: string, body: unknown) => + new NextRequest(`http://localhost/api/workflows/${workflowId}/log`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + +const validResult = { success: true, output: { value: 42 } } + +describe('POST /api/workflows/[id]/log cross-tenant guard', () => { + const OWNER_WORKFLOW_ID = 'wf-owner' + const ATTACKER_WORKFLOW_ID = 'wf-attacker' + const VICTIM_EXECUTION_ID = 'exec-victim-uuid' + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockValidateWorkflowAccess.mockResolvedValue({ error: null }) + mockGetWorkspaceBilledAccountUserId.mockResolvedValue('user-1') + // Default: no existing log (fresh execution) + dbChainMockFns.limit.mockResolvedValue([]) + }) + + it('returns 404 when executionId belongs to a different workflow', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ workflowId: OWNER_WORKFLOW_ID }]) + + const res = await POST( + makeRequest(ATTACKER_WORKFLOW_ID, { + executionId: VICTIM_EXECUTION_ID, + result: validResult, + }), + { params: Promise.resolve({ id: ATTACKER_WORKFLOW_ID }) } + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error).toBe('Execution not found') + }) + + it('proceeds when executionId belongs to the same workflow', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ workflowId: OWNER_WORKFLOW_ID }]) + + const res = await POST( + makeRequest(OWNER_WORKFLOW_ID, { + executionId: VICTIM_EXECUTION_ID, + result: validResult, + }), + { params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) } + ) + + expect(res.status).not.toBe(404) + expect(res.status).not.toBe(403) + }) + + it('proceeds when executionId has no existing log row (fresh execution)', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + const res = await POST( + makeRequest(OWNER_WORKFLOW_ID, { + executionId: 'brand-new-execution-id', + result: validResult, + }), + { params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) } + ) + + expect(res.status).not.toBe(404) + expect(res.status).not.toBe(403) + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/log/route.ts b/apps/sim/app/api/workflows/[id]/log/route.ts index 74d56940aa3..8d319910a88 100644 --- a/apps/sim/app/api/workflows/[id]/log/route.ts +++ b/apps/sim/app/api/workflows/[id]/log/route.ts @@ -1,6 +1,10 @@ +import { db } from '@sim/db' +import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { z } from 'zod' +import { workflowLogContract } from '@/lib/api/contracts/workflows' +import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { LoggingSession } from '@/lib/logs/execution/logging-session' @@ -12,30 +16,12 @@ 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( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id } = await params + const { id } = await context.params try { const accessValidation = await validateWorkflowAccess(request, id, false) @@ -46,18 +32,10 @@ export const POST = withRouteHandler( return createErrorResponse(accessValidation.error.message, accessValidation.error.status) } - const body = await request.json() - const validation = postBodySchema.safeParse(body) - - if (!validation.success) { - logger.warn(`[${requestId}] Invalid request body: ${validation.error.message}`) - return createErrorResponse( - validation.error.errors[0]?.message || 'Invalid request body', - 400 - ) - } + const parsed = await parseRequest(workflowLogContract, request, context) + if (!parsed.success) return parsed.response - const { logs, executionId, result } = validation.data + const { logs, executionId, result } = parsed.data.body if (result) { if (!executionId) { @@ -65,6 +43,19 @@ export const POST = withRouteHandler( return createErrorResponse('executionId is required when logging results', 400) } + const [existingLog] = await db + .select({ workflowId: workflowExecutionLogs.workflowId }) + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.executionId, executionId)) + .limit(1) + + if (existingLog && existingLog.workflowId !== id) { + logger.warn( + `[${requestId}] executionId ${executionId} belongs to workflow ${existingLog.workflowId}, not ${id}` + ) + return createErrorResponse('Execution not found', 404) + } + logger.info(`[${requestId}] Persisting execution result for workflow: ${id}`, { executionId, success: result.success, 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..04d835bba12 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 { pausedWorkflowExecutionByIdContract } from '@/lib/api/contracts/workflows' +import { parseRequest } 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' @@ -9,13 +11,11 @@ export const dynamic = 'force-dynamic' export const GET = withRouteHandler( async ( request: NextRequest, - { - params, - }: { - params: Promise<{ id: string; executionId: string }> - } + context: { params: Promise<{ id: string; executionId: string }> } ) => { - const { id: workflowId, executionId } = await params + const parsed = await parseRequest(pausedWorkflowExecutionByIdContract, request, context) + if (!parsed.success) return parsed.response + const { id: workflowId, executionId } = parsed.data.params 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..74fa1cd4b51 100644 --- a/apps/sim/app/api/workflows/[id]/paused/route.ts +++ b/apps/sim/app/api/workflows/[id]/paused/route.ts @@ -1,44 +1,25 @@ import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { pausedWorkflowExecutionsContract } from '@/lib/api/contracts/workflows' +import { parseRequest } 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' export const GET = withRouteHandler( - async ( - request: NextRequest, - { - params, - }: { - params: Promise<{ id: string }> - } - ) => { - const { id: workflowId } = await params + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const parsed = await parseRequest(pausedWorkflowExecutionsContract, request, context) + if (!parsed.success) return parsed.response + const { id: workflowId } = parsed.data.params const access = await validateWorkflowAccess(request, workflowId, false) if (access.error) { return NextResponse.json({ error: access.error.message }, { status: access.error.status }) } - const validation = queryParamsSchema.safeParse({ - status: request.nextUrl.searchParams.get('status'), - }) - - if (!validation.success) { - return NextResponse.json( - { error: validation.error.errors[0]?.message || 'Invalid query parameters' }, - { status: 400 } - ) - } - - const { status: statusFilter } = validation.data + const { status: statusFilter } = parsed.data.query const pausedExecutions = await PauseResumeManager.listPausedExecutions({ workflowId, diff --git a/apps/sim/app/api/workflows/[id]/restore/route.ts b/apps/sim/app/api/workflows/[id]/restore/route.ts index e0205917494..65a8fa96196 100644 --- a/apps/sim/app/api/workflows/[id]/restore/route.ts +++ b/apps/sim/app/api/workflows/[id]/restore/route.ts @@ -1,20 +1,25 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { assertFolderMutable, FolderLockedError, WorkflowLockedError } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' +import { restoreWorkflowContract } from '@/lib/api/contracts/workflows' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { restoreWorkflow } from '@/lib/workflows/lifecycle' +import { performRestoreWorkflow } from '@/lib/workflows/orchestration' import { getWorkflowById } from '@/lib/workflows/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreWorkflowAPI') export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const { id: workflowId } = await params + const parsed = await parseRequest(restoreWorkflowContract, request, context) + if (!parsed.success) return parsed.response + const { id: workflowId } = parsed.data.params try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -40,31 +45,25 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const result = await restoreWorkflow(workflowId, { requestId }) + if (workflowData.locked) { + throw new WorkflowLockedError('Workflow is locked') + } + await assertFolderMutable(workflowData.folderId) + + const result = await performRestoreWorkflow({ + workflowId, + userId: auth.userId, + requestId, + }) - if (!result.restored) { - return NextResponse.json({ error: 'Workflow is not archived' }, { status: 400 }) + if (!result.success) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + return NextResponse.json({ error: result.error }, { status }) } logger.info(`[${requestId}] Restored workflow ${workflowId}`) - recordAudit({ - workspaceId: workflowData.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.WORKFLOW_RESTORED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: workflowData.name, - description: `Restored workflow "${workflowData.name}"`, - metadata: { - workflowName: workflowData.name, - workspaceId: workflowData.workspaceId || undefined, - }, - request, - }) - captureServerEvent( auth.userId, 'workflow_restored', @@ -74,9 +73,13 @@ export const POST = withRouteHandler( return NextResponse.json({ success: true }) } catch (error) { + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error(`[${requestId}] Error restoring workflow ${workflowId}`, error) return NextResponse.json( - { error: error instanceof Error ? error.message : 'Internal server error' }, + { error: getErrorMessage(error, 'Internal server error') }, { status: 500 } ) } diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 2d3ec73334c..d752a3e6dc5 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -27,8 +27,13 @@ const mockGetWorkflowById = workflowsUtilsMockFns.mockGetWorkflowById const mockAuthorizeWorkflowByWorkspacePermission = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission const mockPerformDeleteWorkflow = workflowsOrchestrationMockFns.mockPerformDeleteWorkflow -const mockDbUpdate = vi.fn() -const mockDbSelect = vi.fn() +const mockPerformUpdateWorkflow = workflowsOrchestrationMockFns.mockPerformUpdateWorkflow + +const { mockDbUpdate, mockDbSelect, mockDbTransaction } = vi.hoisted(() => ({ + mockDbUpdate: vi.fn(), + mockDbSelect: vi.fn(), + mockDbTransaction: vi.fn(), +})) /** * Helper to set mock auth state consistently across getSession and hybrid auth. @@ -65,6 +70,7 @@ vi.mock('@sim/db', () => ({ db: { update: () => mockDbUpdate(), select: () => mockDbSelect(), + transaction: mockDbTransaction, }, workflow: {}, })) @@ -80,6 +86,34 @@ describe('Workflow By ID API Route', () => { }) mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null) + mockPerformUpdateWorkflow.mockImplementation(async (params) => ({ + success: true, + workflow: { + id: params.workflowId, + name: params.name ?? params.currentName, + description: params.description ?? null, + color: params.color ?? null, + workspaceId: params.workspaceId, + folderId: params.folderId ?? params.currentFolderId ?? null, + sortOrder: params.sortOrder ?? null, + locked: params.locked ?? null, + createdAt: new Date(), + updatedAt: new Date(), + archivedAt: null, + }, + })) + mockDbTransaction.mockImplementation(async (callback) => + callback({ + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }), + }) + ) }) describe('GET /api/workflows/[id]', () => { @@ -558,7 +592,7 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(400) const data = await response.json() - expect(data.error).toBe('Invalid request data') + expect(data.error).toBe('Validation error') }) it('should reject rename when duplicate name exists in same folder', async () => { @@ -578,8 +612,11 @@ describe('Workflow By ID API Route', () => { workflow: mockWorkflow, workspacePermission: 'write', }) - - mockDuplicateCheck([{ id: 'workflow-other' }]) + mockPerformUpdateWorkflow.mockResolvedValueOnce({ + success: false, + error: 'A workflow named "Duplicate Name" already exists in this folder', + errorCode: 'conflict', + }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', @@ -611,8 +648,11 @@ describe('Workflow By ID API Route', () => { workflow: mockWorkflow, workspacePermission: 'write', }) - - mockDuplicateCheck([{ id: 'workflow-other' }]) + mockPerformUpdateWorkflow.mockResolvedValueOnce({ + success: false, + error: 'A workflow named "Duplicate Name" already exists in this folder', + errorCode: 'conflict', + }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', @@ -731,9 +771,11 @@ describe('Workflow By ID API Route', () => { workflow: mockWorkflow, workspacePermission: 'write', }) - - // Duplicate exists in target folder - mockDuplicateCheck([{ id: 'workflow-other' }]) + mockPerformUpdateWorkflow.mockResolvedValueOnce({ + success: false, + error: 'A workflow named "My Workflow" already exists in this folder', + errorCode: 'conflict', + }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 7f862131f81..2f3f0ebe3ce 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -1,28 +1,27 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' -import { and, eq, isNull, ne } from 'drizzle-orm' +import { + assertFolderMutable, + assertWorkflowMutable, + authorizeWorkflowByWorkspacePermission, + FolderLockedError, + WorkflowLockedError, +} from '@sim/workflow-authz' +import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateWorkflowContract } from '@/lib/api/contracts/workflows' +import { parseRequest } from '@/lib/api/server' import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { performDeleteWorkflow } from '@/lib/workflows/orchestration' +import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' 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 @@ -86,25 +85,47 @@ export const GET = withRouteHandler( } } - const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + const snapshot = await db.transaction(async (tx) => { + await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`) + const [normalizedData, [workflowRecord]] = await Promise.all([ + loadWorkflowFromNormalizedTables(workflowId, tx), + tx.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1), + ]) + return { normalizedData, workflowRecord } + }) + const responseWorkflowData = snapshot.workflowRecord ?? workflowData + + // Stamp `workflowId` from the path param on each variable so the + // global client-side variables store can filter by workflow without + // requiring persisted variables to carry a redundant `workflowId`. + // The persisted blob may or may not include `workflowId` depending on + // when the variable was last written; the path param is authoritative. + const persistedVariables = + (responseWorkflowData.variables as Record>) || {} + const stampedVariables: Record> = {} + for (const [variableId, variable] of Object.entries(persistedVariables)) { + if (variable && typeof variable === 'object') { + stampedVariables[variableId] = { ...variable, workflowId } + } + } - if (normalizedData) { + if (snapshot.normalizedData) { const finalWorkflowData = { - ...workflowData, + ...responseWorkflowData, state: { - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, + blocks: snapshot.normalizedData.blocks, + edges: snapshot.normalizedData.edges, + loops: snapshot.normalizedData.loops, + parallels: snapshot.normalizedData.parallels, lastSaved: Date.now(), - isDeployed: workflowData.isDeployed || false, - deployedAt: workflowData.deployedAt, + isDeployed: responseWorkflowData.isDeployed || false, + deployedAt: responseWorkflowData.deployedAt, metadata: { - name: workflowData.name, - description: workflowData.description, + name: responseWorkflowData.name, + description: responseWorkflowData.description, }, }, - variables: workflowData.variables || {}, + variables: stampedVariables, } logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`) @@ -115,21 +136,21 @@ export const GET = withRouteHandler( } const emptyWorkflowData = { - ...workflowData, + ...responseWorkflowData, state: { blocks: {}, edges: [], loops: {}, parallels: {}, lastSaved: Date.now(), - isDeployed: workflowData.isDeployed || false, - deployedAt: workflowData.deployedAt, + isDeployed: responseWorkflowData.isDeployed || false, + deployedAt: responseWorkflowData.deployedAt, metadata: { - name: workflowData.name, - description: workflowData.description, + name: responseWorkflowData.name, + description: responseWorkflowData.description, }, }, - variables: workflowData.variables || {}, + variables: stampedVariables, } return NextResponse.json({ data: emptyWorkflowData }, { status: 200 }) @@ -184,6 +205,8 @@ export const DELETE = withRouteHandler( ) } + await assertWorkflowMutable(workflowId) + const { searchParams } = new URL(request.url) const checkTemplates = searchParams.get('check-templates') === 'true' const deleteTemplatesParam = searchParams.get('deleteTemplates') @@ -238,6 +261,10 @@ export const DELETE = withRouteHandler( return NextResponse.json({ success: true }, { status: 200 }) } catch (error: any) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + const elapsed = Date.now() - startTime logger.error(`[${requestId}] Error deleting workflow ${workflowId} after ${elapsed}ms`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) @@ -250,10 +277,10 @@ export const DELETE = withRouteHandler( * Update workflow metadata (name, description, color, folderId) */ export const PUT = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() const startTime = Date.now() - const { id: workflowId } = await params + const { id: workflowId } = await context.params try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -264,8 +291,9 @@ export const PUT = withRouteHandler( const userId = auth.userId - const body = await request.json() - const updates = UpdateWorkflowSchema.parse(body) + const parsed = await parseRequest(updateWorkflowContract, request, context) + if (!parsed.success) return parsed.response + const updates = parsed.data.body // Fetch the workflow to check ownership/access const authorization = await authorizeWorkflowByWorkspacePermission({ @@ -292,78 +320,57 @@ export const PUT = withRouteHandler( ) } - const updateData: Record = { updatedAt: new Date() } - if (updates.name !== undefined) updateData.name = updates.name - if (updates.description !== undefined) updateData.description = updates.description - if (updates.color !== undefined) updateData.color = updates.color - if (updates.folderId !== undefined) updateData.folderId = updates.folderId - if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder - - if (updates.name !== undefined || updates.folderId !== undefined) { - const targetName = updates.name ?? workflowData.name - const targetFolderId = - updates.folderId !== undefined ? updates.folderId : workflowData.folderId - - if (!workflowData.workspaceId) { - logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - - const conditions = [ - eq(workflow.workspaceId, workflowData.workspaceId), - isNull(workflow.archivedAt), - eq(workflow.name, targetName), - ne(workflow.id, workflowId), - ] - - if (targetFolderId) { - conditions.push(eq(workflow.folderId, targetFolderId)) - } else { - conditions.push(isNull(workflow.folderId)) - } + if (updates.locked !== undefined && authorization.workspacePermission !== 'admin') { + logger.warn( + `[${requestId}] User ${userId} denied permission to lock workflow ${workflowId}` + ) + return NextResponse.json( + { error: 'Admin access required to lock workflows' }, + { status: 403 } + ) + } - const [duplicate] = await db - .select({ id: workflow.id }) - .from(workflow) - .where(and(...conditions)) - .limit(1) + const hasNonLockUpdate = Object.keys(updates).some((key) => key !== 'locked') + if (hasNonLockUpdate) { + await assertWorkflowMutable(workflowId) + } + if (updates.folderId !== undefined) { + await assertFolderMutable(updates.folderId) + } - if (duplicate) { - logger.warn( - `[${requestId}] Duplicate workflow name "${targetName}" in folder ${targetFolderId ?? 'root'}` - ) - return NextResponse.json( - { error: `A workflow named "${targetName}" already exists in this folder` }, - { status: 409 } - ) - } + if (!workflowData.workspaceId) { + logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } - // Update the workflow - const [updatedWorkflow] = await db - .update(workflow) - .set(updateData) - .where(eq(workflow.id, workflowId)) - .returning() + const result = await performUpdateWorkflow({ + workflowId, + userId, + workspaceId: workflowData.workspaceId, + currentName: workflowData.name, + currentFolderId: workflowData.folderId, + ...updates, + requestId, + }) + + if (!result.success || !result.workflow) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) + } const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully updated workflow ${workflowId} in ${elapsed}ms`, { - updates: updateData, + updates, }) - return NextResponse.json({ workflow: updatedWorkflow }, { status: 200 }) + return NextResponse.json({ workflow: result.workflow }, { 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 } - ) + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) } + const elapsed = Date.now() - startTime 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..5aa8010084a 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -2,10 +2,15 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' -import { eq } from 'drizzle-orm' +import { + assertWorkflowMutable, + authorizeWorkflowByWorkspacePermission, + WorkflowLockedError, +} from '@sim/workflow-authz' +import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { putWorkflowNormalizedStateContract } from '@/lib/api/contracts/workflows' +import { parseRequest } 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 +28,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. @@ -139,16 +52,42 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const normalized = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalized) { + const snapshot = await db.transaction(async (tx) => { + await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`) + const [normalized, [workflowRecord]] = await Promise.all([ + loadWorkflowFromNormalizedTables(workflowId, tx), + tx + .select({ variables: workflow.variables }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1), + ]) + return { normalized, variables: workflowRecord?.variables } + }) + + if (!snapshot.normalized) { return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 }) } + // Stamp `workflowId` from the path param on each variable so the + // global client-side variables store can filter by workflow without + // requiring clients to thread the path param through. The read + // contract requires this server-stamped field. + const persistedVariables = + (snapshot.variables as Record>) || {} + const variables: Record> = {} + for (const [variableId, variable] of Object.entries(persistedVariables)) { + if (variable && typeof variable === 'object') { + variables[variableId] = { ...variable, workflowId } + } + } + return NextResponse.json({ - blocks: normalized.blocks, - edges: normalized.edges, - loops: normalized.loops || {}, - parallels: normalized.parallels || {}, + blocks: snapshot.normalized.blocks, + edges: snapshot.normalized.edges, + loops: snapshot.normalized.loops || {}, + parallels: snapshot.normalized.parallels || {}, + variables, }) } catch (error) { logger.error('Failed to fetch workflow state', { @@ -165,10 +104,10 @@ export const GET = withRouteHandler( * Save complete workflow state to normalized database tables */ export const PUT = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() const startTime = Date.now() - const { id: workflowId } = await params + const { id: workflowId } = await context.params try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -178,8 +117,9 @@ export const PUT = withRouteHandler( } const userId = auth.userId - const body = await request.json() - const state = WorkflowStateSchema.parse(body) + const parsed = await parseRequest(putWorkflowNormalizedStateContract, request, context) + if (!parsed.success) return parsed.response + const state = parsed.data.body const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId, @@ -205,6 +145,13 @@ export const PUT = withRouteHandler( ) } + await assertWorkflowMutable(workflowId) + + // Note: prior versions cross-checked that each variable's `workflowId` + // equalled the path param. The write contract does not carry `workflowId` + // per variable (the path param is the source of truth), so the check + // is unreachable and was removed. + // Sanitize custom tools in agent blocks before saving const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks( state.blocks as Record @@ -250,10 +197,41 @@ export const PUT = withRouteHandler( deployedAt: state.deployedAt, } - const saveResult = await saveWorkflowToNormalizedTables( - workflowId, - workflowState as WorkflowState - ) + const saveResult = await db.transaction(async (tx) => { + await tx + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + .for('update') + + const result = await saveWorkflowToNormalizedTables( + workflowId, + workflowState as WorkflowState, + tx + ) + + if (!result.success) return result + + // Update workflow's lastSynced timestamp and variables if provided + const updateData: { + lastSynced: Date + updatedAt: Date + variables?: typeof state.variables + } = { + lastSynced: new Date(), + updatedAt: new Date(), + } + + // If variables are provided in the state, update them in the workflow record + if (state.variables !== undefined) { + updateData.variables = state.variables + } + + await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId)) + + return result + }) if (!saveResult.success) { logger.error( @@ -300,19 +278,6 @@ export const PUT = withRouteHandler( logger.error(`[${requestId}] Failed to persist custom tools`, { error, workflowId }) } - // Update workflow's lastSynced timestamp and variables if provided - const updateData: any = { - lastSynced: new Date(), - updatedAt: new Date(), - } - - // If variables are provided in the state, update them in the workflow record - if (state.variables !== undefined) { - updateData.variables = state.variables - } - - await db.update(workflow).set(updateData).where(eq(workflow.id, workflowId)) - const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully saved workflow ${workflowId} state in ${elapsed}ms`) @@ -343,19 +308,16 @@ export const PUT = withRouteHandler( { status: 200 } ) } catch (error: any) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + const elapsed = Date.now() - startTime logger.error( `[${requestId}] Error saving workflow ${workflowId} state after ${elapsed}ms`, 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..4c1d56357d2 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 { getWorkflowStatusContract } from '@/lib/api/contracts/workflows' +import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' @@ -12,12 +14,13 @@ import { const logger = createLogger('WorkflowStatusAPI') export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() + const parsed = await parseRequest(getWorkflowStatusContract, request, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params try { - const { 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 +38,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.test.ts b/apps/sim/app/api/workflows/[id]/variables/route.test.ts index be226926086..a8402ef158e 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.test.ts @@ -12,6 +12,7 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getWorkflowVariablesContract } from '@/lib/api/contracts/workflows' vi.mock('@sim/audit', () => auditMock) @@ -94,7 +95,17 @@ describe('Workflow Variables API Route', () => { expect(response.status).toBe(200) const data = await response.json() - expect(data.data).toEqual(mockWorkflow.variables) + expect(data.data).toEqual({ + 'var-1': { + id: 'var-1', + name: 'test', + type: 'string', + value: 'hello', + workflowId: 'workflow-123', + }, + }) + const parsed = getWorkflowVariablesContract.response.schema.parse(data) + expect(parsed.data['var-1'].workflowId).toBe('workflow-123') }) it('should allow access when user has workspace permissions', async () => { @@ -126,7 +137,16 @@ describe('Workflow Variables API Route', () => { expect(response.status).toBe(200) const data = await response.json() - expect(data.data).toEqual(mockWorkflow.variables) + // GET stamps `workflowId` from the path param on each variable. + expect(data.data).toEqual({ + 'var-1': { + id: 'var-1', + name: 'test', + type: 'string', + value: 'hello', + workflowId: 'workflow-123', + }, + }) }) it('should deny access when user has no workspace permissions', async () => { @@ -313,7 +333,7 @@ describe('Workflow Variables API Route', () => { expect(response.status).toBe(400) const data = await response.json() - expect(data.error).toBe('Invalid request data') + expect(data.error).toBe('Validation error') }) }) diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index 62d90a7e8a5..b2fd323324b 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -2,10 +2,16 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { getErrorMessage } from '@sim/utils/errors' +import { + assertWorkflowMutable, + authorizeWorkflowByWorkspacePermission, + WorkflowLockedError, +} from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { workflowVariablesContract } from '@/lib/api/contracts/workflows' +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' @@ -13,28 +19,10 @@ 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 }> }) => { + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const workflowId = (await params).id + const workflowId = (await context.params).id try { const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) @@ -67,53 +55,50 @@ 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, + await assertWorkflowMutable(workflowId) + + const parsed = await parseRequest(workflowVariablesContract, req, context) + if (!parsed.success) return parsed.response + const { variables } = parsed.data.body + // Note: prior versions cross-checked that each variable's `workflowId` + // equalled the path param. The write contract does not carry `workflowId` + // per variable (the path param is the source of truth), so the check + // is unreachable and was removed. + + // 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 (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 - } + return NextResponse.json({ success: true }) } catch (error) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error(`[${requestId}] Error updating workflow variables`, error) return NextResponse.json({ error: 'Failed to update workflow variables' }, { status: 500 }) } @@ -156,8 +141,17 @@ export const GET = withRouteHandler( ) } - // Return variables if they exist - const variables = (workflowData.variables as Record) || {} + // Return variables if they exist. Stamp `workflowId` from the path + // param on each entry so the global client-side variables store can + // filter by workflow; the read contract requires this stamped field. + const persistedVariables = + (workflowData.variables as Record>) || {} + const variables: Record = {} + for (const [variableId, variable] of Object.entries(persistedVariables)) { + if (variable && typeof variable === 'object') { + variables[variableId] = { ...variable, workflowId } as Variable + } + } // Add cache headers to prevent frequent reloading const variableHash = JSON.stringify(variables).length @@ -175,7 +169,7 @@ export const GET = withRouteHandler( ) } catch (error) { logger.error(`[${requestId}] Workflow variables fetch error`, error) - const errorMessage = error instanceof Error ? error.message : 'Unknown error' + const errorMessage = getErrorMessage(error, 'Unknown error') return NextResponse.json({ error: errorMessage }, { status: 500 }) } } diff --git a/apps/sim/app/api/workflows/middleware.test.ts b/apps/sim/app/api/workflows/middleware.test.ts new file mode 100644 index 00000000000..996466426da --- /dev/null +++ b/apps/sim/app/api/workflows/middleware.test.ts @@ -0,0 +1,130 @@ +/** + * Tests for workflow access middleware — focused on the workspace-scoped + * API key boundary check in the `requireDeployment=false` branch. + * + * @vitest-environment node + */ + +import { + hybridAuthMockFns, + workflowAuthzMock, + workflowAuthzMockFns, + workflowsUtilsMock, + workflowsUtilsMockFns, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) +vi.mock('@sim/workflow-authz', () => workflowAuthzMock) +vi.mock('@/lib/api-key/service', () => ({ + authenticateApiKeyFromHeader: vi.fn(), + updateApiKeyLastUsed: vi.fn(), +})) + +import { validateWorkflowAccess } from '@/app/api/workflows/middleware' + +function makeRequest() { + return new NextRequest(new URL('https://example.com/api/workflows/wf-1/log')) +} + +describe('validateWorkflowAccess (requireDeployment=false)', () => { + beforeEach(() => { + vi.clearAllMocks() + workflowsUtilsMockFns.mockGetWorkflowById.mockResolvedValue({ + id: 'wf-1', + workspaceId: 'ws-A', + isDeployed: true, + }) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'wf-1', workspaceId: 'ws-A' }, + }) + }) + + it('rejects a workspace-scoped API key issued for a different workspace', async () => { + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-1', + authType: 'api_key', + apiKeyType: 'workspace', + workspaceId: 'ws-B', + }) + + const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false) + + expect(result.error).toEqual({ + message: 'API key is not authorized for this workspace', + status: 403, + }) + expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + }) + + it('allows a workspace-scoped API key issued for the matching workspace', async () => { + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-1', + authType: 'api_key', + apiKeyType: 'workspace', + workspaceId: 'ws-A', + }) + + const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false) + + expect(result.error).toBeUndefined() + expect(result.workflow).toBeDefined() + expect(result.auth?.workspaceId).toBe('ws-A') + expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ + workflowId: 'wf-1', + userId: 'user-1', + action: 'read', + }) + }) + + it('allows a personal API key regardless of workspaceId on the auth result', async () => { + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-1', + authType: 'api_key', + apiKeyType: 'personal', + workspaceId: 'ws-B', + }) + + const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false) + + expect(result.error).toBeUndefined() + expect(result.workflow).toBeDefined() + }) + + it('allows session auth (no apiKeyType) when workspace permission grants access', async () => { + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-1', + authType: 'session', + }) + + const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false) + + expect(result.error).toBeUndefined() + expect(result.workflow).toBeDefined() + }) + + it('still enforces workspace-permission rejection for personal keys', async () => { + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-1', + authType: 'api_key', + apiKeyType: 'personal', + }) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: false, + status: 403, + message: 'Access denied', + }) + + const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false) + + expect(result.error).toEqual({ message: 'Access denied', status: 403 }) + }) +}) diff --git a/apps/sim/app/api/workflows/middleware.ts b/apps/sim/app/api/workflows/middleware.ts index 2a66a616c77..10fa3017727 100644 --- a/apps/sim/app/api/workflows/middleware.ts +++ b/apps/sim/app/api/workflows/middleware.ts @@ -54,6 +54,15 @@ export async function validateWorkflowAccess( } } + if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflow.workspaceId) { + return { + error: { + message: 'API key is not authorized for this workspace', + status: 403, + }, + } + } + const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId, userId: auth.userId, diff --git a/apps/sim/app/api/workflows/reorder/route.ts b/apps/sim/app/api/workflows/reorder/route.ts index dbd2980db3b..fdd049f4402 100644 --- a/apps/sim/app/api/workflows/reorder/route.ts +++ b/apps/sim/app/api/workflows/reorder/route.ts @@ -1,9 +1,16 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { + assertFolderMutable, + assertWorkflowMutable, + FolderLockedError, + WorkflowLockedError, +} from '@sim/workflow-authz' import { eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { reorderWorkflowsContract } from '@/lib/api/contracts/workflows' +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' @@ -11,17 +18,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 }) @@ -32,8 +28,9 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { const userId = auth.userId try { - const body = await req.json() - const { workspaceId, updates } = ReorderSchema.parse(body) + const parsed = await parseRequest(reorderWorkflowsContract, req, {}) + if (!parsed.success) return parsed.response + const { workspaceId, updates } = parsed.data.body const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!permission || permission === 'read') { @@ -59,6 +56,13 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'No valid workflows to update' }, { status: 400 }) } + for (const update of validUpdates) { + await assertWorkflowMutable(update.id) + if (update.folderId !== undefined) { + await assertFolderMutable(update.folderId) + } + } + await db.transaction(async (tx) => { for (const update of validUpdates) { const updateData: Record = { @@ -78,12 +82,8 @@ 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 } - ) + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) } logger.error(`[${requestId}] Error reordering workflows`, error) diff --git a/apps/sim/app/api/workflows/route.test.ts b/apps/sim/app/api/workflows/route.test.ts index ed10d8dc497..ebb1e11a1d6 100644 --- a/apps/sim/app/api/workflows/route.test.ts +++ b/apps/sim/app/api/workflows/route.test.ts @@ -87,9 +87,9 @@ describe('Workflows API Route - POST ordering', () => { it('uses top insertion against mixed siblings (folders + workflows)', async () => { const minResultsQueue: Array> = [ + [], [{ minOrder: 5 }], [{ minOrder: 2 }], - [], ] mockDbSelect.mockImplementation(() => ({ diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index d8b902388ea..4a80994510e 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -1,45 +1,33 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { permissions, workflow, workflowFolder } from '@sim/db/schema' +import { permissions, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, asc, eq, inArray, isNull, min, sql } from 'drizzle-orm' +import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { createWorkflowContract, workflowListQuerySchema } from '@/lib/api/contracts/workflows' +import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { 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 { performCreateWorkflow } from '@/lib/workflows/orchestration' 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,16 +63,42 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } } - if (!['active', 'archived', 'all'].includes(scope)) { - return NextResponse.json({ error: 'Invalid scope' }, { status: 400 }) - } - let workflows + /** + * Project only the columns declared in `workflowListItemSchema` so the + * wire response matches the contract shape exactly. The full row is + * larger (`state`, `variables`, `apiKey`, `runCount`, etc.) and would + * be dropped client-side by Zod parse anyway — narrowing here saves + * bytes over the wire. Keep this list aligned with the contract. + */ + const listColumns = { + id: workflow.id, + name: workflow.name, + description: workflow.description, + color: workflow.color, + workspaceId: workflow.workspaceId, + folderId: workflow.folderId, + sortOrder: workflow.sortOrder, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + archivedAt: workflow.archivedAt, + locked: workflow.locked, + } as const const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)] if (workspaceId) { - workflows = await listWorkflows(workspaceId, { scope }) + workflows = await db + .select(listColumns) + .from(workflow) + .where( + scope === 'all' + ? eq(workflow.workspaceId, workspaceId) + : scope === 'archived' + ? and(eq(workflow.workspaceId, workspaceId), sql`${workflow.archivedAt} IS NOT NULL`) + : and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)) + ) + .orderBy(...orderByClause) } else { const workspacePermissionRows = await db .select({ workspaceId: permissions.entityId }) @@ -95,7 +109,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ data: [] }, { status: 200 }) } workflows = await db - .select() + .select(listColumns) .from(workflow) .where( scope === 'all' @@ -114,7 +128,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } catch (error: any) { const elapsed = Date.now() - startTime logger.error(`[${requestId}] Workflow fetch error after ${elapsed}ms`, error) - return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) @@ -129,7 +143,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const userId = auth.userId try { - const body = await req.json() + const parsed = await parseRequest(createWorkflowContract, req, {}) + if (!parsed.success) return parsed.response const { id: clientId, name: requestedName, @@ -139,7 +154,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { folderId, sortOrder: providedSortOrder, deduplicate, - } = CreateWorkflowSchema.parse(body) + } = parsed.data.body if (!workspaceId) { logger.warn(`[${requestId}] Workflow creation blocked: missing workspaceId`) @@ -164,86 +179,31 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - const workflowId = clientId || generateId() - const now = new Date() - - logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${userId}`) - - let sortOrder: number - if (providedSortOrder !== undefined) { - sortOrder = providedSortOrder - } else { - const workflowParentCondition = folderId - ? eq(workflow.folderId, folderId) - : isNull(workflow.folderId) - const folderParentCondition = folderId - ? eq(workflowFolder.parentId, folderId) - : isNull(workflowFolder.parentId) - - const [[workflowMinResult], [folderMinResult]] = await Promise.all([ - db - .select({ minOrder: min(workflow.sortOrder) }) - .from(workflow) - .where( - and( - eq(workflow.workspaceId, workspaceId), - workflowParentCondition, - isNull(workflow.archivedAt) - ) - ), - db - .select({ minOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)), - ]) - - const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce< - number | null - >((currentMin, candidate) => { - if (candidate == null) return currentMin - if (currentMin == null) return candidate - return Math.min(currentMin, candidate) - }, null) + const result = await performCreateWorkflow({ + id: clientId, + name: requestedName, + description, + color, + workspaceId, + folderId, + sortOrder: providedSortOrder, + deduplicate, + userId, + requestId, + }) - sortOrder = minSortOrder != null ? minSortOrder - 1 : 0 + if (!result.success || !result.workflow) { + const status = result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) } - let name = requestedName - - if (deduplicate) { - name = await deduplicateWorkflowName(requestedName, workspaceId, folderId) - } else { - const duplicateConditions = [ - eq(workflow.workspaceId, workspaceId), - isNull(workflow.archivedAt), - eq(workflow.name, requestedName), - ] - - if (folderId) { - duplicateConditions.push(eq(workflow.folderId, folderId)) - } else { - duplicateConditions.push(isNull(workflow.folderId)) - } - - const [duplicateWorkflow] = await db - .select({ id: workflow.id }) - .from(workflow) - .where(and(...duplicateConditions)) - .limit(1) - - if (duplicateWorkflow) { - return NextResponse.json( - { error: `A workflow named "${requestedName}" already exists in this folder` }, - { status: 409 } - ) - } - } + const createdWorkflow = result.workflow import('@/lib/core/telemetry') .then(({ PlatformEvents }) => { PlatformEvents.workflowCreated({ - workflowId, - name, + workflowId: createdWorkflow.id, + name: createdWorkflow.name, workspaceId: workspaceId || undefined, folderId: folderId || undefined, }) @@ -252,86 +212,38 @@ export const POST = withRouteHandler(async (req: NextRequest) => { // Silently fail }) - const { workflowState, subBlockValues, startBlockId } = buildDefaultWorkflowArtifacts() - - await db.transaction(async (tx) => { - await tx.insert(workflow).values({ - id: workflowId, - userId, - workspaceId, - folderId: folderId || null, - sortOrder, - name, - description, - color, - lastSynced: now, - createdAt: now, - updatedAt: now, - isDeployed: false, - runCount: 0, - variables: {}, - }) - - await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) - }) - - logger.info(`[${requestId}] Successfully created workflow ${workflowId} with default blocks`) + logger.info( + `[${requestId}] Successfully created workflow ${createdWorkflow.id} with default blocks` + ) captureServerEvent( userId, 'workflow_created', - { workflow_id: workflowId, workspace_id: workspaceId ?? '', name }, + { + workflow_id: createdWorkflow.id, + workspace_id: workspaceId ?? '', + name: createdWorkflow.name, + }, { groups: workspaceId ? { workspace: workspaceId } : undefined, setOnce: { first_workflow_created_at: new Date().toISOString() }, } ) - recordAudit({ - workspaceId, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.WORKFLOW_CREATED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: name, - description: `Created workflow "${name}"`, - metadata: { - name, - description: description || undefined, - color, - workspaceId, - folderId: folderId || undefined, - sortOrder, - }, - request: req, - }) - return NextResponse.json({ - id: workflowId, - name, - description, - color, - workspaceId, - folderId, - sortOrder, - createdAt: now, - updatedAt: now, - startBlockId, - subBlockValues, + id: createdWorkflow.id, + name: createdWorkflow.name, + description: createdWorkflow.description, + color: createdWorkflow.color, + workspaceId: createdWorkflow.workspaceId, + folderId: createdWorkflow.folderId, + sortOrder: createdWorkflow.sortOrder, + createdAt: createdWorkflow.createdAt, + updatedAt: createdWorkflow.updatedAt, + startBlockId: createdWorkflow.startBlockId, + subBlockValues: createdWorkflow.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/workflows/utils.ts b/apps/sim/app/api/workflows/utils.ts index f460f551b1f..223f6b1e02a 100644 --- a/apps/sim/app/api/workflows/utils.ts +++ b/apps/sim/app/api/workflows/utils.ts @@ -1,9 +1,9 @@ -import { db, workflow, workflowDeploymentVersion } from '@sim/db' +import { db, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' import { and, desc, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { hasWorkflowChanged } from '@/lib/workflows/comparison' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { loadWorkflowDeploymentSnapshot } from '@/lib/workflows/persistence/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -46,25 +46,10 @@ export async function checkNeedsRedeployment(workflowId: string): Promise + /** JSON body schema owned by the concrete route.ts boundary. */ + previewBodySchema: z.ZodType<{ code: string }> } /** @@ -37,8 +44,23 @@ export interface DocumentPreviewRouteConfig { 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 previewContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/_preview', + params: config.routeParamsSchema, + body: config.previewBodySchema, + response: { mode: 'json', schema: config.previewBodySchema }, + }) + + return async function POST(req: NextRequest, context: { params: Promise<{ id: string }> }) { + const paramsResult = config.routeParamsSchema.safeParse(await context.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() @@ -51,17 +73,17 @@ export function createDocumentPreviewRoute(config: DocumentPreviewRouteConfig) { return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - let body: unknown - try { - body = await req.json() - } 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 parsed = await parseRequest(previewContract, req, context, { + validationErrorResponse: (error) => + NextResponse.json( + { error: getValidationErrorMessage(error, 'code is required') }, + { status: 400 } + ), + invalidJsonResponse: () => + NextResponse.json({ error: 'Invalid or missing JSON body' }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + const { code } = parsed.data.body if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { return NextResponse.json({ error: 'code exceeds maximum size' }, { status: 413 }) @@ -83,6 +105,14 @@ export function createDocumentPreviewRoute(config: DocumentPreviewRouteConfig) { }) } catch (err) { const message = toError(err).message + if (err instanceof SandboxUserCodeError) { + logger.warn(`${config.label} preview user code failed`, { + error: message, + errorName: err.name, + workspaceId, + }) + return NextResponse.json({ error: message, errorName: err.name }, { status: 422 }) + } logger.error(`${config.label} preview generation failed`, { error: message, workspaceId }) return NextResponse.json({ error: message }, { status: 500 }) } 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..dab424729cb 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 @@ -2,9 +2,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { and, eq, not } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateWorkspaceApiKeyContract } from '@/lib/api/contracts/api-keys' +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' @@ -13,14 +15,10 @@ 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 }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string; keyId: string }> }) => { const requestId = generateRequestId() - const { id: workspaceId, keyId } = await params + const { id: workspaceId, keyId } = await context.params try { const session = await getSession() @@ -36,8 +34,9 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const body = await request.json() - const { name } = UpdateKeySchema.parse(body) + const parsed = await parseRequest(updateWorkspaceApiKeyContract, request, context) + if (!parsed.success) return parsed.response + const { name } = parsed.data.body const existingKey = await db .select() @@ -118,7 +117,7 @@ export const PUT = withRouteHandler( } catch (error: unknown) { logger.error(`[${requestId}] Workspace API key PUT error`, error) return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to update workspace API key' }, + { error: getErrorMessage(error, 'Failed to update workspace API key') }, { status: 500 } ) } @@ -193,7 +192,7 @@ export const DELETE = withRouteHandler( } catch (error: unknown) { logger.error(`[${requestId}] Workspace API key DELETE error`, error) return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to delete workspace API key' }, + { error: getErrorMessage(error, 'Failed to delete workspace API key') }, { status: 500 } ) } 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..5706dd1699d 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts @@ -2,12 +2,16 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateShortId } from '@sim/utils/id' +import { getErrorMessage } from '@sim/utils/errors' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' -import { hashApiKey } from '@/lib/api-key/crypto' +import { + createWorkspaceApiKeyContract, + deleteWorkspaceApiKeysContract, +} from '@/lib/api/contracts/api-keys' +import { parseRequest } from '@/lib/api/server' +import { getApiKeyDisplayFormat } from '@/lib/api-key/auth' +import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' @@ -17,15 +21,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() @@ -81,7 +76,7 @@ export const GET = withRouteHandler( } catch (error: unknown) { logger.error(`[${requestId}] Workspace API keys GET error`, error) return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to load API keys' }, + { error: getErrorMessage(error, 'Failed to load API keys') }, { status: 500 } ) } @@ -89,9 +84,9 @@ export const GET = withRouteHandler( ) export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const workspaceId = (await params).id + const workspaceId = (await context.params).id try { const session = await getSession() @@ -107,63 +102,21 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const body = await request.json() - const { name, source } = CreateKeySchema.parse(body) - - const existingKey = await db - .select() - .from(apiKey) - .where( - and( - eq(apiKey.workspaceId, workspaceId), - eq(apiKey.name, name), - eq(apiKey.type, 'workspace') - ) - ) - .limit(1) + const parsed = await parseRequest(createWorkspaceApiKeyContract, request, context) + if (!parsed.success) return parsed.response + const { name, source } = parsed.data.body - if (existingKey.length > 0) { - return NextResponse.json( - { - error: `A workspace API key named "${name}" already exists. Please choose a different name.`, - }, - { status: 409 } - ) - } - - const { key: plainKey, encryptedKey } = await createApiKey(true) - - if (!encryptedKey) { - throw new Error('Failed to encrypt API key for storage') - } - - const [newKey] = await db - .insert(apiKey) - .values({ - id: generateShortId(), - workspaceId, - userId: userId, - createdBy: userId, - name, - key: encryptedKey, - keyHash: hashApiKey(plainKey), - type: 'workspace', - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning({ - id: apiKey.id, - name: apiKey.name, - createdAt: apiKey.createdAt, - }) - - try { - PlatformEvents.apiKeyGenerated({ - userId: userId, - keyName: name, - }) - } catch { - // Telemetry should not fail the operation + const result = await performCreateWorkspaceApiKey({ + workspaceId, + userId, + name, + source, + actorName: session.user.name, + actorEmail: session.user.email, + }) + if (!result.success || !result.key) { + const status = result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) } captureServerEvent( @@ -178,30 +131,13 @@ export const POST = withRouteHandler( logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`) - recordAudit({ - workspaceId, - actorId: userId, - actorName: session?.user?.name, - actorEmail: session?.user?.email, - action: AuditAction.API_KEY_CREATED, - resourceType: AuditResourceType.API_KEY, - resourceId: newKey.id, - resourceName: name, - description: `Created API key "${name}"`, - metadata: { keyName: name, keyType: 'workspace', source: source ?? 'settings' }, - request, - }) - return NextResponse.json({ - key: { - ...newKey, - key: plainKey, - }, + key: result.key, }) } catch (error: unknown) { logger.error(`[${requestId}] Workspace API key POST error`, error) return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to create workspace API key' }, + { error: getErrorMessage(error, 'Failed to create workspace API key') }, { status: 500 } ) } @@ -209,9 +145,9 @@ export const POST = withRouteHandler( ) export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const workspaceId = (await params).id + const workspaceId = (await context.params).id try { const session = await getSession() @@ -227,8 +163,9 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const body = await request.json() - const { keys } = DeleteKeysSchema.parse(body) + const parsed = await parseRequest(deleteWorkspaceApiKeysContract, request, context) + if (!parsed.success) return parsed.response + const { keys } = parsed.data.body const deletedCount = await db .delete(apiKey) @@ -271,7 +208,7 @@ export const DELETE = withRouteHandler( } catch (error: unknown) { logger.error(`[${requestId}] Workspace API key DELETE error`, error) return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to delete workspace API keys' }, + { error: getErrorMessage(error, 'Failed to delete workspace API keys') }, { status: 500 } ) } 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..fb2d52d7d34 100644 --- a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts @@ -2,10 +2,12 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workspaceBYOKKeys } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { deleteByokKeyContract, upsertByokKeyContract } from '@/lib/api/contracts/byok-keys' +import { parseRequest } 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 +17,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) @@ -123,7 +99,7 @@ export const GET = withRouteHandler( } catch (error: unknown) { logger.error(`[${requestId}] BYOK keys GET error`, error) return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to load BYOK keys' }, + { error: getErrorMessage(error, 'Failed to load BYOK keys') }, { status: 500 } ) } @@ -131,9 +107,9 @@ export const GET = withRouteHandler( ) export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const workspaceId = (await params).id + const workspaceId = (await context.params).id try { const session = await getSession() @@ -152,8 +128,9 @@ export const POST = withRouteHandler( ) } - const body = await request.json() - const { providerId, apiKey } = UpsertKeySchema.parse(body) + const parsed = await parseRequest(upsertByokKeyContract, request, context) + if (!parsed.success) return parsed.response + const { providerId, apiKey } = parsed.data.body const { encrypted } = await encryptSecret(apiKey) @@ -256,11 +233,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 }) - } return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to save BYOK key' }, + { error: getErrorMessage(error, 'Failed to save BYOK key') }, { status: 500 } ) } @@ -268,9 +242,9 @@ export const POST = withRouteHandler( ) export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const workspaceId = (await params).id + const workspaceId = (await context.params).id try { const session = await getSession() @@ -289,8 +263,9 @@ export const DELETE = withRouteHandler( ) } - const body = await request.json() - const { providerId } = DeleteKeySchema.parse(body) + const parsed = await parseRequest(deleteByokKeyContract, request, context) + if (!parsed.success) return parsed.response + const { providerId } = parsed.data.body const result = await db .delete(workspaceBYOKKeys) @@ -326,11 +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 }) - } return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to delete BYOK key' }, + { error: getErrorMessage(error, 'Failed to delete BYOK key') }, { status: 500 } ) } diff --git a/apps/sim/app/api/workspaces/[id]/data-retention/route.ts b/apps/sim/app/api/workspaces/[id]/data-retention/route.ts deleted file mode 100644 index f1dbc043b33..00000000000 --- a/apps/sim/app/api/workspaces/[id]/data-retention/route.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { workspace } 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 { getSession } from '@/lib/auth' -import { CLEANUP_CONFIG } from '@/lib/billing/cleanup-dispatcher' -import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' -import { isEnterprisePlan } from '@/lib/billing/core/subscription' -import { getPlanType, type PlanCategory } from '@/lib/billing/plan-helpers' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' - -const logger = createLogger('DataRetentionAPI') - -const MIN_HOURS = 24 -const MAX_HOURS = 43800 // 5 years - -interface RetentionValues { - logRetentionHours: number | null - softDeleteRetentionHours: number | null - taskCleanupHours: number | null -} - -function getPlanDefaults(plan: PlanCategory): RetentionValues { - return { - logRetentionHours: CLEANUP_CONFIG['cleanup-logs'].defaults[plan], - softDeleteRetentionHours: CLEANUP_CONFIG['cleanup-soft-deletes'].defaults[plan], - taskCleanupHours: CLEANUP_CONFIG['cleanup-tasks'].defaults[plan], - } -} - -async function resolveWorkspacePlan(billedAccountUserId: string): Promise { - const sub = await getHighestPrioritySubscription(billedAccountUserId) - return getPlanType(sub?.plan) -} - -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(), -}) - -/** - * GET /api/workspaces/[id]/data-retention - * Returns the workspace's data retention config including plan defaults and - * whether the workspace is on an enterprise plan. - */ -export const GET = withRouteHandler( - async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: workspaceId } = await params - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) - } - - const [ws] = await db - .select({ - logRetentionHours: workspace.logRetentionHours, - softDeleteRetentionHours: workspace.softDeleteRetentionHours, - taskCleanupHours: workspace.taskCleanupHours, - billedAccountUserId: workspace.billedAccountUserId, - }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) - - if (!ws) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - const plan = await resolveWorkspacePlan(ws.billedAccountUserId) - const defaults = getPlanDefaults(plan) - const isEnterpriseWorkspace = !isBillingEnabled || plan === 'enterprise' - - return NextResponse.json({ - success: true, - data: { - plan, - isEnterprise: isEnterpriseWorkspace, - defaults, - configured: { - logRetentionHours: ws.logRetentionHours, - softDeleteRetentionHours: ws.softDeleteRetentionHours, - taskCleanupHours: ws.taskCleanupHours, - }, - effective: isEnterpriseWorkspace - ? { - logRetentionHours: ws.logRetentionHours, - softDeleteRetentionHours: ws.softDeleteRetentionHours, - taskCleanupHours: ws.taskCleanupHours, - } - : { - logRetentionHours: defaults.logRetentionHours, - softDeleteRetentionHours: defaults.softDeleteRetentionHours, - taskCleanupHours: defaults.taskCleanupHours, - }, - }, - }) - } catch (error) { - logger.error('Failed to get data retention settings', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) - -/** - * PUT /api/workspaces/[id]/data-retention - * Updates the workspace's data retention settings. - * Requires admin permission and enterprise plan. - */ -export const PUT = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: workspaceId } = await params - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const billedAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId) - if (!billedAccountUserId) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - if (isBillingEnabled) { - const hasEnterprise = await isEnterprisePlan(billedAccountUserId) - if (!hasEnterprise) { - return NextResponse.json( - { error: 'Data Retention configuration is available on Enterprise plans only' }, - { status: 403 } - ) - } - } - - const body = await request.json() - const parsed = updateRetentionSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.errors[0]?.message ?? 'Invalid request body' }, - { status: 400 } - ) - } - - const updateData: Record = { updatedAt: new Date() } - - if (parsed.data.logRetentionHours !== undefined) { - updateData.logRetentionHours = parsed.data.logRetentionHours - } - if (parsed.data.softDeleteRetentionHours !== undefined) { - updateData.softDeleteRetentionHours = parsed.data.softDeleteRetentionHours - } - if (parsed.data.taskCleanupHours !== undefined) { - updateData.taskCleanupHours = parsed.data.taskCleanupHours - } - - const [updated] = await db - .update(workspace) - .set(updateData) - .where(eq(workspace.id, workspaceId)) - .returning({ - logRetentionHours: workspace.logRetentionHours, - softDeleteRetentionHours: workspace.softDeleteRetentionHours, - taskCleanupHours: workspace.taskCleanupHours, - }) - - if (!updated) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - recordAudit({ - workspaceId, - actorId: session.user.id, - action: AuditAction.ORGANIZATION_UPDATED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: workspaceId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: 'Updated data retention settings', - metadata: { changes: parsed.data }, - request, - }) - - const defaults = getPlanDefaults('enterprise') - - return NextResponse.json({ - success: true, - data: { - plan: 'enterprise' as const, - isEnterprise: true, - defaults, - configured: { - logRetentionHours: updated.logRetentionHours, - softDeleteRetentionHours: updated.softDeleteRetentionHours, - taskCleanupHours: updated.taskCleanupHours, - }, - effective: { - logRetentionHours: updated.logRetentionHours, - softDeleteRetentionHours: updated.softDeleteRetentionHours, - taskCleanupHours: updated.taskCleanupHours, - }, - }, - }) - } catch (error) { - logger.error('Failed to update data retention settings', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts b/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts index cffe9cf9aef..6f14fd0649a 100644 --- a/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts @@ -6,9 +6,15 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' -const { mockRunSandboxTask } = vi.hoisted(() => ({ - mockRunSandboxTask: vi.fn(), -})) +const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => { + class SandboxUserCodeError extends Error { + constructor(message: string, name: string) { + super(message) + this.name = name + } + } + return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError } +}) const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership @@ -16,6 +22,7 @@ vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) vi.mock('@/lib/execution/sandbox/run-task', () => ({ runSandboxTask: mockRunSandboxTask, + SandboxUserCodeError, })) import { POST } from '@/app/api/workspaces/[id]/docx/preview/route' @@ -189,4 +196,31 @@ describe('DOCX preview API route', () => { expect(response.status).toBe(500) await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' }) }) + + it('returns 422 when user code throws inside the sandbox', async () => { + mockRunSandboxTask.mockRejectedValue( + new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError') + ) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'const x = ' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(422) + await expect(response.json()).resolves.toEqual({ + error: 'Invalid or unexpected token', + errorName: 'SyntaxError', + }) + }) }) 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 deleted file mode 100644 index 3d0f939073e..00000000000 --- a/apps/sim/app/api/workspaces/[id]/duplicate/route.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -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 }> }) => { - const { id: sourceWorkspaceId } = await params - const requestId = generateRequestId() - const startTime = Date.now() - - const session = await getSession() - if (!session?.user?.id) { - logger.warn( - `[${requestId}] Unauthorized workspace duplication attempt for ${sourceWorkspaceId}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const body = await req.json() - const { name } = DuplicateRequestSchema.parse(body) - - logger.info( - `[${requestId}] Duplicating workspace ${sourceWorkspaceId} for user ${session.user.id}` - ) - - const result = await duplicateWorkspace({ - sourceWorkspaceId, - userId: session.user.id, - name, - requestId, - }) - - const elapsed = Date.now() - startTime - logger.info( - `[${requestId}] Successfully duplicated workspace ${sourceWorkspaceId} to ${result.id} in ${elapsed}ms` - ) - - recordAudit({ - workspaceId: sourceWorkspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.WORKSPACE_DUPLICATED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: result.id, - resourceName: name, - description: `Duplicated workspace to "${name}"`, - metadata: { - sourceWorkspaceId, - affected: { workflows: result.workflowsCount, folders: result.foldersCount }, - }, - request: req, - }) - - return NextResponse.json(result, { status: 201 }) - } catch (error) { - if (error instanceof Error) { - if (error.message === 'Source workspace not found') { - logger.warn(`[${requestId}] Source workspace ${sourceWorkspaceId} not found`) - return NextResponse.json({ error: 'Source workspace not found' }, { status: 404 }) - } - - if (error.message === 'Source workspace not found or access denied') { - logger.warn( - `[${requestId}] User ${session.user.id} denied access to source workspace ${sourceWorkspaceId}` - ) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - } - - 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:`, - error - ) - return NextResponse.json({ error: 'Failed to duplicate workspace' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index 4cabaec2583..23bd4bd18f1 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -3,9 +3,13 @@ import { db } from '@sim/db' import { workspaceEnvironment } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { + removeWorkspaceEnvironmentContract, + upsertWorkspaceEnvironmentContract, +} from '@/lib/api/contracts/environment' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' @@ -19,14 +23,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() @@ -79,9 +75,9 @@ export const GET = withRouteHandler( ) export const PUT = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const workspaceId = (await params).id + const workspaceId = (await context.params).id try { const session = await getSession() @@ -96,19 +92,10 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const body = await request.json() - const { variables } = UpsertSchema.parse(body) + const parsed = await parseRequest(upsertWorkspaceEnvironmentContract, request, context) + if (!parsed.success) return parsed.response + const { variables } = parsed.data.body - // Read existing encrypted ws vars - const existingRows = await db - .select() - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, workspaceId)) - .limit(1) - - const existingEncrypted: Record = (existingRows[0]?.variables as any) || {} - - // Encrypt incoming const encryptedIncoming = await Promise.all( Object.entries(variables).map(async ([key, value]) => { const { encrypted } = await encryptSecret(value) @@ -116,22 +103,37 @@ export const PUT = withRouteHandler( }) ).then((entries) => Object.fromEntries(entries)) - const merged = { ...existingEncrypted, ...encryptedIncoming } - - // Upsert by unique workspace_id - await db - .insert(workspaceEnvironment) - .values({ - id: generateId(), - workspaceId, - variables: merged, - createdAt: new Date(), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [workspaceEnvironment.workspaceId], - set: { variables: merged, updatedAt: new Date() }, - }) + const { existingEncrypted, merged } = await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${workspaceId}))`) + + const [existingRow] = await tx + .select() + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, workspaceId)) + .limit(1) + + const existing = ((existingRow?.variables as Record) ?? {}) as Record< + string, + string + > + const mergedVars = { ...existing, ...encryptedIncoming } + + await tx + .insert(workspaceEnvironment) + .values({ + id: generateId(), + workspaceId, + variables: mergedVars, + createdAt: new Date(), + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [workspaceEnvironment.workspaceId], + set: { variables: mergedVars, updatedAt: new Date() }, + }) + + return { existingEncrypted: existing, merged: mergedVars } + }) const newKeys = Object.keys(variables).filter((k) => !(k in existingEncrypted)) await createWorkspaceEnvCredentials({ workspaceId, newKeys, actingUserId: userId }) @@ -165,9 +167,9 @@ export const PUT = withRouteHandler( ) export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const workspaceId = (await params).id + const workspaceId = (await context.params).id try { const session = await getSession() @@ -182,42 +184,45 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const body = await request.json() - const { keys } = DeleteSchema.parse(body) - - const wsRows = await db - .select() - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, workspaceId)) - .limit(1) - - const current: Record = (wsRows[0]?.variables as any) || {} - let changed = false - for (const k of keys) { - if (k in current) { - delete current[k] - changed = true + const parsed = await parseRequest(removeWorkspaceEnvironmentContract, request, context) + if (!parsed.success) return parsed.response + const { keys } = parsed.data.body + + const result = await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${workspaceId}))`) + + const [existingRow] = await tx + .select() + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, workspaceId)) + .limit(1) + + if (!existingRow) return null + + const current: Record = + (existingRow.variables as Record) ?? {} + let modified = false + for (const k of keys) { + if (k in current) { + delete current[k] + modified = true + } } - } - if (!changed) { + if (!modified) return null + + await tx + .update(workspaceEnvironment) + .set({ variables: current, updatedAt: new Date() }) + .where(eq(workspaceEnvironment.workspaceId, workspaceId)) + + return { remainingKeysCount: Object.keys(current).length } + }) + + if (!result) { return NextResponse.json({ success: true }) } - await db - .insert(workspaceEnvironment) - .values({ - id: wsRows[0]?.id || generateId(), - workspaceId, - variables: current, - createdAt: wsRows[0]?.createdAt || new Date(), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [workspaceEnvironment.workspaceId], - set: { variables: current, updatedAt: new Date() }, - }) - await deleteWorkspaceEnvCredentials({ workspaceId, removedKeys: keys }) recordAudit({ @@ -231,7 +236,7 @@ export const DELETE = withRouteHandler( description: `Removed ${keys.length} workspace environment variable(s)`, metadata: { removedKeys: keys, - remainingKeysCount: Object.keys(current).length, + remainingKeysCount: result.remainingKeysCount, }, request, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts new file mode 100644 index 00000000000..7324c915c20 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts @@ -0,0 +1,103 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { workspaceFileCompiledCheckContract } from '@/lib/api/contracts/workspace-files' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' +import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' +import { validateMermaidSource } from '@/lib/mermaid/validate' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const logger = createLogger('WorkspaceFileCompiledCheckAPI') + +/** + * GET /api/workspaces/[id]/files/[fileId]/compiled-check + * + * Compiles or validates the saved source for generated document-like files and + * returns whether it succeeds. Used by the file agent to self-verify generated + * code or diagram syntax before finalising an edit. + * + * Returns: + * 200 { ok: true } + * 200 { ok: false, error: string, errorName: string } — user code error + * 4xx on auth / missing file / unsupported extension + * 500 on system (sandbox infra) failure + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { + const parsed = await parseRequest(workspaceFileCompiledCheckContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, fileId } = parsed.data.params + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const membership = await verifyWorkspaceMembership(session.user.id, workspaceId) + if (!membership) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const fileRecord = await getWorkspaceFile(workspaceId, fileId) + if (!fileRecord) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + + const ext = fileRecord.name.split('.').pop()?.toLowerCase() ?? '' + const taskId = BINARY_DOC_TASKS[ext] + const isMermaidFile = ext === 'mmd' || ext === 'mermaid' + if (!taskId && !isMermaidFile) { + return NextResponse.json( + { error: `Compiled check only supports .docx, .pptx, .pdf, and .mmd files` }, + { status: 422 } + ) + } + + let buffer: Buffer + try { + buffer = await fetchWorkspaceFileBuffer(fileRecord) + } catch (err) { + logger.error('Failed to download file for compiled check', { + fileId, + error: toError(err).message, + }) + return NextResponse.json({ error: 'Failed to read file' }, { status: 500 }) + } + + const code = buffer.toString('utf-8') + + if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { + return NextResponse.json({ error: 'File source exceeds maximum size' }, { status: 413 }) + } + + if (isMermaidFile) { + return NextResponse.json(await validateMermaidSource(code)) + } + + try { + if (!taskId) { + return NextResponse.json({ error: 'Unsupported compiled check target' }, { status: 422 }) + } + await runSandboxTask(taskId, { code, workspaceId }, { ownerKey: `user:${session.user.id}` }) + return NextResponse.json({ ok: true }) + } catch (err) { + if (err instanceof SandboxUserCodeError) { + logger.info('Compiled check failed with user code error', { + fileId, + taskId, + error: toError(err).message, + errorName: err.name, + }) + return NextResponse.json({ ok: false, error: toError(err).message, errorName: err.name }) + } + throw err + } + } +) 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 606978a9279..beece206917 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts @@ -1,8 +1,10 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { updateWorkspaceFileContentContract } from '@/lib/api/contracts/workspace-files' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -16,36 +18,30 @@ const logger = createLogger('WorkspaceFileContentAPI') * Update a workspace file's text content (requires write permission) */ export const PUT = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - const { id: workspaceId, fileId } = await params - + async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { try { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const parsed = await parseRequest(updateWorkspaceFileContentContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, fileId } = parsed.data.params + const { content, encoding } = parsed.data.body + const userPermission = await getUserEntityPermissions( session.user.id, 'workspace', workspaceId ) if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` - ) + logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`) return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - const body = await request.json() - const { content } = body as { content: string } - - if (typeof content !== 'string') { - return NextResponse.json({ error: 'Content must be a string' }, { status: 400 }) - } - - const buffer = Buffer.from(content, 'utf-8') + const buffer = + encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8') const maxFileSizeBytes = 50 * 1024 * 1024 if (buffer.length > maxFileSizeBytes) { @@ -62,7 +58,7 @@ export const PUT = withRouteHandler( buffer ) - logger.info(`[${requestId}] Updated content for workspace file: ${updatedFile.name}`) + logger.info(`Updated content for workspace file: ${updatedFile.name}`) recordAudit({ workspaceId, @@ -83,15 +79,15 @@ export const PUT = withRouteHandler( file: updatedFile, }) } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to update file content' + const errorMessage = toError(error).message || 'Failed to update file content' const isNotFound = errorMessage.includes('File not found') const isQuotaExceeded = errorMessage.includes('Storage limit exceeded') const status = isNotFound ? 404 : isQuotaExceeded ? 402 : 500 if (status === 500) { - logger.error(`[${requestId}] Error updating file content:`, error) + logger.error('Error updating file content:', error) } else { - logger.warn(`[${requestId}] ${errorMessage}`) + logger.warn(errorMessage) } return NextResponse.json({ success: false, error: errorMessage }, { status }) 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..d56e7ab6442 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,8 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,7 +21,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() @@ -57,7 +67,7 @@ export const POST = withRouteHandler( return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Failed to generate download URL', + error: getErrorMessage(error, 'Failed to generate download URL'), }, { status: 500 } ) 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..0d41810c77e 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,10 +1,12 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files' +import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { FileConflictError, restoreWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { performRestoreWorkspaceFile } from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreWorkspaceFileAPI') @@ -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() @@ -29,31 +38,25 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - await restoreWorkspaceFile(workspaceId, fileId) - - logger.info(`[${requestId}] Restored workspace file ${fileId}`) - - recordAudit({ + const result = await performRestoreWorkspaceFile({ workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_RESTORED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: fileId, - description: `Restored workspace file ${fileId}`, - request, + fileId, + userId: session.user.id, }) + if (!result.success) { + return NextResponse.json( + { error: result.error }, + { status: result.errorCode === 'conflict' ? 409 : 500 } + ) + } + + logger.info(`[${requestId}] Restored workspace file ${fileId}`) return NextResponse.json({ success: true }) } catch (error) { - if (error instanceof FileConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } logger.error(`[${requestId}] Error restoring workspace file ${fileId}`, error) return NextResponse.json( - { error: error instanceof Error ? error.message : 'Internal server error' }, + { error: getErrorMessage(error, 'Internal server error') }, { status: 500 } ) } 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..1826988ea08 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts @@ -1,14 +1,19 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { + renameWorkspaceFileContract, + workspaceFileParamsSchema, +} from '@/lib/api/contracts/workspace-files' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' import { - deleteWorkspaceFile, - FileConflictError, - renameWorkspaceFile, -} from '@/lib/uploads/contexts/workspace' + performDeleteWorkspaceFileItems, + performRenameWorkspaceFile, +} from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -20,9 +25,8 @@ const logger = createLogger('WorkspaceFileAPI') * Rename a workspace file (requires write permission) */ export const PATCH = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { const requestId = generateRequestId() - const { id: workspaceId, fileId } = await params try { const session = await getSession() @@ -30,6 +34,11 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const parsed = await parseRequest(renameWorkspaceFileContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, fileId } = parsed.data.params + const { name } = parsed.data.body + const userPermission = await getUserEntityPermissions( session.user.id, 'workspace', @@ -42,42 +51,39 @@ 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 updatedFile = await renameWorkspaceFile(workspaceId, fileId, name) - - logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${updatedFile.name}"`) - - recordAudit({ + const result = await performRenameWorkspaceFile({ workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_UPDATED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: updatedFile.name, - description: `Renamed file to "${updatedFile.name}"`, - request, + fileId, + name, + userId: session.user.id, }) + if (!result.success || !result.file) { + return NextResponse.json( + { success: false, error: result.error }, + { status: result.errorCode === 'conflict' ? 409 : 500 } + ) + } + logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${result.file.name}"`) + + captureServerEvent( + session.user.id, + 'file_renamed', + { workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) return NextResponse.json({ success: true, - file: updatedFile, + file: result.file, }) } catch (error) { logger.error(`[${requestId}] Error renaming workspace file:`, error) return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Failed to rename file', + error: getErrorMessage(error, 'Failed to rename file'), }, - { status: error instanceof FileConflictError ? 409 : 500 } + { status: 500 } ) } } @@ -90,7 +96,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() @@ -111,22 +124,33 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - await deleteWorkspaceFile(workspaceId, fileId) - - logger.info(`[${requestId}] Archived workspace file: ${fileId}`) - - recordAudit({ + const result = await performDeleteWorkspaceFileItems({ workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_DELETED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - description: `Archived file "${fileId}"`, - request, + userId: session.user.id, + fileIds: [fileId], }) + if (!result.success) { + return NextResponse.json( + { success: false, error: result.error }, + { + status: + result.errorCode === 'validation' + ? 400 + : result.errorCode === 'not_found' + ? 404 + : 500, + } + ) + } + + logger.info(`[${requestId}] Archived workspace file: ${fileId}`) + captureServerEvent( + session.user.id, + 'file_deleted', + { workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) return NextResponse.json({ success: true, }) @@ -135,7 +159,7 @@ export const DELETE = withRouteHandler( return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Failed to delete file', + error: getErrorMessage(error, 'Failed to delete file'), }, { status: 500 } ) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts new file mode 100644 index 00000000000..cc68e4dc348 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts @@ -0,0 +1,90 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { workspaceFileStyleContract } from '@/lib/api/contracts/workspace-files' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const logger = createLogger('WorkspaceFileStyleAPI') + +/** + * GET /api/workspaces/[id]/files/[fileId]/style + * Extract a compact JSON style summary from an uploaded .docx, .pptx, or .pdf file. + * OOXML files return theme colors, font pair, and named styles. + * PDF files return page dimensions and embedded font names. + */ +const MAX_STYLE_FILE_BYTES = 100 * 1024 * 1024 // 100 MB + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(workspaceFileStyleContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, fileId } = parsed.data.params + + const membership = await verifyWorkspaceMembership(session.user.id, workspaceId) + if (!membership) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const fileRecord = await getWorkspaceFile(workspaceId, fileId) + if (!fileRecord) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + + const rawExt = fileRecord.name.split('.').pop()?.toLowerCase() + if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') { + return NextResponse.json( + { error: 'Style extraction supports .docx, .pptx, and .pdf files' }, + { status: 422 } + ) + } + const ext: 'docx' | 'pptx' | 'pdf' = rawExt + + if (fileRecord.size > MAX_STYLE_FILE_BYTES) { + return NextResponse.json( + { error: 'File is too large for style extraction (limit: 100 MB)' }, + { status: 422 } + ) + } + + let buffer: Buffer + try { + buffer = await fetchWorkspaceFileBuffer(fileRecord) + } catch (err) { + logger.error('Failed to download file for style extraction', { + fileId, + error: toError(err).message, + }) + return NextResponse.json({ error: 'Failed to read file' }, { status: 500 }) + } + + const summary = await extractDocumentStyle(buffer, ext) + if (!summary) { + return NextResponse.json( + { + error: + 'Could not extract style — file may be encrypted, corrupt, image-only, or contain no parseable style information', + }, + { status: 422 } + ) + } + + logger.info('Extracted style summary via API', { fileId, format: ext }) + + return NextResponse.json(summary, { + headers: { 'Cache-Control': 'private, max-age=300' }, + }) + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts new file mode 100644 index 00000000000..1f47f650bd6 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts @@ -0,0 +1,70 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { bulkArchiveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceFileBulkArchiveAPI') + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(bulkArchiveWorkspaceFileItemsContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { fileIds, folderIds } = parsed.data.body + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId: session.user.id, + fileIds, + folderIds, + }) + if (!result.success) { + return NextResponse.json( + { success: false, error: result.error }, + { + status: + result.errorCode === 'validation' + ? 400 + : result.errorCode === 'not_found' + ? 404 + : 500, + } + ) + } + if (!result.deletedItems) { + return NextResponse.json( + { success: false, error: 'Failed to delete workspace file items' }, + { status: 500 } + ) + } + + captureServerEvent( + session.user.id, + 'file_bulk_deleted', + { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, + { groups: { workspace: workspaceId } } + ) + + return NextResponse.json({ success: true, deletedItems: result.deletedItems }) + } catch (error) { + logger.error('Failed to bulk archive workspace file items:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.ts new file mode 100644 index 00000000000..c65b5438158 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.ts @@ -0,0 +1,150 @@ +import { createLogger } from '@sim/logger' +import JSZip from 'jszip' +import { type NextRequest, NextResponse } from 'next/server' +import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + buildWorkspaceFileFolderPathMap, + fetchWorkspaceFileBuffer, + listWorkspaceFileFolders, + listWorkspaceFiles, +} from '@/lib/uploads/contexts/workspace' +import { formatFileSize } from '@/lib/uploads/utils/file-utils' +import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' + +const logger = createLogger('WorkspaceFilesDownloadAPI') +const MAX_ZIP_DOWNLOAD_FILES = 100 +const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024 + +function safeZipPath(path: string): string { + return path + .split('/') + .map((segment) => { + const cleaned = segment.trim().replace(/[<>:"\\|?*\x00-\x1f]/g, '_') + return cleaned === '.' || cleaned === '..' ? '_' : cleaned + }) + .filter(Boolean) + .join('/') +} + +function withZipPathSuffix(path: string, suffix: number): string { + const slashIndex = path.lastIndexOf('/') + const directory = slashIndex >= 0 ? `${path.slice(0, slashIndex + 1)}` : '' + const filename = slashIndex >= 0 ? path.slice(slashIndex + 1) : path + const dotIndex = filename.lastIndexOf('.') + + return dotIndex > 0 + ? `${directory}${filename.slice(0, dotIndex)} (${suffix})${filename.slice(dotIndex)}` + : `${directory}${filename} (${suffix})` +} + +function collectDescendantFolderIds( + selectedFolderIds: string[], + folders: Array<{ id: string; parentId: string | null }> +): Set { + const folderIds = new Set(selectedFolderIds) + let changed = true + while (changed) { + changed = false + for (const folder of folders) { + if (folder.parentId && folderIds.has(folder.parentId) && !folderIds.has(folder.id)) { + folderIds.add(folder.id) + changed = true + } + } + } + return folderIds +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(downloadWorkspaceFileItemsContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { fileIds, folderIds } = parsed.data.query + + const permission = await verifyWorkspaceMembership(session.user.id, workspaceId) + if (!permission) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const [files, folders] = await Promise.all([ + listWorkspaceFiles(workspaceId, { hydrateFolderPaths: false }), + listWorkspaceFileFolders(workspaceId), + ]) + const folderPaths = buildWorkspaceFileFolderPathMap(folders) + const selectedFolderIds = collectDescendantFolderIds(folderIds, folders) + const requestedFileIds = new Set(fileIds) + const filesToZip = files.filter( + (file) => + requestedFileIds.has(file.id) || (file.folderId && selectedFolderIds.has(file.folderId)) + ) + + if (filesToZip.length === 0) { + return NextResponse.json({ error: 'No files selected for download' }, { status: 400 }) + } + + if (filesToZip.length > MAX_ZIP_DOWNLOAD_FILES) { + return NextResponse.json( + { + error: `Too many files selected for download. Select ${MAX_ZIP_DOWNLOAD_FILES} or fewer files.`, + }, + { status: 400 } + ) + } + + const totalBytes = filesToZip.reduce((sum, file) => sum + file.size, 0) + if (totalBytes > MAX_ZIP_DOWNLOAD_BYTES) { + return NextResponse.json( + { + error: `Selected files total ${formatFileSize(totalBytes)}, which exceeds the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit.`, + }, + { status: 400 } + ) + } + + const buffers = await Promise.all(filesToZip.map((file) => fetchWorkspaceFileBuffer(file))) + + // Assemble zip synchronously so path deduplication is deterministic. + const zip = new JSZip() + const usedPaths = new Set() + for (let i = 0; i < filesToZip.length; i++) { + const file = filesToZip[i] + const buffer = buffers[i] + const folderPath = file.folderId ? folderPaths.get(file.folderId) : null + const basePath = + safeZipPath(folderPath ? `${folderPath}/${file.name}` : file.name) || + safeZipPath(file.name) || + file.id + let zipPath = basePath + let suffix = 2 + while (usedPaths.has(zipPath)) { + zipPath = withZipPathSuffix(basePath, suffix) + suffix++ + } + usedPaths.add(zipPath) + zip.file(zipPath, buffer) + } + + const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' }) + return new NextResponse(new Uint8Array(zipBuffer), { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': 'attachment; filename="workspace-files.zip"', + 'Cache-Control': 'no-store', + }, + }) + } catch (error) { + logger.error('Failed to download workspace file selection:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts new file mode 100644 index 00000000000..86df6e83f91 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts @@ -0,0 +1,66 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { restoreWorkspaceFileFolderContract } from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { + performRestoreWorkspaceFileFolder, + workspaceFilesOrchestrationStatus, +} from '@/lib/workspace-files/orchestration' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceFileFolderRestoreAPI') + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(restoreWorkspaceFileFolderContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, folderId } = parsed.data.params + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performRestoreWorkspaceFileFolder({ + workspaceId, + folderId, + userId: session.user.id, + }) + if (!result.success) { + return NextResponse.json( + { success: false, error: result.error }, + { status: workspaceFilesOrchestrationStatus(result.errorCode) } + ) + } + const { folder, restoredItems } = result + if (!folder || !restoredItems) { + return NextResponse.json( + { success: false, error: 'Failed to restore workspace file folder' }, + { status: 500 } + ) + } + + logger.info(`Restored workspace file folder: ${folderId}`) + + captureServerEvent( + session.user.id, + 'folder_restored', + { folder_id: folderId, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + return NextResponse.json({ success: true, folder, restoredItems }) + } catch (error) { + logger.error('Failed to restore workspace file folder:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts new file mode 100644 index 00000000000..78232e8a704 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts @@ -0,0 +1,121 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { + deleteWorkspaceFileFolderContract, + updateWorkspaceFileFolderContract, +} from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { + performDeleteWorkspaceFileItems, + performUpdateWorkspaceFileFolder, + workspaceFilesOrchestrationStatus, +} from '@/lib/workspace-files/orchestration' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceFileFolderAPI') + +async function assertWritePermission(userId: string, workspaceId: string) { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + return permission === 'admin' || permission === 'write' +} + +export const PATCH = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(updateWorkspaceFileFolderContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, folderId } = parsed.data.params + + if (!(await assertWritePermission(session.user.id, workspaceId))) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performUpdateWorkspaceFileFolder({ + workspaceId, + folderId, + userId: session.user.id, + ...parsed.data.body, + }) + if (!result.success || !result.folder) { + return NextResponse.json( + { success: false, error: result.error }, + { status: workspaceFilesOrchestrationStatus(result.errorCode) } + ) + } + captureServerEvent( + session.user.id, + 'folder_renamed', + { workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + return NextResponse.json({ success: true, folder: result.folder }) + } catch (error) { + logger.error('Failed to update workspace file folder:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(deleteWorkspaceFileFolderContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, folderId } = parsed.data.params + + if (!(await assertWritePermission(session.user.id, workspaceId))) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId: session.user.id, + folderIds: [folderId], + }) + if (!result.success) { + return NextResponse.json( + { success: false, error: result.error }, + { + status: + result.errorCode === 'validation' + ? 400 + : result.errorCode === 'not_found' + ? 404 + : 500, + } + ) + } + if (!result.deletedItems) { + return NextResponse.json( + { success: false, error: 'Failed to delete workspace file folder' }, + { status: 500 } + ) + } + + captureServerEvent( + session.user.id, + 'folder_deleted', + { workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + + return NextResponse.json({ success: true, deletedItems: result.deletedItems }) + } catch (error) { + logger.error('Failed to delete workspace file folder:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts new file mode 100644 index 00000000000..02de14dcf1e --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts @@ -0,0 +1,88 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { + createWorkspaceFileFolderContract, + listWorkspaceFileFoldersContract, +} from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace' +import { + performCreateWorkspaceFileFolder, + workspaceFilesOrchestrationStatus, +} from '@/lib/workspace-files/orchestration' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceFileFoldersAPI') + +async function getWorkspacePermission(userId: string, workspaceId: string) { + return getUserEntityPermissions(userId, 'workspace', workspaceId) +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(listWorkspaceFileFoldersContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { scope } = parsed.data.query + + const permission = await getWorkspacePermission(session.user.id, workspaceId) + if (!permission) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const folders = await listWorkspaceFileFolders(workspaceId, { scope }) + return NextResponse.json({ success: true, folders }) + } +) + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(createWorkspaceFileFolderContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { name, parentId } = parsed.data.body + + const permission = await getWorkspacePermission(session.user.id, workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performCreateWorkspaceFileFolder({ + workspaceId, + userId: session.user.id, + name, + parentId, + }) + if (!result.success || !result.folder) { + return NextResponse.json( + { success: false, error: result.error }, + { status: workspaceFilesOrchestrationStatus(result.errorCode) } + ) + } + captureServerEvent( + session.user.id, + 'folder_created', + { workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + return NextResponse.json({ success: true, folder: result.folder }) + } catch (error) { + logger.error('Failed to create workspace file folder:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/move/route.ts b/apps/sim/app/api/workspaces/[id]/files/move/route.ts new file mode 100644 index 00000000000..81861789eee --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/move/route.ts @@ -0,0 +1,78 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { moveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceFileMoveAPI') + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(moveWorkspaceFileItemsContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { fileIds, folderIds, targetFolderId } = parsed.data.body + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performMoveWorkspaceFileItems({ + workspaceId, + userId: session.user.id, + fileIds, + folderIds, + targetFolderId, + }) + if (!result.success || !result.movedItems) { + return NextResponse.json( + { success: false, error: result.error }, + { + status: + result.errorCode === 'conflict' + ? 409 + : result.errorCode === 'not_found' + ? 404 + : result.errorCode === 'validation' + ? 400 + : 500, + } + ) + } + if (fileIds.length > 0) { + captureServerEvent( + session.user.id, + 'file_moved', + { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, + { groups: { workspace: workspaceId } } + ) + } + if (folderIds.length > 0) { + captureServerEvent( + session.user.id, + 'folder_moved', + { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, + { groups: { workspace: workspaceId } } + ) + } + return NextResponse.json({ + success: true, + movedItems: result.movedItems, + }) + } catch (error) { + logger.error('Failed to move workspace file items:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts new file mode 100644 index 00000000000..8fee00a3f25 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts @@ -0,0 +1,161 @@ +/** + * @vitest-environment node + */ +import { + authMockFns, + permissionsMock, + permissionsMockFns, + storageServiceMock, + storageServiceMockFns, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckStorageQuota, mockGenerateWorkspaceFileKey, mockUseBlobStorage } = vi.hoisted( + () => ({ + mockCheckStorageQuota: vi.fn(), + mockGenerateWorkspaceFileKey: vi.fn(), + mockUseBlobStorage: { value: false }, + }) +) + +vi.mock('@/lib/billing/storage', () => ({ + checkStorageQuota: mockCheckStorageQuota, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + generateWorkspaceFileKey: mockGenerateWorkspaceFileKey, +})) + +vi.mock('@/lib/uploads/config', () => ({ + get USE_BLOB_STORAGE() { + return mockUseBlobStorage.value + }, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) + +const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' + +import { POST } from '@/app/api/workspaces/[id]/files/presigned/route' + +const params = (id = WS) => ({ params: Promise.resolve({ id }) }) + +const makeRequest = (body: unknown) => + new NextRequest(`http://localhost/api/workspaces/${WS}/files/presigned`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + +const validBody = { + fileName: 'video.mp4', + contentType: 'video/mp4', + fileSize: 10 * 1024 * 1024, +} + +describe('POST /api/workspaces/[id]/files/presigned', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + mockCheckStorageQuota.mockResolvedValue({ allowed: true }) + storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true) + mockGenerateWorkspaceFileKey.mockReturnValue(`workspace/${WS}/123-abc-video.mp4`) + storageServiceMockFns.mockGeneratePresignedUploadUrl.mockResolvedValue({ + url: 'https://s3/presigned', + key: `workspace/${WS}/123-abc-video.mp4`, + uploadHeaders: { 'Content-Type': 'video/mp4' }, + }) + }) + + it('returns 401 when unauthenticated', async () => { + authMockFns.mockGetSession.mockResolvedValueOnce(null) + const res = await POST(makeRequest(validBody), params()) + expect(res.status).toBe(401) + }) + + it('returns 403 when user has read-only permission', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read') + const res = await POST(makeRequest(validBody), params()) + expect(res.status).toBe(403) + }) + + it('returns 400 for missing fileName', async () => { + const res = await POST(makeRequest({ ...validBody, fileName: '' }), params()) + expect(res.status).toBe(400) + }) + + it('returns 400 for negative fileSize', async () => { + const res = await POST(makeRequest({ ...validBody, fileSize: -1 }), params()) + expect(res.status).toBe(400) + }) + + it('accepts fileSize === 0 (empty new files)', async () => { + const res = await POST(makeRequest({ ...validBody, fileSize: 0 }), params()) + expect(res.status).toBe(200) + }) + + it('returns 413 when fileSize exceeds 5 GiB ceiling', async () => { + const res = await POST( + makeRequest({ ...validBody, fileSize: 6 * 1024 * 1024 * 1024 }), + params() + ) + expect(res.status).toBe(413) + }) + + it('returns 413 when storage quota would be exceeded', async () => { + mockCheckStorageQuota.mockResolvedValueOnce({ allowed: false, error: 'Over quota' }) + const res = await POST(makeRequest(validBody), params()) + const body = await res.json() + expect(res.status).toBe(413) + expect(body.error).toBe('Over quota') + }) + + it('returns local fallback signal when cloud storage is not configured', async () => { + storageServiceMockFns.mockHasCloudStorage.mockReturnValueOnce(false) + const res = await POST(makeRequest(validBody), params()) + const body = await res.json() + expect(res.status).toBe(200) + expect(body.directUploadSupported).toBe(false) + expect(body.presignedUrl).toBe('') + expect(body.fileInfo.name).toBe('video.mp4') + expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).not.toHaveBeenCalled() + }) + + it('issues a presigned URL bound to the workspace', async () => { + const res = await POST(makeRequest(validBody), params()) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.directUploadSupported).toBe(true) + expect(body.presignedUrl).toBe('https://s3/presigned') + expect(body.fileInfo.key).toBe(`workspace/${WS}/123-abc-video.mp4`) + expect(body.fileInfo.path).toContain('?context=workspace') + expect(body.fileInfo.path).toContain('s3') + expect(body.uploadHeaders).toEqual({ 'Content-Type': 'video/mp4' }) + + expect(mockGenerateWorkspaceFileKey).toHaveBeenCalledWith(WS, 'video.mp4') + expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).toHaveBeenCalledWith( + expect.objectContaining({ + context: 'workspace', + userId: 'user-1', + customKey: `workspace/${WS}/123-abc-video.mp4`, + metadata: { workspaceId: WS }, + }) + ) + }) + + it('serves blob path when blob storage is configured', async () => { + mockUseBlobStorage.value = true + try { + const res = await POST(makeRequest(validBody), params()) + const body = await res.json() + expect(body.fileInfo.path).toContain('/blob/') + } finally { + mockUseBlobStorage.value = false + } + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts b/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts new file mode 100644 index 00000000000..abb83b3f6c9 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts @@ -0,0 +1,109 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { workspacePresignedUploadContract } from '@/lib/api/contracts/workspace-files' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { checkStorageQuota } from '@/lib/billing/storage' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { USE_BLOB_STORAGE } from '@/lib/uploads/config' +import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' +import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service' +import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspacePresignedAPI') + +/** + * POST /api/workspaces/[id]/files/presigned + * Returns a presigned PUT URL for a workspace-scoped object key. The client + * uploads the bytes directly to S3/Blob, then calls /files/register to + * insert metadata. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = session.user.id + + const parsed = await parseRequest(workspacePresignedUploadContract, request, context) + if (!parsed.success) return parsed.response + const { params, body } = parsed.data + const workspaceId = params.id + const { fileName, contentType, fileSize, folderId } = body + + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + logger.warn(`User ${userId} lacks write permission for ${workspaceId}`) + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + if (fileSize > MAX_WORKSPACE_FILE_SIZE) { + return NextResponse.json( + { error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` }, + { status: 413 } + ) + } + + let targetFolderId: string | null + try { + targetFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId) + } catch (error) { + return NextResponse.json( + { error: getErrorMessage(error, 'Invalid target folder') }, + { status: 400 } + ) + } + + if (!hasCloudStorage()) { + logger.info(`Local storage detected, signaling API fallback for ${fileName}`) + return NextResponse.json({ + fileName, + presignedUrl: '', + fileInfo: { path: '', key: '', name: fileName, size: fileSize, type: contentType }, + directUploadSupported: false, + }) + } + + const quotaCheck = await checkStorageQuota(userId, fileSize) + if (!quotaCheck.allowed) { + return NextResponse.json( + { error: quotaCheck.error || 'Storage limit exceeded' }, + { status: 413 } + ) + } + + const key = generateWorkspaceFileKey(workspaceId, fileName) + const presigned = await generatePresignedUploadUrl({ + fileName, + contentType, + fileSize, + context: 'workspace', + userId, + customKey: key, + expirationSeconds: 3600, + metadata: { workspaceId, ...(targetFolderId ? { folderId: targetFolderId } : {}) }, + }) + + const finalPath = `/api/files/serve/${USE_BLOB_STORAGE ? 'blob' : 's3'}/${encodeURIComponent(key)}?context=workspace` + + logger.info(`Issued workspace presigned URL for ${fileName} -> ${key}`) + + return NextResponse.json({ + fileName, + presignedUrl: presigned.url, + fileInfo: { + path: finalPath, + key: presigned.key, + name: fileName, + size: fileSize, + type: contentType, + }, + uploadHeaders: presigned.uploadHeaders, + directUploadSupported: true, + }) + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/register/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/register/route.test.ts new file mode 100644 index 00000000000..3d9fd1465e3 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/register/route.test.ts @@ -0,0 +1,171 @@ +/** + * @vitest-environment node + */ +import { + auditMock, + auditMockFns, + authMockFns, + permissionsMock, + permissionsMockFns, + posthogServerMock, + posthogServerMockFns, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRegisterUploadedWorkspaceFile, mockParseWorkspaceFileKey, FileConflictErrorImpl } = + vi.hoisted(() => { + class FileConflictErrorImpl extends Error { + constructor(message: string) { + super(message) + this.name = 'FileConflictError' + } + } + return { + mockRegisterUploadedWorkspaceFile: vi.fn(), + mockParseWorkspaceFileKey: vi.fn(), + FileConflictErrorImpl, + } + }) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + registerUploadedWorkspaceFile: mockRegisterUploadedWorkspaceFile, + parseWorkspaceFileKey: mockParseWorkspaceFileKey, + FileConflictError: FileConflictErrorImpl, +})) + +vi.mock('@/lib/posthog/server', () => posthogServerMock) +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@sim/audit', () => auditMock) + +const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' +const VALID_KEY = `workspace/${WS}/123-abc-video.mp4` + +import { POST } from '@/app/api/workspaces/[id]/files/register/route' + +const params = (id = WS) => ({ params: Promise.resolve({ id }) }) + +const makeRequest = (body: unknown) => + new NextRequest(`http://localhost/api/workspaces/${WS}/files/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + +const validBody = { + key: VALID_KEY, + name: 'video.mp4', + contentType: 'video/mp4', +} + +describe('POST /api/workspaces/[id]/files/register', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'User One', email: 'u@example.com' }, + }) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + mockParseWorkspaceFileKey.mockImplementation((key: string) => { + const match = key.match(/^workspace\/([^/]+)\//) + return match ? match[1] : null + }) + mockRegisterUploadedWorkspaceFile.mockResolvedValue({ + file: { + id: 'wf_123', + name: 'video.mp4', + size: 10 * 1024 * 1024, + type: 'video/mp4', + url: '/api/files/serve/...', + key: VALID_KEY, + context: 'workspace', + }, + created: true, + }) + }) + + it('returns 401 when unauthenticated', async () => { + authMockFns.mockGetSession.mockResolvedValueOnce(null) + const res = await POST(makeRequest(validBody), params()) + expect(res.status).toBe(401) + }) + + it('returns 403 when user lacks write permission', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read') + const res = await POST(makeRequest(validBody), params()) + expect(res.status).toBe(403) + }) + + it('rejects keys belonging to a different workspace', async () => { + const otherWsKey = `workspace/00000000-0000-0000-0000-000000000000/123-abc-video.mp4` + const res = await POST(makeRequest({ ...validBody, key: otherWsKey }), params()) + const body = await res.json() + expect(res.status).toBe(400) + expect(body.error).toContain('does not belong') + expect(mockRegisterUploadedWorkspaceFile).not.toHaveBeenCalled() + }) + + it('returns 400 for empty key/name', async () => { + const res = await POST(makeRequest({ ...validBody, key: '' }), params()) + expect(res.status).toBe(400) + }) + + it('returns 404 when storage object is missing', async () => { + mockRegisterUploadedWorkspaceFile.mockRejectedValueOnce( + new Error('Uploaded object not found in storage') + ) + const res = await POST(makeRequest(validBody), params()) + expect(res.status).toBe(404) + }) + + it('returns 409 on duplicate file conflict', async () => { + mockRegisterUploadedWorkspaceFile.mockRejectedValueOnce(new FileConflictErrorImpl('video.mp4')) + const res = await POST(makeRequest(validBody), params()) + const body = await res.json() + expect(res.status).toBe(409) + expect(body.isDuplicate).toBe(true) + }) + + it('skips audit + analytics on idempotent re-register (created=false)', async () => { + mockRegisterUploadedWorkspaceFile.mockResolvedValueOnce({ + file: { + id: 'wf_123', + name: 'video.mp4', + size: 10 * 1024 * 1024, + type: 'video/mp4', + url: '/api/files/serve/...', + key: VALID_KEY, + context: 'workspace', + }, + created: false, + }) + + const res = await POST(makeRequest(validBody), params()) + expect(res.status).toBe(200) + expect(posthogServerMockFns.mockCaptureServerEvent).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + }) + + it('finalizes upload, records audit and analytics', async () => { + const res = await POST(makeRequest(validBody), params()) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.success).toBe(true) + expect(body.file).toMatchObject({ id: 'wf_123', key: VALID_KEY }) + + expect(mockRegisterUploadedWorkspaceFile).toHaveBeenCalledWith({ + workspaceId: WS, + userId: 'user-1', + key: VALID_KEY, + originalName: 'video.mp4', + contentType: 'video/mp4', + }) + + expect(posthogServerMockFns.mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_uploaded', + expect.objectContaining({ workspace_id: WS, file_type: 'video/mp4' }), + expect.any(Object) + ) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/register/route.ts b/apps/sim/app/api/workspaces/[id]/files/register/route.ts new file mode 100644 index 00000000000..fd29a6c9dfb --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/register/route.ts @@ -0,0 +1,103 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { registerWorkspaceFileContract } from '@/lib/api/contracts/workspace-files' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { + FileConflictError, + parseWorkspaceFileKey, + registerUploadedWorkspaceFile, +} from '@/lib/uploads/contexts/workspace' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceRegisterAPI') + +/** + * POST /api/workspaces/[id]/files/register + * Finalize a direct-to-storage upload by inserting metadata, updating quota, + * and recording an audit log. Validates the storage key belongs to the + * caller's workspace to prevent cross-tenant key smuggling. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = session.user.id + + const parsed = await parseRequest(registerWorkspaceFileContract, request, context) + if (!parsed.success) return parsed.response + const { params, body } = parsed.data + const workspaceId = params.id + const { key, name, contentType, folderId } = body + + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + logger.warn(`User ${userId} lacks write permission for ${workspaceId}`) + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + if (parseWorkspaceFileKey(key) !== workspaceId) { + logger.warn(`Key ${key} does not belong to workspace ${workspaceId}`) + return NextResponse.json( + { error: 'Storage key does not belong to this workspace' }, + { status: 400 } + ) + } + + try { + const { file: userFile, created } = await registerUploadedWorkspaceFile({ + workspaceId, + userId, + key, + originalName: name, + contentType, + folderId, + }) + + if (created) { + logger.info(`Registered direct upload ${name} -> ${key}`) + + captureServerEvent( + userId, + 'file_uploaded', + { workspace_id: workspaceId, file_type: contentType }, + { groups: { workspace: workspaceId } } + ) + + recordAudit({ + workspaceId, + actorId: userId, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: userFile.id, + resourceName: name, + description: `Uploaded file "${name}"`, + metadata: { fileSize: userFile.size, fileType: contentType }, + request, + }) + } else { + logger.info(`Idempotent re-register for existing upload ${name} -> ${key}`) + } + + return NextResponse.json({ success: true, file: userFile }) + } catch (error) { + logger.error('Failed to register workspace file:', error) + + const errorMessage = getErrorMessage(error, 'Failed to register file') + const isDuplicate = + error instanceof FileConflictError || errorMessage.includes('already exists') + const isMissing = errorMessage.includes('not found in storage') + + const status = isDuplicate ? 409 : isMissing ? 404 : 500 + return NextResponse.json({ success: false, error: errorMessage, isDuplicate }, { status }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index a006dd2e963..75caa8542e7 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -1,6 +1,12 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { + 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,8 +15,8 @@ import { FileConflictError, listWorkspaceFiles, uploadWorkspaceFile, - type WorkspaceFileScope, } from '@/lib/uploads/contexts/workspace' +import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' @@ -25,7 +31,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 +55,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 }) @@ -61,7 +79,7 @@ export const GET = withRouteHandler( return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : 'Failed to list files', + error: getErrorMessage(error, 'Failed to list files'), }, { status: 500 } ) @@ -76,7 +94,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() @@ -99,6 +124,9 @@ export const POST = withRouteHandler( const formData = await request.formData() const rawFile = formData.get('file') + const rawFolderId = formData.get('folderId') + const folderId = + typeof rawFolderId === 'string' && rawFolderId.length > 0 ? rawFolderId : null if (!rawFile || !(rawFile instanceof File)) { return NextResponse.json({ error: 'No file provided' }, { status: 400 }) @@ -106,13 +134,12 @@ export const POST = withRouteHandler( const fileName = rawFile.name || 'untitled.md' - const maxSize = 100 * 1024 * 1024 - if (rawFile.size > maxSize) { + if (rawFile.size > MAX_WORKSPACE_FORMDATA_FILE_SIZE) { return NextResponse.json( { - error: `File size exceeds 100MB limit (${(rawFile.size / (1024 * 1024)).toFixed(2)}MB)`, + error: `File size exceeds maximum of ${MAX_WORKSPACE_FORMDATA_FILE_SIZE} bytes (${(rawFile.size / (1024 * 1024)).toFixed(2)}MB)`, }, - { status: 400 } + { status: 413 } ) } @@ -123,7 +150,8 @@ export const POST = withRouteHandler( session.user.id, buffer, fileName, - rawFile.type || 'application/octet-stream' + rawFile.type || 'application/octet-stream', + { folderId } ) logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`) @@ -156,7 +184,7 @@ export const POST = withRouteHandler( } catch (error) { logger.error(`[${requestId}] Error uploading workspace file:`, error) - const errorMessage = error instanceof Error ? error.message : 'Failed to upload file' + const errorMessage = getErrorMessage(error, 'Failed to upload file') const isDuplicate = error instanceof FileConflictError || errorMessage.includes('already exists') diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.ts index 34cdc7e8037..aa3a3ccc357 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.ts @@ -1,8 +1,10 @@ import { db, mothershipInboxTask, workspace } from '@sim/db' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { updateInboxConfigContract } from '@/lib/api/contracts/inbox' +import { parseRequest } 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 +13,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 @@ -82,8 +79,8 @@ export const GET = withRouteHandler( ) export const PATCH = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id: workspaceId } = await params + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const { id: workspaceId } = await context.params const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -101,7 +98,9 @@ export const PATCH = withRouteHandler( } try { - const body = patchSchema.parse(await req.json()) + const parsed = await parseRequest(updateInboxConfigContract, req, context) + if (!parsed.success) return parsed.response + const body = parsed.data.body if (body.enabled === true) { const [current] = await db @@ -128,19 +127,12 @@ 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', + error: getErrorMessage(error, 'Unknown error'), }) return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to update inbox' }, + { error: getErrorMessage(error, 'Failed to update inbox') }, { status: 500 } ) } 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..a4e2728aec3 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 { addInboxSenderContract, removeInboxSenderContract } from '@/lib/api/contracts/inbox' +import { parseRequest } 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 @@ -77,8 +69,8 @@ export const GET = withRouteHandler( ) export const POST = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id: workspaceId } = await params + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const { id: workspaceId } = await context.params const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -96,7 +88,9 @@ export const POST = withRouteHandler( } try { - const { email, label } = addSenderSchema.parse(await req.json()) + const parsed = await parseRequest(addInboxSenderContract, req, context) + if (!parsed.success) return parsed.response + const { email, label } = parsed.data.body const normalizedEmail = email.toLowerCase() const [existing] = await db @@ -127,12 +121,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 }) } @@ -140,8 +128,8 @@ export const POST = withRouteHandler( ) export const DELETE = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id: workspaceId } = await params + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const { id: workspaceId } = await context.params const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -159,7 +147,9 @@ export const DELETE = withRouteHandler( } try { - const { senderId } = deleteSenderSchema.parse(await req.json()) + const parsed = await parseRequest(removeInboxSenderContract, req, context) + if (!parsed.success) return parsed.response + const { senderId } = parsed.data.body await db .delete(mothershipInboxAllowedSender) @@ -172,12 +162,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..3de7e2f26c8 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts @@ -4,97 +4,16 @@ 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 { updateNotificationServerContract } from '@/lib/api/contracts/notifications' +import { parseRequest, validationErrorResponse } 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' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -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 = 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( @@ -167,14 +86,14 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou } }) -export const PUT = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { +export const PUT = withRouteHandler(async (request: NextRequest, context: RouteParams) => { try { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: workspaceId, notificationId } = await params + const { id: workspaceId, notificationId } = await context.params const { hasAccess } = await checkWorkspaceWriteAccess(session.user.id, workspaceId) if (!hasAccess) { @@ -187,17 +106,11 @@ export const PUT = withRouteHandler(async (request: NextRequest, { params }: Rou return NextResponse.json({ error: 'Notification not found' }, { status: 404 }) } - const body = await request.json() - const validationResult = updateNotificationSchema.safeParse(body) - - if (!validationResult.success) { - return NextResponse.json( - { error: 'Invalid request', details: validationResult.error.errors }, - { status: 400 } - ) - } - - const data = validationResult.data + const parsed = await parseRequest(updateNotificationServerContract, request, context, { + validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request'), + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body if (data.workflowIds && data.workflowIds.length > 0) { const workflowsInWorkspace = await db 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..9ddb0ed3fa8 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/route.ts @@ -5,109 +5,17 @@ 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 { createNotificationServerContract } from '@/lib/api/contracts/notifications' +import { parseRequest, validationErrorResponse } 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' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { MAX_EMAIL_RECIPIENTS, MAX_NOTIFICATIONS_PER_TYPE, MAX_WORKFLOW_IDS } from './constants' +import { MAX_NOTIFICATIONS_PER_TYPE } from './constants' 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 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 @@ -165,31 +73,25 @@ export const GET = withRouteHandler( ) export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { try { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: workspaceId } = await params + const { id: workspaceId } = await context.params const { hasAccess } = await checkWorkspaceWriteAccess(session.user.id, workspaceId) if (!hasAccess) { return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - const body = await request.json() - const validationResult = createNotificationSchema.safeParse(body) - - if (!validationResult.success) { - return NextResponse.json( - { error: 'Invalid request', details: validationResult.error.errors }, - { status: 400 } - ) - } - - const data = validationResult.data + const parsed = await parseRequest(createNotificationServerContract, request, context, { + validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request'), + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body const existingCount = await db .select({ id: workspaceNotificationSubscription.id }) diff --git a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts index cf5bd49e454..2dd189f89c7 100644 --- a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts @@ -6,9 +6,15 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' -const { mockRunSandboxTask } = vi.hoisted(() => ({ - mockRunSandboxTask: vi.fn(), -})) +const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => { + class SandboxUserCodeError extends Error { + constructor(message: string, name: string) { + super(message) + this.name = name + } + } + return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError } +}) const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership @@ -16,6 +22,7 @@ vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) vi.mock('@/lib/execution/sandbox/run-task', () => ({ runSandboxTask: mockRunSandboxTask, + SandboxUserCodeError, })) import { POST } from '@/app/api/workspaces/[id]/pdf/preview/route' @@ -187,4 +194,31 @@ describe('PDF preview API route', () => { expect(response.status).toBe(500) await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' }) }) + + it('returns 422 when user code throws inside the sandbox', async () => { + mockRunSandboxTask.mockRejectedValue( + new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError') + ) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'const x = ' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(422) + await expect(response.json()).resolves.toEqual({ + error: 'Invalid or unexpected token', + errorName: 'SyntaxError', + }) + }) }) 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..cdfa510a2af 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 { bulkAddPermissionGroupMembersContract } from '@/lib/api/contracts/permission-groups' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { isWorkspaceOnEnterprisePlan } from '@/lib/billing' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -29,19 +30,14 @@ 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 }> }) => { + async (req: NextRequest, context: { params: Promise<{ id: string; groupId: string }> }) => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: workspaceId, groupId: id } = await params + const { id: workspaceId, groupId: id } = await context.params try { const isWorkspaceAdmin = await hasWorkspaceAdminAccess(session.user.id, workspaceId) @@ -62,8 +58,12 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) } - const body = await req.json() - const { userIds, addAllWorkspaceMembers } = bulkAddSchema.parse(body) + const parsed = await parseRequest(bulkAddPermissionGroupMembersContract, req, context, { + validationErrorResponse: (error) => + NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + const { userIds, addAllWorkspaceMembers } = parsed.data.body let targetUserIds: string[] = [] @@ -183,9 +183,6 @@ 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 (getPostgresErrorCode(error) === '23505') { const constraint = getPostgresConstraintName(error) if ( 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..880e8bc6bd4 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 { addPermissionGroupMemberContract } from '@/lib/api/contracts/permission-groups' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { isWorkspaceOnEnterprisePlan } from '@/lib/billing' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -76,18 +77,14 @@ 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 }> }) => { + async (req: NextRequest, context: { params: Promise<{ id: string; groupId: string }> }) => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: workspaceId, groupId: id } = await params + const { id: workspaceId, groupId: id } = await context.params try { const isWorkspaceAdmin = await hasWorkspaceAdminAccess(session.user.id, workspaceId) @@ -108,8 +105,12 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) } - const body = await req.json() - const { userId } = addMemberSchema.parse(body) + const parsed = await parseRequest(addPermissionGroupMemberContract, req, context, { + validationErrorResponse: (error) => + NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + const { userId } = parsed.data.body const [workspaceMember] = await db .select({ email: user.email }) @@ -202,9 +203,6 @@ 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 (error instanceof Error && error.message === 'ALREADY_IN_GROUP') { return NextResponse.json( { error: 'User is already in this permission group' }, 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..a4b04aa3fd6 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 { updatePermissionGroupContract } from '@/lib/api/contracts/permission-groups' +import { getValidationErrorMessage, parseRequest } 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({ @@ -85,13 +78,13 @@ export const GET = withRouteHandler( ) export const PUT = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string; groupId: string }> }) => { + async (req: NextRequest, context: { params: Promise<{ id: string; groupId: string }> }) => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: workspaceId, groupId: id } = await params + const { id: workspaceId, groupId: id } = await context.params try { const isWorkspaceAdmin = await hasWorkspaceAdminAccess(session.user.id, workspaceId) @@ -112,8 +105,12 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) } - const body = await req.json() - const updates = updateSchema.parse(body) + const parsed = await parseRequest(updatePermissionGroupContract, req, context, { + validationErrorResponse: (error) => + NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + const updates = parsed.data.body if (updates.name) { const existingGroup = await db @@ -201,9 +198,6 @@ export const PUT = withRouteHandler( }, }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { 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..ffc84b0077a 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 { createPermissionGroupContract } from '@/lib/api/contracts/permission-groups' +import { getValidationErrorMessage, parseRequest } 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() @@ -89,13 +82,13 @@ export const GET = withRouteHandler( ) export const POST = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: workspaceId } = await params + const { id: workspaceId } = await context.params try { const isWorkspaceAdmin = await hasWorkspaceAdminAccess(session.user.id, workspaceId) @@ -111,8 +104,12 @@ export const POST = withRouteHandler( ) } - const body = await req.json() - const { name, description, config, autoAddNewMembers } = createSchema.parse(body) + const parsed = await parseRequest(createPermissionGroupContract, req, context, { + validationErrorResponse: (error) => + NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + const { name, description, config, autoAddNewMembers } = parsed.data.body const existingGroup = await db .select({ id: permissionGroup.id }) @@ -182,9 +179,6 @@ 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 }) - } 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..c2452fdbfde 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/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 { updateWorkspacePermissionsContract } from '@/lib/api/contracts/workspaces' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' @@ -21,15 +22,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 * @@ -98,9 +90,9 @@ export const GET = withRouteHandler( * @returns Success message or error */ export const PATCH = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { try { - const { id: workspaceId } = await params + const { id: workspaceId } = await context.params const session = await getSession() if (!session?.user?.id) { @@ -116,7 +108,9 @@ export const PATCH = withRouteHandler( ) } - const body = updatePermissionsSchema.parse(await request.json()) + const parsed = await parseRequest(updateWorkspacePermissionsContract, request, context) + if (!parsed.success) return parsed.response + const body = parsed.data.body const workspaceRow = await db .select({ billedAccountUserId: workspace.billedAccountUserId }) diff --git a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts index 08a8e11f889..900dd41f639 100644 --- a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts @@ -6,9 +6,15 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' -const { mockRunSandboxTask } = vi.hoisted(() => ({ - mockRunSandboxTask: vi.fn(), -})) +const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => { + class SandboxUserCodeError extends Error { + constructor(message: string, name: string) { + super(message) + this.name = name + } + } + return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError } +}) const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership @@ -16,6 +22,7 @@ vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) vi.mock('@/lib/execution/sandbox/run-task', () => ({ runSandboxTask: mockRunSandboxTask, + SandboxUserCodeError, })) import { POST } from '@/app/api/workspaces/[id]/pptx/preview/route' @@ -189,4 +196,31 @@ describe('PPTX preview API route', () => { expect(response.status).toBe(500) await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' }) }) + + it('returns 422 when user code throws inside the sandbox', async () => { + mockRunSandboxTask.mockRejectedValue( + new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError') + ) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'const x = ' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(422) + await expect(response.json()).resolves.toEqual({ + error: 'Invalid or unexpected token', + errorName: 'SyntaxError', + }) + }) }) 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..61bddb78267 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, updateWorkspaceContract } from '@/lib/api/contracts' +import { parseRequest, validationErrorResponse } 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) @@ -112,15 +96,17 @@ export const GET = withRouteHandler( ) export const PATCH = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id } = await params + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const workspaceId = id + const parsed = await parseRequest(updateWorkspaceContract, request, context) + if (!parsed.success) return parsed.response + + const workspaceId = parsed.data.params.id // Check if user has admin permissions to update workspace const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) @@ -129,7 +115,7 @@ export const PATCH = withRouteHandler( } try { - const body = patchWorkspaceSchema.parse(await request.json()) + const body = parsed.data.body const { name, color, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body if ( @@ -298,8 +284,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 = deleteWorkspaceBodySchema.safeParse(rawBody) + if (!bodyValidation.success) return validationErrorResponse(bodyValidation.error) + 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 new file mode 100644 index 00000000000..b43a45bade0 --- /dev/null +++ b/apps/sim/app/api/workspaces/invitations/batch/route.ts @@ -0,0 +1,111 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { batchWorkspaceInvitationsContract } from '@/lib/api/contracts/invitations' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { normalizeEmail } from '@/lib/invitations/core' +import { + createWorkspaceInvitation, + prepareWorkspaceInvitationContext, + WorkspaceInvitationError, + type WorkspaceInvitationResult, +} from '@/lib/invitations/workspace-invitations' +import { InvitationsNotAllowedError } from '@/ee/access-control/utils/permission-check' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('WorkspaceInvitationBatchAPI') + +interface BatchInvitationFailure { + email: string + error: string +} + +function batchErrorResponse(error: unknown) { + if (error instanceof WorkspaceInvitationError) { + return NextResponse.json( + { + error: error.message, + ...(error.email ? { email: error.email } : {}), + ...(error.upgradeRequired !== undefined ? { upgradeRequired: error.upgradeRequired } : {}), + }, + { status: error.status } + ) + } + + if (error instanceof InvitationsNotAllowedError) { + return NextResponse.json({ error: error.message }, { status: 403 }) + } + + logger.error('Error creating workspace invitation batch:', error) + return NextResponse.json({ error: 'Failed to create invitation batch' }, { status: 500 }) +} + +export const POST = withRouteHandler(async (req: NextRequest) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseRequest(batchWorkspaceInvitationsContract, req, {}) + if (!parsed.success) return parsed.response + const { body } = parsed.data + + const context = await prepareWorkspaceInvitationContext({ + workspaceId: body.workspaceId, + inviterId: session.user.id, + inviterName: session.user.name || session.user.email || 'A user', + inviterEmail: session.user.email, + }) + + const successful: string[] = [] + const failed: BatchInvitationFailure[] = [] + const invitations: WorkspaceInvitationResult[] = [] + const seenEmails = new Set() + + for (const item of body.invitations) { + const normalizedEmail = normalizeEmail(item.email) + if (seenEmails.has(normalizedEmail)) { + failed.push({ + email: normalizedEmail, + error: `${normalizedEmail} appears more than once in this invitation batch`, + }) + continue + } + seenEmails.add(normalizedEmail) + + try { + const invitation = await createWorkspaceInvitation({ + context, + email: item.email, + permission: item.permission, + request: req, + }) + successful.push(invitation.email) + invitations.push(invitation) + } catch (error) { + if (error instanceof WorkspaceInvitationError) { + failed.push({ email: error.email ?? normalizedEmail, error: error.message }) + continue + } + + logger.error('Unexpected workspace invitation batch item failure:', { + email: normalizedEmail, + error, + }) + throw error + } + } + + return NextResponse.json({ + success: failed.length === 0, + successful, + failed, + invitations, + }) + } catch (error) { + return batchErrorResponse(error) + } +}) diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index e15b4236061..c364d8228e4 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -8,6 +8,7 @@ import { createMockRequest, permissionsMock, permissionsMockFns, + posthogServerMock, schemaMock, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -94,9 +95,7 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({ vi.mock('@sim/audit', () => auditMock) -vi.mock('@/lib/posthog/server', () => ({ - captureServerEvent: vi.fn(), -})) +vi.mock('@/lib/posthog/server', () => posthogServerMock) vi.mock('@/lib/core/telemetry', () => ({ PlatformEvents: { @@ -108,9 +107,9 @@ const mockGetSession = authMockFns.mockGetSession const mockGetWorkspaceWithOwner = permissionsMockFns.mockGetWorkspaceWithOwner import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants' -import { POST } from '@/app/api/workspaces/invitations/route' +import { POST } from '@/app/api/workspaces/invitations/batch/route' -describe('POST /api/workspaces/invitations', () => { +describe('POST /api/workspaces/invitations/batch', () => { beforeEach(() => { vi.clearAllMocks() mockDbResults.value = [] @@ -169,8 +168,7 @@ describe('POST /api/workspaces/invitations', () => { const request = createMockRequest('POST', { workspaceId: 'workspace-1', - email: 'new@example.com', - permission: 'read', + invitations: [{ email: 'new@example.com', permission: 'read' }], }) const response = await POST(request) @@ -201,8 +199,7 @@ describe('POST /api/workspaces/invitations', () => { const request = createMockRequest('POST', { workspaceId: 'workspace-1', - email: 'new@example.com', - permission: 'read', + invitations: [{ email: 'new@example.com', permission: 'read' }], }) const response = await POST(request) @@ -213,7 +210,7 @@ describe('POST /api/workspaces/invitations', () => { expect(mockCreatePendingInvitation).not.toHaveBeenCalled() }) - it('rejects org-owned invites when the organization has no available seats', async () => { + it('reports org-owned invites as failed when the organization has no available seats', async () => { mockGetWorkspaceWithOwner.mockResolvedValueOnce({ id: 'workspace-1', name: 'Org Workspace', @@ -240,20 +237,25 @@ describe('POST /api/workspaces/invitations', () => { const request = createMockRequest('POST', { workspaceId: 'workspace-1', - email: 'new@example.com', - permission: 'read', + invitations: [{ email: 'new@example.com', permission: 'read' }], }) const response = await POST(request) const data = await response.json() - expect(response.status).toBe(400) - expect(data.error).toContain('No available seats') + expect(response.status).toBe(200) + expect(data.success).toBe(false) + expect(data.failed).toEqual([ + { + email: 'new@example.com', + error: 'No available seats. Currently using 5 of 5 seats.', + }, + ]) expect(mockValidateSeatAvailability).toHaveBeenCalledWith('org-1', 1) expect(mockCreatePendingInvitation).not.toHaveBeenCalled() }) - it('rejects org-owned invites for users already in another organization', async () => { + it('creates an external workspace invitation for users already in another organization', async () => { mockGetWorkspaceWithOwner.mockResolvedValueOnce({ id: 'workspace-1', name: 'Org Workspace', @@ -281,16 +283,25 @@ describe('POST /api/workspaces/invitations', () => { const request = createMockRequest('POST', { workspaceId: 'workspace-1', - email: 'new@example.com', - permission: 'read', + invitations: [{ email: 'new@example.com', permission: 'read' }], }) const response = await POST(request) const data = await response.json() - expect(response.status).toBe(409) - expect(data.error).toContain('already a member of another organization') - expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.invitations[0].membershipIntent).toBe('external') + expect(mockValidateSeatAvailability).not.toHaveBeenCalled() + expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'workspace', + email: 'new@example.com', + organizationId: 'org-1', + membershipIntent: 'external', + grants: [{ workspaceId: 'workspace-1', permission: 'read' }], + }) + ) }) it('creates a unified workspace invitation for a grandfathered workspace', async () => { @@ -306,8 +317,7 @@ describe('POST /api/workspaces/invitations', () => { const request = createMockRequest('POST', { workspaceId: 'workspace-1', - email: 'new@example.com', - permission: 'write', + invitations: [{ email: 'new@example.com', permission: 'write' }], }) const response = await POST(request) @@ -327,6 +337,40 @@ describe('POST /api/workspaces/invitations', () => { expect(mockValidateSeatAvailability).not.toHaveBeenCalled() }) + it('creates multiple workspace invitations in one batch request', async () => { + mockDbResults.value = [[{ permissionType: 'admin' }], [], []] + mockCreatePendingInvitation + .mockResolvedValueOnce({ + invitationId: 'inv-1', + token: 'tok-1', + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }) + .mockResolvedValueOnce({ + invitationId: 'inv-2', + token: 'tok-2', + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }) + + const request = createMockRequest('POST', { + workspaceId: 'workspace-1', + invitations: [ + { email: 'first@example.com', permission: 'read' }, + { email: 'second@example.com', permission: 'write' }, + ], + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.successful).toEqual(['first@example.com', 'second@example.com']) + expect(data.failed).toEqual([]) + expect(data.invitations).toHaveLength(2) + expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(2) + expect(mockSendInvitationEmail).toHaveBeenCalledTimes(2) + }) + it('rolls back the unified invitation when email delivery fails', async () => { mockGetWorkspaceWithOwner.mockResolvedValueOnce({ id: 'workspace-1', @@ -344,13 +388,18 @@ describe('POST /api/workspaces/invitations', () => { const request = createMockRequest('POST', { workspaceId: 'workspace-1', - email: 'new@example.com', - permission: 'read', + invitations: [{ email: 'new@example.com', permission: 'read' }], }) const response = await POST(request) - expect(response.status).toBe(502) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual( + expect.objectContaining({ + success: false, + failed: [{ email: 'new@example.com', error: 'mailer unavailable' }], + }) + ) expect(mockCancelPendingInvitation).toHaveBeenCalledWith('inv-1') }) }) diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index a994d6daa48..378b169ad66 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -1,35 +1,16 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { permissions, type permissionTypeEnum, user, workspace } from '@sim/db/schema' +import { permissions, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, isNull, sql } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' -import { getUserOrganization } from '@/lib/billing/organizations/membership' -import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' -import { PlatformEvents } from '@/lib/core/telemetry' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { listInvitationsForWorkspaces, normalizeEmail } from '@/lib/invitations/core' -import { - cancelPendingInvitation, - createPendingInvitation, - findPendingGrantForWorkspaceEmail, - sendInvitationEmail, -} from '@/lib/invitations/send' -import { captureServerEvent } from '@/lib/posthog/server' -import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' -import { getWorkspaceInvitePolicy } from '@/lib/workspaces/policy' -import { - InvitationsNotAllowedError, - validateInvitationsAllowed, -} from '@/ee/access-control/utils/permission-check' +import { listInvitationsForWorkspaces } from '@/lib/invitations/core' export const dynamic = 'force-dynamic' const logger = createLogger('WorkspaceInvitationsAPI') -type PermissionType = (typeof permissionTypeEnum.enumValues)[number] - export const GET = withRouteHandler(async (req: NextRequest) => { const session = await getSession() if (!session?.user?.id) { @@ -61,241 +42,3 @@ export const GET = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Failed to fetch invitations' }, { status: 500 }) } }) - -export const POST = withRouteHandler(async (req: NextRequest) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const { workspaceId, email, permission = 'read' } = await req.json() - - if (!workspaceId || !email) { - return NextResponse.json({ error: 'Workspace ID and email are required' }, { status: 400 }) - } - - await validateInvitationsAllowed(session.user.id, workspaceId) - - const validPermissions: PermissionType[] = ['admin', 'write', 'read'] - if (!validPermissions.includes(permission)) { - return NextResponse.json( - { error: `Invalid permission: must be one of ${validPermissions.join(', ')}` }, - { status: 400 } - ) - } - - const normalizedEmail = normalizeEmail(email) - - const userPermission = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityId, workspaceId), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, session.user.id), - eq(permissions.permissionType, 'admin') - ) - ) - .then((rows) => rows[0]) - - if (!userPermission) { - return NextResponse.json( - { error: 'You need admin permissions to invite users' }, - { status: 403 } - ) - } - - const workspaceDetails = await getWorkspaceWithOwner(workspaceId) - if (!workspaceDetails) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - const invitePolicy = await getWorkspaceInvitePolicy(workspaceDetails) - if (!invitePolicy.allowed) { - return NextResponse.json( - { - error: invitePolicy.reason ?? 'Invites are disabled for this workspace.', - upgradeRequired: invitePolicy.upgradeRequired, - }, - { status: 403 } - ) - } - - const existingUser = await db - .select() - .from(user) - .where(sql`lower(${user.email}) = ${normalizedEmail}`) - .then((rows) => rows[0]) - - if (existingUser) { - const existingPermission = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityId, workspaceId), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, existingUser.id) - ) - ) - .then((rows) => rows[0]) - - if (existingPermission) { - return NextResponse.json( - { - error: `${normalizedEmail} already has access to this workspace`, - email: normalizedEmail, - }, - { status: 400 } - ) - } - - if (invitePolicy.requiresSeat && invitePolicy.organizationId) { - const existingMembership = await getUserOrganization(existingUser.id) - if ( - existingMembership && - existingMembership.organizationId !== invitePolicy.organizationId - ) { - return NextResponse.json( - { - error: - 'This user is already a member of another organization. They must leave it before joining this workspace.', - email: normalizedEmail, - }, - { status: 409 } - ) - } - - if (!existingMembership) { - const seatValidation = await validateSeatAvailability(invitePolicy.organizationId, 1) - if (!seatValidation.canInvite) { - return NextResponse.json( - { - error: seatValidation.reason || 'No available seats for this organization.', - email: normalizedEmail, - }, - { status: 400 } - ) - } - } - } - } else if (invitePolicy.requiresSeat && invitePolicy.organizationId) { - const seatValidation = await validateSeatAvailability(invitePolicy.organizationId, 1) - if (!seatValidation.canInvite) { - return NextResponse.json( - { - error: seatValidation.reason || 'No available seats for this organization.', - email: normalizedEmail, - }, - { status: 400 } - ) - } - } - - const existingInvitation = await findPendingGrantForWorkspaceEmail({ - workspaceId, - email: normalizedEmail, - }) - if (existingInvitation) { - return NextResponse.json( - { - error: `${normalizedEmail} has already been invited to this workspace`, - email: normalizedEmail, - }, - { status: 400 } - ) - } - - const { invitationId, token } = await createPendingInvitation({ - kind: 'workspace', - email: normalizedEmail, - inviterId: session.user.id, - organizationId: workspaceDetails.organizationId, - role: 'member', - grants: [ - { - workspaceId, - permission, - }, - ], - }) - - try { - PlatformEvents.workspaceMemberInvited({ - workspaceId, - invitedBy: session.user.id, - inviteeEmail: normalizedEmail, - role: permission, - }) - } catch { - // telemetry must not fail the operation - } - - captureServerEvent( - session.user.id, - 'workspace_member_invited', - { workspace_id: workspaceId, invitee_role: permission }, - { - groups: { workspace: workspaceId }, - setOnce: { first_invitation_sent_at: new Date().toISOString() }, - } - ) - - const emailResult = await sendInvitationEmail({ - invitationId, - token, - kind: 'workspace', - email: normalizedEmail, - inviterName: session.user.name || session.user.email || 'A user', - organizationId: workspaceDetails.organizationId, - organizationRole: 'member', - grants: [{ workspaceId, permission }], - }) - - if (!emailResult.success) { - await cancelPendingInvitation(invitationId) - return NextResponse.json( - { error: emailResult.error || 'Failed to send invitation email' }, - { status: 502 } - ) - } - - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.MEMBER_INVITED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: workspaceId, - resourceName: normalizedEmail, - description: `Invited ${normalizedEmail} as ${permission}`, - metadata: { - targetEmail: normalizedEmail, - targetRole: permission, - workspaceName: workspaceDetails.name, - invitationId, - }, - request: req, - }) - - return NextResponse.json({ - success: true, - invitation: { - id: invitationId, - workspaceId, - email: normalizedEmail, - permission, - expiresAt: undefined, - }, - }) - } catch (error) { - if (error instanceof InvitationsNotAllowedError) { - return NextResponse.json({ error: error.message }, { status: 403 }) - } - logger.error('Error creating workspace invitation:', error) - return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index 43add66c447..9b37bb2c74a 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -1,25 +1,23 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { permissions, workspace } from '@sim/db/schema' +import { permissionGroupMember, permissions, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' +import { removeWorkspaceMemberContract } from '@/lib/api/contracts/invitations' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access' +import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' 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( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const { id: userId } = await params + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { const session = await getSession() if (!session?.user?.id) { @@ -27,12 +25,16 @@ export const DELETE = withRouteHandler( } try { - // Get the workspace ID from the request body or URL - const body = deleteMemberSchema.parse(await req.json()) - const { workspaceId } = body + const parsed = await parseRequest(removeWorkspaceMemberContract, req, context) + if (!parsed.success) return parsed.response + const { id: userId } = parsed.data.params + const { workspaceId } = parsed.data.body const workspaceRow = await db - .select({ billedAccountUserId: workspace.billedAccountUserId }) + .select({ + ownerId: workspace.ownerId, + billedAccountUserId: workspace.billedAccountUserId, + }) .from(workspace) .where(eq(workspace.id, workspaceId)) .limit(1) @@ -61,7 +63,10 @@ export const DELETE = withRouteHandler( ) .then((rows) => rows[0]) - if (!userPermission) { + const isRemovingWorkspaceOwner = workspaceRow[0].ownerId === userId + const isOwnerOnlyRemoval = isRemovingWorkspaceOwner && !userPermission + + if (!userPermission && !isOwnerOnlyRemoval) { return NextResponse.json({ error: 'User not found in workspace' }, { status: 404 }) } @@ -73,8 +78,19 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } + if ( + isRemovingWorkspaceOwner && + !isSelf && + session.user.id !== workspaceRow[0].billedAccountUserId + ) { + return NextResponse.json( + { error: 'Only the workspace owner or billing account can remove the workspace owner' }, + { status: 403 } + ) + } + // Prevent removing yourself if you're the last admin - if (isSelf && userPermission.permissionType === 'admin') { + if (isSelf && userPermission?.permissionType === 'admin' && !isRemovingWorkspaceOwner) { const otherAdmins = await db .select() .from(permissions) @@ -95,18 +111,78 @@ export const DELETE = withRouteHandler( } } - // Delete the user's permissions for this workspace - await db - .delete(permissions) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) + const ownershipTransferred = await db.transaction(async (tx) => { + let didTransferOwnership = false + + if (isRemovingWorkspaceOwner) { + /** + * Invariant: the billed account is the org owner for org workspaces, + * the owner for personal workspaces, and a workspace admin for + * grandfathered shared workspaces. + */ + const newOwnerId = workspaceRow[0].billedAccountUserId + + await tx + .update(workspace) + .set({ ownerId: newOwnerId, updatedAt: new Date() }) + .where(eq(workspace.id, workspaceId)) + + const [existingNewOwnerPermission] = await tx + .select({ id: permissions.id }) + .from(permissions) + .where( + and( + eq(permissions.userId, newOwnerId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + .limit(1) + + if (existingNewOwnerPermission) { + await tx + .update(permissions) + .set({ permissionType: 'admin', updatedAt: new Date() }) + .where(eq(permissions.id, existingNewOwnerPermission.id)) + } else { + const now = new Date() + await tx.insert(permissions).values({ + id: generateId(), + userId: newOwnerId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + createdAt: now, + updatedAt: now, + }) + } + + didTransferOwnership = true + } + + await tx + .delete(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) ) - ) - await revokeWorkspaceCredentialMemberships(workspaceId, userId) + await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId) + + await tx + .delete(permissionGroupMember) + .where( + and( + eq(permissionGroupMember.userId, userId), + eq(permissionGroupMember.workspaceId, workspaceId) + ) + ) + + return didTransferOwnership + }) captureServerEvent( session.user.id, @@ -126,8 +202,9 @@ export const DELETE = withRouteHandler( description: isSelf ? 'Left the workspace' : `Removed member ${userId} from the workspace`, metadata: { removedUserId: userId, - removedUserRole: userPermission.permissionType, + removedUserRole: userPermission?.permissionType ?? 'owner', selfRemoval: isSelf, + ownershipTransferred, }, request: req, }) diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index e1e874170a9..4855893e97d 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -4,8 +4,10 @@ import { permissions, settings, type WorkspaceMode, workflow, workspace } from ' 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 { type NextRequest, NextResponse } from 'next/server' +import { listWorkspacesQuerySchema } from '@/lib/api/contracts' +import { createWorkspaceContract } from '@/lib/api/contracts/workspaces' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -22,19 +24,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 +42,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 }) @@ -172,7 +170,7 @@ export const GET = withRouteHandler(async (request: Request) => { }) // POST /api/workspaces - Create a new workspace -export const POST = withRouteHandler(async (req: Request) => { +export const POST = withRouteHandler(async (req: NextRequest) => { const session = await getSession() if (!session?.user?.id) { @@ -180,7 +178,9 @@ export const POST = withRouteHandler(async (req: Request) => { } try { - const { name, color, skipDefaultWorkflow } = createWorkspaceSchema.parse(await req.json()) + const parsed = await parseRequest(createWorkspaceContract, req, {}) + if (!parsed.success) return parsed.response + const { name, color, skipDefaultWorkflow } = parsed.data.body const activeOrganizationId = (session.session as { activeOrganizationId?: string } | null)?.activeOrganizationId ?? null const creationPolicy = await getWorkspaceCreationPolicy({ diff --git a/apps/sim/app/changelog/components/changelog-content.tsx b/apps/sim/app/changelog/components/changelog-content.tsx index 3befc2cf99c..395af926f9b 100644 --- a/apps/sim/app/changelog/components/changelog-content.tsx +++ b/apps/sim/app/changelog/components/changelog-content.tsx @@ -65,21 +65,21 @@ export default async function ChangelogContent() { rel='noopener noreferrer' className='inline-flex items-center gap-2 rounded-[5px] border border-[var(--white)] bg-[var(--white)] px-[9px] py-[5px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]' > - + View on GitHub - + Documentation - + RSS Feed
diff --git a/apps/sim/app/changelog/components/timeline-list.tsx b/apps/sim/app/changelog/components/timeline-list.tsx index 26c91197345..703731c1e17 100644 --- a/apps/sim/app/changelog/components/timeline-list.tsx +++ b/apps/sim/app/changelog/components/timeline-list.tsx @@ -103,8 +103,8 @@ export default function ChangelogList({ initialEntries }: Props) { {entry.tag}
{entry.contributors && entry.contributors.length > 0 && ( -
- {entry.contributors.slice(0, 5).map((contributor) => ( +
+ {entry.contributors.slice(0, 5).map((contributor, index) => ( ))} {entry.contributors.length > 5 && ( -
+
+{entry.contributors.length - 5}
)} @@ -195,7 +195,7 @@ export default function ChangelogList({ initialEntries }: Props) { ), inlineCode: ({ children }) => ( - + {children} ), diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/chat/[identifier]/chat.tsx index f43faf5eb51..f5678291c87 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 { @@ -191,9 +191,10 @@ export default function ChatClient({ identifier }: { identifier: string }) { setUserHasScrolled(false) isUserScrollingRef.current = true - setTimeout(() => { + const timeoutId = setTimeout(() => { isUserScrollingRef.current = false }, 1000) + return () => clearTimeout(timeoutId) } }, [isStreamingResponse]) @@ -275,6 +276,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { files: payload.files ? `${payload.files.length} files` : undefined, }) + // boundary-raw-fetch: deployed chat endpoint returns an SSE stream consumed by handleStreamedResponse via response.body.getReader() const response = await fetch(`/api/chat/${identifier}`, { method: 'POST', headers: { @@ -299,13 +301,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/[identifier]/loading.tsx b/apps/sim/app/chat/[identifier]/loading.tsx index 4e060c20ea3..921e5a801cc 100644 --- a/apps/sim/app/chat/[identifier]/loading.tsx +++ b/apps/sim/app/chat/[identifier]/loading.tsx @@ -6,7 +6,7 @@ export default function ChatLoading() {
- +
diff --git a/apps/sim/app/chat/[identifier]/office-embed-init.tsx b/apps/sim/app/chat/[identifier]/office-embed-init.tsx new file mode 100644 index 00000000000..02729d65274 --- /dev/null +++ b/apps/sim/app/chat/[identifier]/office-embed-init.tsx @@ -0,0 +1,50 @@ +'use client' + +import Script from 'next/script' + +declare global { + interface Window { + Office?: { + onReady: () => Promise<{ host: string | null; platform: string | null }> + } + } +} + +/** + * Office.js nullifies window.history.replaceState and pushState (a legacy + * IE10 workaround inside the library) which breaks Next.js's client-side + * router. Cache the originals at module load — before ', { + workflowSearchHighlight: { + range: { start: 0, end: 8 }, + rawValue: 'ipt>' + const vtt = `${HEADER}00:00:00.000 --> 00:00:02.000\n${crafted}\n` + const result = parseVtt(vtt) + expect(result).not.toMatch(/<\/?[^>]+>/) + expect(result.toLowerCase()).not.toContain('script') + }) + + it.concurrent('sanitizes crafted speaker names that embed tag fragments', () => { + const vtt = `${HEADER}00:00:00.000 --> 00:00:02.000\nEvil
>payload\n` + const result = parseVtt(vtt) + expect(result).not.toMatch(/<\/?[^>]+>/) + }) + + it.concurrent('terminates on adversarial deeply-nested input', () => { + const crafted = `${'<'.repeat(50)}b${'>'.repeat(50)}text${'<'.repeat(50)}/b${'>'.repeat(50)}` + const vtt = `${HEADER}00:00:00.000 --> 00:00:02.000\n${crafted}\n` + const result = parseVtt(vtt) + expect(result).not.toMatch(/<\/?[^>]+>/) + }) +}) diff --git a/apps/sim/connectors/zoom/zoom.ts b/apps/sim/connectors/zoom/zoom.ts new file mode 100644 index 00000000000..240031d1cf3 --- /dev/null +++ b/apps/sim/connectors/zoom/zoom.ts @@ -0,0 +1,524 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { ZoomIcon } from '@/components/icons' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { parseTagDate } from '@/connectors/utils' + +const logger = createLogger('ZoomConnector') + +const ZOOM_API_BASE = 'https://api.zoom.us/v2' +const PAGE_SIZE = 300 +const WINDOW_DAYS = 30 +const DEFAULT_LOOKBACK_DAYS = 180 +const MAX_LOOKBACK_DAYS = 180 +/** + * Days of overlap added when computing the incremental sync window. Zoom transcript + * generation is usually fast, but AI Companion / audio transcription can lag hours to + * days for large accounts. A 30-day overlap catches late-arriving transcripts at the + * cost of at most one extra 30-day window per sync. + */ +const INCREMENTAL_OVERLAP_DAYS = 30 +const MS_PER_DAY = 24 * 60 * 60 * 1000 + +interface ZoomRecordingFile { + id?: string + meeting_id?: string + recording_start?: string + recording_end?: string + file_type?: string + file_extension?: string + file_size?: number + download_url?: string + status?: string + recording_type?: string +} + +interface ZoomRecording { + uuid: string + id?: number | string + topic?: string + start_time?: string + duration?: number + total_size?: number + recording_count?: number + share_url?: string + host_email?: string + host_id?: string + account_id?: string + type?: number + recording_files?: ZoomRecordingFile[] +} + +interface ZoomRecordingsListResponse { + meetings?: ZoomRecording[] + next_page_token?: string + page_size?: number + total_records?: number + from?: string + to?: string +} + +interface CursorState { + windowIndex: number + pageToken?: string +} + +/** + * URL-encodes a Zoom meeting UUID. Double-encodes when the UUID starts with '/' + * or contains '//', per Zoom's API requirements. + */ +function encodeMeetingUuid(uuid: string): string { + const encoded = encodeURIComponent(uuid) + if (uuid.startsWith('/') || uuid.includes('//')) { + return encodeURIComponent(encoded) + } + return encoded +} + +function formatDate(date: Date): string { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + return `${y}-${m}-${d}` +} + +function encodeCursor(state: CursorState): string { + return Buffer.from(JSON.stringify(state), 'utf8').toString('base64url') +} + +function decodeCursor(cursor?: string): CursorState { + if (!cursor) return { windowIndex: 0 } + try { + const json = Buffer.from(cursor, 'base64url').toString('utf8') + const parsed = JSON.parse(json) as Partial + return { + windowIndex: Number(parsed.windowIndex) || 0, + pageToken: typeof parsed.pageToken === 'string' ? parsed.pageToken : undefined, + } + } catch { + return { windowIndex: 0 } + } +} + +/** + * Picks the best transcript file from a recording's files array. + * Prefers the AI Companion audio_transcript (file_type TRANSCRIPT) and falls back + * to closed captions (file_type CC) — both are VTT and contain spoken text. + */ +function findTranscriptFile(files?: ZoomRecordingFile[]): ZoomRecordingFile | undefined { + if (!files) return undefined + const eligible = (f: ZoomRecordingFile) => + Boolean(f.download_url) && (f.status === 'completed' || f.status == null) + + const transcript = files.find((f) => f.file_type === 'TRANSCRIPT' && eligible(f)) + if (transcript) return transcript + return files.find((f) => f.file_type === 'CC' && eligible(f)) +} + +/** + * Extracts spoken text from a Zoom WebVTT transcript, stripping cue identifiers, + * timestamps, and inline markup. Handles both Zoom's `Speaker: text` convention + * and standard WebVTT `text` voice tags. + * + * Exported for unit tests; not part of the connector's public surface. + */ +export function parseVtt(vtt: string): string { + const lines = vtt.split(/\r?\n/) + const segments: string[] = [] + let i = 0 + + while (i < lines.length && lines[i].trim() !== '') i++ + + while (i < lines.length) { + while (i < lines.length && lines[i].trim() === '') i++ + if (i >= lines.length) break + + if (i + 1 < lines.length && !lines[i].includes('-->') && lines[i + 1].includes('-->')) { + i++ + } + + if (i < lines.length && lines[i].includes('-->')) { + i++ + } else { + while (i < lines.length && lines[i].trim() !== '') i++ + continue + } + + const textParts: string[] = [] + while (i < lines.length && lines[i].trim() !== '') { + textParts.push(lines[i]) + i++ + } + + if (textParts.length > 0) { + const raw = textParts.join(' ') + const withSpeakers = raw.replace(/]+)?\s+([^>]+)>([\s\S]*?)<\/v>/g, '$1: $2') + let withoutTags = withSpeakers + let previous: string + do { + previous = withoutTags + withoutTags = withoutTags.replace(/<\/?[^>]+>/g, '') + } while (withoutTags !== previous) + const stripped = withoutTags.replace(/\s+/g, ' ').trim() + if (stripped) segments.push(stripped) + } + } + + return segments.join('\n') +} + +function formatTranscriptContent(recording: ZoomRecording, transcript: string): string { + const parts: string[] = [] + if (recording.topic) parts.push(`Meeting: ${recording.topic}`) + if (recording.start_time) parts.push(`Date: ${recording.start_time}`) + if (recording.duration != null) parts.push(`Duration: ${recording.duration} minutes`) + if (recording.host_email) parts.push(`Host: ${recording.host_email}`) + + parts.push('') + parts.push('--- Transcript ---') + parts.push(transcript) + + return parts.join('\n') +} + +function buildContentHash(recording: ZoomRecording, file: ZoomRecordingFile): string { + return `zoom:${recording.uuid}:${file.id ?? ''}:${file.file_size ?? ''}:${file.recording_end ?? ''}` +} + +function buildSourceUrl(recording: ZoomRecording): string | undefined { + return recording.share_url || undefined +} + +function recordingToStub( + recording: ZoomRecording, + transcriptFile: ZoomRecordingFile +): ExternalDocument { + return { + externalId: recording.uuid, + title: recording.topic?.trim() || 'Untitled Zoom Meeting', + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: buildSourceUrl(recording), + contentHash: buildContentHash(recording, transcriptFile), + metadata: { + meetingId: recording.id != null ? String(recording.id) : undefined, + hostEmail: recording.host_email, + duration: recording.duration, + meetingDate: recording.start_time, + topic: recording.topic, + }, + } +} + +/** + * Computes the effective lookback window in days, narrowing to the time since + * the last successful sync (plus an overlap to catch transcripts that finished + * processing late) when incremental sync is active. + */ +function computeLookbackDays( + sourceConfig: Record, + lastSyncAt: Date | undefined +): number { + const raw = sourceConfig.lookback as string | undefined + const configured = Number(raw) + const baseline = + Number.isFinite(configured) && configured > 0 + ? Math.min(Math.floor(configured), MAX_LOOKBACK_DAYS) + : DEFAULT_LOOKBACK_DAYS + + if (!lastSyncAt) return baseline + + const sinceLastSync = Math.ceil((Date.now() - lastSyncAt.getTime()) / MS_PER_DAY) + const incremental = Math.max(sinceLastSync + INCREMENTAL_OVERLAP_DAYS, INCREMENTAL_OVERLAP_DAYS) + return Math.min(incremental, baseline) +} + +export const zoomConnector: ConnectorConfig = { + id: 'zoom', + name: 'Zoom', + description: 'Sync meeting transcripts from Zoom cloud recordings', + version: '1.0.0', + icon: ZoomIcon, + + auth: { + mode: 'oauth', + provider: 'zoom', + requiredScopes: [ + 'user:read:user', + 'cloud_recording:read:list_user_recordings', + 'cloud_recording:read:list_recording_files', + ], + }, + + supportsIncrementalSync: true, + + configFields: [ + { + id: 'lookback', + title: 'Date Range', + type: 'dropdown', + required: false, + options: [ + { label: 'Last 30 days', id: '30' }, + { label: 'Last 90 days', id: '90' }, + { label: 'Last 6 months (recommended)', id: '180' }, + ], + description: + 'On initial sync only. Zoom only allows access to cloud recordings within the last 6 months.', + }, + { + id: 'maxRecordings', + title: 'Max Recordings', + type: 'short-input', + required: false, + placeholder: 'e.g. 200 (default: unlimited)', + }, + ], + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + cursor?: string, + syncContext?: Record, + lastSyncAt?: Date + ): Promise => { + const lookbackDays = computeLookbackDays(sourceConfig, lastSyncAt) + const maxRecordings = sourceConfig.maxRecordings ? Number(sourceConfig.maxRecordings) : 0 + const numWindows = Math.max(1, Math.ceil(lookbackDays / WINDOW_DAYS)) + const state = decodeCursor(cursor) + + if (state.windowIndex >= numWindows) { + return { documents: [], hasMore: false } + } + + const now = new Date() + const earliest = new Date(now.getTime() - lookbackDays * MS_PER_DAY) + const toDate = new Date(now.getTime() - state.windowIndex * WINDOW_DAYS * MS_PER_DAY) + const rawFromDate = new Date(toDate.getTime() - WINDOW_DAYS * MS_PER_DAY) + const fromDate = rawFromDate < earliest ? earliest : rawFromDate + + if (fromDate >= toDate) { + return { documents: [], hasMore: false } + } + + const queryParams = new URLSearchParams({ + page_size: String(PAGE_SIZE), + from: formatDate(fromDate), + to: formatDate(toDate), + trash: 'false', + }) + if (state.pageToken) queryParams.set('next_page_token', state.pageToken) + + const url = `${ZOOM_API_BASE}/users/me/recordings?${queryParams.toString()}` + + logger.info('Listing Zoom recordings', { + windowIndex: state.windowIndex, + windowTotal: numWindows, + from: formatDate(fromDate), + to: formatDate(toDate), + hasToken: Boolean(state.pageToken), + incremental: Boolean(lastSyncAt), + }) + + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error('Failed to list Zoom recordings', { + status: response.status, + error: errorText.slice(0, 500), + }) + throw new Error(`Failed to list Zoom recordings: ${response.status}`) + } + + const data = (await response.json()) as ZoomRecordingsListResponse + const meetings = data.meetings ?? [] + const nextPageToken = data.next_page_token?.trim() || undefined + + const allDocuments: ExternalDocument[] = [] + for (const meeting of meetings) { + if (!meeting.uuid) continue + const transcript = findTranscriptFile(meeting.recording_files) + if (!transcript) continue + allDocuments.push(recordingToStub(meeting, transcript)) + } + + const prevFetched = (syncContext?.totalDocsFetched as number) ?? 0 + let documents = allDocuments + if (maxRecordings > 0) { + const remaining = Math.max(0, maxRecordings - prevFetched) + if (allDocuments.length > remaining) { + documents = allDocuments.slice(0, remaining) + } + } + + const totalFetched = prevFetched + documents.length + if (syncContext) syncContext.totalDocsFetched = totalFetched + const hitLimit = maxRecordings > 0 && totalFetched >= maxRecordings + if (hitLimit && syncContext) syncContext.listingCapped = true + + let nextCursor: string | undefined + let hasMore = false + + if (hitLimit) { + // Stop syncing — limit reached + } else if (nextPageToken) { + nextCursor = encodeCursor({ windowIndex: state.windowIndex, pageToken: nextPageToken }) + hasMore = true + } else if (state.windowIndex + 1 < numWindows) { + nextCursor = encodeCursor({ windowIndex: state.windowIndex + 1 }) + hasMore = true + } + + return { documents, nextCursor, hasMore } + }, + + getDocument: async ( + accessToken: string, + _sourceConfig: Record, + externalId: string + ): Promise => { + try { + if (!externalId) return null + + const url = `${ZOOM_API_BASE}/meetings/${encodeMeetingUuid(externalId)}/recordings` + + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + if (response.status === 404 || response.status === 410) return null + throw new Error(`Failed to fetch Zoom recording: ${response.status}`) + } + + const recording = (await response.json()) as ZoomRecording + const transcript = findTranscriptFile(recording.recording_files) + + if (!transcript?.download_url) { + logger.info('Transcript no longer available for Zoom recording', { externalId }) + return null + } + + const vttResponse = await fetchWithRetry(transcript.download_url, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!vttResponse.ok) { + logger.warn('Failed to download Zoom transcript', { + externalId, + status: vttResponse.status, + }) + return null + } + + const vttText = await vttResponse.text() + const transcriptText = parseVtt(vttText).trim() + if (!transcriptText) return null + + const content = formatTranscriptContent(recording, transcriptText) + + return { + externalId: recording.uuid || externalId, + title: recording.topic?.trim() || 'Untitled Zoom Meeting', + content, + contentDeferred: false, + mimeType: 'text/plain', + sourceUrl: buildSourceUrl(recording), + contentHash: buildContentHash(recording, transcript), + metadata: { + meetingId: recording.id != null ? String(recording.id) : undefined, + hostEmail: recording.host_email, + duration: recording.duration, + meetingDate: recording.start_time, + topic: recording.topic, + }, + } + } catch (error) { + logger.warn('Failed to get Zoom recording', { + externalId, + error: toError(error).message, + }) + return null + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + const maxRecordings = sourceConfig.maxRecordings as string | undefined + if (maxRecordings && (Number.isNaN(Number(maxRecordings)) || Number(maxRecordings) < 0)) { + return { valid: false, error: 'Max recordings must be a non-negative number' } + } + + try { + const response = await fetchWithRetry( + `${ZOOM_API_BASE}/users/me`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }, + VALIDATE_RETRY_OPTIONS + ) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + return { + valid: false, + error: `Zoom access failed: ${response.status}${errorText ? ` — ${errorText.slice(0, 200)}` : ''}`, + } + } + + return { valid: true } + } catch (error) { + const message = getErrorMessage(error, 'Failed to validate configuration') + return { valid: false, error: message } + } + }, + + tagDefinitions: [ + { id: 'topic', displayName: 'Topic', fieldType: 'text' }, + { id: 'hostEmail', displayName: 'Host Email', fieldType: 'text' }, + { id: 'duration', displayName: 'Duration (minutes)', fieldType: 'number' }, + { id: 'meetingDate', displayName: 'Meeting Date', fieldType: 'date' }, + ], + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + if (typeof metadata.topic === 'string' && metadata.topic.trim()) { + result.topic = metadata.topic + } + + if (typeof metadata.hostEmail === 'string' && metadata.hostEmail.trim()) { + result.hostEmail = metadata.hostEmail + } + + if (metadata.duration != null) { + const num = Number(metadata.duration) + if (!Number.isNaN(num)) result.duration = num + } + + const meetingDate = parseTagDate(metadata.meetingDate) + if (meetingDate) result.meetingDate = meetingDate + + return result + }, +} diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 3cea9b0bf8d..930c9da176b 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -15,6 +15,7 @@ import { Modal, ModalBody, ModalContent, + ModalDescription, ModalFooter, ModalHeader, ModalTabs, @@ -134,6 +135,9 @@ function AddMembersModal({ Add Members + + Search and select workspace members to add to this permission group + {availableMembers.length === 0 ? (

All workspace members are already in this group. @@ -142,7 +146,7 @@ function AddMembersModal({

- +
- +
@@ -800,7 +804,7 @@ export function AccessControl() {
Members
@@ -810,7 +814,7 @@ export function AccessControl() { {[1, 2].map((i) => (
- +
@@ -904,10 +908,14 @@ export function AccessControl() { + + Configure model provider, block, and platform permissions for this permission + group +
- + toggleProvider(providerId)} /> -
+
{ProviderIcon && }
{providerName} @@ -968,7 +976,7 @@ export function AccessControl() {
- + toggleIntegration(block.type)} />
{BlockIcon && ( @@ -1058,7 +1066,7 @@ export function AccessControl() { onCheckedChange={() => toggleIntegration(block.type)} />
{BlockIcon && ( @@ -1078,7 +1086,7 @@ export function AccessControl() {
- + Unsaved Changes -

+ You have unsaved changes. Do you want to save them before closing? -

+
@@ -1299,6 +1307,9 @@ export function AccessControl() { Create Permission Group + + Enter a name and optional description to create a new permission group +
@@ -1351,14 +1362,14 @@ export function AccessControl() { Delete Permission Group -

+ Are you sure you want to delete{' '} {deletingGroup?.name}? All members will be removed from this group. {' '} This action cannot be undone. -

+
+
+ +
+ {drainsError ? ( + + Failed to load data drains: {toError(drainsError).message} + + ) : drains && drains.length > 0 ? ( + filteredDrains.length > 0 ? ( + + + + Name + Source + Destination + Cadence + Last run + Enabled + + + + + {filteredDrains.map((drain) => ( + + setExpandedDrainId(expandedDrainId === drain.id ? null : drain.id) + } + /> + ))} + +
+ ) : ( +
+ No results for "{searchTerm.trim()}" +
+ ) + ) : ( +
+ Click "New drain" above to get started +
+ )} +
+ + {createOpen && ( + setCreateOpen(false)} /> + )} +
+ ) +} + +interface DrainRowProps { + drain: DataDrain + organizationId: string + expanded: boolean + onToggleExpand: () => void +} + +function DrainRow({ drain, organizationId, expanded, onToggleExpand }: DrainRowProps) { + const updateMutation = useUpdateDataDrain() + const deleteMutation = useDeleteDataDrain() + const runMutation = useRunDataDrainNow() + const testMutation = useTestDataDrain() + + async function handleToggleEnabled() { + try { + await updateMutation.mutateAsync({ + organizationId, + drainId: drain.id, + body: { enabled: !drain.enabled }, + }) + toast.success(drain.enabled ? 'Drain disabled' : 'Drain enabled') + } catch (error) { + toast.error(toError(error).message) + } + } + + async function handleRunNow() { + try { + await runMutation.mutateAsync({ organizationId, drainId: drain.id }) + toast.success('Drain run enqueued') + } catch (error) { + toast.error(toError(error).message) + } + } + + async function handleTest() { + try { + await testMutation.mutateAsync({ organizationId, drainId: drain.id }) + toast.success('Connection test succeeded') + } catch (error) { + toast.error(toError(error).message) + } + } + + async function handleDelete() { + if (!window.confirm(`Delete drain "${drain.name}"? This cannot be undone.`)) return + try { + await deleteMutation.mutateAsync({ organizationId, drainId: drain.id }) + toast.success('Drain deleted') + } catch (error) { + toast.error(toError(error).message) + } + } + + return ( + <> + + +
+ + {drain.name} +
+
+ + {SOURCE_LABELS[drain.source]} + + + {DESTINATION_LABELS[drain.destinationType]} + + {CADENCE_LABELS[drain.scheduleCadence]} + + {drain.lastRunAt ? new Date(drain.lastRunAt).toLocaleString() : 'Never'} + + e.stopPropagation()}> + + + e.stopPropagation()}> + + + + + + + Run now + + Test connection + + Delete + + + + +
+ {expanded && ( + + + + + + )} + + ) +} + +interface DrainRunsPanelProps { + organizationId: string + drainId: string +} + +function DrainRunsPanel({ organizationId, drainId }: DrainRunsPanelProps) { + const { data: runs, isLoading } = useDataDrainRuns(organizationId, drainId, 10) + + if (isLoading) { + return
Loading runs...
+ } + if (!runs || runs.length === 0) { + return
No runs yet.
+ } + + return ( +
+
Recent runs
+ {runs.map((run) => ( + + ))} +
+ ) +} + +function RunRow({ run }: { run: DataDrainRun }) { + const statusColor = + run.status === 'success' + ? 'text-green-600' + : run.status === 'failed' + ? 'text-red-600' + : 'text-[var(--text-muted)]' + return ( +
+
+
+ {run.status} + {run.trigger} + + {new Date(run.startedAt).toLocaleString()} + +
+ {run.error &&
{run.error}
} +
+
+
{run.rowsExported.toLocaleString()} rows
+
{(run.bytesWritten / 1024).toFixed(1)} KB
+
+
+ ) +} + +interface CreateDrainModalProps { + organizationId: string + onClose: () => void +} + +function CreateDrainModal({ organizationId, onClose }: CreateDrainModalProps) { + const createMutation = useCreateDataDrain() + + const [name, setName] = useState('') + const [source, setSource] = useState<(typeof SOURCE_TYPES)[number]>('workflow_logs') + const [cadence, setCadence] = useState<(typeof CADENCE_TYPES)[number]>('daily') + const [destinationType, setDestinationType] = useState<(typeof DESTINATION_TYPES)[number]>( + DESTINATION_TYPES[0] + ) + const [destState, setDestState] = useState( + () => DESTINATION_FORM_REGISTRY[DESTINATION_TYPES[0]].initialState + ) + + const spec = DESTINATION_FORM_REGISTRY[destinationType] + const canSubmit = name.trim().length > 0 && spec.isComplete(destState) + + function handleDestinationChange(next: (typeof DESTINATION_TYPES)[number]) { + setDestinationType(next) + setDestState(DESTINATION_FORM_REGISTRY[next].initialState) + } + + async function handleSubmit() { + if (!canSubmit) return + try { + const body = { + name: name.trim(), + source, + scheduleCadence: cadence, + ...spec.toDestinationBranch(destState), + } as CreateDataDrainBody + await createMutation.mutateAsync({ organizationId, body }) + toast.success('Drain created') + onClose() + } catch (error) { + const msg = toError(error).message + logger.error('Failed to create data drain', { error: msg }) + toast.error(msg) + } + } + + return ( + !open && onClose()}> + + New data drain + + + Configure a new data drain to export workflow logs to an external destination + +
+ + setName(e.target.value)} + placeholder='Workflow logs export' + /> + + + setSource(v as (typeof SOURCE_TYPES)[number])} + options={SOURCE_OPTIONS} + dropdownWidth='trigger' + /> + + + setCadence(v as (typeof CADENCE_TYPES)[number])} + options={CADENCE_OPTIONS} + dropdownWidth='trigger' + /> + + + handleDestinationChange(v as (typeof DESTINATION_TYPES)[number])} + options={DESTINATION_OPTIONS} + dropdownWidth='trigger' + overlayContent={ +
+ {getDestinationIcon(destinationType)} + + {DESTINATION_LABELS[destinationType]} + +
+ } + /> +
+
+ +
+ +
+
+ + + + +
+
+ ) +} diff --git a/apps/sim/ee/data-drains/components/data-drains-skeleton.tsx b/apps/sim/ee/data-drains/components/data-drains-skeleton.tsx new file mode 100644 index 00000000000..71e6bcdf011 --- /dev/null +++ b/apps/sim/ee/data-drains/components/data-drains-skeleton.tsx @@ -0,0 +1,18 @@ +import { Skeleton } from '@/components/emcn' + +export function DataDrainsSkeleton() { + return ( +
+ +
+ + +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/apps/sim/ee/data-drains/destinations/registry.tsx b/apps/sim/ee/data-drains/destinations/registry.tsx new file mode 100644 index 00000000000..17ef9ef6d38 --- /dev/null +++ b/apps/sim/ee/data-drains/destinations/registry.tsx @@ -0,0 +1,515 @@ +'use client' + +import type { ComponentType } from 'react' +import { Combobox, FormField, Input, SecretInput, Switch, Textarea } from '@/components/emcn' +import type { CreateDataDrainBody } from '@/lib/api/contracts/data-drains' +import type { DestinationType } from '@/lib/data-drains/types' + +type DestinationBranch = Pick< + CreateDataDrainBody, + 'destinationType' | 'destinationConfig' | 'destinationCredentials' +> + +interface DestinationFormSpec { + readonly displayName: string + readonly initialState: TState + readonly FormFields: ComponentType<{ + state: TState + setState: (state: TState) => void + }> + readonly isComplete: (state: TState) => boolean + readonly toDestinationBranch: (state: TState) => DestinationBranch +} + +interface S3State { + bucket: string + region: string + prefix: string + endpoint: string + forcePathStyle: boolean + accessKeyId: string + secretAccessKey: string +} + +const s3FormSpec: DestinationFormSpec = { + displayName: 'Amazon S3', + initialState: { + bucket: '', + region: 'us-east-1', + prefix: '', + endpoint: '', + forcePathStyle: false, + accessKeyId: '', + secretAccessKey: '', + }, + FormFields: ({ state, setState }) => ( + <> + + setState({ ...state, bucket: e.target.value })} + /> + + + setState({ ...state, region: e.target.value })} + /> + + + setState({ ...state, prefix: e.target.value })} + placeholder='exports/sim' + /> + + + setState({ ...state, endpoint: e.target.value })} + placeholder='https://s3.example.com' + /> + + + setState({ ...state, forcePathStyle: v })} + /> + + + setState({ ...state, accessKeyId: v })} + /> + + + setState({ ...state, secretAccessKey: v })} + /> + + + ), + isComplete: (s) => + s.bucket.length > 0 && + s.region.length > 0 && + s.accessKeyId.length > 0 && + s.secretAccessKey.length > 0, + toDestinationBranch: (s) => ({ + destinationType: 's3', + destinationConfig: { + bucket: s.bucket, + region: s.region, + prefix: s.prefix || undefined, + endpoint: s.endpoint || undefined, + forcePathStyle: s.forcePathStyle, + }, + destinationCredentials: { + accessKeyId: s.accessKeyId, + secretAccessKey: s.secretAccessKey, + }, + }), +} + +interface GCSState { + bucket: string + prefix: string + serviceAccountJson: string +} + +const gcsFormSpec: DestinationFormSpec = { + displayName: 'Google Cloud Storage', + initialState: { bucket: '', prefix: '', serviceAccountJson: '' }, + FormFields: ({ state, setState }) => ( + <> + + setState({ ...state, bucket: e.target.value })} + /> + + + setState({ ...state, prefix: e.target.value })} + placeholder='exports/sim' + /> + + +