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
This commit is contained in:
Vikhyath Mondreti
2026-04-30 19:37:51 -07:00
committed by GitHub
parent 0c25fc4ee1
commit be9c959f1a
138 changed files with 3122 additions and 1587 deletions
+23 -7
View File
@@ -5,6 +5,7 @@ These rules apply to files under `apps/sim/` in addition to the repository root
## 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
@@ -47,6 +48,7 @@ feature/
```
### Naming Conventions
- **Components**: PascalCase (`WorkflowList`)
- **Hooks**: `use` prefix (`useWorkflowOperations`)
- **Files**: kebab-case (`workflow-list.tsx`)
@@ -71,7 +73,7 @@ feature/
## API Contracts
Boundary HTTP request and response shapes for all routes under `apps/sim/app/api/**` live in `apps/sim/lib/api/contracts/**` (one file per resource family). Routes never define route-local boundary Zod schemas, and clients never define ad-hoc wire types — both sides consume the same contract.
Boundary HTTP request and response shapes for all routes under `apps/sim/app/api/`** live in `apps/sim/lib/api/contracts/**` (one file per resource family). Routes never define route-local boundary Zod schemas, and clients never define ad-hoc wire types — both sides consume the same contract.
- Each contract is built with `defineRouteContract({ method, path, params?, query?, body?, headers?, response: { mode: 'json', schema } })` from `@/lib/api/contracts`.
- Contracts export named schemas AND named TypeScript type aliases (e.g., `export type CreateFolderBody = z.input<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
A small number of legitimate exceptions to the boundary rules are tolerated when annotated. The audit script recognizes four annotation forms:
- `// 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.
- `// 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.
- `// double-cast-allowed: <reason>` — placed on the line directly above an `as unknown as X` cast outside test files.
- `// 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`.
- `// 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).
- `// 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`.
- `// 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).
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`).
@@ -104,6 +106,19 @@ const response = await fetch(`/api/copilot/chat/stream?chatId=${chatId}`, { sign
const provider = config as unknown as LegacyProvider
```
```ts
// 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)
```
```ts
// untyped-response: forwards firecrawl /v2/parse response unchanged for downstream tool consumers
output: z.unknown(),
```
## 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`:
@@ -149,18 +164,18 @@ When adding a new route + client surface, follow this order. Each step has one p
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.
- `**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: <specific reason>` in a `schema:` slot.
- `**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.
- **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.
## React Query Client Boundary
Hooks in `apps/sim/hooks/queries/**` consume contracts the same way routes do. Every same-origin JSON call must go through `requestJson(contract, ...)` from `@/lib/api/client/request` instead of raw `fetch`:
Hooks in `apps/sim/hooks/queries/`** consume contracts the same way routes do. Every same-origin JSON call must go through `requestJson(contract, ...)` from `@/lib/api/client/request` instead of raw `fetch`:
- Hooks import named type aliases from `@/lib/api/contracts/**`. Never write `z.input<...>` / `z.output<...>` in hooks, and never `import { z } from 'zod'` in client code.
- `requestJson` parses params, query, body, and headers against the contract on the way out and validates the JSON response on the way back. Hooks always forward `signal` for cancellation.
@@ -204,3 +219,4 @@ export function useEntityList(workspaceId?: string) {
- **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)
+12 -14
View File
@@ -15,6 +15,8 @@ import {
ModalDescription,
ModalHeader,
} from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import { forgetPasswordContract } from '@/lib/api/contracts'
import { client } from '@/lib/auth/auth-client'
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
@@ -282,20 +284,16 @@ export default function LoginPage({
setIsSubmittingReset(true)
setResetStatus({ type: null, message: '' })
const response = await fetch('/api/auth/forget-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: forgotPasswordEmail,
redirectTo: `${getBaseUrl()}/reset-password`,
}),
})
if (!response.ok) {
const errorData = await response.json()
let errorMessage = errorData.message || 'Failed to request password reset'
try {
await requestJson(forgetPasswordContract, {
body: {
email: forgotPasswordEmail,
redirectTo: `${getBaseUrl()}/reset-password`,
},
})
} catch (requestError) {
let errorMessage =
requestError instanceof Error ? requestError.message : 'Failed to request password reset'
if (
errorMessage.includes('Invalid body parameters') ||
+9 -5
View File
@@ -5,6 +5,8 @@ import { ArrowLeftRight } from 'lucide-react'
import Image from 'next/image'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button, Loader } from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import { oauthAuthorizeParamsContract } from '@/lib/api/contracts/oauth-connections'
import { signOut, useSession } from '@/lib/auth/auth-client'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
@@ -44,6 +46,7 @@ export default function OAuthConsentPage() {
return
}
// boundary-raw-fetch: better-auth catch-all OAuth client lookup, no app-level contract
fetch(`/api/auth/oauth2/client/${encodeURIComponent(clientId)}`, { credentials: 'include' })
.then(async (res) => {
if (!res.ok) return
@@ -65,6 +68,7 @@ export default function OAuthConsentPage() {
setSubmitting(true)
try {
// boundary-raw-fetch: better-auth catch-all OAuth consent submission, no app-level contract
const res = await fetch('/api/auth/oauth2/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -100,15 +104,15 @@ export default function OAuthConsentPage() {
const handleSwitchAccount = useCallback(async () => {
if (!consentCode) return
const res = await fetch(`/api/auth/oauth2/authorize-params?consent_code=${consentCode}`, {
credentials: 'include',
})
if (!res.ok) {
const params = await requestJson(oauthAuthorizeParamsContract, {
query: { consent_code: consentCode },
}).catch(() => null)
if (!params) {
setError('Unable to switch accounts. Please re-initiate the connection.')
return
}
const params = (await res.json()) as Record<string, string | null>
const authorizeUrl = new URL('/api/auth/oauth2/authorize', window.location.origin)
for (const [key, value] of Object.entries(params)) {
if (value) authorizeUrl.searchParams.set(key, value)
@@ -4,6 +4,8 @@ import { Suspense, useState } from 'react'
import { createLogger } from '@sim/logger'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { requestJson } from '@/lib/api/client/request'
import { resetPasswordContract } from '@/lib/api/contracts'
import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
const logger = createLogger('ResetPasswordPage')
@@ -27,26 +29,18 @@ function ResetPasswordContent() {
: null
const handleResetPassword = async (password: string) => {
if (!token) return
try {
setIsSubmitting(true)
setStatusMessage({ type: null, text: '' })
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
await requestJson(resetPasswordContract, {
body: {
token,
newPassword: password,
}),
},
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Failed to reset password')
}
setStatusMessage({
type: 'success',
text: 'Password reset successful! Redirecting to login...',
@@ -14,6 +14,8 @@ import {
ModalTrigger,
} from '@/components/emcn'
import { GithubIcon, GoogleIcon } from '@/components/icons'
import { requestJson } from '@/lib/api/client/request'
import { type AuthProviderStatusResponse, getAuthProvidersContract } from '@/lib/api/contracts/auth'
import { client } from '@/lib/auth/auth-client'
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
import { captureClientEvent } from '@/lib/posthog/client'
@@ -30,13 +32,9 @@ interface AuthModalProps {
source: PostHogEventMap['auth_modal_opened']['source']
}
interface ProviderStatus {
githubAvailable: boolean
googleAvailable: boolean
registrationDisabled: boolean
}
type ProviderStatus = AuthProviderStatusResponse
let fetchPromise: Promise<ProviderStatus> | null = null
let fetchPromise: Promise<AuthProviderStatusResponse> | null = null
const FALLBACK_STATUS: ProviderStatus = {
githubAvailable: false,
@@ -49,12 +47,8 @@ const SOCIAL_BTN =
function fetchProviderStatus(): Promise<ProviderStatus> {
if (fetchPromise) return fetchPromise
fetchPromise = fetch('/api/auth/providers')
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json()
})
.then(({ githubAvailable, googleAvailable, registrationDisabled }: ProviderStatus) => ({
fetchPromise = requestJson(getAuthProvidersContract, {})
.then(({ githubAvailable, googleAvailable, registrationDisabled }) => ({
githubAvailable,
googleAvailable,
registrationDisabled,
@@ -7,10 +7,13 @@ import { useMutation } from '@tanstack/react-query'
import Link from 'next/link'
import { Combobox, Input, Textarea } from '@/components/emcn'
import { Check } from '@/components/emcn/icons'
import { requestJson } from '@/lib/api/client/request'
import {
CONTACT_TOPIC_OPTIONS,
type ContactRequestPayload,
contactRequestSchema,
type SubmitContactBody,
submitContactContract,
} from '@/lib/api/contracts/contact'
import { flattenFieldErrors } from '@/lib/api/contracts/primitives'
import { getEnv } from '@/lib/core/config/env'
@@ -53,29 +56,8 @@ const LANDING_SUBMIT =
const LANDING_LABEL =
'font-[500] font-season text-[13px] text-[var(--landing-text)] tracking-[0.02em]'
interface SubmitContactRequestInput extends ContactRequestPayload {
website: string
captchaToken?: string
captchaUnavailable?: boolean
}
async function submitContactRequest(payload: SubmitContactRequestInput) {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = (await response.json().catch(() => null)) as {
error?: string
message?: string
} | null
if (!response.ok) {
throw new Error(result?.error || 'Failed to send message')
}
return result
async function submitContactRequest(payload: SubmitContactBody) {
return requestJson(submitContactContract, { body: payload })
}
export function ContactForm() {
@@ -14,10 +14,12 @@ import {
Textarea,
} from '@/components/emcn'
import { Check } from '@/components/emcn/icons'
import { requestJson } from '@/lib/api/client/request'
import {
DEMO_REQUEST_COMPANY_SIZE_OPTIONS,
type DemoRequestPayload,
demoRequestSchema,
submitDemoRequestContract,
} from '@/lib/api/contracts/demo-requests'
import { flattenFieldErrors } from '@/lib/api/contracts/primitives'
import { captureClientEvent } from '@/lib/posthog/client'
@@ -56,22 +58,7 @@ const LANDING_INPUT =
'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'
async function submitDemoRequest(payload: DemoRequestPayload) {
const response = await fetch('/api/demo-requests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = (await response.json().catch(() => null)) as {
error?: string
message?: string
} | null
if (!response.ok) {
throw new Error(result?.error || 'Failed to submit demo request')
}
return result
return requestJson(submitDemoRequestContract, { body: payload })
}
export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalProps) {
@@ -12,6 +12,8 @@ import {
ModalHeader,
Textarea,
} from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import { integrationRequestContract } from '@/lib/api/contracts/common'
type SubmitStatus = 'idle' | 'submitting' | 'success' | 'error'
@@ -46,18 +48,14 @@ export function RequestIntegrationModal() {
setStatus('submitting')
try {
const res = await fetch('/api/help/integration-request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
await requestJson(integrationRequestContract, {
body: {
integrationName: integrationName.trim(),
email: email.trim(),
useCase: useCase.trim() || undefined,
}),
},
})
if (!res.ok) throw new Error('Request failed')
setStatus('success')
setTimeout(() => setOpen(false), 1500)
} catch {
@@ -4,6 +4,8 @@ import type React from 'react'
import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import { listCreatorOrganizationsContract } from '@/lib/api/contracts/creator-profile'
import { client } from '@/lib/auth/auth-client'
import { extractSessionDataFromAuthClientResult } from '@/lib/auth/session-response'
@@ -92,14 +94,9 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
}
try {
const response = await fetch('/api/organizations')
if (!response.ok) {
return
}
const orgData = await requestJson(listCreatorOrganizationsContract, {}).catch(() => null)
if (!orgData) return
const orgData = (await response.json()) as {
organizations?: Array<{ id: string }>
}
const organizationId = orgData.organizations?.[0]?.id
if (!organizationId || isCancelled) {
@@ -3,8 +3,8 @@ import { verification } from '@sim/db/schema'
import { and, eq, gt } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { oauthAuthorizeParamsQuerySchema } from '@/lib/api/contracts/oauth-connections'
import { getValidationErrorMessage } from '@/lib/api/server'
import { oauthAuthorizeParamsContract } from '@/lib/api/contracts/oauth-connections'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -19,16 +19,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsedQuery = oauthAuthorizeParamsQuerySchema.safeParse({
consent_code: request.nextUrl.searchParams.get('consent_code') || undefined,
})
if (!parsedQuery.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(parsedQuery.error) },
{ status: 400 }
)
}
const consentCode = parsedQuery.data.consent_code
const parsed = await parseRequest(oauthAuthorizeParamsContract, request, {})
if (!parsed.success) return parsed.response
const consentCode = parsed.data.query.consent_code
const [record] = await db
.select({ value: verification.value })
+7 -1
View File
@@ -1,11 +1,17 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getAuthProvidersContract } from '@/lib/api/contracts/auth'
import { parseRequest } from '@/lib/api/server'
import { isRegistrationDisabled } from '@/lib/core/config/feature-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
export const dynamic = 'force-dynamic'
export const GET = withRouteHandler(async () => {
export const GET = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(getAuthProvidersContract, request, {})
if (!parsed.success) return parsed.response
const { githubAvailable, googleAvailable } = await getOAuthProviderStatus()
return NextResponse.json({
githubAvailable,
+5 -6
View File
@@ -2,7 +2,8 @@ import { db, member, ssoProvider } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { ssoProvidersQuerySchema } from '@/lib/api/contracts/auth'
import { listSsoProvidersContract } from '@/lib/api/contracts/auth'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -12,11 +13,9 @@ const logger = createLogger('SSOProvidersRoute')
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
const { searchParams } = new URL(request.url)
const query = ssoProvidersQuerySchema.parse({
organizationId: searchParams.get('organizationId') || undefined,
})
const { organizationId } = query
const parsed = await parseRequest(listSsoProvidersContract, request, {})
if (!parsed.success) return parsed.response
const { organizationId } = parsed.data.query
let providers
if (session?.user?.id) {
+2 -2
View File
@@ -2,9 +2,9 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { renderHelpConfirmationEmail } from '@/components/emails'
import {
contactRequestSchema,
getContactTopicLabel,
mapContactTopicToHelpType,
submitContactBodySchema,
} from '@/lib/api/contracts/contact'
import { getValidationErrorMessage } from '@/lib/api/server'
import { env } from '@/lib/core/config/env'
@@ -120,7 +120,7 @@ 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`, {
@@ -10,7 +10,11 @@ import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { credentialSetInviteTokenParamsSchema } from '@/lib/api/contracts/credential-sets'
import {
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'
@@ -19,8 +23,10 @@ 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 } = credentialSetInviteTokenParamsSchema.parse(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({
@@ -69,8 +75,10 @@ export const GET = withRouteHandler(
)
export const POST = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ token: string }> }) => {
const { token } = credentialSetInviteTokenParamsSchema.parse(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) {
@@ -1,11 +1,8 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
invitationActionBodySchema,
invitationActionParamsSchema,
} from '@/lib/api/contracts/invitations'
import { getValidationErrorMessage } from '@/lib/api/server'
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'
@@ -13,35 +10,23 @@ import { acceptInvitation } from '@/lib/invitations/core'
const logger = createLogger('InvitationAcceptAPI')
export const POST = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const parsedParams = invitationActionParamsSchema.safeParse(await params)
if (!parsedParams.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(parsedParams.error) },
{ status: 400 }
)
}
const { id } = parsedParams.data
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 = invitationActionBodySchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(parsed.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) {
@@ -1,11 +1,8 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
invitationActionBodySchema,
invitationActionParamsSchema,
} from '@/lib/api/contracts/invitations'
import { getValidationErrorMessage } from '@/lib/api/server'
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'
@@ -13,35 +10,23 @@ import { rejectInvitation } from '@/lib/invitations/core'
const logger = createLogger('InvitationRejectAPI')
export const POST = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const parsedParams = invitationActionParamsSchema.safeParse(await params)
if (!parsedParams.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(parsedParams.error) },
{ status: 400 }
)
}
const { id } = parsedParams.data
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 = invitationActionBodySchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(parsed.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) {
+8 -13
View File
@@ -5,8 +5,8 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
getInvitationContract,
invitationParamsSchema,
invitationQuerySchema,
updateInvitationContract,
} from '@/lib/api/contracts/invitations'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
@@ -19,30 +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 parsedParams = invitationParamsSchema.safeParse(await params)
if (!parsedParams.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(parsedParams.error) },
{ status: 400 }
)
}
const { id } = parsedParams.data
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 } = invitationQuerySchema.parse({
token: request.nextUrl.searchParams.get('token') || undefined,
})
const isInvitee = normalizeEmail(session.user.email || '') === normalizeEmail(inv.email)
const tokenMatches = !!token && token === inv.token
+5 -1
View File
@@ -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: [
@@ -211,7 +215,7 @@ 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 },
})
@@ -1,9 +1,11 @@
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 async function POST(request: NextRequest) {
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()
@@ -14,4 +16,4 @@ export async function POST(request: NextRequest) {
}
return copilotAbortPost(request, undefined)
}
})
@@ -1,6 +1,7 @@
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,
@@ -8,6 +9,7 @@ import {
} from '@/app/api/copilot/chat/resources/route'
async function validateResourceRequestEnvelope(request: NextRequest): Promise<NextResponse | null> {
// 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()
@@ -19,23 +21,23 @@ async function validateResourceRequestEnvelope(request: NextRequest): Promise<Ne
return null
}
export async function POST(request: NextRequest) {
export const POST = withRouteHandler(async (request: NextRequest) => {
const validationResponse = await validateResourceRequestEnvelope(request)
if (validationResponse) return validationResponse
return copilotResourcesPost(request, undefined)
}
})
export async function PATCH(request: NextRequest) {
export const PATCH = withRouteHandler(async (request: NextRequest) => {
const validationResponse = await validateResourceRequestEnvelope(request)
if (validationResponse) return validationResponse
return copilotResourcesPatch(request, undefined)
}
})
export async function DELETE(request: NextRequest) {
export const DELETE = withRouteHandler(async (request: NextRequest) => {
const validationResponse = await validateResourceRequestEnvelope(request)
if (validationResponse) return validationResponse
return copilotResourcesDelete(request, undefined)
}
})
+8 -1
View File
@@ -1,9 +1,10 @@
import type { NextRequest } from 'next/server'
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'
@@ -21,6 +22,12 @@ export const GET = withRouteHandler((request: NextRequest) => {
})
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()
@@ -1,10 +1,11 @@
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'
// Unified stop route surface.
export async function POST(request: NextRequest) {
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()
@@ -15,4 +16,4 @@ export async function POST(request: NextRequest) {
}
return copilotStopPost(request, undefined)
}
})
+7 -2
View File
@@ -3,9 +3,11 @@ import { db } from '@sim/db'
import { member, organization, subscription as subscriptionTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
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 } from '@/lib/api/server'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
import {
@@ -26,7 +28,7 @@ import {
const logger = createLogger('OrganizationsAPI')
export const GET = withRouteHandler(async () => {
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
@@ -34,6 +36,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,
@@ -3,8 +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 { updateTemplateOgImageBodySchema } from '@/lib/api/contracts/templates'
import { parseJsonBody } from '@/lib/api/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'
@@ -21,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,
@@ -42,22 +46,6 @@ export const PUT = withRouteHandler(
return NextResponse.json({ error }, { status: status || 403 })
}
const parsedBody = await parseJsonBody(request)
const bodyResult = parsedBody.success
? updateTemplateOgImageBodySchema.safeParse(parsedBody.data)
: null
const body = bodyResult?.success
? bodyResult.data
: ({ imageData: undefined } as { imageData?: string })
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')
@@ -105,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 })
}
}
+9 -17
View File
@@ -4,8 +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 { useTemplateBodySchema } from '@/lib/api/contracts/templates'
import { parseJsonBody } from '@/lib/api/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'
@@ -31,28 +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 })
}
const parsedBody = await parseJsonBody(request)
const bodyResult = parsedBody.success
? useTemplateBodySchema.safeParse(parsedBody.data)
: null
const body = bodyResult?.success
? bodyResult.data
: ({ workspaceId: undefined, connectToTemplate: false } as {
workspaceId?: string
connectToTemplate?: boolean
})
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`)
@@ -236,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 })
}
}
+30 -5
View File
@@ -14,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'
@@ -86,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
@@ -128,7 +130,8 @@ async function updateUserStatsForWand(
}
await recordUsage({
userId,
userId: billingUserId,
workspaceId: workspaceId ?? undefined,
entries: [
{
category: 'model',
@@ -143,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)
}
@@ -223,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
@@ -339,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 {
@@ -556,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,
+28 -4
View File
@@ -81,6 +81,20 @@ export const GET = withRouteHandler(
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
// 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 =
(workflowData.variables as Record<string, Record<string, unknown>>) || {}
const stampedVariables: Record<string, Record<string, unknown>> = {}
for (const [variableId, variable] of Object.entries(persistedVariables)) {
if (variable && typeof variable === 'object') {
stampedVariables[variableId] = { ...variable, workflowId }
}
}
if (normalizedData) {
const finalWorkflowData = {
...workflowData,
@@ -97,7 +111,7 @@ export const GET = withRouteHandler(
description: workflowData.description,
},
},
variables: workflowData.variables || {},
variables: stampedVariables,
}
logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`)
@@ -122,7 +136,7 @@ export const GET = withRouteHandler(
description: workflowData.description,
},
},
variables: workflowData.variables || {},
variables: stampedVariables,
}
return NextResponse.json({ data: emptyWorkflowData }, { status: 200 })
@@ -333,12 +347,22 @@ export const PUT = withRouteHandler(
}
}
// Update the workflow
const [updatedWorkflow] = await db
.update(workflow)
.set(updateData)
.where(eq(workflow.id, workflowId))
.returning()
.returning({
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,
})
const elapsed = Date.now() - startTime
logger.info(`[${requestId}] Successfully updated workflow ${workflowId} in ${elapsed}ms`, {
+18 -20
View File
@@ -53,12 +53,25 @@ export const GET = withRouteHandler(
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 =
(authorization.workflow?.variables as Record<string, Record<string, unknown>>) || {}
const variables: Record<string, Record<string, unknown>> = {}
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 || {},
variables: authorization.workflow?.variables || {},
variables,
})
} catch (error) {
logger.error('Failed to fetch workflow state', {
@@ -116,25 +129,10 @@ export const PUT = withRouteHandler(
)
}
if (state.variables) {
const mismatchedVariable = Object.values(state.variables).find(
(variable) => variable.workflowId !== workflowId
)
if (mismatchedVariable) {
return NextResponse.json(
{
error: 'Invalid workflow state',
details: [
{
path: ['variables', mismatchedVariable.id, 'workflowId'],
message: 'Variable workflowId must match the workflow route parameter',
},
],
},
{ status: 400 }
)
}
}
// 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(
@@ -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 () => {
@@ -53,23 +53,10 @@ export const POST = withRouteHandler(
const parsed = await parseRequest(workflowVariablesContract, req, context)
if (!parsed.success) return parsed.response
const { variables } = parsed.data.body
const mismatchedVariable = Object.values(variables).find(
(variable) => variable.workflowId !== workflowId
)
if (mismatchedVariable) {
return NextResponse.json(
{
error: 'Invalid request data',
details: [
{
path: ['variables', mismatchedVariable.id, 'workflowId'],
message: 'Variable workflowId must match the workflow route parameter',
},
],
},
{ status: 400 }
)
}
// 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
@@ -143,8 +130,17 @@ export const GET = withRouteHandler(
)
}
// Return variables if they exist
const variables = (workflowData.variables as Record<string, Variable>) || {}
// 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<string, Record<string, unknown>>) || {}
const variables: Record<string, Variable> = {}
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
+32 -3
View File
@@ -13,7 +13,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { deduplicateWorkflowName, listWorkflows } from '@/lib/workflows/utils'
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
@@ -69,10 +69,39 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
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,
} 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 })
@@ -83,7 +112,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'
+2 -1
View File
@@ -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 { createWorkspaceContract, listWorkspacesQuerySchema } from '@/lib/api/contracts'
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'
+1
View File
@@ -275,6 +275,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: {
@@ -5,6 +5,8 @@ import { createLogger } from '@sim/logger'
import { Mic, MicOff, Phone } from 'lucide-react'
import dynamic from 'next/dynamic'
import { Button } from '@/components/ui/button'
import { requestJson } from '@/lib/api/client/request'
import { speechTokenContract } from '@/lib/api/contracts/media-tools'
import { cn } from '@/lib/core/utils/cn'
import { arrayBufferToBase64, floatTo16BitPCM } from '@/lib/speech/audio'
import {
@@ -152,22 +154,21 @@ export function VoiceInterface({
const connectWebSocket = useCallback(async (): Promise<boolean> => {
try {
const body: Record<string, string> = {}
if (chatId) body.chatId = chatId
const tokenResponse = await fetch('/api/speech/token', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!tokenResponse.ok) {
logger.error('Failed to get STT token', { status: tokenResponse.status })
let tokenData: Awaited<ReturnType<typeof requestJson<typeof speechTokenContract>>>
try {
tokenData = await requestJson(speechTokenContract, {
body: chatId ? { chatId } : {},
})
} catch (err) {
logger.error('Failed to get STT token', err)
return false
}
const { token } = await tokenResponse.json()
const token = typeof tokenData.token === 'string' ? tokenData.token : undefined
if (!token) {
logger.error('STT token missing from response')
return false
}
const params = new URLSearchParams({
token,
@@ -92,6 +92,7 @@ export function useAudioStreaming(sharedAudioContextRef?: RefObject<AudioContext
if (audioContext.state === 'suspended') {
await audioContext.resume()
}
// boundary-raw-fetch: TTS proxy returns raw audio bytes consumed via response.arrayBuffer() and decoded by AudioContext.decodeAudioData
const response = await fetch('/api/proxy/tts/stream', {
method: 'POST',
headers: {
@@ -5,21 +5,28 @@ import { createLogger } from '@sim/logger'
import { Mail } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { GmailIcon, OutlookIcon } from '@/components/icons'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import {
acceptCredentialSetInvitationContract,
type CredentialSetInvitePreview,
getCredentialSetInvitationContract,
} from '@/lib/api/contracts'
import { listOAuthConnectionsContract } from '@/lib/api/contracts/oauth-connections'
import { client, useSession } from '@/lib/auth/auth-client'
import { getProviderDisplayName, isPollingProvider } from '@/lib/credential-sets/providers'
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
const logger = createLogger('CredentialAccount')
interface InvitationInfo {
credentialSetName: string
organizationName: string
providerId: string | null
email: string | null
}
type AcceptedState = 'connecting' | 'already-connected'
function getErrorMessageFromBody(body: unknown): string | undefined {
if (!body || typeof body !== 'object') return undefined
const error = (body as { error?: unknown }).error
return typeof error === 'string' ? error : undefined
}
export default function CredentialAccountInvitePage() {
const params = useParams()
const router = useRouter()
@@ -27,7 +34,7 @@ export default function CredentialAccountInvitePage() {
const { data: session, isPending: sessionLoading } = useSession()
const [invitation, setInvitation] = useState<InvitationInfo | null>(null)
const [invitation, setInvitation] = useState<CredentialSetInvitePreview | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [accepting, setAccepting] = useState(false)
@@ -36,16 +43,16 @@ export default function CredentialAccountInvitePage() {
useEffect(() => {
async function fetchInvitation() {
try {
const res = await fetch(`/api/credential-sets/invite/${token}`)
if (!res.ok) {
const data = await res.json()
setError(data.error || 'Failed to load invitation')
return
}
const data = await res.json()
const data = await requestJson(getCredentialSetInvitationContract, {
params: { token },
})
setInvitation(data.invitation)
} catch {
setError('Failed to load invitation')
} catch (fetchError) {
if (fetchError instanceof ApiClientError) {
setError(getErrorMessageFromBody(fetchError.body) || fetchError.message)
} else {
setError('Failed to load invitation')
}
} finally {
setLoading(false)
}
@@ -64,34 +71,33 @@ export default function CredentialAccountInvitePage() {
setAccepting(true)
try {
const res = await fetch(`/api/credential-sets/invite/${token}`, {
method: 'POST',
const acceptResponse = await requestJson(acceptCredentialSetInvitationContract, {
params: { token },
}).catch((acceptError: unknown) => {
const fallback = 'Failed to accept invitation'
if (acceptError instanceof ApiClientError) {
setError(getErrorMessageFromBody(acceptError.body) || acceptError.message || fallback)
} else {
setError(fallback)
}
return null
})
if (!res.ok) {
const data = await res.json()
setError(data.error || 'Failed to accept invitation')
if (!acceptResponse) {
return
}
const data = await res.json()
const credentialSetProviderId = data.providerId || invitation?.providerId
const credentialSetProviderId = acceptResponse.providerId || invitation?.providerId
// Check if user already has this provider connected
let isAlreadyConnected = false
if (credentialSetProviderId && isPollingProvider(credentialSetProviderId)) {
try {
const connectionsRes = await fetch('/api/auth/oauth/connections')
if (connectionsRes.ok) {
const connectionsData = await connectionsRes.json()
const connections = connectionsData.connections || []
isAlreadyConnected = connections.some(
(conn: { provider: string; accounts?: { id: string }[] }) =>
conn.provider === credentialSetProviderId &&
conn.accounts &&
conn.accounts.length > 0
)
}
const connectionsData = await requestJson(listOAuthConnectionsContract, {})
isAlreadyConnected = (connectionsData.connections ?? []).some(
(conn) =>
conn.provider === credentialSetProviderId && conn.accounts && conn.accounts.length > 0
)
} catch {
// If we can't check connections, proceed with OAuth flow
}
+3
View File
@@ -71,6 +71,7 @@ export default function Form({ identifier }: { identifier: string }) {
setIsLoading(true)
setError(null)
// boundary-raw-fetch: GET /api/form/[identifier] is a polymorphic form-discovery endpoint that returns either a form config OR a 401 envelope carrying `error: 'auth_required_password' | 'auth_required_email'` plus partial title/customizations for the auth gate; modelling this as a contract requires a discriminated response schema and a custom error path that surfaces 401-as-data without throwing
const response = await fetch(`/api/form/${identifier}`, { signal })
if (signal?.aborted) return
@@ -165,6 +166,7 @@ export default function Form({ identifier }: { identifier: string }) {
setIsSubmitting(true)
setError(null)
// boundary-raw-fetch: POST /api/form/[identifier] is the public form submission endpoint; the same route also accepts `{ password }` or `{ email }` auth gate bodies (handled by fetchFormConfig/handlePasswordAuth) and runs workflow execution with CORS headers/streaming envelopes that don't fit the current `requestJson` contract surface
const response = await fetch(`/api/form/${identifier}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -201,6 +203,7 @@ export default function Form({ identifier }: { identifier: string }) {
setIsLoading(true)
setError(null)
// boundary-raw-fetch: POST /api/form/[identifier] doubles as the password auth gate; same polymorphic route as the form submission above (separate `{ password }` body branch on the server)
const response = await fetch(`/api/form/${identifier}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+35 -52
View File
@@ -4,6 +4,13 @@ import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import {
acceptInvitationContract,
getInvitationContract,
type InvitationDetails,
} from '@/lib/api/contracts/invitations'
import { client, useSession } from '@/lib/auth/auth-client'
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
import { organizationKeys } from '@/hooks/queries/organization'
@@ -37,26 +44,6 @@ interface InviteError {
canRetry?: boolean
}
type InvitationKind = 'organization' | 'workspace'
interface InvitationDetails {
id: string
kind: InvitationKind
email: string
organizationId: string | null
organizationName: string | null
role: string
status: string
expiresAt: string
inviterName: string | null
inviterEmail: string | null
grants: Array<{
workspaceId: string
workspaceName: string | null
permission: 'admin' | 'write' | 'read'
}>
}
function getInviteError(code: string): InviteError {
const errorMap: Record<string, InviteError> = {
'missing-token': {
@@ -156,6 +143,15 @@ function codeFromStatus(status: number): InviteErrorCode {
return 'unknown'
}
function codeFromApiClientError(error: ApiClientError): string {
if (error.body && typeof error.body === 'object') {
const code = (error.body as { error?: unknown }).error
if (typeof code === 'string' && code.length > 0) return code
}
return codeFromStatus(error.status)
}
export default function Invite() {
const router = useRouter()
const params = useParams()
@@ -200,23 +196,19 @@ export default function Invite() {
async function fetchInvitation() {
setIsLoading(true)
try {
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : ''
const response = await fetch(`/api/invitations/${inviteId}${tokenParam}`)
if (!response.ok) {
const data = await response.json().catch(() => ({}))
const code = data.error || codeFromStatus(response.status)
setError(getInviteError(code))
setIsLoading(false)
return
}
const data = await response.json()
setInvitation(data.invitation as InvitationDetails)
const data = await requestJson(getInvitationContract, {
params: { id: inviteId },
query: { token: token ?? undefined },
})
setInvitation(data.invitation)
setError(null)
} catch (fetchError) {
logger.error('Error fetching invitation:', fetchError)
setError(getInviteError('network-error'))
const code =
fetchError instanceof ApiClientError
? codeFromApiClientError(fetchError)
: 'network-error'
setError(getInviteError(code))
} finally {
setIsLoading(false)
}
@@ -230,23 +222,11 @@ export default function Invite() {
setIsAccepting(true)
try {
const response = await fetch(`/api/invitations/${inviteId}/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ token: token ?? undefined }),
const data = await requestJson(acceptInvitationContract, {
params: { id: inviteId },
body: { token: token ?? undefined },
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
const code = data.error || codeFromStatus(response.status)
setError(getInviteError(code))
setIsAccepting(false)
return
}
const data = await response.json()
if (invitation.organizationId) {
try {
await client.organization.setActive({ organizationId: invitation.organizationId })
@@ -263,11 +243,14 @@ export default function Invite() {
setAccepted(true)
setIsAccepting(false)
const redirectPath = typeof data.redirectPath === 'string' ? data.redirectPath : '/workspace'
setTimeout(() => router.push(redirectPath), 1200)
setTimeout(() => router.push(data.redirectPath), 1200)
} catch (acceptError) {
logger.error('Error accepting invitation:', acceptError)
setError(getInviteError('network-error'))
const code =
acceptError instanceof ApiClientError
? codeFromApiClientError(acceptError)
: 'network-error'
setError(getInviteError(code))
setIsAccepting(false)
}
}
@@ -29,6 +29,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { requestJson } from '@/lib/api/client/request'
import { resumeWorkflowExecutionContract } from '@/lib/api/contracts/workflows'
import Navbar from '@/app/(landing)/components/navbar/navbar'
import { useBrandConfig } from '@/ee/whitelabeling'
import type { ResumeStatus } from '@/executor/types'
@@ -533,6 +535,7 @@ export default function ResumeExecutionPage({
const loadDetail = async () => {
setLoadingDetail(true)
try {
// boundary-raw-fetch: GET /api/resume/[workflowId]/[executionId]/[contextId] has no contract (server route only models POST resume submission)
const response = await fetch(
`/api/resume/${workflowId}/${executionId}/${selectedContextId}`,
{
@@ -615,13 +618,11 @@ export default function ResumeExecutionPage({
const refreshExecutionDetail = useCallback(async () => {
setRefreshingExecution(true)
try {
const response = await fetch(`/api/resume/${workflowId}/${executionId}`, {
method: 'GET',
credentials: 'include',
cache: 'no-store',
const raw = await requestJson(resumeWorkflowExecutionContract, {
params: { workflowId, executionId },
})
if (!response.ok) return
const data: PausedExecutionDetail = await response.json()
// double-cast-allowed: contract pause-point shape is z.record(z.string(), z.unknown()) but the page works against the more specific local PausedExecutionDetail / PausePointWithQueue interfaces
const data = raw as unknown as PausedExecutionDetail
setExecutionDetail(data)
if (!selectedContextId) {
const first =
@@ -640,6 +641,7 @@ export default function ResumeExecutionPage({
async (contextId: string, showLoader = true) => {
try {
if (showLoader) setLoadingDetail(true)
// boundary-raw-fetch: GET /api/resume/[workflowId]/[executionId]/[contextId] has no contract (server route only models POST resume submission)
const response = await fetch(`/api/resume/${workflowId}/${executionId}/${contextId}`, {
method: 'GET',
credentials: 'include',
@@ -750,6 +752,7 @@ export default function ResumeExecutionPage({
resumePayload = parsedInput
}
try {
// boundary-raw-fetch: resume-context POST contract has no body schema (route uses tolerant raw JSON parse for resume input forwarded to PauseResumeManager)
const response = await fetch(
`/api/resume/${workflowId}/${executionId}/${selectedContextId}`,
{
+48 -77
View File
@@ -31,6 +31,13 @@ import {
Skeleton,
} from '@/components/emcn'
import { VerifiedBadge } from '@/components/ui/verified-badge'
import { requestJson } from '@/lib/api/client/request'
import {
listCreatorOrganizationsContract,
updateCreatorProfileContract,
} from '@/lib/api/contracts/creator-profile'
import { updateTemplateContract, useTemplateContract } from '@/lib/api/contracts/templates'
import { listWorkspacesContract } from '@/lib/api/contracts/workspaces'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -170,18 +177,15 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
if (!currentUserId) return
try {
const response = await fetch('/api/organizations')
if (response.ok) {
const data = await response.json()
const orgs = data.organizations || []
const orgIds = orgs.map((org: any) => org.id)
const orgRoles = orgs.map((org: any) => ({
const data = await requestJson(listCreatorOrganizationsContract, {})
const orgs = data.organizations
setCurrentUserOrgs(orgs.map((org) => org.id))
setCurrentUserOrgRoles(
orgs.map((org) => ({
organizationId: org.id,
role: org.role,
}))
setCurrentUserOrgs(orgIds)
setCurrentUserOrgRoles(orgRoles)
}
)
} catch (error) {
logger.error('Error fetching organizations:', error)
}
@@ -196,18 +200,13 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
const fetchWorkspaces = async () => {
try {
setIsLoadingWorkspaces(true)
const response = await fetch('/api/workspaces')
if (response.ok) {
const data = await response.json()
const availableWorkspaces = data.workspaces
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
.map((ws: any) => ({
id: ws.id,
name: ws.name,
permissions: ws.permissions,
}))
setWorkspaces(availableWorkspaces)
}
const data = await requestJson(listWorkspacesContract, { query: { scope: 'active' } })
const availableWorkspaces = data.workspaces.flatMap((ws) =>
ws.permissions === 'write' || ws.permissions === 'admin'
? [{ id: ws.id, name: ws.name, permissions: ws.permissions }]
: []
)
setWorkspaces(availableWorkspaces)
} catch (error) {
logger.error('Error fetching workspaces:', error)
} finally {
@@ -262,6 +261,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
}
try {
// boundary-raw-fetch: workflow access probe needs HTTP status discrimination (200 vs 403 vs other) without parsing the body
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
if (checkResponse.status === 403) {
setHasWorkspaceAccess(false)
@@ -391,6 +391,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
if (isWorkspaceContext && workspaceId && template.workflowId) {
setIsEditing(true)
try {
// boundary-raw-fetch: workflow access probe needs HTTP status check (200 vs not-ok) without parsing the body
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
if (checkResponse.ok) {
@@ -407,6 +408,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
if (template.workflowId && !isWorkspaceContext) {
setIsEditing(true)
try {
// boundary-raw-fetch: workflow probe reads passthrough data.workspaceId (not in getWorkflowStateContract typed response) and discriminates 403 vs 200
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
if (checkResponse.status === 403) {
@@ -441,18 +443,11 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
setIsUsing(true)
try {
const response = await fetch(`/api/templates/${template.id}/use`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
const { workflowId } = await requestJson(useTemplateContract, {
params: { id: template.id },
body: { workspaceId },
})
if (!response.ok) {
throw new Error('Failed to use template')
}
const { workflowId } = await response.json()
window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
} catch (error) {
logger.error('Error using template:', error)
@@ -467,18 +462,11 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
setIsUsing(true)
setShowWorkspaceSelectorForEdit(false)
try {
const response = await fetch(`/api/templates/${template.id}/use`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId, connectToTemplate: true }),
const { workflowId } = await requestJson(useTemplateContract, {
params: { id: template.id },
body: { workspaceId, connectToTemplate: true },
})
if (!response.ok) {
throw new Error('Failed to import template for editing')
}
const { workflowId } = await response.json()
window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
} catch (error) {
logger.error('Error importing template for editing:', error)
@@ -492,18 +480,14 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
setIsApproving(true)
try {
const response = await fetch(`/api/templates/${template.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'approved' }),
await requestJson(updateTemplateContract, {
params: { id: template.id },
body: { status: 'approved' },
})
if (response.ok) {
if (isWorkspaceContext && workspaceId) {
router.push(`/workspace/${workspaceId}/templates`)
} else {
router.push('/templates')
}
if (isWorkspaceContext && workspaceId) {
router.push(`/workspace/${workspaceId}/templates`)
} else {
router.push('/templates')
}
} catch (error) {
logger.error('Error approving template:', error)
@@ -517,18 +501,14 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
setIsRejecting(true)
try {
const response = await fetch(`/api/templates/${template.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'rejected' }),
await requestJson(updateTemplateContract, {
params: { id: template.id },
body: { status: 'rejected' },
})
if (response.ok) {
if (isWorkspaceContext && workspaceId) {
router.push(`/workspace/${workspaceId}/templates`)
} else {
router.push('/templates')
}
if (isWorkspaceContext && workspaceId) {
router.push(`/workspace/${workspaceId}/templates`)
} else {
router.push('/templates')
}
} catch (error) {
logger.error('Error rejecting template:', error)
@@ -542,23 +522,14 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
setIsVerifying(true)
try {
const response = await fetch(`/api/creators/${template.creator.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verified: !template.creator.verified }),
await requestJson(updateCreatorProfileContract, {
params: { id: template.creator.id },
body: { verified: !template.creator.verified },
})
if (response.ok) {
// Refresh page to show updated verification status
window.location.reload()
} else {
const error = await response.json()
logger.error('Error toggling verification:', error)
alert(`Failed to ${template.creator.verified ? 'unverify' : 'verify'} creator`)
}
window.location.reload()
} catch (error) {
logger.error('Error toggling verification:', error)
alert('An error occurred while toggling verification')
alert(`Failed to ${template.creator.verified ? 'unverify' : 'verify'} creator`)
} finally {
setIsVerifying(false)
}
+6 -7
View File
@@ -6,6 +6,8 @@ import { Layout, Search } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/emcn'
import { Input } from '@/components/ui/input'
import { requestJson } from '@/lib/api/client/request'
import { listWorkspacesContract } from '@/lib/api/contracts/workspaces'
import type { CredentialRequirement } from '@/lib/workflows/credentials/credential-extractor'
import type { CreatorProfileDetails } from '@/app/_types/creator-profile'
import { TemplateCard, TemplateCardSkeleton } from '@/app/templates/components/template-card'
@@ -66,13 +68,10 @@ export default function Templates({
if (currentUserId) {
const redirectToWorkspace = async () => {
try {
const response = await fetch('/api/workspaces')
if (response.ok) {
const data = await response.json()
const defaultWorkspace = data.workspaces?.[0]
if (defaultWorkspace) {
router.push(`/workspace/${defaultWorkspace.id}/templates`)
}
const data = await requestJson(listWorkspacesContract, { query: { scope: 'active' } })
const defaultWorkspace = data.workspaces[0]
if (defaultWorkspace) {
router.push(`/workspace/${defaultWorkspace.id}/templates`)
}
} catch (error) {
logger.error('Error redirecting to workspace:', error)
+39 -67
View File
@@ -3,22 +3,13 @@
import { Suspense, useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { Loader } from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import type { ContractJsonResponse } from '@/lib/api/contracts'
import { unsubscribeGetContract, unsubscribePostContract } from '@/lib/api/contracts/user'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { InviteLayout } from '@/app/invite/components'
interface UnsubscribeData {
success: boolean
email: string
token: string
emailType: string
isTransactional: boolean
currentPreferences: {
unsubscribeAll?: boolean
unsubscribeMarketing?: boolean
unsubscribeUpdates?: boolean
unsubscribeNotifications?: boolean
}
}
type UnsubscribeData = ContractJsonResponse<typeof unsubscribeGetContract>
function UnsubscribeContent() {
const searchParams = useSearchParams()
@@ -38,19 +29,13 @@ function UnsubscribeContent() {
return
}
fetch(
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
)
.then((res) => res.json())
.then((data) => {
if (data.success) {
setData(data)
} else {
setError(data.error || 'Invalid unsubscribe link')
}
requestJson(unsubscribeGetContract, { query: { email, token } })
.then((response) => {
setData(response)
})
.catch(() => {
setError('Failed to validate unsubscribe link')
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : 'Failed to validate unsubscribe link'
setError(message)
})
.finally(() => {
setLoading(false)
@@ -63,53 +48,40 @@ function UnsubscribeContent() {
setProcessing(true)
try {
const response = await fetch('/api/users/me/settings/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
token,
type,
}),
await requestJson(unsubscribePostContract, {
body: { email, token, type },
})
const result = await response.json()
if (result.success) {
setUnsubscribed(true)
if (data) {
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
if (validTypes.includes(type)) {
if (type === 'all') {
setData({
...data,
currentPreferences: {
...data.currentPreferences,
unsubscribeAll: true,
},
})
} else {
const propertyKey = `unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as
| 'unsubscribeMarketing'
| 'unsubscribeUpdates'
| 'unsubscribeNotifications'
setData({
...data,
currentPreferences: {
...data.currentPreferences,
[propertyKey]: true,
},
})
}
setUnsubscribed(true)
if (data) {
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
if (validTypes.includes(type)) {
if (type === 'all') {
setData({
...data,
currentPreferences: {
...data.currentPreferences,
unsubscribeAll: true,
},
})
} else {
const propertyKey = `unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as
| 'unsubscribeMarketing'
| 'unsubscribeUpdates'
| 'unsubscribeNotifications'
setData({
...data,
currentPreferences: {
...data.currentPreferences,
[propertyKey]: true,
},
})
}
}
} else {
setError(result.error || 'Failed to unsubscribe')
}
} catch {
setError('Failed to process unsubscribe request')
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to process unsubscribe request'
setError(message)
} finally {
setProcessing(false)
}
@@ -141,6 +141,7 @@ export const DocxPreview = memo(function DocxPreview({
try {
setRendering(true)
// boundary-raw-fetch: route returns binary DOCX (read via response.arrayBuffer()), not JSON
const response = await fetch(`/api/workspaces/${workspaceId}/docx/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -175,6 +175,7 @@ const IframePreview = memo(function IframePreview({
try {
setRendering(true)
// boundary-raw-fetch: route returns binary PDF (read via response.arrayBuffer()), not JSON
const response = await fetch(`/api/workspaces/${workspaceId}/pdf/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -157,6 +157,7 @@ export const PptxPreview = memo(function PptxPreview({
try {
setRendering(true)
// boundary-raw-fetch: route returns binary PPTX (read via response.arrayBuffer()), not JSON
const response = await fetch(`/api/workspaces/${workspaceId}/pptx/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -6,6 +6,8 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { Button } from '@/components/emcn'
import { PanelLeft } from '@/components/emcn/icons'
import { requestJson } from '@/lib/api/client/request'
import { createWorkflowContract } from '@/lib/api/contracts'
import { useSession } from '@/lib/auth/auth-client'
import {
LandingPromptStorage,
@@ -54,24 +56,15 @@ export function Home({ chatId }: HomeProps = {}) {
descriptionOverride: seed.workflowDescription || 'Imported from landing template',
colorOverride: seed.color,
createWorkflow: async ({ name, description, color, workspaceId }) => {
const response = await fetch('/api/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
return requestJson(createWorkflowContract, {
body: {
name,
description,
color,
workspaceId,
deduplicate: true,
}),
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to create workflow')
}
return response.json()
},
})
@@ -1405,6 +1405,7 @@ export function useChat(
const persistChatId = chatIdRef.current ?? selectedChatIdRef.current
if (persistChatId) {
// boundary-raw-fetch: fire-and-forget side-effect during stream lifecycle; intentionally avoids requestJson's response parsing/throw semantics so a failure here cannot interrupt the active turn
fetch('/api/mothership/chat/resources', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1595,6 +1596,7 @@ export function useChat(
void recoverPendingClientWorkflowTools(mappedMessages)
if (chatHistory.resources.some((r) => r.id === 'streaming-file')) {
// boundary-raw-fetch: fire-and-forget cleanup during chat-history hydration; failures are silently swallowed to keep hydration non-blocking
fetch('/api/mothership/chat/resources', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
@@ -2730,6 +2732,7 @@ export function useChat(
afterCursor: string,
signal?: AbortSignal
): Promise<StreamBatchResponse> => {
// boundary-raw-fetch: stream-resume batch endpoint requires dynamic per-request traceparent header propagation that the contract layer does not model, and the response is consumed alongside live SSE tail fetches
const response = await fetch(
`/api/mothership/chat/stream?streamId=${encodeURIComponent(streamId)}&after=${encodeURIComponent(afterCursor)}&batch=true`,
{
@@ -2811,6 +2814,7 @@ export function useChat(
logger.info('Opening live stream tail', { streamId, afterCursor: latestCursor })
// boundary-raw-fetch: live SSE tail endpoint streams events consumed via response.body.getReader() and processSSEStream
const sseRes = await fetch(
`/api/mothership/chat/stream?streamId=${encodeURIComponent(streamId)}&after=${encodeURIComponent(latestCursor)}`,
{
@@ -3618,6 +3622,7 @@ export function useChat(
const executionId = execState.getCurrentExecutionId(workflowId)
if (executionId) {
execState.setCurrentExecutionId(workflowId, null)
// boundary-raw-fetch: fire-and-forget execution cancellation invoked from a stop-generation barrier; failures are silently swallowed so the stop teardown cannot stall on a contract-validation throw
fetch(`/api/workflows/${workflowId}/executions/${executionId}/cancel`, {
method: 'POST',
}).catch(() => {})
@@ -3751,6 +3756,7 @@ export function useChat(
const resolvedChatId = chatIdRef.current
const abortPromise = sid
? (async () => {
// boundary-raw-fetch: stream-abort endpoint requires propagating the snapshotted traceparent header from the in-flight stream and has no contract authored yet
const res = await fetch('/api/mothership/chat/abort', {
method: 'POST',
headers: {
@@ -2,6 +2,9 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Label, Switch } from '@/components/emcn'
import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { getKnowledgeChunkContract } from '@/lib/api/contracts/knowledge'
import type { ChunkData, DocumentData } from '@/lib/knowledge/types'
import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators'
import { useCreateChunk, useUpdateChunk } from '@/hooks/queries/kb/knowledge'
@@ -64,16 +67,15 @@ export function ChunkEditor({
useEffect(() => {
if (isCreateMode || !chunk?.id) return
const controller = new AbortController()
const chunkId = chunk.id
const handleVisibility = async () => {
if (document.visibilityState !== 'visible') return
try {
const res = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentData.id}/chunks/${chunk.id}`,
{ signal: controller.signal }
)
if (!res.ok) return
const json = await res.json()
const serverContent: string = json.data?.content ?? ''
const json = await requestJson(getKnowledgeChunkContract, {
params: { id: knowledgeBaseId, documentId: documentData.id, chunkId },
signal: controller.signal,
})
const serverContent = json.data.content ?? ''
if (serverContent === savedContentRef.current) return
const isClean = editedContentRef.current === savedContentRef.current
savedContentRef.current = serverContent
@@ -83,6 +85,7 @@ export function ChunkEditor({
}
} catch (err) {
if ((err as Error).name === 'AbortError') return
if (isApiClientError(err)) return
}
}
document.addEventListener('visibilitychange', handleVisibility)
@@ -15,6 +15,8 @@ import {
ModalHeader,
Trash,
} from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import { getTagUsageContract, type TagUsageData } from '@/lib/api/contracts/knowledge'
import { cn } from '@/lib/core/utils/cn'
import { SUPPORTED_FIELD_TYPES, TAG_SLOT_CONFIG } from '@/lib/knowledge/constants'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
@@ -33,13 +35,6 @@ const FIELD_TYPE_LABELS: Record<string, string> = {
boolean: 'Boolean',
}
interface TagUsageData {
tagName: string
tagSlot: string
documentCount: number
documents: Array<{ id: string; name: string; tagValue: string }>
}
interface DocumentListProps {
documents: Array<{ id: string; name: string; tagValue: string }>
totalCount: number
@@ -107,11 +102,9 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
if (!knowledgeBaseId) return
try {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`)
if (!response.ok) {
throw new Error('Failed to fetch tag usage')
}
const result = await response.json()
const result = await requestJson(getTagUsageContract, {
params: { id: knowledgeBaseId },
})
if (result.success) {
setTagUsageData(result.data)
}
@@ -2,6 +2,9 @@ import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { sleep } from '@sim/utils/helpers'
import { useQueryClient } from '@tanstack/react-query'
import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { createKnowledgeDocumentsContract } from '@/lib/api/contracts/knowledge/documents'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
@@ -59,7 +62,7 @@ class KnowledgeUploadError extends Error {
constructor(
message: string,
public code: string,
public details?: any
public details?: unknown
) {
super(message)
this.name = 'KnowledgeUploadError'
@@ -67,49 +70,75 @@ class KnowledgeUploadError extends Error {
}
class PresignedUrlError extends KnowledgeUploadError {
constructor(message: string, details?: any) {
constructor(message: string, details?: unknown) {
super(message, 'PRESIGNED_URL_ERROR', details)
}
}
class DirectUploadError extends KnowledgeUploadError {
constructor(message: string, details?: any) {
constructor(message: string, details?: unknown) {
super(message, 'DIRECT_UPLOAD_ERROR', details)
}
}
class ProcessingError extends KnowledgeUploadError {
constructor(message: string, details?: any) {
constructor(message: string, details?: unknown) {
super(message, 'PROCESSING_ERROR', details)
}
}
/**
* Reads a failed `Response`'s JSON body and produces a user-facing error
* string that combines the top-level `error`/`message` with any Zod
* `details[].message` entries. Falls back to `statusText` then status code
* when the body is unreadable.
* Loosely-typed shape of a failed `Response` JSON body returned by routes
* in this codebase. Routes generally surface `{ error?, message?, details? }`
* where `details` is a Zod issue array (`{ message: string }[]`). Treated
* as a structural read-only view; missing/undefined fields are tolerated.
*/
async function readResponseError(
interface ApiErrorBodyShape {
error?: unknown
message?: unknown
details?: unknown
}
/**
* Reads a failed `Response`'s JSON body into a typed shape and returns the
* parsed body plus a human-readable error string that combines the
* top-level `error`/`message` with any Zod `details[].message` entries.
* Falls back to `statusText` then status code when the body is unreadable.
*/
async function readApiResponseError(
response: Response,
fallback = 'Unknown error'
): Promise<{ message: string; body: any }> {
let body: any = null
): Promise<{ message: string; body: ApiErrorBodyShape | null }> {
let body: ApiErrorBodyShape | null = null
try {
body = await response.json()
const parsed: unknown = await response.json()
if (parsed && typeof parsed === 'object') {
body = parsed as ApiErrorBodyShape
}
} catch {
body = null
}
const base =
body?.error || body?.message || response.statusText || `HTTP ${response.status}` || fallback
const baseError =
(typeof body?.error === 'string' && body.error) ||
(typeof body?.message === 'string' && body.message) ||
response.statusText ||
`HTTP ${response.status}` ||
fallback
const detailMessages = Array.isArray(body?.details)
? body.details
.map((d: any) => (typeof d?.message === 'string' ? d.message : null))
.filter((m: string | null): m is string => Boolean(m))
.map((d) =>
d && typeof d === 'object' && 'message' in d && typeof d.message === 'string'
? d.message
: null
)
.filter((m): m is string => Boolean(m))
.join(', ')
: ''
return {
message: detailMessages ? `${base}: ${detailMessages}` : base,
message: detailMessages ? `${baseError}: ${detailMessages}` : baseError,
body,
}
}
@@ -309,6 +338,7 @@ const getPresignedData = async (
const startTime = getHighResTime()
try {
// boundary-raw-fetch: presigned URL coordination is part of the multipart-signed-url upload flow tracked together with XHR PUT progress; keeping this on raw fetch preserves a single retry/timeout AbortController shared with the direct upload XHR
const presignedResponse = await fetch('/api/files/presigned?type=knowledge-base', {
method: 'POST',
headers: {
@@ -323,12 +353,18 @@ const getPresignedData = async (
})
if (!presignedResponse.ok) {
const { message, body } = await readResponseError(presignedResponse)
const { message: fullError, body: errorDetails } =
await readApiResponseError(presignedResponse)
logger.error('Presigned URL request failed', {
status: presignedResponse.status,
fileSize: file.size,
})
throw new PresignedUrlError(`Failed to get presigned URL for ${file.name}: ${message}`, body)
throw new PresignedUrlError(
`Failed to get presigned URL for ${file.name}: ${fullError}`,
errorDetails
)
}
const presignedData = await presignedResponse.json()
@@ -628,6 +664,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
throw new Error('workspaceId is required for multipart upload')
}
// boundary-raw-fetch: multipart-signed-url initiate step coordinates the cloud multipart upload token consumed by raw-fetch PUTs to provider-issued part URLs below
const initiateResponse = await fetch('/api/files/multipart?action=initiate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -650,6 +687,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
const numParts = Math.ceil(file.size / chunkSize)
const partNumbers = Array.from({ length: numParts }, (_, i) => i + 1)
// boundary-raw-fetch: multipart-signed-url get-part-urls step issues provider-signed PUT URLs consumed by the raw-fetch PUT loop below; kept on raw fetch to preserve retry/abort coordination with the part PUTs
const partUrlsResponse = await fetch('/api/files/multipart?action=get-part-urls', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -660,6 +698,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
})
if (!partUrlsResponse.ok) {
// boundary-raw-fetch: multipart-signed-url abort step issued from inside the multipart upload error path; keeps cleanup on the same raw-fetch path as the rest of the multipart flow
await fetch('/api/files/multipart?action=abort', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -743,6 +782,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
clearTimeout(multipartTimeoutId)
}
// boundary-raw-fetch: multipart-signed-url complete step finalizes the multipart upload coordinated with raw-fetch part PUTs above
const completeResponse = await fetch('/api/files/multipart?action=complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -799,6 +839,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
formData.append('workspaceId', options.workspaceId)
}
// boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies
const uploadResponse = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
@@ -806,8 +847,8 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
})
if (!uploadResponse.ok) {
const { message, body } = await readResponseError(uploadResponse)
throw new DirectUploadError(`Failed to upload ${file.name}: ${message}`, body)
const { message: fullError, body: errorData } = await readApiResponseError(uploadResponse)
throw new DirectUploadError(`Failed to upload ${file.name}: ${fullError}`, errorData)
}
const uploadResult = await uploadResponse.json()
@@ -882,6 +923,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
})),
}
// boundary-raw-fetch: signed-URL batch coordination for direct uploads handed to raw-fetch PUTs against provider URLs; kept on raw fetch to preserve the same controller/timeout used by the multipart flow
const batchResponse = await fetch('/api/files/presigned/batch?type=knowledge-base', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -889,13 +931,8 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
})
if (!batchResponse.ok) {
const { message } = await readResponseError(batchResponse)
logger.error('Batch presigned URL generation failed', {
batchIndex: batchIndex + 1,
totalBatches: batches.length,
status: batchResponse.status,
})
throw new Error(message)
const { message: fullError } = await readApiResponseError(batchResponse)
throw new Error(`Batch ${batchIndex + 1} presigned URL generation failed: ${fullError}`)
}
const { files: presignedData } = await batchResponse.json()
@@ -1028,39 +1065,32 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
bulk: true,
}
const processResponse = await fetch(`/api/knowledge/${knowledgeBaseId}/documents`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(processPayload),
})
if (!processResponse.ok) {
const { message, body } = await readResponseError(processResponse)
logger.error('Document processing failed:', {
status: processResponse.status,
error: body,
uploadedFiles: uploadedFiles.map((f) => ({
filename: f.filename,
fileUrl: f.fileUrl,
fileSize: f.fileSize,
mimeType: f.mimeType,
})),
let processResult: Awaited<
ReturnType<typeof requestJson<typeof createKnowledgeDocumentsContract>>
>
try {
processResult = await requestJson(createKnowledgeDocumentsContract, {
params: { id: knowledgeBaseId },
body: processPayload,
})
throw new ProcessingError(`Failed to start document processing: ${message}`, body)
} catch (err) {
if (isApiClientError(err)) {
logger.error('Document processing failed:', {
status: err.status,
error: err.body,
uploadedFiles: uploadedFiles.map((f) => ({
filename: f.filename,
fileUrl: f.fileUrl,
fileSize: f.fileSize,
mimeType: f.mimeType,
})),
})
throw new ProcessingError(`Failed to start document processing: ${err.message}`, err.body)
}
throw err
}
const processResult = await processResponse.json()
if (!processResult.success) {
throw new ProcessingError(
`Document processing failed: ${processResult.error || 'Unknown error'}`,
processResult
)
}
if (!processResult.data || !processResult.data.documentsCreated) {
if (!('documentsCreated' in processResult.data)) {
throw new ProcessingError(
'Invalid processing response: missing document data',
processResult
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Hash, Lock } from 'lucide-react'
import { Combobox, type ComboboxOption } from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import { slackChannelsSelectorContract } from '@/lib/api/contracts'
const logger = createLogger('SlackChannelSelector')
@@ -45,19 +47,16 @@ export function SlackChannelSelector({
setFetchError(null)
try {
const response = await fetch('/api/tools/slack/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: accountId }),
const data = await requestJson(slackChannelsSelectorContract, {
body: { credential: accountId },
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || 'Failed to fetch channels')
}
const data = await response.json()
setChannels(data.channels || [])
setChannels(
(data.channels ?? []).map((channel) => ({
id: channel.id,
name: channel.name,
isPrivate: channel.isPrivate,
}))
)
} catch (err) {
logger.error('Failed to fetch Slack channels', { error: err })
setFetchError(err instanceof Error ? err.message : 'Failed to fetch channels')
@@ -3,7 +3,6 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Plus, X } from 'lucide-react'
import type { z } from 'zod'
import {
Badge,
Button,
@@ -25,9 +24,9 @@ import {
} from '@/components/emcn'
import { SlackIcon } from '@/components/icons'
import type {
alertRuleSchema,
notificationLevelSchema,
notificationTypeSchema,
NotificationAlertRule,
NotificationLogLevel,
NotificationType,
} from '@/lib/api/contracts/notifications'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
@@ -53,10 +52,9 @@ const logger = createLogger('NotificationSettings')
const TRIGGER_OPTIONS = getTriggerOptions()
const ALL_TRIGGER_VALUES = TRIGGER_OPTIONS.map((t) => t.value)
type NotificationType = z.output<typeof notificationTypeSchema>
type LogLevel = z.output<typeof notificationLevelSchema>
type LogLevel = NotificationLogLevel
/** Contract alert rule plus a UI-only `'none'` sentinel meaning "no alert config". */
type AlertRule = z.output<typeof alertRuleSchema> | 'none'
type AlertRule = NotificationAlertRule | 'none'
const ALERT_RULES: { value: AlertRule; label: string; description: string }[] = [
{ value: 'none', label: 'None', description: 'Notify on every matching execution' },
@@ -18,6 +18,8 @@ import {
Switch,
Tooltip,
} from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import { telemetryContract } from '@/lib/api/contracts/telemetry'
import { signOut, useSession } from '@/lib/auth/auth-client'
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
import { getEnv, isTruthy } from '@/lib/core/config/env'
@@ -230,14 +232,12 @@ export function General() {
if (checked) {
if (typeof window !== 'undefined') {
fetch('/api/telemetry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
requestJson(telemetryContract, {
body: {
category: 'consent',
action: 'enable_from_settings',
timestamp: new Date().toISOString(),
}),
},
}).catch(() => {})
}
}
@@ -15,6 +15,8 @@ import {
Tooltip,
} from '@/components/emcn'
import { Input } from '@/components/ui'
import { requestJson } from '@/lib/api/client/request'
import { getWorkflowStateContract } from '@/lib/api/contracts/workflows'
import { cn } from '@/lib/core/utils/cn'
import {
getIssueBadgeLabel,
@@ -39,6 +41,7 @@ import {
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { McpServerFormModal, McpServerSkeleton } from './components'
const logger = createLogger('McpSettings')
@@ -272,14 +275,16 @@ export function MCP({ initialServerId }: MCPProps) {
if (activeWorkflowId && result.updatedWorkflowIds?.includes(activeWorkflowId)) {
logger.info(`Active workflow ${activeWorkflowId} was updated, reloading subblock values`)
try {
const response = await fetch(`/api/workflows/${activeWorkflowId}`)
if (response.ok) {
const { data: workflowData } = await response.json()
if (workflowData?.state?.blocks) {
useSubBlockStore
.getState()
.initializeFromWorkflow(activeWorkflowId, workflowData.state.blocks)
}
const { data: workflowData } = await requestJson(getWorkflowStateContract, {
params: { id: activeWorkflowId },
})
if (workflowData?.state?.blocks) {
useSubBlockStore
.getState()
.initializeFromWorkflow(
activeWorkflowId,
workflowData.state.blocks as Record<string, BlockState>
)
}
} catch (reloadError) {
logger.warn('Failed to reload workflow subblock values:', reloadError)
@@ -18,6 +18,8 @@ import {
Switch,
Tooltip,
} from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import { billingSwitchPlanContract } from '@/lib/api/contracts/subscription'
import { useSession, useSubscription } from '@/lib/auth/auth-client'
import { USAGE_THRESHOLDS } from '@/lib/billing/client/consts'
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
@@ -559,13 +561,9 @@ export function Subscription() {
'Interval switching is not available on legacy plans. Please upgrade first.'
)
}
const res = await fetch('/api/billing/switch-plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetPlanName: subscription.plan, interval }),
await requestJson(billingSwitchPlanContract, {
body: { targetPlanName: subscription.plan, interval },
})
const data = await res.json()
if (!res.ok) throw new Error(data?.error || 'Failed to switch interval')
await refetchSubscription()
},
[refetchSubscription, subscription.plan, isLegacyPlan]
@@ -779,16 +777,12 @@ export function Subscription() {
? async () => {
const planType = subscription.isTeam ? 'team' : 'pro'
try {
const res = await fetch('/api/billing/switch-plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
await requestJson(billingSwitchPlanContract, {
body: {
targetPlanName: `${planType}_${MAX_TIER.credits}`,
interval: isAnnual ? 'year' : 'month',
}),
},
})
const data = await res.json()
if (!res.ok) throw new Error(data?.error || 'Failed to upgrade')
await refetchSubscription()
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to upgrade')
@@ -861,13 +855,9 @@ export function Subscription() {
const planType = subscription.isTeam ? 'team' : 'pro'
const targetPlanName = `${planType}_${targetTier.credits}`
try {
const res = await fetch('/api/billing/switch-plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetPlanName }),
await requestJson(billingSwitchPlanContract, {
body: { targetPlanName },
})
const data = await res.json()
if (!res.ok) throw new Error(data?.error || 'Failed to switch plan')
await refetchSubscription()
setManagePlanModalOpen(false)
} catch (e) {
@@ -882,13 +872,9 @@ export function Subscription() {
const planType = subscription.isTeam ? 'team' : 'pro'
const targetPlanName = `${planType}_${currentTier.credits}`
try {
const res = await fetch('/api/billing/switch-plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetPlanName }),
await requestJson(billingSwitchPlanContract, {
body: { targetPlanName },
})
const data = await res.json()
if (!res.ok) throw new Error(data?.error || 'Failed to migrate plan')
await refetchSubscription()
setManagePlanModalOpen(false)
} catch (e) {
@@ -72,6 +72,7 @@ export function useProfilePictureUpload({
formData.append('workspaceId', workspaceId)
}
// boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
@@ -142,6 +142,7 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
formData.append('workspaceId', workspaceId)
}
// boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies
const uploadResponse = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
@@ -3,6 +3,11 @@
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useShallow } from 'zustand/react/shallow'
import { requestJson } from '@/lib/api/client/request'
import { listCopilotChatsContract } from '@/lib/api/contracts/copilot'
import { listKnowledgeBasesContract } from '@/lib/api/contracts/knowledge/base'
import { listLogsContract } from '@/lib/api/contracts/logs'
import { listTemplatesContract } from '@/lib/api/contracts/templates'
import { useWorkflows } from '@/hooks/queries/workflows'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -222,19 +227,17 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
if (isLoadingPastChats || pastChats.length > 0) return
try {
setIsLoadingPastChats(true)
const resp = await fetch('/api/copilot/chats')
if (!resp.ok) throw new Error(`Failed to load chats: ${resp.status}`)
const data = await resp.json()
const items = Array.isArray(data?.chats) ? data.chats : []
const data = await requestJson(listCopilotChatsContract, {})
const items = data.chats
const currentWorkflowChats = items.filter((c: any) => c.workflowId === workflowId)
const currentWorkflowChats = items.filter((c) => c.workflowId === workflowId)
setPastChats(
currentWorkflowChats.map((c: any) => ({
currentWorkflowChats.map((c) => ({
id: c.id,
title: c.title ?? null,
workflowId: c.workflowId ?? null,
updatedAt: c.updatedAt,
updatedAt: c.updatedAt ?? undefined,
}))
)
} catch {
@@ -250,16 +253,16 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
if (isLoadingKnowledge || knowledgeBases.length > 0) return
try {
setIsLoadingKnowledge(true)
const resp = await fetch(`/api/knowledge?workspaceId=${workspaceId}`)
if (!resp.ok) throw new Error(`Failed to load knowledge bases: ${resp.status}`)
const data = await resp.json()
const items = Array.isArray(data?.data) ? data.data : []
const sorted = [...items].sort((a: any, b: any) => {
const result = await requestJson(listKnowledgeBasesContract, {
query: { workspaceId },
})
const items = result.data
const sorted = [...items].sort((a, b) => {
const ta = new Date(a.updatedAt || a.createdAt || 0).getTime()
const tb = new Date(b.updatedAt || b.createdAt || 0).getTime()
return tb - ta
})
setKnowledgeBases(sorted.map((k: any) => ({ id: k.id, name: k.name || 'Untitled' })))
setKnowledgeBases(sorted.map((k) => ({ id: k.id, name: k.name || 'Untitled' })))
} catch {
} finally {
setIsLoadingKnowledge(false)
@@ -321,13 +324,13 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
if (isLoadingTemplates || templatesList.length > 0) return
try {
setIsLoadingTemplates(true)
const resp = await fetch('/api/templates?limit=50&offset=0')
if (!resp.ok) throw new Error(`Failed to load templates: ${resp.status}`)
const data = await resp.json()
const items = Array.isArray(data?.data) ? data.data : []
const data = await requestJson(listTemplatesContract, {
query: { limit: 50, offset: 0 },
})
const items = data.data
const mapped = items
.map((t: any) => ({ id: t.id, name: t.name || 'Untitled Template', stars: t.stars || 0 }))
.sort((a: any, b: any) => b.stars - a.stars)
.map((t) => ({ id: t.id, name: t.name || 'Untitled Template', stars: t.stars || 0 }))
.sort((a, b) => b.stars - a.stars)
setTemplatesList(mapped)
} catch {
} finally {
@@ -342,20 +345,17 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
if (isLoadingLogs || logsList.length > 0) return
try {
setIsLoadingLogs(true)
const resp = await fetch(`/api/logs?workspaceId=${workspaceId}&limit=50&details=full`)
if (!resp.ok) throw new Error(`Failed to load logs: ${resp.status}`)
const data = await resp.json()
const items = Array.isArray(data?.data) ? data.data : []
const mapped = items.map((l: any) => ({
const data = await requestJson(listLogsContract, {
query: { workspaceId, limit: 50, details: 'full' },
})
const items = data.data
const mapped = items.map((l) => ({
id: l.id,
executionId: l.executionId || l.id,
level: l.level,
trigger: l.trigger || null,
createdAt: l.createdAt,
workflowName:
(l.workflow && (l.workflow.name || l.workflow.title)) ||
l.workflowName ||
'Untitled Workflow',
workflowName: l.workflow?.name ?? 'Untitled Workflow',
}))
setLogsList(mapped)
} catch {
@@ -6,6 +6,9 @@ import { X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Combobox } from '@/components/emcn/components'
import { Progress } from '@/components/ui/progress'
import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { fileDeleteContract } from '@/lib/api/contracts/storage-transfer'
import { cn } from '@/lib/core/utils/cn'
import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
@@ -315,6 +318,7 @@ export function FileUpload({
formData.append('workspaceId', workspaceId)
}
// boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
@@ -486,18 +490,15 @@ export function FileUpload({
(decodedPath.includes(`/${workspaceId}/`) || decodedPath.includes(`${workspaceId}/`))
if (!isWorkspaceFile) {
const response = await fetch('/api/files/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filePath: file.path }),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: response.statusText }))
const errorMessage = errorData.error || `Failed to delete file: ${response.status}`
throw new Error(errorMessage)
try {
await requestJson(fileDeleteContract, {
body: { filePath: file.path },
})
} catch (err) {
if (isApiClientError(err)) {
throw new Error(err.message || `Failed to delete file: ${err.status}`)
}
throw err
}
}
@@ -35,6 +35,12 @@ import {
} from '@/components/emcn'
import { Lock, Unlock, Upload } from '@/components/emcn/icons'
import { VariableIcon } from '@/components/icons'
import { requestJson } from '@/lib/api/client/request'
import {
createWorkflowCopilotChatContract,
deleteCopilotChatContract,
} from '@/lib/api/contracts/copilot'
import { getWorkflowNormalizedStateContract } from '@/lib/api/contracts/workflows'
import { useSession } from '@/lib/auth/auth-client'
import { captureEvent } from '@/lib/posthog/client'
import { generateWorkflowJson } from '@/lib/workflows/operations/import-export'
@@ -284,18 +290,16 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
const handleCopilotDeleteChat = useCallback(
(chatId: string) => {
fetch('/api/copilot/chat/delete', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chatId }),
})
requestJson(deleteCopilotChatContract, { body: { chatId } })
.then(() => {
if (copilotChatId === chatId) {
setCopilotChatId(undefined)
}
loadCopilotChats()
})
.catch(() => {})
.catch((err) => {
logger.error('Failed to delete copilot chat', { error: toError(err).message, chatId })
})
},
[copilotChatId, loadCopilotChats, setCopilotChatId]
)
@@ -308,11 +312,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
const baselineWorkflow = captureBaselineSnapshot(workflowId)
fetch(`/api/workflows/${workflowId}/state`)
.then((res) => {
if (!res.ok) throw new Error(`State fetch failed: ${res.status}`)
return res.json()
})
requestJson(getWorkflowNormalizedStateContract, { params: { id: workflowId } })
.then((freshState) => {
const diffStore = useWorkflowDiffStore.getState()
return diffStore.setProposedChanges(freshState as WorkflowState, undefined, {
@@ -353,14 +353,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
const handleCopilotNewChat = useCallback(() => {
if (!activeWorkflowId || !workspaceId) return
fetch('/api/copilot/chats', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId, workflowId: activeWorkflowId }),
requestJson(createWorkflowCopilotChatContract, {
body: { workspaceId, workflowId: activeWorkflowId },
})
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('create chat failed'))))
.then((data: { id?: string }) => {
if (!data?.id) return
.then((data) => {
// Seed the new chat into the list cache before selecting it. Without this, the
// auto-select effect sees a selected id that isn't in the (still-stale) list and
// deselects it, which leaves the panel detached from the freshly created row.
@@ -368,7 +364,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
copilotChatsKeys.list(activeWorkflowId),
(prev) => [
{
id: data.id!,
id: data.id,
title: null,
workflowId: activeWorkflowId,
updatedAt: new Date().toISOString(),
@@ -381,7 +377,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
loadCopilotChats()
})
.catch((err) => {
logger.error('Failed to create copilot chat', err)
logger.error('Failed to create copilot chat', { error: toError(err).message })
})
}, [activeWorkflowId, workspaceId, loadCopilotChats, setCopilotChatId, queryClient])
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { requestJson } from '@/lib/api/client/request'
import { listWebhooksByBlockContract, updateWebhookContract } from '@/lib/api/contracts/webhooks'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -81,28 +83,16 @@ export function useWebhookInfo(blockId: string, workflowId: string): UseWebhookI
}
try {
const params = new URLSearchParams({
workflowId,
blockId,
const data = await requestJson(listWebhooksByBlockContract, {
query: { workflowId, blockId },
})
const response = await fetch(`/api/webhooks?${params}`, {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' },
})
if (!response.ok) {
setWebhookStatus({ isDisabled: false, webhookId: undefined })
return
}
const data = await response.json()
const webhooks = data.webhooks || []
const webhooks = data.webhooks
if (webhooks.length > 0) {
const webhook = webhooks[0].webhook
const isActive = webhook.isActive !== false
setWebhookStatus({
isDisabled: !webhook.isActive,
isDisabled: !isActive,
webhookId: webhook.id,
})
} else {
@@ -121,22 +111,14 @@ export function useWebhookInfo(blockId: string, workflowId: string): UseWebhookI
const reactivateWebhook = useCallback(
async (webhookId: string) => {
try {
const response = await fetch(`/api/webhooks/${webhookId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
await requestJson(updateWebhookContract, {
params: { id: webhookId },
body: {
isActive: true,
failedCount: 0,
}),
},
})
if (response.ok) {
await fetchWebhookStatus()
} else {
logger.error('Failed to reactivate webhook')
}
await fetchWebhookStatus()
} catch (error) {
logger.error('Error reactivating webhook:', error)
}
@@ -6,6 +6,8 @@ import { generateId } from '@sim/utils/id'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import { requestJson } from '@/lib/api/client/request'
import { cancelWorkflowExecutionContract, workflowLogContract } from '@/lib/api/contracts/workflows'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { processStreamingBlockLogs } from '@/lib/tokenization'
import {
@@ -363,21 +365,15 @@ export function useWorkflowExecution() {
}
}
const response = await fetch(`/api/workflows/${activeWorkflowId}/log`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
if (!activeWorkflowId) return executionId
await requestJson(workflowLogContract, {
params: { id: activeWorkflowId },
body: {
executionId,
result: enrichedResult,
}),
},
})
if (!response.ok) {
throw new Error('Failed to persist logs')
}
return executionId
} catch (error) {
logger.error('Error persisting logs:', error)
@@ -461,7 +457,7 @@ export function useWorkflowExecution() {
formData.append('executionId', executionId)
formData.append('workspaceId', workspaceId)
// Upload the file
// boundary-raw-fetch: multipart/form-data file upload, requestJson only supports JSON bodies
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
@@ -1523,8 +1519,8 @@ export function useWorkflowExecution() {
if (storedExecutionId) {
setCurrentExecutionId(activeWorkflowId, null)
fetch(`/api/workflows/${activeWorkflowId}/executions/${storedExecutionId}/cancel`, {
method: 'POST',
requestJson(cancelWorkflowExecutionContract, {
params: { id: activeWorkflowId, executionId: storedExecutionId },
}).catch(() => {})
handleExecutionCancelledConsole({
workflowId: activeWorkflowId,
@@ -1,4 +1,12 @@
import { createLogger } from '@sim/logger'
import type { Edge } from 'reactflow'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import {
putWorkflowNormalizedStateContract,
type WorkflowStateContractInput,
workflowAutoLayoutContract,
} from '@/lib/api/contracts/workflows'
import {
DEFAULT_HORIZONTAL_SPACING,
DEFAULT_LAYOUT_PADDING,
@@ -6,6 +14,7 @@ import {
} from '@/lib/workflows/autolayout/constants'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('AutoLayoutUtils')
@@ -77,40 +86,37 @@ export async function applyAutoLayoutAndUpdateStore(
gridSize: options.gridSize,
}
// Call the autolayout API route
const response = await fetch(`/api/workflows/${workflowId}/autolayout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...layoutOptions,
blocks,
edges,
loops,
parallels,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
const errorMessage = errorData?.error || `Auto layout failed: ${response.statusText}`
logger.error('Auto layout API call failed:', {
status: response.status,
error: errorMessage,
let result: Awaited<ReturnType<typeof requestJson<typeof workflowAutoLayoutContract>>>
try {
result = await requestJson(workflowAutoLayoutContract, {
params: { id: workflowId },
body: {
...layoutOptions,
blocks,
edges,
loops,
parallels,
},
})
} catch (error) {
const errorMessage =
error instanceof ApiClientError
? error.message
: error instanceof Error
? error.message
: 'Auto layout failed'
logger.error('Auto layout API call failed:', { error: errorMessage })
return { success: false, error: errorMessage }
}
const result = await response.json()
if (!result.success) {
const errorMessage = result.error || 'Auto layout failed'
logger.error('Auto layout failed:', { error: errorMessage })
return { success: false, error: errorMessage }
}
const layoutedBlocks = result.data?.layoutedBlocks || blocks
// The contract's `workflowBlockStateSchema` and the store's `BlockState`
// describe the same runtime shape but TS sees the contract's
// `SubBlockState.value` as `unknown` (zod default) while the store
// narrows it to the editor's per-input value union. The contract's
// shape is structurally a supertype of the store's, so this is a
// single safe widening cast (store -> wire) on the way back in.
const layoutedBlocks: Record<string, BlockState> =
(result.data.layoutedBlocks as Record<string, BlockState>) || blocks
const mergedBlocks = mergeSubblockState(layoutedBlocks, workflowId)
const newWorkflowState = {
@@ -129,38 +135,37 @@ export async function applyAutoLayoutAndUpdateStore(
const { dragStartPosition, ...stateToSave } = newWorkflowState
const cleanedWorkflowState = {
type ContractEdgeInput = WorkflowStateContractInput['edges'][number]
// Mirror the diff store's sanitization: schema rejects nullable
// sourceHandle/targetHandle (input type is `string | undefined`),
// but the store's reactflow `Edge` type carries `string | null |
// undefined`. Drop nulls before sending so the contract input
// parses cleanly.
const sanitizedEdges: ContractEdgeInput[] = (stateToSave.edges || []).map((edge: Edge) => {
const { sourceHandle, targetHandle, ...rest } = edge
const sanitized: ContractEdgeInput = { ...rest } as ContractEdgeInput
if (typeof sourceHandle === 'string' && sourceHandle.length > 0) {
sanitized.sourceHandle = sourceHandle
}
if (typeof targetHandle === 'string' && targetHandle.length > 0) {
sanitized.targetHandle = targetHandle
}
return sanitized
})
const cleanedWorkflowState: WorkflowStateContractInput = {
...stateToSave,
loops: stateToSave.loops || {},
parallels: stateToSave.parallels || {},
edges: (stateToSave.edges || []).map((edge: any) => {
const { sourceHandle, targetHandle, ...rest } = edge || {}
const sanitized: any = { ...rest }
if (typeof sourceHandle === 'string' && sourceHandle.length > 0) {
sanitized.sourceHandle = sourceHandle
}
if (typeof targetHandle === 'string' && targetHandle.length > 0) {
sanitized.targetHandle = targetHandle
}
return sanitized
}),
edges: sanitizedEdges,
}
const saveResponse = await fetch(`/api/workflows/${workflowId}/state`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(cleanedWorkflowState),
await requestJson(putWorkflowNormalizedStateContract, {
params: { id: workflowId },
body: cleanedWorkflowState,
})
if (!saveResponse.ok) {
const errorData = await saveResponse.json()
throw new Error(
errorData.error || `HTTP ${saveResponse.status}: ${saveResponse.statusText}`
)
}
logger.info('Auto layout successfully persisted to database', { workflowId })
return { success: true }
} catch (saveError) {
@@ -641,6 +641,7 @@ export async function executeWorkflowWithFullLogging(
: {}),
}
// boundary-raw-fetch: workflow execute returns an SSE stream consumed via response.body.getReader() in processSSEStream
const response = await fetch(`/api/workflows/${targetWorkflowId}/execute`, {
method: 'POST',
headers: {
@@ -108,6 +108,7 @@ async function submitHelpRequest({ data, images, workflowId, workspaceId }: Subm
formData.append(`image_${index}`, image)
})
// boundary-raw-fetch: multipart/form-data submission with image attachments, requestJson only supports JSON bodies
const response = await fetch('/api/help', {
method: 'POST',
body: formData,
@@ -104,9 +104,7 @@ export function SettingsSidebar({
const isSSOProviderOwner = useMemo(() => {
if (isHosted) return null
if (!userId || isLoadingSSO) return null
return (
ssoProvidersData?.providers?.some((p: { userId?: string }) => p.userId === userId) || false
)
return ssoProvidersData?.providers?.some((p) => p.userId === userId) || false
}, [userId, ssoProvidersData?.providers, isLoadingSSO])
const navigationItems = useMemo(() => {
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { Badge, Skeleton } from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import { createBillingPortalContract } from '@/lib/api/contracts'
import { USAGE_PILL_COLORS, USAGE_THRESHOLDS } from '@/lib/billing/client/consts'
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
import {
@@ -444,18 +446,12 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const context = isOrgScoped ? 'organization' : 'user'
const organizationId = subscriptionData?.data?.organization?.id
const response = await fetch('/api/billing/portal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context, organizationId }),
const { url } = await requestJson(createBillingPortalContract, {
body: { context, organizationId },
})
if (response.ok) {
const { url } = await response.json()
window.open(url, '_blank')
logger.info('Opened billing portal for blocked account', { context, organizationId })
return
}
window.open(url, '_blank')
logger.info('Opened billing portal for blocked account', { context, organizationId })
return
} catch (portalError) {
logger.warn('Failed to open billing portal, falling back to settings', {
error: portalError,
@@ -67,6 +67,7 @@ export function useWorkspaceLogoUpload({
formData.append('workspaceId', workspaceIdRef.current)
}
// boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { requestJson } from '@/lib/api/client/request'
import { updateUserSettingsContract } from '@/lib/api/contracts'
import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
import { useLeaveWorkspace } from '@/hooks/queries/invitations'
import {
@@ -80,10 +82,8 @@ export function useWorkspaceManagement({
if (syncTimerRef.current) clearTimeout(syncTimerRef.current)
syncTimerRef.current = setTimeout(() => {
fetch('/api/users/me/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lastActiveWorkspaceId: id }),
requestJson(updateUserSettingsContract, {
body: { lastActiveWorkspaceId: id },
}).catch(() => {})
}, 1000)
}, [])
@@ -1,6 +1,8 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { requestJson } from '@/lib/api/client/request'
import { duplicateWorkspaceContract } from '@/lib/api/contracts'
const logger = createLogger('useDuplicateWorkspace')
@@ -36,20 +38,11 @@ export function useDuplicateWorkspace({ workspaceId, onSuccess }: UseDuplicateWo
setIsDuplicating(true)
try {
const response = await fetch(`/api/workspaces/${workspaceId}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: `${workspaceName} (Copy)`,
}),
const duplicatedWorkspace = await requestJson(duplicateWorkspaceContract, {
params: { id: workspaceId },
body: { name: `${workspaceName} (Copy)` },
})
if (!response.ok) {
throw new Error(`Failed to duplicate workspace: ${response.statusText}`)
}
const duplicatedWorkspace = await response.json()
logger.info('Workspace duplicated successfully', {
sourceWorkspaceId: workspaceId,
newWorkspaceId: duplicatedWorkspace.id,
@@ -1,5 +1,11 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { requestJson } from '@/lib/api/client/request'
import {
getWorkspaceContract,
listFoldersContract,
listWorkflowsContract,
} from '@/lib/api/contracts'
import {
downloadFile,
exportWorkspaceToZip,
@@ -32,24 +38,20 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {})
try {
logger.info('Exporting workspace', { workspaceId })
const workflowsResponse = await fetch(`/api/workflows?workspaceId=${workspaceId}`)
if (!workflowsResponse.ok) {
throw new Error('Failed to fetch workflows')
}
const { data: workflows } = await workflowsResponse.json()
const { data: workflows } = await requestJson(listWorkflowsContract, {
query: { workspaceId },
})
const foldersResponse = await fetch(`/api/folders?workspaceId=${workspaceId}`)
if (!foldersResponse.ok) {
throw new Error('Failed to fetch folders')
}
const foldersData = await foldersResponse.json()
const foldersData = await requestJson(listFoldersContract, {
query: { workspaceId },
})
const workflowsToExport: WorkflowExportData[] = []
for (const workflow of workflows) {
const exportData = await fetchWorkflowForExport(workflow.id, {
name: workflow.name,
description: workflow.description,
description: workflow.description ?? undefined,
color: workflow.color,
folderId: workflow.folderId,
})
@@ -59,19 +61,22 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {})
}
}
const foldersToExport: FolderExportData[] = (foldersData.folders || []).map(
(folder: FolderExportData) => ({
id: folder.id,
name: folder.name,
parentId: folder.parentId,
sortOrder: folder.sortOrder,
})
)
const foldersToExport: FolderExportData[] = (foldersData.folders || []).map((folder) => ({
id: folder.id,
name: folder.name,
parentId: folder.parentId,
sortOrder: folder.sortOrder,
}))
const workspaceResponse = await fetch(`/api/workspaces/${workspaceId}`)
const workspaceColor = workspaceResponse.ok
? ((await workspaceResponse.json()).workspace?.color as string | undefined)
: undefined
let workspaceColor: string | undefined
try {
const workspaceData = await requestJson(getWorkspaceContract, {
params: { id: workspaceId },
})
workspaceColor = workspaceData.workspace?.color
} catch {
workspaceColor = undefined
}
const zipBlob = await exportWorkspaceToZip(
workspaceName,
@@ -2,6 +2,14 @@ import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { useRouter } from 'next/navigation'
import { requestJson } from '@/lib/api/client/request'
import {
createWorkflowContract,
createWorkspaceContract,
putWorkflowNormalizedStateContract,
type WorkflowStateContractInput,
workflowVariablesContract,
} from '@/lib/api/contracts'
import {
extractWorkflowName,
extractWorkflowsFromZip,
@@ -58,21 +66,13 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
}
const workspaceName = metadata?.workspaceName || zipFile.name.replace(/\.zip$/i, '')
const createResponse = await fetch('/api/workspaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
const { workspace: newWorkspace } = await requestJson(createWorkspaceContract, {
body: {
name: workspaceName,
...(metadata?.workspaceColor && { color: metadata.workspaceColor }),
skipDefaultWorkflow: true,
}),
},
})
if (!createResponse.ok) {
throw new Error('Failed to create workspace')
}
const { workspace: newWorkspace } = await createResponse.json()
logger.info('Created new workspace:', newWorkspace)
const folderMap = new Map<string, string>()
@@ -168,34 +168,52 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
const workflowColor =
(workflowData.metadata as { color?: string } | undefined)?.color || '#3972F6'
const createWorkflowResponse = await fetch('/api/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: workflowName,
description: workflowData.metadata?.description || 'Imported from workspace export',
color: workflowColor,
workspaceId: newWorkspace.id,
folderId: targetFolderId,
deduplicate: true,
}),
})
if (!createWorkflowResponse.ok) {
logger.error(`Failed to create workflow ${workflowName}`)
let newWorkflow: Awaited<ReturnType<typeof requestJson<typeof createWorkflowContract>>>
try {
newWorkflow = await requestJson(createWorkflowContract, {
body: {
name: workflowName,
description:
workflowData.metadata?.description || 'Imported from workspace export',
color: workflowColor,
workspaceId: newWorkspace.id,
folderId: targetFolderId,
deduplicate: true,
},
})
} catch (error) {
logger.error(`Failed to create workflow ${workflowName}`, { error })
continue
}
const newWorkflow = await createWorkflowResponse.json()
type ContractEdgeInput = WorkflowStateContractInput['edges'][number]
const stateResponse = await fetch(`/api/workflows/${newWorkflow.id}/state`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(workflowData),
const sanitizedEdges: ContractEdgeInput[] = (workflowData.edges || []).map((edge) => {
const { sourceHandle, targetHandle, ...rest } = edge
const sanitized: ContractEdgeInput = { ...rest } as ContractEdgeInput
if (typeof sourceHandle === 'string' && sourceHandle.length > 0) {
sanitized.sourceHandle = sourceHandle
}
if (typeof targetHandle === 'string' && targetHandle.length > 0) {
sanitized.targetHandle = targetHandle
}
return sanitized
})
if (!stateResponse.ok) {
logger.error(`Failed to save workflow state for ${newWorkflow.id}`)
const workflowStateBody: WorkflowStateContractInput = {
...workflowData,
loops: workflowData.loops || {},
parallels: workflowData.parallels || {},
edges: sanitizedEdges,
}
try {
await requestJson(putWorkflowNormalizedStateContract, {
params: { id: newWorkflow.id },
body: workflowStateBody,
})
} catch (error) {
logger.error(`Failed to save workflow state for ${newWorkflow.id}`, { error })
continue
}
@@ -205,33 +223,29 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
: Object.values(workflowData.variables)
if (variablesArray.length > 0) {
const variablesRecord: Record<
string,
{ id: string; workflowId: string; name: string; type: string; value: unknown }
> = {}
type WorkflowVariablesBodyInput = NonNullable<
Parameters<typeof requestJson<typeof workflowVariablesContract>>[1]['body']
>
const variablesRecord: WorkflowVariablesBodyInput['variables'] = {}
for (const v of variablesArray) {
const id = typeof v.id === 'string' && v.id.trim() ? v.id : generateId()
variablesRecord[id] = {
id,
workflowId: newWorkflow.id,
name: v.name,
type: v.type,
value: v.value,
}
}
const variablesResponse = await fetch(
`/api/workflows/${newWorkflow.id}/variables`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variables: variablesRecord }),
}
)
if (!variablesResponse.ok) {
logger.error(`Failed to save variables for ${newWorkflow.id}`)
try {
await requestJson(workflowVariablesContract, {
params: { id: newWorkflow.id },
body: { variables: variablesRecord },
})
} catch (error) {
logger.error(`Failed to save variables for ${newWorkflow.id}`, { error })
}
}
}
+17 -20
View File
@@ -3,6 +3,9 @@
import { useEffect, useRef } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { requestJson } from '@/lib/api/client/request'
import { getWorkflowStateContract } from '@/lib/api/contracts/workflows'
import { createWorkspaceContract } from '@/lib/api/contracts/workspaces'
import { useSession } from '@/lib/auth/auth-client'
import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
import { useWorkspacesWithMetadata, type WorkspaceCreationPolicy } from '@/hooks/queries/workspace'
@@ -82,15 +85,14 @@ async function handleWorkflowRedirect(
router: ReturnType<typeof useRouter>
): Promise<void> {
try {
const response = await fetch(`/api/workflows/${workflowId}`)
if (response.ok) {
const workflowData = await response.json()
const workspaceId = workflowData.data?.workspaceId
if (workspaceId) {
logger.info(`Redirecting workflow ${workflowId} to workspace ${workspaceId}`)
router.replace(`/workspace/${workspaceId}/w/${workflowId}`)
return
}
const workflowData = await requestJson(getWorkflowStateContract, {
params: { id: workflowId },
})
const workspaceId = workflowData.data.workspaceId
if (workspaceId) {
logger.info(`Redirecting workflow ${workflowId} to workspace ${workspaceId}`)
router.replace(`/workspace/${workspaceId}/w/${workflowId}`)
return
}
} catch (error) {
logger.error('Error fetching workflow for redirect:', error)
@@ -114,18 +116,13 @@ async function handleNoWorkspaces(
logger.warn('No workspaces found, creating default workspace')
try {
const response = await fetch('/api/workspaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'My Workspace' }),
const data = await requestJson(createWorkspaceContract, {
body: { name: 'My Workspace' },
})
if (response.ok) {
const data = await response.json()
if (data.workspace?.id) {
logger.info(`Created default workspace: ${data.workspace.id}`)
router.replace(`/workspace/${data.workspace.id}/home`)
return
}
if (data.workspace?.id) {
logger.info(`Created default workspace: ${data.workspace.id}`)
router.replace(`/workspace/${data.workspace.id}/home`)
return
}
logger.error('Failed to create default workspace')
} catch (error) {
@@ -303,6 +303,9 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
)
const generateSocketToken = async (): Promise<string> => {
// boundary-raw-fetch: pre-bootstrap one-time socket token mint — Better Auth's
// generateOneTimeToken handler (in INDIRECT_ZOD_ROUTES baseline) has no client
// contract; called from Socket.IO auth callback before any contract-bound client.
const res = await fetch('/api/auth/socket-token', {
method: 'POST',
credentials: 'include',
@@ -1,7 +1,6 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { z } from 'zod'
import { requestJson } from '@/lib/api/client/request'
import {
bulkAddPermissionGroupMembersContract,
@@ -10,17 +9,15 @@ import {
getUserPermissionConfigContract,
listPermissionGroupMembersContract,
listPermissionGroupsContract,
type permissionGroupMemberSchema,
type permissionGroupSchema,
type PermissionGroup,
type PermissionGroupMember,
removePermissionGroupMemberContract,
type UserPermissionConfig,
updatePermissionGroupContract,
type userPermissionConfigSchema,
} from '@/lib/api/contracts'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
export type PermissionGroup = z.output<typeof permissionGroupSchema>
export type PermissionGroupMember = z.output<typeof permissionGroupMemberSchema>
export type UserPermissionConfig = z.output<typeof userPermissionConfigSchema>
export type { PermissionGroup, PermissionGroupMember, UserPermissionConfig }
export const permissionGroupKeys = {
all: ['permissionGroups'] as const,
+15 -23
View File
@@ -1,5 +1,6 @@
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
import type { EnterpriseAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import { requestJson } from '@/lib/api/client/request'
import { type AuditLogPage, listAuditLogsContract } from '@/lib/api/contracts/audit-logs'
export const auditLogKeys = {
all: ['audit-logs'] as const,
@@ -16,33 +17,24 @@ export interface AuditLogFilters {
endDate?: string
}
interface AuditLogPage {
success: boolean
data: EnterpriseAuditLogEntry[]
nextCursor?: string
}
async function fetchAuditLogs(
filters: AuditLogFilters,
cursor?: string,
signal?: AbortSignal
): Promise<AuditLogPage> {
const params = new URLSearchParams()
params.set('limit', '50')
if (filters.search) params.set('search', filters.search)
if (filters.action) params.set('action', filters.action)
if (filters.resourceType) params.set('resourceType', filters.resourceType)
if (filters.actorId) params.set('actorId', filters.actorId)
if (filters.startDate) params.set('startDate', filters.startDate)
if (filters.endDate) params.set('endDate', filters.endDate)
if (cursor) params.set('cursor', cursor)
const response = await fetch(`/api/audit-logs?${params.toString()}`, { signal })
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body.error || `Failed to fetch audit logs: ${response.status}`)
}
return response.json()
return requestJson(listAuditLogsContract, {
query: {
limit: '50',
search: filters.search,
action: filters.action,
resourceType: filters.resourceType,
actorId: filters.actorId,
startDate: filters.startDate,
endDate: filters.endDate,
cursor,
},
signal,
})
}
export function useAuditLogs(filters: AuditLogFilters, enabled = true) {
@@ -1,19 +1,16 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
getOrganizationDataRetentionContract,
type OrganizationDataRetention,
type OrganizationRetentionValues,
updateOrganizationDataRetentionContract,
} from '@/lib/api/contracts/organization'
export interface RetentionValues {
logRetentionHours: number | null
softDeleteRetentionHours: number | null
taskCleanupHours: number | null
}
export interface DataRetentionResponse {
isEnterprise: boolean
defaults: RetentionValues
configured: RetentionValues
effective: RetentionValues
}
export type RetentionValues = OrganizationRetentionValues
export type DataRetentionResponse = OrganizationDataRetention
export const dataRetentionKeys = {
all: ['dataRetention'] as const,
@@ -24,15 +21,11 @@ async function fetchDataRetention(
orgId: string,
signal?: AbortSignal
): Promise<DataRetentionResponse> {
const response = await fetch(`/api/organizations/${orgId}/data-retention`, { signal })
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new Error(error.error ?? 'Failed to fetch data retention settings')
}
const { data } = await response.json()
return data as DataRetentionResponse
const { data } = await requestJson(getOrganizationDataRetentionContract, {
params: { id: orgId },
signal,
})
return data
}
export function useOrganizationRetention(orgId: string | undefined) {
@@ -53,21 +46,11 @@ export function useUpdateOrganizationRetention() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ orgId, settings }: UpdateRetentionVariables) => {
const response = await fetch(`/api/organizations/${orgId}/data-retention`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
})
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new Error(error.error ?? 'Failed to update data retention settings')
}
const { data } = await response.json()
return data as DataRetentionResponse
},
mutationFn: ({ orgId, settings }: UpdateRetentionVariables) =>
requestJson(updateOrganizationDataRetentionContract, {
params: { id: orgId },
body: settings,
}),
onSettled: (_data, _error, { orgId }) => {
queryClient.invalidateQueries({ queryKey: dataRetentionKeys.settings(orgId) })
},
+12 -16
View File
@@ -4,6 +4,9 @@ import { type KeyboardEvent, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { Input, Label, Loader } from '@/components/emcn'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { authenticateDeployedChatContract } from '@/lib/api/contracts/chats'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import AuthBackground from '@/app/(auth)/components/auth-background'
@@ -66,28 +69,21 @@ export default function SSOAuth({ identifier }: SSOAuthProps) {
setIsLoading(true)
try {
const checkResponse = await fetch(`/api/chat/${identifier}`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ email, checkSSOAccess: true }),
await requestJson(authenticateDeployedChatContract, {
params: { identifier },
body: { email, checkSSOAccess: true },
})
if (!checkResponse.ok) {
const errorData = await checkResponse.json()
setEmailErrors([errorData.error || 'Email not authorized for this chat'])
setShowEmailValidationError(true)
setIsLoading(false)
return
}
const callbackUrl = `/chat/${identifier}`
const ssoUrl = `/sso?email=${encodeURIComponent(email)}&callbackUrl=${encodeURIComponent(callbackUrl)}`
router.push(ssoUrl)
} catch (error) {
if (error instanceof ApiClientError) {
setEmailErrors([error.message || 'Email not authorized for this chat'])
setShowEmailValidationError(true)
setIsLoading(false)
return
}
logger.error('SSO authentication error:', error)
setEmailErrors(['An error occurred during authentication'])
setShowEmailValidationError(true)
+14 -22
View File
@@ -1,6 +1,12 @@
'use client'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
listSsoProvidersContract,
type SsoRegistrationBody,
ssoRegistrationContract,
} from '@/lib/api/contracts/auth'
import { organizationKeys } from '@/hooks/queries/organization'
/**
@@ -15,14 +21,10 @@ export const ssoKeys = {
* Fetch SSO providers
*/
async function fetchSSOProviders(signal: AbortSignal, organizationId?: string) {
const url = organizationId
? `/api/auth/sso/providers?organizationId=${encodeURIComponent(organizationId)}`
: '/api/auth/sso/providers'
const response = await fetch(url, { signal })
if (!response.ok) {
throw new Error('Failed to fetch SSO providers')
}
return response.json()
return requestJson(listSsoProvidersContract, {
query: organizationId ? { organizationId } : {},
signal,
})
}
/**
@@ -52,20 +54,10 @@ export function useConfigureSSO() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (config: ConfigureSSOParams) => {
const response = await fetch('/api/auth/sso/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || error.details || 'Failed to configure SSO')
}
return response.json()
},
mutationFn: (config: ConfigureSSOParams) =>
requestJson(ssoRegistrationContract, {
body: config as SsoRegistrationBody,
}),
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: ssoKeys.providers() })
+14 -21
View File
@@ -1,6 +1,11 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
getOrganizationWhitelabelContract,
updateOrganizationWhitelabelContract,
} from '@/lib/api/contracts/organization'
import type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
import { organizationKeys } from '@/hooks/queries/organization'
@@ -25,15 +30,11 @@ async function fetchWhitelabelSettings(
orgId: string,
signal?: AbortSignal
): Promise<OrganizationWhitelabelSettings> {
const response = await fetch(`/api/organizations/${orgId}/whitelabel`, { signal })
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new Error(error.error ?? 'Failed to fetch whitelabel settings')
}
const { data } = await response.json()
return data as OrganizationWhitelabelSettings
const { data } = await requestJson(getOrganizationWhitelabelContract, {
params: { id: orgId },
signal,
})
return data
}
/**
@@ -61,19 +62,11 @@ export function useUpdateWhitelabelSettings() {
return useMutation({
mutationFn: async ({ orgId, settings }: UpdateWhitelabelVariables) => {
const response = await fetch(`/api/organizations/${orgId}/whitelabel`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
const result = await requestJson(updateOrganizationWhitelabelContract, {
params: { id: orgId },
body: settings,
})
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new Error(error.error ?? 'Failed to update whitelabel settings')
}
const { data } = await response.json()
return data as OrganizationWhitelabelSettings
return result.data
},
onSettled: (_data, _error, { orgId }) => {
queryClient.invalidateQueries({ queryKey: whitelabelKeys.settings(orgId) })
+19 -6
View File
@@ -5,12 +5,14 @@ import {
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import {
addMothershipChatResourceContract,
createMothershipChatContract,
deleteMothershipChatContract,
forkMothershipChatContract,
getMothershipChatContract,
listMothershipChatsContract,
type MothershipTask,
removeMothershipChatResourceContract,
@@ -226,14 +228,21 @@ export async function fetchChatHistory(
chatId: string,
signal?: AbortSignal
): Promise<TaskChatHistory> {
// boundary-raw-fetch: mothership chat GET returns variable shape with optional stream snapshots
const mothershipRes = await fetch(`/api/mothership/chats/${chatId}`, { signal })
if (mothershipRes.ok) {
return parseChatHistory(await mothershipRes.json(), 'mothership')
try {
const data = await requestJson(getMothershipChatContract, {
params: { chatId },
signal,
})
return parseChatHistory(data, 'mothership')
} catch (error) {
if (!isApiClientError(error)) throw error
// Fall through to the legacy copilot-shape alias on any HTTP error (typically 404
// when the chat lives in the older copilot table and isn't a mothership-typed row).
}
// boundary-raw-fetch: copilot chat fallback returns a different mothership/copilot lifecycle shape
// boundary-raw-fetch: legacy alias path /api/mothership/chat?chatId=... returns the
// copilot lifecycle shape (activeStreamId, not conversationId) for chats stored under
// the older copilot table; no contract exists for this alias path
const copilotRes = await fetch(`/api/mothership/chat?chatId=${encodeURIComponent(chatId)}`, {
signal,
})
@@ -546,6 +555,7 @@ export function useCreateTask(workspaceId?: string) {
return createChat(workspaceId)
},
onSuccess: (data) => {
if (!workspaceId) return
const existing = queryClient.getQueryData<TaskMetadata[]>(taskKeys.list(workspaceId)) ?? []
const newTask: TaskMetadata = {
id: data.id,
@@ -557,6 +567,7 @@ export function useCreateTask(workspaceId?: string) {
queryClient.setQueryData<TaskMetadata[]>(taskKeys.list(workspaceId), [newTask, ...existing])
},
onSettled: () => {
if (!workspaceId) return
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
},
})
@@ -578,6 +589,7 @@ export function useForkTask(workspaceId?: string) {
return useMutation({
mutationFn: forkChat,
onSuccess: async (data, variables) => {
if (!workspaceId) return
await queryClient.cancelQueries({ queryKey: taskKeys.list(workspaceId) })
const existing = queryClient.getQueryData<TaskMetadata[]>(taskKeys.list(workspaceId))
if (existing) {
@@ -597,6 +609,7 @@ export function useForkTask(workspaceId?: string) {
}
},
onSettled: () => {
if (!workspaceId) return
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
},
})
+42 -24
View File
@@ -14,6 +14,8 @@ import { generateId } from '@sim/utils/id'
import { useQueryClient } from '@tanstack/react-query'
import type { Edge } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
import { requestJson } from '@/lib/api/client/request'
import { getWorkflowStateContract } from '@/lib/api/contracts'
import { useSession } from '@/lib/auth/auth-client'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { getBlock } from '@/blocks'
@@ -30,7 +32,13 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { filterNewEdges, filterValidEdges, mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
import type {
BlockState,
Loop,
Parallel,
Position,
WorkflowState,
} from '@/stores/workflows/workflow/types'
import { findAllDescendantNodes, isBlockProtected } from '@/stores/workflows/workflow/utils'
const logger = createLogger('CollaborativeWorkflow')
@@ -554,40 +562,52 @@ export function useCollaborativeWorkflow() {
}
const reloadWorkflowFromApi = async (workflowId: string, reason: string): Promise<boolean> => {
const response = await fetch(`/api/workflows/${workflowId}`)
if (!response.ok) {
logger.error(`Failed to fetch workflow data after ${reason}: ${response.statusText}`)
// The contract's `state` is `workflowStateSchema` (loose at the wire
// level — `subBlocks.value` is `unknown`, optional flags omitted),
// but downstream consumers (replaceWorkflowState, the undo/redo
// graph) operate on the store's narrower `WorkflowState`. The
// server-of-record persists store-shaped values, so the runtime
// shape is the store type; we narrow once here at the trust
// boundary instead of sprinkling per-field casts.
let workflowState: WorkflowState | null = null
try {
const responseData = await requestJson(getWorkflowStateContract, {
params: { id: workflowId },
})
const wireState = responseData.data?.state
if (wireState) {
// double-cast-allowed: workflowStateSchema is structurally a supertype of the store's WorkflowState (subBlocks.value is `unknown`, optional booleans, etc.); the server persists store-shaped values so the runtime shape matches
workflowState = wireState as unknown as WorkflowState
}
} catch (error) {
logger.error(`Failed to fetch workflow data after ${reason}`, { error })
return false
}
const responseData = await response.json()
const workflowData = responseData.data
if (!workflowData?.state) {
logger.error(`No state found in workflow data after ${reason}`, { workflowData })
if (!workflowState) {
logger.error(`No state found in workflow data after ${reason}`, { workflowId })
return false
}
isApplyingRemoteChange.current = true
try {
useWorkflowStore.getState().replaceWorkflowState({
blocks: workflowData.state.blocks || {},
edges: workflowData.state.edges || [],
loops: workflowData.state.loops || {},
parallels: workflowData.state.parallels || {},
lastSaved: workflowData.state.lastSaved || Date.now(),
blocks: workflowState.blocks || {},
edges: workflowState.edges || [],
loops: workflowState.loops || {},
parallels: workflowState.parallels || {},
lastSaved: workflowState.lastSaved || Date.now(),
})
const subblockValues: Record<string, Record<string, any>> = {}
Object.entries(workflowData.state.blocks || {}).forEach(([blockId, block]) => {
const blockState = block as any
const subblockValues: Record<string, Record<string, unknown>> = {}
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
subblockValues[blockId] = {}
Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => {
subblockValues[blockId][subblockId] = (subblock as any).value
Object.entries(block.subBlocks || {}).forEach(([subblockId, subblock]) => {
subblockValues[blockId][subblockId] = subblock?.value
})
})
useSubBlockStore.setState((state: any) => ({
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[workflowId]: subblockValues,
@@ -595,10 +615,8 @@ export function useCollaborativeWorkflow() {
}))
const graph = {
blocksById: workflowData.state.blocks || {},
edgesById: Object.fromEntries(
(workflowData.state.edges || []).map((e: any) => [e.id, e])
),
blocksById: workflowState.blocks || {},
edgesById: Object.fromEntries((workflowState.edges || []).map((e) => [e.id, e])),
}
const undoRedoStore = useUndoRedoStore.getState()
+3
View File
@@ -208,6 +208,7 @@ export function useExecutionStream() {
sharedAbortControllers.set(workflowId, abortController)
try {
// boundary-raw-fetch: workflow execute endpoint returns an SSE stream consumed via response.body.getReader() and processSSEStream; also reads the X-Execution-Id response header
const response = await fetch(`/api/workflows/${workflowId}/execute`, {
method: 'POST',
headers: {
@@ -285,6 +286,7 @@ export function useExecutionStream() {
sharedAbortControllers.set(workflowId, abortController)
try {
// boundary-raw-fetch: run-from-block endpoint returns an SSE stream consumed via response.body.getReader() and processSSEStream; also reads the X-Execution-Id response header
const response = await fetch(`/api/workflows/${workflowId}/execute`, {
method: 'POST',
headers: {
@@ -358,6 +360,7 @@ export function useExecutionStream() {
const abortController = new AbortController()
sharedAbortControllers.set(workflowId, abortController)
try {
// boundary-raw-fetch: execution reconnect endpoint returns an SSE stream consumed via response.body.getReader() and processSSEStream
const response = await fetch(
`/api/workflows/${workflowId}/executions/${executionId}/stream?from=${fromEventId}`,
{ signal: abortController.signal }
+6 -8
View File
@@ -3,6 +3,8 @@
import { useEffect, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { toast } from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import { listWorkspaceCredentialsContract } from '@/lib/api/contracts'
import {
ADD_CONNECTOR_SEARCH_PARAM,
consumeOAuthReturnContext,
@@ -21,14 +23,10 @@ async function resolveOAuthMessage(ctx: OAuthReturnContext): Promise<string> {
}
try {
const response = await fetch(
`/api/credentials?workspaceId=${encodeURIComponent(ctx.workspaceId)}&type=oauth`
)
const data = response.ok ? await response.json() : { credentials: [] }
const oauthCredentials = (data.credentials ?? []) as Array<{
displayName: string
providerId: string | null
}>
const data = await requestJson(listWorkspaceCredentialsContract, {
query: { workspaceId: ctx.workspaceId, type: 'oauth' },
})
const oauthCredentials = data.credentials ?? []
const forProvider = oauthCredentials.filter((c) => c.providerId === ctx.providerId)
if (forProvider.length > ctx.preCount) {
+13 -3
View File
@@ -3,6 +3,9 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { getAllowedIntegrationsContract } from '@/lib/api/contracts/common'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import {
DEFAULT_PERMISSION_GROUP_CONFIG,
@@ -30,9 +33,16 @@ function useAllowedIntegrationsFromEnv() {
return useQuery<AllowedIntegrationsResponse>({
queryKey: ['allowedIntegrations', 'env'],
queryFn: async ({ signal }) => {
const response = await fetch('/api/settings/allowed-integrations', { signal })
if (!response.ok) return { allowedIntegrations: null }
return response.json()
try {
return await requestJson(getAllowedIntegrationsContract, { signal })
} catch (error) {
// Treat any auth/server failure as "no env allowlist configured"
// so the UI falls back to the permission-group-driven allowlist.
if (error instanceof ApiClientError) {
return { allowedIntegrations: null }
}
throw error
}
},
staleTime: 5 * 60 * 1000,
})
+13 -12
View File
@@ -2,6 +2,10 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { getVoiceSettingsContract } from '@/lib/api/contracts/common'
import { speechTokenContract } from '@/lib/api/contracts/media-tools'
import { arrayBufferToBase64, floatTo16BitPCM } from '@/lib/speech/audio'
import {
CHUNK_SEND_INTERVAL_MS,
@@ -73,8 +77,7 @@ export function useSpeechToText({
return
}
fetch('/api/settings/voice', { credentials: 'include' })
.then((r) => (r.ok ? r.json() : { sttAvailable: false }))
requestJson(getVoiceSettingsContract, {})
.then((data) => {
if (mountedRef.current) setIsSupported(data.sttAvailable === true)
})
@@ -163,21 +166,19 @@ export function useSpeechToText({
startingRef.current = true
try {
const tokenResponse = await fetch('/api/speech/token', {
method: 'POST',
credentials: 'include',
})
if (!tokenResponse.ok) {
if (tokenResponse.status === 402) {
let tokenData: Awaited<ReturnType<typeof requestJson<typeof speechTokenContract>>>
try {
tokenData = await requestJson(speechTokenContract, { body: {} })
} catch (err) {
if (isApiClientError(err) && err.status === 402) {
onUsageLimitExceededRef.current?.()
return false
}
const body = await tokenResponse.json().catch(() => ({}))
throw new Error(body.error || 'Failed to get speech token')
throw err instanceof Error ? err : new Error('Failed to get speech token')
}
const { token } = await tokenResponse.json()
const token = typeof tokenData.token === 'string' ? tokenData.token : undefined
if (!token) throw new Error('Failed to get speech token')
if (!mountedRef.current) return false
const stream = await navigator.mediaDevices.getUserMedia({
+2
View File
@@ -70,6 +70,7 @@ if (typeof window !== 'undefined') {
const sent = navigator.sendBeacon('/api/telemetry', payload)
if (!sent) {
// boundary-raw-fetch: pre-bootstrap instrumentation
fetch('/api/telemetry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -80,6 +81,7 @@ if (typeof window !== 'undefined') {
})
}
} else {
// boundary-raw-fetch: pre-bootstrap instrumentation
fetch('/api/telemetry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+6 -1
View File
@@ -10,7 +10,12 @@ import type {
EmptySchemaOutput,
} from '@/lib/api/contracts'
type MaybeField<Key extends string, Value> = Value extends undefined
// Tuple-wrapped to suppress distributive conditionals: when `Value` is a
// union (e.g. a discriminated union body), naked `Value extends undefined`
// distributes and produces `{ body: A } | { body: B }` instead of
// `{ body: A | B }`. The `[Value] extends [undefined]` form preserves the
// union as-is. See request.test.ts for repro and rationale.
type MaybeField<Key extends string, Value> = [Value] extends [undefined]
? { [K in Key]?: never }
: { [K in Key]: Value }
+37 -7
View File
@@ -77,6 +77,17 @@ export const v1AdminAuditLogsQuerySchema = z.object({
endDate: z.preprocess((value) => (value === '' ? undefined : value), isoDateString.optional()),
})
/**
* Generic wrapper used by v1 admin audit-log responses. The `data` and
* `limits` halves are intentionally `z.unknown()` because this proxy returns
* provider-shaped payloads that vary per route family; tightening here would
* require a discriminated union per route, which is tracked as a follow-up.
*
* boundary-policy: this is the "validates nothing" alias form that the audit
* script's `untyped-response` regex doesn't currently catch. Treat any new
* wrapper of this shape the same way and either annotate at the contract use
* site with `// untyped-response: <reason>` or replace with a concrete schema.
*/
const apiResponseWithLimitsSchema = z
.object({
data: z.unknown(),
@@ -84,19 +95,38 @@ const apiResponseWithLimitsSchema = z
})
.passthrough()
export const enterpriseAuditLogEntrySchema = z.object({
id: z.string(),
workspaceId: z.string().nullable(),
actorId: z.string().nullable(),
actorName: z.string().nullable(),
actorEmail: z.string().nullable(),
action: z.string(),
resourceType: z.string(),
resourceId: z.string().nullable(),
resourceName: z.string().nullable(),
description: z.string().nullable(),
metadata: z.unknown(),
createdAt: z.string(),
})
export type EnterpriseAuditLogEntry = z.output<typeof enterpriseAuditLogEntrySchema>
export const listAuditLogsResponseSchema = z.object({
success: z.boolean(),
data: z.array(enterpriseAuditLogEntrySchema),
nextCursor: z.string().optional(),
})
export type AuditLogPage = z.output<typeof listAuditLogsResponseSchema>
export const listAuditLogsContract = defineRouteContract({
method: 'GET',
path: '/api/audit-logs',
query: auditLogsQuerySchema,
response: {
mode: 'json',
schema: z
.object({
success: z.literal(true),
data: z.array(z.unknown()),
nextCursor: z.string().nullable().optional(),
})
.passthrough(),
schema: listAuditLogsResponseSchema,
},
})
+42
View File
@@ -1,10 +1,17 @@
import { z } from 'zod'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const ssoProvidersQuerySchema = z.object({
organizationId: z.string().min(1).optional(),
})
export const authProviderStatusResponseSchema = z.object({
githubAvailable: z.boolean(),
googleAvailable: z.boolean(),
registrationDisabled: z.boolean(),
})
const ssoMappingSchema = z
.object({
id: z.string().default('sub'),
@@ -81,3 +88,38 @@ export const ssoRegistrationContract = defineRouteContract({
}),
},
})
const ssoProviderListEntrySchema = z.object({
id: z.string().optional(),
providerId: z.string().optional(),
domain: z.string().nullable(),
issuer: z.string().nullable().optional(),
oidcConfig: z.string().nullable().optional(),
samlConfig: z.string().nullable().optional(),
userId: z.string().nullable().optional(),
organizationId: z.string().nullable().optional(),
providerType: z.enum(['oidc', 'saml']).optional(),
})
export const listSsoProvidersContract = defineRouteContract({
method: 'GET',
path: '/api/auth/sso/providers',
query: ssoProvidersQuerySchema,
response: {
mode: 'json',
schema: z.object({
providers: z.array(ssoProviderListEntrySchema),
}),
},
})
export const getAuthProvidersContract = defineRouteContract({
method: 'GET',
path: '/api/auth/providers',
response: {
mode: 'json',
schema: authProviderStatusResponseSchema,
},
})
export type AuthProviderStatusResponse = ContractJsonResponse<typeof getAuthProvidersContract>
+13
View File
@@ -57,6 +57,19 @@ export const getAllowedProvidersContract = defineRouteContract({
},
})
export const getAllowedIntegrationsContract = defineRouteContract({
method: 'GET',
path: '/api/settings/allowed-integrations',
response: {
mode: 'json',
schema: z.object({
// `null` means "no env-derived allowlist" (unrestricted); a non-null
// array narrows the visible integrations.
allowedIntegrations: z.array(z.string()).nullable(),
}),
},
})
export const getVoiceSettingsContract = defineRouteContract({
method: 'GET',
path: '/api/settings/voice',
+10 -1
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import type { ContractBodyInput } from '@/lib/api/contracts/types'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { NO_EMAIL_HEADER_CONTROL_CHARS_REGEX } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
@@ -64,6 +65,12 @@ export const contactRequestSchema = z.object({
export type ContactRequestPayload = z.infer<typeof contactRequestSchema>
export type ContactRequestBody = z.input<typeof contactRequestSchema>
export const submitContactBodySchema = contactRequestSchema.extend({
website: z.string().optional(),
captchaToken: z.string().optional(),
captchaUnavailable: z.boolean().optional(),
})
export function getContactTopicLabel(value: ContactRequestPayload['topic']): string {
return CONTACT_TOPIC_OPTIONS.find((option) => option.value === value)?.label ?? value
}
@@ -91,9 +98,11 @@ export const contactResponseSchema = z.object({
export const submitContactContract = defineRouteContract({
method: 'POST',
path: '/api/contact',
body: contactRequestSchema,
body: submitContactBodySchema,
response: {
mode: 'json',
schema: contactResponseSchema,
},
})
export type SubmitContactBody = ContractBodyInput<typeof submitContactContract>
@@ -63,6 +63,13 @@ export const credentialSetInvitationDetailSchema = z.object({
invitedBy: z.string(),
})
export const credentialSetInvitePreviewSchema = z.object({
credentialSetName: z.string(),
organizationName: z.string(),
providerId: z.string().nullable(),
email: z.string().nullable(),
})
export const credentialSetMemberSchema = z.object({
id: z.string(),
userId: z.string(),
@@ -85,6 +92,7 @@ export type CredentialSetMembership = z.output<typeof credentialSetMembershipSch
export type CredentialSetInvitation = z.output<typeof credentialSetInvitationSchema>
export type CredentialSetMember = z.output<typeof credentialSetMemberSchema>
export type CredentialSetInvitationDetail = z.output<typeof credentialSetInvitationDetailSchema>
export type CredentialSetInvitePreview = z.output<typeof credentialSetInvitePreviewSchema>
export const listCredentialSetsQuerySchema = z.object({
organizationId: z.string().min(1),
@@ -242,6 +250,18 @@ export const listCredentialSetInvitationDetailsContract = defineRouteContract({
},
})
export const getCredentialSetInvitationContract = defineRouteContract({
method: 'GET',
path: '/api/credential-sets/invite/[token]',
params: credentialSetInviteTokenParamsSchema,
response: {
mode: 'json',
schema: z.object({
invitation: credentialSetInvitePreviewSchema,
}),
},
})
export const acceptCredentialSetInvitationContract = defineRouteContract({
method: 'POST',
path: '/api/credential-sets/invite/[token]',
+3 -3
View File
@@ -3,7 +3,7 @@ import { defineRouteContract } from '@/lib/api/contracts/types'
import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const workflowStateSchema = z.custom<WorkflowState>(
const deployedWorkflowStateSchema = z.custom<WorkflowState>(
(value) => typeof value === 'object' && value !== null,
'Expected workflow state'
)
@@ -157,7 +157,7 @@ export const updatePublicApiResponseSchema = z.object({
export type UpdatePublicApiResponse = z.output<typeof updatePublicApiResponseSchema>
export const deployedWorkflowStateResponseSchema = z.object({
deployedState: workflowStateSchema.nullable(),
deployedState: deployedWorkflowStateSchema.nullable(),
})
export type DeployedWorkflowStateResponse = z.output<typeof deployedWorkflowStateResponseSchema>
@@ -251,7 +251,7 @@ export const getDeploymentVersionStateContract = defineRouteContract({
response: {
mode: 'json',
schema: z.object({
deployedState: workflowStateSchema,
deployedState: deployedWorkflowStateSchema,
}),
},
})
+69
View File
@@ -69,6 +69,39 @@ export const invitationActionBodySchema = z.object({
token: z.string().min(1).optional(),
})
export const invitationDetailsSchema = z.object({
id: z.string(),
kind: z.enum(['organization', 'workspace']),
email: z.string(),
organizationId: z.string().nullable(),
organizationName: z.string().nullable(),
membershipIntent: z.enum(['internal', 'external']),
role: z.string(),
status: z.string(),
expiresAt: z.string(),
createdAt: z.string(),
inviterName: z.string().nullable(),
inviterEmail: z.string().nullable(),
grants: z.array(
z.object({
workspaceId: z.string(),
workspaceName: z.string().nullable(),
permission: workspacePermissionSchema,
})
),
})
export const acceptInvitationResponseSchema = z.object({
success: z.literal(true),
redirectPath: z.string(),
invitation: z.object({
id: z.string(),
kind: z.enum(['organization', 'workspace']),
organizationId: z.string().nullable(),
acceptedWorkspaceIds: z.array(z.string()),
}),
})
const successResponseSchema = z
.object({
success: z.boolean(),
@@ -107,6 +140,41 @@ export const updateInvitationContract = defineRouteContract({
},
})
export const getInvitationContract = defineRouteContract({
method: 'GET',
path: '/api/invitations/[id]',
params: invitationParamsSchema,
query: invitationQuerySchema,
response: {
mode: 'json',
schema: z.object({
invitation: invitationDetailsSchema,
}),
},
})
export const acceptInvitationContract = defineRouteContract({
method: 'POST',
path: '/api/invitations/[id]/accept',
params: invitationActionParamsSchema,
body: invitationActionBodySchema,
response: {
mode: 'json',
schema: acceptInvitationResponseSchema,
},
})
export const rejectInvitationContract = defineRouteContract({
method: 'POST',
path: '/api/invitations/[id]/reject',
params: invitationActionParamsSchema,
body: invitationActionBodySchema,
response: {
mode: 'json',
schema: successResponseSchema,
},
})
export const cancelInvitationContract = defineRouteContract({
method: 'DELETE',
path: '/api/invitations/[id]',
@@ -140,3 +208,4 @@ export const removeWorkspaceMemberContract = defineRouteContract({
export type PendingInvitationRow = z.infer<typeof pendingWorkspaceInvitationSchema>
export type BatchInvitationResult = z.infer<typeof batchInvitationResultSchema>
export type InvitationDetails = z.infer<typeof invitationDetailsSchema>
@@ -109,6 +109,16 @@ export const createKnowledgeChunkContract = defineRouteContract({
},
})
export const getKnowledgeChunkContract = defineRouteContract({
method: 'GET',
path: '/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]',
params: knowledgeChunkParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema(chunkDataSchema),
},
})
export const updateKnowledgeChunkContract = defineRouteContract({
method: 'PUT',
path: '/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]',
+16 -1
View File
@@ -62,6 +62,21 @@ export const saveDocumentTagDefinitionsDataSchema = z
.or(z.array(tagDefinitionDataSchema))
export type SaveDocumentTagDefinitionsResult = z.output<typeof saveDocumentTagDefinitionsDataSchema>
export const tagUsageDocumentSchema = z.object({
id: z.string(),
name: z.string(),
tagValue: z.string(),
})
export const tagUsageDataSchema = z.object({
tagName: z.string(),
tagSlot: z.string(),
documentCount: z.number(),
documents: z.array(tagUsageDocumentSchema),
})
export type TagUsageData = z.output<typeof tagUsageDataSchema>
export const listTagDefinitionsContract = defineRouteContract({
method: 'GET',
path: '/api/knowledge/[id]/tag-definitions',
@@ -141,6 +156,6 @@ export const getTagUsageContract = defineRouteContract({
params: knowledgeBaseParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema(z.unknown()),
schema: successResponseSchema(z.array(tagUsageDataSchema)),
},
})
+3 -2
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { booleanQueryFlagSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
const comparisonOperatorSchema = z.enum(['=', '>', '<', '>=', '<=', '!='])
@@ -131,8 +132,8 @@ export const v1ListLogsQuerySchema = z.object({
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),
includeTraceSpans: booleanQueryFlagSchema.optional().default(false),
includeFinalOutput: booleanQueryFlagSchema.optional().default(false),
limit: z.coerce.number().optional().default(100),
cursor: z.string().optional(),
order: z.enum(['desc', 'asc']).optional().default('desc'),
+14 -2
View File
@@ -333,7 +333,19 @@ export const fileManageBodySchema = z.discriminatedUnion('operation', [
fileManageAppendBodySchema,
])
const toolJsonResponseSchema = z.record(z.string(), z.unknown())
const toolJsonResponseSchema = z
.object({
success: z.boolean().optional(),
output: z.unknown().optional(),
error: z.string().optional(),
message: z.string().optional(),
data: z.unknown().optional(),
})
.passthrough()
export const speechTokenResponseSchema = z.object({
token: z.string(),
})
export const imageProxyContract = defineRouteContract({
method: 'GET',
@@ -423,7 +435,7 @@ export const speechTokenContract = defineRouteContract({
method: 'POST',
path: '/api/speech/token',
body: speechTokenBodySchema,
response: { mode: 'json', schema: toolJsonResponseSchema },
response: { mode: 'json', schema: speechTokenResponseSchema },
})
export const fileManageContract = defineRouteContract({
@@ -122,6 +122,17 @@ export const oauthAuthorizeParamsQuerySchema = z.object({
consent_code: z.string({ error: 'consent_code is required' }).min(1, 'consent_code is required'),
})
export const oauthAuthorizeParamsResponseSchema = z.object({
client_id: z.string(),
redirect_uri: z.string(),
scope: z.string(),
code_challenge: z.string(),
code_challenge_method: z.string(),
state: z.string().nullable(),
nonce: z.string().nullable(),
response_type: z.literal('code'),
})
const SHOPIFY_SHOP_DOMAIN_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/
export const shopifyShopDomainSchema = z.string().regex(SHOPIFY_SHOP_DOMAIN_REGEX)
@@ -136,6 +147,16 @@ export const listOAuthConnectionsContract = defineRouteContract({
},
})
export const oauthAuthorizeParamsContract = defineRouteContract({
method: 'GET',
path: '/api/auth/oauth2/authorize-params',
query: oauthAuthorizeParamsQuerySchema,
response: {
mode: 'json',
schema: oauthAuthorizeParamsResponseSchema,
},
})
export const disconnectOAuthContract = defineRouteContract({
method: 'POST',
path: '/api/auth/oauth/disconnect',
@@ -187,3 +208,4 @@ export const trelloCallbackContract = defineRouteContract({
export type StoreTrelloTokenBody = ContractBody<typeof storeTrelloTokenContract>
export type StoreTrelloTokenBodyInput = ContractBodyInput<typeof storeTrelloTokenContract>
export type StoreTrelloTokenResponse = ContractJsonResponse<typeof storeTrelloTokenContract>
export type OAuthAuthorizeParamsResponse = ContractJsonResponse<typeof oauthAuthorizeParamsContract>

Some files were not shown because too many files have changed in this diff Show More