Skip to content

Commit ed1a875

Browse files
improvement(repo): centralized zod contracts (#4336)
* improvement(repo): zod schema contracts * type checks * fix(notion): correctly register tool (#4337) * fix func blokc * more improvements * fix tests * type check * remove v3 refs * minor type improvements * address comments
1 parent ed7786d commit ed1a875

1,130 files changed

Lines changed: 40919 additions & 23853 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/add-integration/SKILL.md

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -578,38 +578,67 @@ tools: {
578578

579579
#### 3. Create Internal API Route
580580

581-
Create `apps/sim/app/api/tools/{service}/{action}/route.ts`:
581+
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.
582582

583583
```typescript
584+
// apps/sim/lib/api/contracts/{service}-tools.ts
585+
import { z } from 'zod'
586+
import { defineRouteContract } from '@/lib/api/contracts'
587+
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
588+
589+
export const {service}UploadBodySchema = z.object({
590+
accessToken: z.string(),
591+
file: FileInputSchema.optional().nullable(),
592+
fileContent: z.string().optional().nullable(),
593+
// ... other params
594+
})
595+
596+
export const {service}UploadResponseSchema = z.object({
597+
success: z.boolean(),
598+
output: z.object({ id: z.string(), url: z.string() }).optional(),
599+
error: z.string().optional(),
600+
})
601+
602+
export const {service}UploadContract = defineRouteContract({
603+
method: 'POST',
604+
path: '/api/tools/{service}/upload',
605+
body: {service}UploadBodySchema,
606+
response: { mode: 'json', schema: {service}UploadResponseSchema },
607+
})
608+
609+
export type {Service}UploadBody = z.input<typeof {service}UploadBodySchema>
610+
export type {Service}UploadResponse = z.output<typeof {service}UploadResponseSchema>
611+
```
612+
613+
```typescript
614+
// apps/sim/app/api/tools/{service}/upload/route.ts
584615
import { createLogger } from '@sim/logger'
585616
import { NextResponse, type NextRequest } from 'next/server'
586-
import { z } from 'zod'
617+
import { {service}UploadContract } from '@/lib/api/contracts/{service}-tools'
618+
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
587619
import { checkInternalAuth } from '@/lib/auth/hybrid'
588620
import { generateRequestId } from '@/lib/core/utils/request'
589-
import { FileInputSchema, type RawFileInput } from '@/lib/uploads/utils/file-schemas'
621+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
622+
import { type RawFileInput } from '@/lib/uploads/utils/file-schemas'
590623
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
591624
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
592625

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

595-
const RequestSchema = z.object({
596-
accessToken: z.string(),
597-
file: FileInputSchema.optional().nullable(),
598-
// Legacy field for backwards compatibility
599-
fileContent: z.string().optional().nullable(),
600-
// ... other params
601-
})
602-
603-
export async function POST(request: NextRequest) {
628+
export const POST = withRouteHandler(async (request: NextRequest) => {
604629
const requestId = generateRequestId()
605630

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

611-
const body = await request.json()
612-
const data = RequestSchema.parse(body)
636+
let data
637+
try {
638+
;({ body: data } = await parseRequest({service}UploadContract, request))
639+
} catch (error) {
640+
return validationErrorResponseFromError(error)
641+
}
613642

614643
let fileBuffer: Buffer
615644
let fileName: string
@@ -624,22 +653,20 @@ export async function POST(request: NextRequest) {
624653
fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
625654
fileName = userFile.name
626655
} else if (data.fileContent) {
627-
// Legacy: base64 string (backwards compatibility)
628656
fileBuffer = Buffer.from(data.fileContent, 'base64')
629657
fileName = 'file'
630658
} else {
631659
return NextResponse.json({ success: false, error: 'File required' }, { status: 400 })
632660
}
633661

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

641668
// ... handle response
642-
}
669+
})
643670
```
644671

645672
#### 4. Update Tool to Use Internal Route

.agents/skills/cleanup/SKILL.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,8 @@ Run each of these skills in order on the specified scope, passing through the sc
2323
6. `/emcn-design-review $ARGUMENTS`
2424

2525
After all skills have run, output a summary of what was found and fixed (or proposed) across all six passes.
26+
27+
## Boundary Audit Guidance
28+
29+
- 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: <reason>` or `// double-cast-allowed: <reason>` 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.
30+
- 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`.

.cursor/rules/sim-architecture.mdc

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,16 @@ feature/
5454
- **Create `utils.ts` when** 2+ files need the same helper
5555
- **Check existing sources** before duplicating (`lib/` has many utilities)
5656
- **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use)
57+
58+
## API Contracts
59+
60+
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.
61+
62+
- 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>`).
63+
- Shared identifier schemas live in `apps/sim/lib/api/contracts/primitives.ts`.
64+
- 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`.
65+
- Clients call `requestJson(contract, ...)` from `apps/sim/lib/api/client/request.ts`; hooks import named type aliases from contracts, never `z.input/z.output`.
66+
- 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.
67+
- `bun run check:api-validation` enforces this policy and must pass on PRs.
68+
69+
`bun run check:api-validation:strict` is the strict CI gate and additionally fails on annotations with empty reasons. Two per-line opt-out forms are recognized: `// boundary-raw-fetch: <reason>` (placed immediately above a legitimate raw `fetch(` call in `apps/sim/hooks/queries/**` or `apps/sim/hooks/selectors/**` for stream/binary/multipart/signed-URL/OAuth-redirect/external-origin cases) and `// double-cast-allowed: <reason>` (placed immediately above an `as unknown as X` cast outside test files). The reason must be non-empty. Whole-file allowlists for routes that legitimately import Zod for non-boundary reasons go through `INDIRECT_ZOD_ROUTES` in `scripts/check-api-validation-contracts.ts`, not per-line annotations.

.cursor/rules/sim-queries.mdc

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,18 @@ Never use inline query keys — always use the factory.
3737
- Every `queryFn` must destructure and forward `signal` for request cancellation
3838
- Every query must have an explicit `staleTime`
3939
- Use `keepPreviousData` only on variable-key queries (where params change), never on static keys
40+
- Same-origin JSON calls must go through `requestJson(contract, ...)` from `@/lib/api/client/request` against the contract in `@/lib/api/contracts/**`
4041

4142
```typescript
42-
async function fetchEntities(workspaceId: string, signal?: AbortSignal) {
43-
const response = await fetch(`/api/entities?workspaceId=${workspaceId}`, { signal })
44-
if (!response.ok) throw new Error('Failed to fetch entities')
45-
return response.json()
43+
import { requestJson } from '@/lib/api/client/request'
44+
import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities'
45+
46+
async function fetchEntities(workspaceId: string, signal?: AbortSignal): Promise<EntityList> {
47+
const data = await requestJson(listEntitiesContract, {
48+
query: { workspaceId },
49+
signal,
50+
})
51+
return data.entities
4652
}
4753

4854
export function useEntityList(workspaceId?: string, options?: { enabled?: boolean }) {
@@ -51,7 +57,7 @@ export function useEntityList(workspaceId?: string, options?: { enabled?: boolea
5157
queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal),
5258
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
5359
staleTime: 60 * 1000,
54-
placeholderData: keepPreviousData, // OK: workspaceId varies
60+
placeholderData: keepPreviousData,
5561
})
5662
}
5763
```
@@ -118,6 +124,12 @@ const handler = useCallback(() => {
118124
}, [data])
119125
```
120126

127+
## Boundary Types
128+
129+
- 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.
130+
- Hooks must not `import { z } from 'zod'`. Boundary types come from contract aliases; non-boundary helpers can stay in plain TypeScript.
131+
- 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: <reason>` 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.
132+
121133
## Naming
122134

123135
- **Keys**: `entityKeys`

.github/workflows/test-build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ jobs:
106106
- name: Enforce monorepo boundaries
107107
run: bun run check:boundaries
108108

109+
- name: API contract boundary audit
110+
run: bun run check:api-validation:strict
111+
109112
- name: Verify realtime prune graph
110113
run: bun run check:realtime-prune
111114

AGENTS.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ You are a professional software engineer. All code must follow best practices: a
44

55
## Global Standards
66

7+
- **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
78
- **Logging**: Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`
89
- **Comments**: Use TSDoc for documentation. No `====` separators. No non-TSDoc comments
910
- **Styling**: Never update global styles. Keep all styling local to components
@@ -115,6 +116,101 @@ export function Component({ requiredProp, optionalProp = false }: ComponentProps
115116

116117
Extract when: 50+ lines, used in 2+ files, or has own state/logic. Keep inline when: < 10 lines, single use, purely presentational.
117118

119+
## API Contracts
120+
121+
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.
122+
123+
- Each contract is built with `defineRouteContract({ method, path, params?, query?, body?, headers?, response: { mode: 'json', schema } })` from `@/lib/api/contracts`
124+
- Contracts export named schemas (e.g., `createFolderBodySchema`) AND named TypeScript type aliases (e.g., `export type CreateFolderBody = z.input<typeof createFolderBodySchema>`)
125+
- Clients (hooks, utilities, components) import the named type aliases from the contract file. They must never write `z.input<...>` / `z.output<...>` themselves
126+
- 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
127+
- 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
128+
129+
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.
130+
131+
### Boundary annotations
132+
133+
A small number of legitimate exceptions to the boundary rules are tolerated when annotated. The audit script recognizes two annotation forms:
134+
135+
- `// boundary-raw-fetch: <reason>` — 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
136+
- `// double-cast-allowed: <reason>` — placed on the line directly above an `as unknown as X` cast outside test files
137+
138+
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`).
139+
140+
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.
141+
142+
Examples:
143+
144+
```ts
145+
// boundary-raw-fetch: streaming SSE chunks must be processed as they arrive
146+
const response = await fetch(`/api/copilot/chat/stream?chatId=${chatId}`, { signal })
147+
```
148+
149+
```ts
150+
// double-cast-allowed: legacy provider type lacks the discriminator field we need
151+
const provider = config as unknown as LegacyProvider
152+
```
153+
154+
## API Route Pattern
155+
156+
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`:
157+
158+
- `parseRequest(contract, request, context)` — fully contract-bound routes; parses params, query, body, and headers in one call
159+
- `validateJsonBody(request, schema)` — when the body schema comes from a contract but you need to assemble query/headers manually
160+
- `validateSchema(schema, data)` — for ad-hoc validation against a contract schema or primitive
161+
- `validationErrorResponse(error)` and `getValidationErrorMessage(error, fallback)` — produce 400 responses from a `ZodError`
162+
- `validationErrorResponseFromError(error)` — when handling unknown caught errors that may or may not be a `ZodError`
163+
- `isZodError(error)` — type guard. Routes never use `instanceof z.ZodError`
164+
165+
### Fully contract-bound route (`parseRequest`)
166+
167+
```typescript
168+
import { createLogger } from '@sim/logger'
169+
import type { NextRequest } from 'next/server'
170+
import { NextResponse } from 'next/server'
171+
import { createFolderContract } from '@/lib/api/contracts/folders'
172+
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
173+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
174+
175+
const logger = createLogger('FoldersAPI')
176+
177+
export const POST = withRouteHandler(async (request: NextRequest) => {
178+
try {
179+
const { body } = await parseRequest(createFolderContract, request)
180+
logger.info('Creating folder', { workspaceId: body.workspaceId })
181+
return NextResponse.json({ ok: true })
182+
} catch (error) {
183+
return validationErrorResponseFromError(error)
184+
}
185+
})
186+
```
187+
188+
### Partial validation (`validateJsonBody`)
189+
190+
```typescript
191+
import type { NextRequest } from 'next/server'
192+
import { NextResponse } from 'next/server'
193+
import { updateFolderBodySchema } from '@/lib/api/contracts/folders'
194+
import { isZodError, validateJsonBody, validationErrorResponse } from '@/lib/api/server'
195+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
196+
197+
export const PATCH = withRouteHandler(async (
198+
request: NextRequest,
199+
{ params }: { params: Promise<{ id: string }> }
200+
) => {
201+
const { id } = await params
202+
try {
203+
const body = await validateJsonBody(request, updateFolderBodySchema)
204+
return NextResponse.json({ id, ...body })
205+
} catch (error) {
206+
if (isZodError(error)) return validationErrorResponse(error)
207+
throw error
208+
}
209+
})
210+
```
211+
212+
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.
213+
118214
## Hooks
119215

120216
```typescript
@@ -160,6 +256,38 @@ Use `devtools` middleware. Use `persist` only when data should survive reload wi
160256

161257
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.
162258

259+
### Client Boundary
260+
261+
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`:
262+
263+
- 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
264+
- `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
265+
- 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
266+
267+
```typescript
268+
import { keepPreviousData, useQuery } from '@tanstack/react-query'
269+
import { requestJson } from '@/lib/api/client/request'
270+
import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities'
271+
272+
async function fetchEntities(workspaceId: string, signal?: AbortSignal): Promise<EntityList> {
273+
const data = await requestJson(listEntitiesContract, {
274+
query: { workspaceId },
275+
signal,
276+
})
277+
return data.entities
278+
}
279+
280+
export function useEntityList(workspaceId?: string) {
281+
return useQuery({
282+
queryKey: entityKeys.list(workspaceId),
283+
queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal),
284+
enabled: Boolean(workspaceId),
285+
staleTime: 60 * 1000,
286+
placeholderData: keepPreviousData,
287+
})
288+
}
289+
```
290+
163291
### Query Key Factory
164292

165293
Every file must have a hierarchical key factory with an `all` root key and intermediate plural keys for prefix invalidation:

0 commit comments

Comments
 (0)