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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
63 changes: 45 additions & 18 deletions .agents/skills/add-integration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -578,38 +578,67 @@ 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<typeof {service}UploadBodySchema>
export type {Service}UploadResponse = z.output<typeof {service}UploadResponseSchema>
```

```typescript
// apps/sim/app/api/tools/{service}/upload/route.ts
import { createLogger } from '@sim/logger'
import { NextResponse, type NextRequest } from 'next/server'
import { z } from 'zod'
import { {service}UploadContract } from '@/lib/api/contracts/{service}-tools'
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { FileInputSchema, type RawFileInput } from '@/lib/uploads/utils/file-schemas'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { type RawFileInput } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'

const logger = createLogger('{Service}UploadAPI')

const RequestSchema = z.object({
accessToken: z.string(),
file: FileInputSchema.optional().nullable(),
// Legacy field for backwards compatibility
fileContent: z.string().optional().nullable(),
// ... other params
})

export async function POST(request: NextRequest) {
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()

const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}

const body = await request.json()
const data = RequestSchema.parse(body)
let data
try {
;({ body: data } = await parseRequest({service}UploadContract, request))
} catch (error) {
return validationErrorResponseFromError(error)
}

let fileBuffer: Buffer
let fileName: string
Expand All @@ -624,22 +653,20 @@ export async function POST(request: NextRequest) {
fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
fileName = userFile.name
} else if (data.fileContent) {
// Legacy: base64 string (backwards compatibility)
fileBuffer = Buffer.from(data.fileContent, 'base64')
fileName = 'file'
} else {
return NextResponse.json({ success: false, error: 'File required' }, { status: 400 })
}

// Now call external API with fileBuffer
const response = await fetch('https://api.{service}.com/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${data.accessToken}` },
body: new Uint8Array(fileBuffer), // Convert Buffer for fetch
body: new Uint8Array(fileBuffer),
})

// ... handle response
}
})
```

#### 4. Update Tool to Use Internal Route
Expand Down
11 changes: 11 additions & 0 deletions .cursor/rules/sim-architecture.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,14 @@ 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<typeof createFolderBodySchema>`).
- Shared identifier schemas live in `apps/sim/lib/api/contracts/primitives.ts`.
- Routes validate via canonical helpers in `apps/sim/lib/api/server/validation.ts` (`parseRequest`, `validateJsonBody`, `validateSchema`, `validationErrorResponse`, `getValidationErrorMessage`, `isZodError`). Routes never `import { z } from 'zod'` and never use `instanceof z.ZodError`.
- Clients call `requestJson(contract, ...)` from `apps/sim/lib/api/client/request.ts`; hooks import named type aliases from contracts, never `z.input/z.output`.
- Routes under `apps/sim/app/api/v1/**` use `apps/sim/app/api/v1/middleware.ts` for shared auth, rate-limit, and workspace access. Compose contract validation inside that middleware.
- `bun run check:api-validation` enforces this policy and must pass on PRs.
22 changes: 17 additions & 5 deletions .cursor/rules/sim-queries.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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<EntityList> {
const data = await requestJson(listEntitiesContract, {
query: { workspaceId },
signal,
})
return data.entities
}

export function useEntityList(workspaceId?: string, options?: { enabled?: boolean }) {
Expand All @@ -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,
})
}
```
Expand Down Expand Up @@ -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`. Mark each raw `fetch` with a TSDoc comment explaining which exception applies.

## Naming

- **Keys**: `entityKeys`
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ jobs:
- name: Enforce monorepo boundaries
run: bun run check:boundaries

- name: API contract boundary audit
run: bun run check:api-validation:strict

- name: Verify realtime prune graph
run: bun run check:realtime-prune

Expand Down
105 changes: 105 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -115,6 +116,78 @@ 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<typeof createFolderBodySchema>`)
- 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

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.

## API Route Pattern

Routes never `import { z } from 'zod'` and never define route-local boundary schemas. They consume the contract from `@/lib/api/contracts/**` and validate with canonical helpers from `@/lib/api/server`:

- `parseRequest(contract, request, context)` — fully contract-bound routes; parses params, query, body, and headers in one call
- `validateJsonBody(request, schema)` — when the body schema comes from a contract but you need to assemble query/headers manually
- `validateSchema(schema, data)` — for ad-hoc validation against a contract schema or primitive
- `validationErrorResponse(error)` and `getValidationErrorMessage(error, fallback)` — produce 400 responses from a `ZodError`
- `validationErrorResponseFromError(error)` — when handling unknown caught errors that may or may not be a `ZodError`
- `isZodError(error)` — type guard. Routes never use `instanceof z.ZodError`

### Fully contract-bound route (`parseRequest`)

```typescript
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { createFolderContract } from '@/lib/api/contracts/folders'
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

const logger = createLogger('FoldersAPI')

export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const { body } = await parseRequest(createFolderContract, request)
logger.info('Creating folder', { workspaceId: body.workspaceId })
return NextResponse.json({ ok: true })
} catch (error) {
return validationErrorResponseFromError(error)
}
})
```

### Partial validation (`validateJsonBody`)

```typescript
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { updateFolderBodySchema } from '@/lib/api/contracts/folders'
import { isZodError, validateJsonBody, validationErrorResponse } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

export const PATCH = withRouteHandler(async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) => {
const { id } = await params
try {
const body = await validateJsonBody(request, updateFolderBodySchema)
return NextResponse.json({ id, ...body })
} catch (error) {
if (isZodError(error)) return validationErrorResponse(error)
throw error
}
})
```

Routes under `apps/sim/app/api/v1/**` use the shared middleware in `apps/sim/app/api/v1/middleware.ts` for auth, rate-limit, and workspace access. Compose contract validation inside that middleware — never reimplement auth/rate-limit per-route.

## Hooks

```typescript
Expand Down Expand Up @@ -160,6 +233,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<EntityList> {
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:
Expand Down
Loading