Skip to content

Commit be9c959

Browse files
improvement(types): enforce patterns outside just hooks directory and fix CI check + fix tracing billing issue (#4367)
* improvement(types): enforce outside just hooks dir and update CI checks * fix billing account details for kb embeddings * more fixes * fix byok issue * address comments * fix * more comments * address bugbot
1 parent 0c25fc4 commit be9c959

138 files changed

Lines changed: 3122 additions & 1587 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.

apps/sim/AGENTS.md

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ These rules apply to files under `apps/sim/` in addition to the repository root
55
## Architecture
66

77
### Core Principles
8+
89
1. **Single Responsibility**: Each component, hook, store has one clear purpose
910
2. **Composition Over Complexity**: Break down complex logic into smaller pieces
1011
3. **Type Safety First**: TypeScript interfaces for all props, state, return types
@@ -47,6 +48,7 @@ feature/
4748
```
4849

4950
### Naming Conventions
51+
5052
- **Components**: PascalCase (`WorkflowList`)
5153
- **Hooks**: `use` prefix (`useWorkflowOperations`)
5254
- **Files**: kebab-case (`workflow-list.tsx`)
@@ -71,7 +73,7 @@ feature/
7173

7274
## API Contracts
7375

74-
Boundary HTTP request and response shapes for all routes under `apps/sim/app/api/**` live in `apps/sim/lib/api/contracts/**` (one file per resource family). Routes never define route-local boundary Zod schemas, and clients never define ad-hoc wire types — both sides consume the same contract.
76+
Boundary HTTP request and response shapes for all routes under `apps/sim/app/api/`** live in `apps/sim/lib/api/contracts/**` (one file per resource family). Routes never define route-local boundary Zod schemas, and clients never define ad-hoc wire types — both sides consume the same contract.
7577

7678
- Each contract is built with `defineRouteContract({ method, path, params?, query?, body?, headers?, response: { mode: 'json', schema } })` from `@/lib/api/contracts`.
7779
- Contracts export named schemas AND named TypeScript type aliases (e.g., `export type CreateFolderBody = z.input<typeof createFolderBodySchema>`). Clients import the named aliases — never `z.input<...>` / `z.output<...>` in hooks.
@@ -83,10 +85,10 @@ Boundary HTTP request and response shapes for all routes under `apps/sim/app/api
8385

8486
A small number of legitimate exceptions to the boundary rules are tolerated when annotated. The audit script recognizes four annotation forms:
8587

86-
- `// 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.
88+
- `// boundary-raw-fetch: <reason>` — placed on the line directly above a raw `fetch(` call inside `apps/sim/hooks/queries/**`, `apps/sim/hooks/selectors/**`, or any other client/UI source under `apps/sim/**` that targets a same-origin `/api/...` URL. Use only for documented exceptions: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests.
8789
- `// double-cast-allowed: <reason>` — placed on the line directly above an `as unknown as X` cast outside test files.
88-
- `// boundary-raw-json: <reason>` — 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`.
89-
- `// untyped-response: <reason>` — 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).
90+
- `// boundary-raw-json: <reason>` — placed on the line directly above a raw `await request.json()` / `await req.json()` read (or the multi-line `await request.clone().json()` shim variant) in a route handler. Use only when the body is a JSON-RPC envelope, a tolerant `.catch(() => ({}))` parse, or otherwise cannot go through `parseRequest`.
91+
- `// untyped-response: <reason>` — placed on the line directly above a `schema: z.unknown()` / `schema: z.object({}).passthrough()` / `schema: z.record(z.string(), z.unknown())` response declaration (or a simple alias to one of those) in a contract file. Use only when the response body is genuinely opaque (user-supplied data, third-party passthrough).
9092

9193
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`).
9294

@@ -104,6 +106,19 @@ const response = await fetch(`/api/copilot/chat/stream?chatId=${chatId}`, { sign
104106
const provider = config as unknown as LegacyProvider
105107
```
106108

109+
```ts
110+
// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
111+
const body = await request
112+
.clone()
113+
.json()
114+
.catch(() => undefined)
115+
```
116+
117+
```ts
118+
// untyped-response: forwards firecrawl /v2/parse response unchanged for downstream tool consumers
119+
output: z.unknown(),
120+
```
121+
107122
## API Route Pattern
108123

109124
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`:
@@ -149,18 +164,18 @@ When adding a new route + client surface, follow this order. Each step has one p
149164

150165
LLMs will write contracts that compile but are sloppy. The human reviewer should optimize attention on:
151166

152-
- **`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.
167+
- `**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.
153168
- **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.
154169
- **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.
155170
- **Bounds are set** on arrays (`.min(1)`, `.max(N)`), strings (`.min(1).max(N)` for IDs/names), and numbers (`.min().max()` for limits/sizes).
156-
- **`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: <specific reason>` in a `schema:` slot.
171+
- `**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: <specific reason>` in a `schema:` slot.
157172
- **Discriminated unions over plain unions** when the wire has a discriminant field — gives clients exhaustive narrowing.
158173

159174
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.
160175

161176
## React Query Client Boundary
162177

163-
Hooks in `apps/sim/hooks/queries/**` consume contracts the same way routes do. Every same-origin JSON call must go through `requestJson(contract, ...)` from `@/lib/api/client/request` instead of raw `fetch`:
178+
Hooks in `apps/sim/hooks/queries/`** consume contracts the same way routes do. Every same-origin JSON call must go through `requestJson(contract, ...)` from `@/lib/api/client/request` instead of raw `fetch`:
164179

165180
- 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.
166181
- `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.
@@ -204,3 +219,4 @@ export function useEntityList(workspaceId?: string) {
204219
- **Create `utils.ts` when** 2+ files need the same helper
205220
- **Check existing sources** before duplicating (`lib/` has many utilities)
206221
- **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use)
222+

apps/sim/app/(auth)/login/login-form.tsx

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
ModalDescription,
1616
ModalHeader,
1717
} from '@/components/emcn'
18+
import { requestJson } from '@/lib/api/client/request'
19+
import { forgetPasswordContract } from '@/lib/api/contracts'
1820
import { client } from '@/lib/auth/auth-client'
1921
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
2022
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
@@ -282,20 +284,16 @@ export default function LoginPage({
282284
setIsSubmittingReset(true)
283285
setResetStatus({ type: null, message: '' })
284286

285-
const response = await fetch('/api/auth/forget-password', {
286-
method: 'POST',
287-
headers: {
288-
'Content-Type': 'application/json',
289-
},
290-
body: JSON.stringify({
291-
email: forgotPasswordEmail,
292-
redirectTo: `${getBaseUrl()}/reset-password`,
293-
}),
294-
})
295-
296-
if (!response.ok) {
297-
const errorData = await response.json()
298-
let errorMessage = errorData.message || 'Failed to request password reset'
287+
try {
288+
await requestJson(forgetPasswordContract, {
289+
body: {
290+
email: forgotPasswordEmail,
291+
redirectTo: `${getBaseUrl()}/reset-password`,
292+
},
293+
})
294+
} catch (requestError) {
295+
let errorMessage =
296+
requestError instanceof Error ? requestError.message : 'Failed to request password reset'
299297

300298
if (
301299
errorMessage.includes('Invalid body parameters') ||

apps/sim/app/(auth)/oauth/consent/page.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { ArrowLeftRight } from 'lucide-react'
55
import Image from 'next/image'
66
import { useRouter, useSearchParams } from 'next/navigation'
77
import { Button, Loader } from '@/components/emcn'
8+
import { requestJson } from '@/lib/api/client/request'
9+
import { oauthAuthorizeParamsContract } from '@/lib/api/contracts/oauth-connections'
810
import { signOut, useSession } from '@/lib/auth/auth-client'
911
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
1012

@@ -44,6 +46,7 @@ export default function OAuthConsentPage() {
4446
return
4547
}
4648

49+
// boundary-raw-fetch: better-auth catch-all OAuth client lookup, no app-level contract
4750
fetch(`/api/auth/oauth2/client/${encodeURIComponent(clientId)}`, { credentials: 'include' })
4851
.then(async (res) => {
4952
if (!res.ok) return
@@ -65,6 +68,7 @@ export default function OAuthConsentPage() {
6568

6669
setSubmitting(true)
6770
try {
71+
// boundary-raw-fetch: better-auth catch-all OAuth consent submission, no app-level contract
6872
const res = await fetch('/api/auth/oauth2/consent', {
6973
method: 'POST',
7074
headers: { 'Content-Type': 'application/json' },
@@ -100,15 +104,15 @@ export default function OAuthConsentPage() {
100104
const handleSwitchAccount = useCallback(async () => {
101105
if (!consentCode) return
102106

103-
const res = await fetch(`/api/auth/oauth2/authorize-params?consent_code=${consentCode}`, {
104-
credentials: 'include',
105-
})
106-
if (!res.ok) {
107+
const params = await requestJson(oauthAuthorizeParamsContract, {
108+
query: { consent_code: consentCode },
109+
}).catch(() => null)
110+
111+
if (!params) {
107112
setError('Unable to switch accounts. Please re-initiate the connection.')
108113
return
109114
}
110115

111-
const params = (await res.json()) as Record<string, string | null>
112116
const authorizeUrl = new URL('/api/auth/oauth2/authorize', window.location.origin)
113117
for (const [key, value] of Object.entries(params)) {
114118
if (value) authorizeUrl.searchParams.set(key, value)

apps/sim/app/(auth)/reset-password/reset-password-content.tsx

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { Suspense, useState } from 'react'
44
import { createLogger } from '@sim/logger'
55
import Link from 'next/link'
66
import { useRouter, useSearchParams } from 'next/navigation'
7+
import { requestJson } from '@/lib/api/client/request'
8+
import { resetPasswordContract } from '@/lib/api/contracts'
79
import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
810

911
const logger = createLogger('ResetPasswordPage')
@@ -27,26 +29,18 @@ function ResetPasswordContent() {
2729
: null
2830

2931
const handleResetPassword = async (password: string) => {
32+
if (!token) return
3033
try {
3134
setIsSubmitting(true)
3235
setStatusMessage({ type: null, text: '' })
3336

34-
const response = await fetch('/api/auth/reset-password', {
35-
method: 'POST',
36-
headers: {
37-
'Content-Type': 'application/json',
38-
},
39-
body: JSON.stringify({
37+
await requestJson(resetPasswordContract, {
38+
body: {
4039
token,
4140
newPassword: password,
42-
}),
41+
},
4342
})
4443

45-
if (!response.ok) {
46-
const errorData = await response.json()
47-
throw new Error(errorData.message || 'Failed to reset password')
48-
}
49-
5044
setStatusMessage({
5145
type: 'success',
5246
text: 'Password reset successful! Redirecting to login...',

apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
ModalTrigger,
1515
} from '@/components/emcn'
1616
import { GithubIcon, GoogleIcon } from '@/components/icons'
17+
import { requestJson } from '@/lib/api/client/request'
18+
import { type AuthProviderStatusResponse, getAuthProvidersContract } from '@/lib/api/contracts/auth'
1719
import { client } from '@/lib/auth/auth-client'
1820
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
1921
import { captureClientEvent } from '@/lib/posthog/client'
@@ -30,13 +32,9 @@ interface AuthModalProps {
3032
source: PostHogEventMap['auth_modal_opened']['source']
3133
}
3234

33-
interface ProviderStatus {
34-
githubAvailable: boolean
35-
googleAvailable: boolean
36-
registrationDisabled: boolean
37-
}
35+
type ProviderStatus = AuthProviderStatusResponse
3836

39-
let fetchPromise: Promise<ProviderStatus> | null = null
37+
let fetchPromise: Promise<AuthProviderStatusResponse> | null = null
4038

4139
const FALLBACK_STATUS: ProviderStatus = {
4240
githubAvailable: false,
@@ -49,12 +47,8 @@ const SOCIAL_BTN =
4947

5048
function fetchProviderStatus(): Promise<ProviderStatus> {
5149
if (fetchPromise) return fetchPromise
52-
fetchPromise = fetch('/api/auth/providers')
53-
.then((r) => {
54-
if (!r.ok) throw new Error(`HTTP ${r.status}`)
55-
return r.json()
56-
})
57-
.then(({ githubAvailable, googleAvailable, registrationDisabled }: ProviderStatus) => ({
50+
fetchPromise = requestJson(getAuthProvidersContract, {})
51+
.then(({ githubAvailable, googleAvailable, registrationDisabled }) => ({
5852
githubAvailable,
5953
googleAvailable,
6054
registrationDisabled,

apps/sim/app/(landing)/components/contact/contact-form.tsx

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ import { useMutation } from '@tanstack/react-query'
77
import Link from 'next/link'
88
import { Combobox, Input, Textarea } from '@/components/emcn'
99
import { Check } from '@/components/emcn/icons'
10+
import { requestJson } from '@/lib/api/client/request'
1011
import {
1112
CONTACT_TOPIC_OPTIONS,
1213
type ContactRequestPayload,
1314
contactRequestSchema,
15+
type SubmitContactBody,
16+
submitContactContract,
1417
} from '@/lib/api/contracts/contact'
1518
import { flattenFieldErrors } from '@/lib/api/contracts/primitives'
1619
import { getEnv } from '@/lib/core/config/env'
@@ -53,29 +56,8 @@ const LANDING_SUBMIT =
5356
const LANDING_LABEL =
5457
'font-[500] font-season text-[13px] text-[var(--landing-text)] tracking-[0.02em]'
5558

56-
interface SubmitContactRequestInput extends ContactRequestPayload {
57-
website: string
58-
captchaToken?: string
59-
captchaUnavailable?: boolean
60-
}
61-
62-
async function submitContactRequest(payload: SubmitContactRequestInput) {
63-
const response = await fetch('/api/contact', {
64-
method: 'POST',
65-
headers: { 'Content-Type': 'application/json' },
66-
body: JSON.stringify(payload),
67-
})
68-
69-
const result = (await response.json().catch(() => null)) as {
70-
error?: string
71-
message?: string
72-
} | null
73-
74-
if (!response.ok) {
75-
throw new Error(result?.error || 'Failed to send message')
76-
}
77-
78-
return result
59+
async function submitContactRequest(payload: SubmitContactBody) {
60+
return requestJson(submitContactContract, { body: payload })
7961
}
8062

8163
export function ContactForm() {

apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ import {
1414
Textarea,
1515
} from '@/components/emcn'
1616
import { Check } from '@/components/emcn/icons'
17+
import { requestJson } from '@/lib/api/client/request'
1718
import {
1819
DEMO_REQUEST_COMPANY_SIZE_OPTIONS,
1920
type DemoRequestPayload,
2021
demoRequestSchema,
22+
submitDemoRequestContract,
2123
} from '@/lib/api/contracts/demo-requests'
2224
import { flattenFieldErrors } from '@/lib/api/contracts/primitives'
2325
import { captureClientEvent } from '@/lib/posthog/client'
@@ -56,22 +58,7 @@ const LANDING_INPUT =
5658
'h-[32px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-5)] px-2.5 font-[430] font-season text-[13.5px] text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none'
5759

5860
async function submitDemoRequest(payload: DemoRequestPayload) {
59-
const response = await fetch('/api/demo-requests', {
60-
method: 'POST',
61-
headers: { 'Content-Type': 'application/json' },
62-
body: JSON.stringify(payload),
63-
})
64-
65-
const result = (await response.json().catch(() => null)) as {
66-
error?: string
67-
message?: string
68-
} | null
69-
70-
if (!response.ok) {
71-
throw new Error(result?.error || 'Failed to submit demo request')
72-
}
73-
74-
return result
61+
return requestJson(submitDemoRequestContract, { body: payload })
7562
}
7663

7764
export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalProps) {

apps/sim/app/(landing)/integrations/components/request-integration-modal.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
ModalHeader,
1313
Textarea,
1414
} from '@/components/emcn'
15+
import { requestJson } from '@/lib/api/client/request'
16+
import { integrationRequestContract } from '@/lib/api/contracts/common'
1517

1618
type SubmitStatus = 'idle' | 'submitting' | 'success' | 'error'
1719

@@ -46,18 +48,14 @@ export function RequestIntegrationModal() {
4648
setStatus('submitting')
4749

4850
try {
49-
const res = await fetch('/api/help/integration-request', {
50-
method: 'POST',
51-
headers: { 'Content-Type': 'application/json' },
52-
body: JSON.stringify({
51+
await requestJson(integrationRequestContract, {
52+
body: {
5353
integrationName: integrationName.trim(),
5454
email: email.trim(),
5555
useCase: useCase.trim() || undefined,
56-
}),
56+
},
5757
})
5858

59-
if (!res.ok) throw new Error('Request failed')
60-
6159
setStatus('success')
6260
setTimeout(() => setOpen(false), 1500)
6361
} catch {

apps/sim/app/_shell/providers/session-provider.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type React from 'react'
44
import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
55
import { createLogger } from '@sim/logger'
66
import { useQueryClient } from '@tanstack/react-query'
7+
import { requestJson } from '@/lib/api/client/request'
8+
import { listCreatorOrganizationsContract } from '@/lib/api/contracts/creator-profile'
79
import { client } from '@/lib/auth/auth-client'
810
import { extractSessionDataFromAuthClientResult } from '@/lib/auth/session-response'
911

@@ -92,14 +94,9 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
9294
}
9395

9496
try {
95-
const response = await fetch('/api/organizations')
96-
if (!response.ok) {
97-
return
98-
}
97+
const orgData = await requestJson(listCreatorOrganizationsContract, {}).catch(() => null)
98+
if (!orgData) return
9999

100-
const orgData = (await response.json()) as {
101-
organizations?: Array<{ id: string }>
102-
}
103100
const organizationId = orgData.organizations?.[0]?.id
104101

105102
if (!organizationId || isCancelled) {

0 commit comments

Comments
 (0)