improvement(routes): type all untyped routes (#1848)

* improvement(routes): type all untyped routes

* fix routes, remove unused workspace members route

* fix obfuscation of errors behind zod errors

* remove extraneous comments
This commit is contained in:
Waleed
2025-11-07 15:24:30 -08:00
committed by GitHub
parent c4278266ef
commit bb7016a99f
48 changed files with 1043 additions and 558 deletions

View File

@@ -163,6 +163,31 @@ export function SetNewPasswordForm({
return return
} }
if (password.length > 100) {
setValidationMessage('Password must not exceed 100 characters')
return
}
if (!/[A-Z]/.test(password)) {
setValidationMessage('Password must contain at least one uppercase letter')
return
}
if (!/[a-z]/.test(password)) {
setValidationMessage('Password must contain at least one lowercase letter')
return
}
if (!/[0-9]/.test(password)) {
setValidationMessage('Password must contain at least one number')
return
}
if (!/[^A-Za-z0-9]/.test(password)) {
setValidationMessage('Password must contain at least one special character')
return
}
if (password !== confirmPassword) { if (password !== confirmPassword) {
setValidationMessage('Passwords do not match') setValidationMessage('Passwords do not match')
return return

View File

@@ -104,7 +104,7 @@ describe('Forget Password API Route', () => {
const data = await response.json() const data = await response.json()
expect(response.status).toBe(400) expect(response.status).toBe(400)
expect(data.message).toBe('Email is required') expect(data.message).toBe('Please provide a valid email address')
const auth = await import('@/lib/auth') const auth = await import('@/lib/auth')
expect(auth.auth.api.forgetPassword).not.toHaveBeenCalled() expect(auth.auth.api.forgetPassword).not.toHaveBeenCalled()

View File

@@ -1,4 +1,5 @@
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth' import { auth } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
@@ -6,15 +7,36 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('ForgetPasswordAPI') const logger = createLogger('ForgetPasswordAPI')
const forgetPasswordSchema = z.object({
email: z
.string({ required_error: 'Email is required' })
.email('Please provide a valid email address'),
redirectTo: z
.string()
.url('Redirect URL must be a valid URL')
.optional()
.or(z.literal(''))
.transform((val) => (val === '' ? undefined : val)),
})
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const { email, redirectTo } = body
if (!email) { const validationResult = forgetPasswordSchema.safeParse(body)
return NextResponse.json({ message: 'Email is required' }, { status: 400 })
if (!validationResult.success) {
const firstError = validationResult.error.errors[0]
const errorMessage = firstError?.message || 'Invalid request data'
logger.warn('Invalid forget password request data', {
errors: validationResult.error.format(),
})
return NextResponse.json({ message: errorMessage }, { status: 400 })
} }
const { email, redirectTo } = validationResult.data
await auth.api.forgetPassword({ await auth.api.forgetPassword({
body: { body: {
email, email,

View File

@@ -3,9 +3,9 @@ import { account, user, workflow } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { jwtDecode } from 'jwt-decode' import { jwtDecode } from 'jwt-decode'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid' import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import type { OAuthService } from '@/lib/oauth/oauth'
import { parseProvider } from '@/lib/oauth/oauth' import { parseProvider } from '@/lib/oauth/oauth'
import { getUserEntityPermissions } from '@/lib/permissions/utils' import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { generateRequestId } from '@/lib/utils' import { generateRequestId } from '@/lib/utils'
@@ -14,6 +14,17 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('OAuthCredentialsAPI') const logger = createLogger('OAuthCredentialsAPI')
const credentialsQuerySchema = z
.object({
provider: z.string().nullish(),
workflowId: z.string().uuid('Workflow ID must be a valid UUID').nullish(),
credentialId: z.string().uuid('Credential ID must be a valid UUID').nullish(),
})
.refine((data) => data.provider || data.credentialId, {
message: 'Provider or credentialId is required',
path: ['provider'],
})
interface GoogleIdToken { interface GoogleIdToken {
email?: string email?: string
sub?: string sub?: string
@@ -27,11 +38,43 @@ export async function GET(request: NextRequest) {
const requestId = generateRequestId() const requestId = generateRequestId()
try { try {
// Get query params
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const providerParam = searchParams.get('provider') as OAuthService | null const rawQuery = {
const workflowId = searchParams.get('workflowId') provider: searchParams.get('provider'),
const credentialId = searchParams.get('credentialId') workflowId: searchParams.get('workflowId'),
credentialId: searchParams.get('credentialId'),
}
const parseResult = credentialsQuerySchema.safeParse(rawQuery)
if (!parseResult.success) {
const refinementError = parseResult.error.errors.find((err) => err.code === 'custom')
if (refinementError) {
logger.warn(`[${requestId}] Invalid query parameters: ${refinementError.message}`)
return NextResponse.json(
{
error: refinementError.message,
},
{ status: 400 }
)
}
const firstError = parseResult.error.errors[0]
const errorMessage = firstError?.message || 'Validation failed'
logger.warn(`[${requestId}] Invalid query parameters`, {
errors: parseResult.error.errors,
})
return NextResponse.json(
{
error: errorMessage,
},
{ status: 400 }
)
}
const { provider: providerParam, workflowId, credentialId } = parseResult.data
// Authenticate requester (supports session, API key, internal JWT) // Authenticate requester (supports session, API key, internal JWT)
const authResult = await checkHybridAuth(request) const authResult = await checkHybridAuth(request)
@@ -84,11 +127,6 @@ export async function GET(request: NextRequest) {
effectiveUserId = requesterUserId effectiveUserId = requesterUserId
} }
if (!providerParam && !credentialId) {
logger.warn(`[${requestId}] Missing provider parameter`)
return NextResponse.json({ error: 'Provider or credentialId is required' }, { status: 400 })
}
// Parse the provider to get base provider and feature type (if provider is present) // Parse the provider to get base provider and feature type (if provider is present)
const { baseProvider } = parseProvider(providerParam || 'google-default') const { baseProvider } = parseProvider(providerParam || 'google-default')

View File

@@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { account } from '@sim/db/schema' import { account } from '@sim/db/schema'
import { and, eq, like, or } from 'drizzle-orm' import { and, eq, like, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils' import { generateRequestId } from '@/lib/utils'
@@ -10,6 +11,11 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('OAuthDisconnectAPI') const logger = createLogger('OAuthDisconnectAPI')
const disconnectSchema = z.object({
provider: z.string({ required_error: 'Provider is required' }).min(1, 'Provider is required'),
providerId: z.string().optional(),
})
/** /**
* Disconnect an OAuth provider for the current user * Disconnect an OAuth provider for the current user
*/ */
@@ -17,23 +23,34 @@ export async function POST(request: NextRequest) {
const requestId = generateRequestId() const requestId = generateRequestId()
try { try {
// Get the session
const session = await getSession() const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) { if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated disconnect request rejected`) logger.warn(`[${requestId}] Unauthenticated disconnect request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
} }
// Get the provider and providerId from the request body const rawBody = await request.json()
const { provider, providerId } = await request.json() const parseResult = disconnectSchema.safeParse(rawBody)
if (!provider) { if (!parseResult.success) {
logger.warn(`[${requestId}] Missing provider in disconnect request`) const firstError = parseResult.error.errors[0]
return NextResponse.json({ error: 'Provider is required' }, { status: 400 }) const errorMessage = firstError?.message || 'Validation failed'
logger.warn(`[${requestId}] Invalid disconnect request`, {
errors: parseResult.error.errors,
})
return NextResponse.json(
{
error: errorMessage,
},
{ status: 400 }
)
} }
const { provider, providerId } = parseResult.data
logger.info(`[${requestId}] Processing OAuth disconnect request`, { logger.info(`[${requestId}] Processing OAuth disconnect request`, {
provider, provider,
hasProviderId: !!providerId, hasProviderId: !!providerId,

View File

@@ -1,4 +1,5 @@
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkHybridAuth } from '@/lib/auth/hybrid' import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
@@ -9,6 +10,22 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('OAuthTokenAPI') const logger = createLogger('OAuthTokenAPI')
const tokenRequestSchema = z.object({
credentialId: z
.string({ required_error: 'Credential ID is required' })
.min(1, 'Credential ID is required'),
workflowId: z.string().min(1, 'Workflow ID is required').nullish(),
})
const tokenQuerySchema = z.object({
credentialId: z
.string({
required_error: 'Credential ID is required',
invalid_type_error: 'Credential ID is required',
})
.min(1, 'Credential ID is required'),
})
/** /**
* Get an access token for a specific credential * Get an access token for a specific credential
* Supports both session-based authentication (for client-side requests) * Supports both session-based authentication (for client-side requests)
@@ -20,19 +37,31 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] OAuth token API POST request received`) logger.info(`[${requestId}] OAuth token API POST request received`)
try { try {
// Parse request body const rawBody = await request.json()
const body = await request.json() const parseResult = tokenRequestSchema.safeParse(rawBody)
const { credentialId, workflowId } = body
if (!credentialId) { if (!parseResult.success) {
logger.warn(`[${requestId}] Credential ID is required`) const firstError = parseResult.error.errors[0]
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) const errorMessage = firstError?.message || 'Validation failed'
logger.warn(`[${requestId}] Invalid token request`, {
errors: parseResult.error.errors,
})
return NextResponse.json(
{
error: errorMessage,
},
{ status: 400 }
)
} }
const { credentialId, workflowId } = parseResult.data
// We already have workflowId from the parsed body; avoid forcing hybrid auth to re-read it // We already have workflowId from the parsed body; avoid forcing hybrid auth to re-read it
const authz = await authorizeCredentialUse(request, { const authz = await authorizeCredentialUse(request, {
credentialId, credentialId,
workflowId, workflowId: workflowId ?? undefined,
requireWorkflowIdForInternal: false, requireWorkflowIdForInternal: false,
}) })
if (!authz.ok || !authz.credentialOwnerUserId) { if (!authz.ok || !authz.credentialOwnerUserId) {
@@ -63,15 +92,31 @@ export async function GET(request: NextRequest) {
const requestId = generateRequestId() const requestId = generateRequestId()
try { try {
// Get the credential ID from the query params
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId') const rawQuery = {
credentialId: searchParams.get('credentialId'),
if (!credentialId) {
logger.warn(`[${requestId}] Missing credential ID`)
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
} }
const parseResult = tokenQuerySchema.safeParse(rawQuery)
if (!parseResult.success) {
const firstError = parseResult.error.errors[0]
const errorMessage = firstError?.message || 'Validation failed'
logger.warn(`[${requestId}] Invalid query parameters`, {
errors: parseResult.error.errors,
})
return NextResponse.json(
{
error: errorMessage,
},
{ status: 400 }
)
}
const { credentialId } = parseResult.data
// For GET requests, we only support session-based authentication // For GET requests, we only support session-based authentication
const auth = await checkHybridAuth(request, { requireWorkflowId: false }) const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || auth.authType !== 'session' || !auth.userId) { if (!auth.success || auth.authType !== 'session' || !auth.userId) {

View File

@@ -24,7 +24,7 @@ describe('Reset Password API Route', () => {
const req = createMockRequest('POST', { const req = createMockRequest('POST', {
token: 'valid-reset-token', token: 'valid-reset-token',
newPassword: 'newSecurePassword123', newPassword: 'newSecurePassword123!',
}) })
const { POST } = await import('@/app/api/auth/reset-password/route') const { POST } = await import('@/app/api/auth/reset-password/route')
@@ -39,7 +39,7 @@ describe('Reset Password API Route', () => {
expect(auth.auth.api.resetPassword).toHaveBeenCalledWith({ expect(auth.auth.api.resetPassword).toHaveBeenCalledWith({
body: { body: {
token: 'valid-reset-token', token: 'valid-reset-token',
newPassword: 'newSecurePassword123', newPassword: 'newSecurePassword123!',
}, },
method: 'POST', method: 'POST',
}) })
@@ -58,7 +58,7 @@ describe('Reset Password API Route', () => {
const data = await response.json() const data = await response.json()
expect(response.status).toBe(400) expect(response.status).toBe(400)
expect(data.message).toBe('Token and new password are required') expect(data.message).toBe('Token is required')
const auth = await import('@/lib/auth') const auth = await import('@/lib/auth')
expect(auth.auth.api.resetPassword).not.toHaveBeenCalled() expect(auth.auth.api.resetPassword).not.toHaveBeenCalled()
@@ -77,7 +77,7 @@ describe('Reset Password API Route', () => {
const data = await response.json() const data = await response.json()
expect(response.status).toBe(400) expect(response.status).toBe(400)
expect(data.message).toBe('Token and new password are required') expect(data.message).toBe('Password is required')
const auth = await import('@/lib/auth') const auth = await import('@/lib/auth')
expect(auth.auth.api.resetPassword).not.toHaveBeenCalled() expect(auth.auth.api.resetPassword).not.toHaveBeenCalled()
@@ -97,7 +97,7 @@ describe('Reset Password API Route', () => {
const data = await response.json() const data = await response.json()
expect(response.status).toBe(400) expect(response.status).toBe(400)
expect(data.message).toBe('Token and new password are required') expect(data.message).toBe('Token is required')
const auth = await import('@/lib/auth') const auth = await import('@/lib/auth')
expect(auth.auth.api.resetPassword).not.toHaveBeenCalled() expect(auth.auth.api.resetPassword).not.toHaveBeenCalled()
@@ -117,7 +117,7 @@ describe('Reset Password API Route', () => {
const data = await response.json() const data = await response.json()
expect(response.status).toBe(400) expect(response.status).toBe(400)
expect(data.message).toBe('Token and new password are required') expect(data.message).toBe('Password must be at least 8 characters long')
const auth = await import('@/lib/auth') const auth = await import('@/lib/auth')
expect(auth.auth.api.resetPassword).not.toHaveBeenCalled() expect(auth.auth.api.resetPassword).not.toHaveBeenCalled()
@@ -137,7 +137,7 @@ describe('Reset Password API Route', () => {
const req = createMockRequest('POST', { const req = createMockRequest('POST', {
token: 'invalid-token', token: 'invalid-token',
newPassword: 'newSecurePassword123', newPassword: 'newSecurePassword123!',
}) })
const { POST } = await import('@/app/api/auth/reset-password/route') const { POST } = await import('@/app/api/auth/reset-password/route')
@@ -168,7 +168,7 @@ describe('Reset Password API Route', () => {
const req = createMockRequest('POST', { const req = createMockRequest('POST', {
token: 'valid-reset-token', token: 'valid-reset-token',
newPassword: 'newSecurePassword123', newPassword: 'newSecurePassword123!',
}) })
const { POST } = await import('@/app/api/auth/reset-password/route') const { POST } = await import('@/app/api/auth/reset-password/route')

View File

@@ -1,4 +1,5 @@
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth' import { auth } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
@@ -6,15 +7,35 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('PasswordResetAPI') const logger = createLogger('PasswordResetAPI')
const resetPasswordSchema = z.object({
token: z.string({ required_error: 'Token is required' }).min(1, 'Token is required'),
newPassword: z
.string({ required_error: 'Password is required' })
.min(8, 'Password must be at least 8 characters long')
.max(100, 'Password must not exceed 100 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
})
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const { token, newPassword } = body
if (!token || !newPassword) { const validationResult = resetPasswordSchema.safeParse(body)
return NextResponse.json({ message: 'Token and new password are required' }, { status: 400 })
if (!validationResult.success) {
const firstError = validationResult.error.errors[0]
const errorMessage = firstError?.message || 'Invalid request data'
logger.warn('Invalid password reset request data', {
errors: validationResult.error.format(),
})
return NextResponse.json({ message: errorMessage }, { status: 400 })
} }
const { token, newPassword } = validationResult.data
await auth.api.resetPassword({ await auth.api.resetPassword({
body: { body: {
newPassword, newPassword,

View File

@@ -1,68 +1,93 @@
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth' import { auth } from '@/lib/auth'
import { env } from '@/lib/env' import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('SSO-Register') const logger = createLogger('SSO-Register')
const mappingSchema = z
.object({
id: z.string().default('sub'),
email: z.string().default('email'),
name: z.string().default('name'),
image: z.string().default('picture'),
})
.default({
id: 'sub',
email: 'email',
name: 'name',
image: 'picture',
})
const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
z.object({
providerType: z.literal('oidc').default('oidc'),
providerId: z.string().min(1, 'Provider ID is required'),
issuer: z.string().url('Issuer must be a valid URL'),
domain: z.string().min(1, 'Domain is required'),
mapping: mappingSchema,
clientId: z.string().min(1, 'Client ID is required for OIDC'),
clientSecret: z.string().min(1, 'Client Secret is required for OIDC'),
scopes: z
.union([
z.string().transform((s) =>
s
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '')
),
z.array(z.string()),
])
.default(['openid', 'profile', 'email']),
pkce: z.boolean().default(true),
}),
z.object({
providerType: z.literal('saml'),
providerId: z.string().min(1, 'Provider ID is required'),
issuer: z.string().url('Issuer must be a valid URL'),
domain: z.string().min(1, 'Domain is required'),
mapping: mappingSchema,
entryPoint: z.string().url('Entry point must be a valid URL for SAML'),
cert: z.string().min(1, 'Certificate is required for SAML'),
callbackUrl: z.string().url().optional(),
audience: z.string().optional(),
wantAssertionsSigned: z.boolean().optional(),
signatureAlgorithm: z.string().optional(),
digestAlgorithm: z.string().optional(),
identifierFormat: z.string().optional(),
idpMetadata: z.string().optional(),
}),
])
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
if (!env.SSO_ENABLED) { if (!env.SSO_ENABLED) {
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 }) return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
} }
const body = await request.json() const rawBody = await request.json()
const {
providerId, const parseResult = ssoRegistrationSchema.safeParse(rawBody)
issuer,
domain, if (!parseResult.success) {
providerType = 'oidc', const firstError = parseResult.error.errors[0]
// OIDC specific fields const errorMessage = firstError?.message || 'Validation failed'
clientId,
clientSecret, logger.warn('Invalid SSO registration request', {
scopes = ['openid', 'profile', 'email'], errors: parseResult.error.errors,
pkce = true, })
// SAML specific fields
entryPoint,
cert,
callbackUrl,
audience,
wantAssertionsSigned,
signatureAlgorithm,
digestAlgorithm,
identifierFormat,
idpMetadata,
// Mapping configuration
mapping = {
id: 'sub',
email: 'email',
name: 'name',
image: 'picture',
},
} = body
if (!providerId || !issuer || !domain) {
return NextResponse.json( return NextResponse.json(
{ error: 'Missing required fields: providerId, issuer, domain' }, {
error: errorMessage,
},
{ status: 400 } { status: 400 }
) )
} }
if (providerType === 'oidc') { const body = parseResult.data
if (!clientId || !clientSecret) { const { providerId, issuer, domain, providerType, mapping } = body
return NextResponse.json(
{ error: 'Missing required OIDC fields: clientId, clientSecret' },
{ status: 400 }
)
}
} else if (providerType === 'saml') {
if (!entryPoint || !cert) {
return NextResponse.json(
{ error: 'Missing required SAML fields: entryPoint, cert' },
{ status: 400 }
)
}
}
const headers: Record<string, string> = {} const headers: Record<string, string> = {}
request.headers.forEach((value, key) => { request.headers.forEach((value, key) => {
@@ -77,18 +102,14 @@ export async function POST(request: NextRequest) {
} }
if (providerType === 'oidc') { if (providerType === 'oidc') {
const { clientId, clientSecret, scopes, pkce } = body
const oidcConfig: any = { const oidcConfig: any = {
clientId, clientId,
clientSecret, clientSecret,
scopes: scopes: Array.isArray(scopes)
typeof scopes === 'string' ? scopes.filter((s: string) => s !== 'offline_access')
? scopes : ['openid', 'profile', 'email'].filter((s: string) => s !== 'offline_access'),
.split(',')
.map((s: string) => s.trim())
.filter((s: string) => s !== 'offline_access')
: (scopes || ['openid', 'profile', 'email']).filter(
(s: string) => s !== 'offline_access'
),
pkce: pkce ?? true, pkce: pkce ?? true,
} }
@@ -138,6 +159,18 @@ export async function POST(request: NextRequest) {
providerConfig.oidcConfig = oidcConfig providerConfig.oidcConfig = oidcConfig
} else if (providerType === 'saml') { } else if (providerType === 'saml') {
const {
entryPoint,
cert,
callbackUrl,
audience,
wantAssertionsSigned,
signatureAlgorithm,
digestAlgorithm,
identifierFormat,
idpMetadata,
} = body
const computedCallbackUrl = const computedCallbackUrl =
callbackUrl || `${issuer.replace('/metadata', '')}/callback/${providerId}` callbackUrl || `${issuer.replace('/metadata', '')}/callback/${providerId}`

View File

@@ -53,7 +53,6 @@ export async function POST(req: NextRequest) {
) )
} }
// Parse and validate request body
const body = await req.json() const body = await req.json()
const validation = UpdateCostSchema.safeParse(body) const validation = UpdateCostSchema.safeParse(body)

View File

@@ -3,6 +3,7 @@ import { chat, workflow, workspace } from '@sim/db/schema'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { LoggingSession } from '@/lib/logs/execution/logging-session' import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { ChatFiles } from '@/lib/uploads' import { ChatFiles } from '@/lib/uploads'
@@ -17,6 +18,22 @@ import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/
const logger = createLogger('ChatIdentifierAPI') const logger = createLogger('ChatIdentifierAPI')
const chatFileSchema = z.object({
name: z.string().min(1, 'File name is required'),
type: z.string().min(1, 'File type is required'),
size: z.number().positive('File size must be positive'),
data: z.string().min(1, 'File data is required'),
lastModified: z.number().optional(),
})
const chatPostBodySchema = z.object({
input: z.string().optional(),
password: z.string().optional(),
email: z.string().email('Invalid email format').optional().or(z.literal('')),
conversationId: z.string().optional(),
files: z.array(chatFileSchema).optional().default([]),
})
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const runtime = 'nodejs' export const runtime = 'nodejs'
@@ -32,7 +49,21 @@ export async function POST(
let parsedBody let parsedBody
try { try {
parsedBody = await request.json() const rawBody = await request.json()
const validation = chatPostBodySchema.safeParse(rawBody)
if (!validation.success) {
const errorMessage = validation.error.errors
.map((err) => `${err.path.join('.')}: ${err.message}`)
.join(', ')
logger.warn(`[${requestId}] Validation error: ${errorMessage}`)
return addCorsHeaders(
createErrorResponse(`Invalid request body: ${errorMessage}`, 400),
request
)
}
parsedBody = validation.data
} catch (_error) { } catch (_error) {
return addCorsHeaders(createErrorResponse('Invalid request body', 400), request) return addCorsHeaders(createErrorResponse('Invalid request body', 400), request)
} }

View File

@@ -68,7 +68,6 @@ export async function POST(request: NextRequest) {
return createErrorResponse('Unauthorized', 401) return createErrorResponse('Unauthorized', 401)
} }
// Parse and validate request body
const body = await request.json() const body = await request.json()
try { try {

View File

@@ -2,11 +2,20 @@ import { db } from '@sim/db'
import { chat } from '@sim/db/schema' import { chat } from '@sim/db/schema'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('ChatValidateAPI') const logger = createLogger('ChatValidateAPI')
const validateQuerySchema = z.object({
identifier: z
.string()
.min(1, 'Identifier is required')
.regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens')
.max(100, 'Identifier must be 100 characters or less'),
})
/** /**
* GET endpoint to validate chat identifier availability * GET endpoint to validate chat identifier availability
*/ */
@@ -15,27 +24,34 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const identifier = searchParams.get('identifier') const identifier = searchParams.get('identifier')
if (!identifier) { const validation = validateQuerySchema.safeParse({ identifier })
return createErrorResponse('Identifier parameter is required', 400)
if (!validation.success) {
const errorMessage = validation.error.errors[0]?.message || 'Invalid identifier'
logger.warn(`Validation error: ${errorMessage}`)
if (identifier && !/^[a-z0-9-]+$/.test(identifier)) {
return createSuccessResponse({
available: false,
error: errorMessage,
})
}
return createErrorResponse(errorMessage, 400)
} }
if (!/^[a-z0-9-]+$/.test(identifier)) { const { identifier: validatedIdentifier } = validation.data
return createSuccessResponse({
available: false,
error: 'Identifier can only contain lowercase letters, numbers, and hyphens',
})
}
const existingChat = await db const existingChat = await db
.select({ id: chat.id }) .select({ id: chat.id })
.from(chat) .from(chat)
.where(eq(chat.identifier, identifier)) .where(eq(chat.identifier, validatedIdentifier))
.limit(1) .limit(1)
const isAvailable = existingChat.length === 0 const isAvailable = existingChat.length === 0
logger.debug( logger.debug(
`Identifier "${identifier}" availability check: ${isAvailable ? 'available' : 'taken'}` `Identifier "${validatedIdentifier}" availability check: ${isAvailable ? 'available' : 'taken'}`
) )
return createSuccessResponse({ return createSuccessResponse({

View File

@@ -1,8 +1,11 @@
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { env } from '@/lib/env' import { env } from '@/lib/env'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent/constants' import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent/constants'
const GenerateApiKeySchema = z.object({}).optional()
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const session = await getSession() const session = await getSession()
@@ -15,7 +18,18 @@ export async function POST(req: NextRequest) {
// Move environment variable access inside the function // Move environment variable access inside the function
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
await req.json().catch(() => ({})) const body = await req.json().catch(() => ({}))
const validationResult = GenerateApiKeySchema.safeParse(body)
if (!validationResult.success) {
return NextResponse.json(
{
error: 'Invalid request body',
details: validationResult.error.errors,
},
{ status: 400 }
)
}
const res = await fetch(`${SIM_AGENT_API_URL}/api/validate-key/generate`, { const res = await fetch(`${SIM_AGENT_API_URL}/api/validate-key/generate`, {
method: 'POST', method: 'POST',

View File

@@ -1,25 +1,39 @@
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor'
import { checkInternalApiKey } from '@/lib/copilot/utils' import { checkInternalApiKey } from '@/lib/copilot/utils'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('CopilotApiKeysValidate') const logger = createLogger('CopilotApiKeysValidate')
const ValidateApiKeySchema = z.object({
userId: z.string().min(1, 'userId is required'),
})
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
// Authenticate via internal API key header
const auth = checkInternalApiKey(req) const auth = checkInternalApiKey(req)
if (!auth.success) { if (!auth.success) {
return new NextResponse(null, { status: 401 }) return new NextResponse(null, { status: 401 })
} }
const body = await req.json().catch(() => null) const body = await req.json().catch(() => null)
const userId = typeof body?.userId === 'string' ? body.userId : undefined
if (!userId) { const validationResult = ValidateApiKeySchema.safeParse(body)
return NextResponse.json({ error: 'userId is required' }, { status: 400 })
if (!validationResult.success) {
logger.warn('Invalid validation request', { errors: validationResult.error.errors })
return NextResponse.json(
{
error: 'userId is required',
details: validationResult.error.errors,
},
{ status: 400 }
)
} }
const { userId } = validationResult.data
logger.info('[API VALIDATION] Validating usage limit', { userId }) logger.info('[API VALIDATION] Validating usage limit', { userId })
const { isExceeded, currentUsage, limit } = await checkServerSideUsageLimits(userId) const { isExceeded, currentUsage, limit } = await checkServerSideUsageLimits(userId)

View File

@@ -1,4 +1,5 @@
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { env } from '@/lib/env' import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
@@ -7,6 +8,13 @@ const logger = createLogger('CopilotTrainingExamplesAPI')
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
const TrainingExampleSchema = z.object({
json: z.string().min(1, 'JSON string is required'),
title: z.string().min(1, 'Title is required'),
tags: z.array(z.string()).optional(),
metadata: z.record(z.unknown()).optional(),
})
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const baseUrl = env.AGENT_INDEXER_URL const baseUrl = env.AGENT_INDEXER_URL
if (!baseUrl) { if (!baseUrl) {
@@ -23,8 +31,24 @@ export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const validationResult = TrainingExampleSchema.safeParse(body)
if (!validationResult.success) {
logger.warn('Invalid training example format', { errors: validationResult.error.errors })
return NextResponse.json(
{
error: 'Invalid training example format',
details: validationResult.error.errors,
},
{ status: 400 }
)
}
const validatedData = validationResult.data
logger.info('Sending workflow example to agent indexer', { logger.info('Sending workflow example to agent indexer', {
hasJsonField: typeof body?.json === 'string', hasJsonField: typeof validatedData.json === 'string',
title: validatedData.title,
}) })
const upstream = await fetch(`${baseUrl}/examples/add`, { const upstream = await fetch(`${baseUrl}/examples/add`, {
@@ -33,7 +57,7 @@ export async function POST(request: NextRequest) {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-api-key': apiKey, 'x-api-key': apiKey,
}, },
body: JSON.stringify(body), body: JSON.stringify(validatedData),
}) })
if (!upstream.ok) { if (!upstream.ok) {

View File

@@ -5,18 +5,25 @@ import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('CopilotTrainingAPI') const logger = createLogger('CopilotTrainingAPI')
// Schema for the request body const WorkflowStateSchema = z.record(z.unknown())
const OperationSchema = z.object({
type: z.string(),
data: z.record(z.unknown()).optional(),
timestamp: z.number().optional(),
metadata: z.record(z.unknown()).optional(),
})
const TrainingDataSchema = z.object({ const TrainingDataSchema = z.object({
title: z.string().min(1), title: z.string().min(1, 'Title is required'),
prompt: z.string().min(1), prompt: z.string().min(1, 'Prompt is required'),
input: z.any(), // Workflow state (start) input: WorkflowStateSchema,
output: z.any(), // Workflow state (end) output: WorkflowStateSchema,
operations: z.any(), operations: z.array(OperationSchema),
}) })
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
// Check for required environment variables
const baseUrl = env.AGENT_INDEXER_URL const baseUrl = env.AGENT_INDEXER_URL
if (!baseUrl) { if (!baseUrl) {
logger.error('Missing AGENT_INDEXER_URL environment variable') logger.error('Missing AGENT_INDEXER_URL environment variable')
@@ -32,7 +39,6 @@ export async function POST(request: NextRequest) {
) )
} }
// Parse and validate request body
const body = await request.json() const body = await request.json()
const validationResult = TrainingDataSchema.safeParse(body) const validationResult = TrainingDataSchema.safeParse(body)
@@ -51,10 +57,9 @@ export async function POST(request: NextRequest) {
logger.info('Sending training data to agent indexer', { logger.info('Sending training data to agent indexer', {
title, title,
operationsCount: Array.isArray(operations) ? operations.length : 0, operationsCount: operations.length,
}) })
// Forward to agent indexer
const upstreamUrl = `${baseUrl}/operations/add` const upstreamUrl = `${baseUrl}/operations/add`
const upstreamResponse = await fetch(upstreamUrl, { const upstreamResponse = await fetch(upstreamUrl, {
method: 'POST', method: 'POST',

View File

@@ -2,12 +2,20 @@ import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema' import { workflow, workflowFolder } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils' import { getUserEntityPermissions } from '@/lib/permissions/utils'
const logger = createLogger('FoldersIDAPI') const logger = createLogger('FoldersIDAPI')
const updateFolderSchema = z.object({
name: z.string().optional(),
color: z.string().optional(),
isExpanded: z.boolean().optional(),
parentId: z.string().nullable().optional(),
})
// PUT - Update a folder // PUT - Update a folder
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try { try {
@@ -18,7 +26,19 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const { id } = await params const { id } = await params
const body = await request.json() const body = await request.json()
const { name, color, isExpanded, parentId } = body
const validationResult = updateFolderSchema.safeParse(body)
if (!validationResult.success) {
logger.error('Folder update validation failed:', {
errors: validationResult.error.errors,
})
const errorMessages = validationResult.error.errors
.map((err) => `${err.path.join('.')}: ${err.message}`)
.join(', ')
return NextResponse.json({ error: `Validation failed: ${errorMessages}` }, { status: 400 })
}
const { name, color, isExpanded, parentId } = validationResult.data
// Verify the folder exists // Verify the folder exists
const existingFolder = await db const existingFolder = await db

View File

@@ -2,11 +2,32 @@ import { db } from '@sim/db'
import { memory } from '@sim/db/schema' import { memory } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils' import { generateRequestId } from '@/lib/utils'
const logger = createLogger('MemoryByIdAPI') const logger = createLogger('MemoryByIdAPI')
const memoryQuerySchema = z.object({
workflowId: z.string().uuid('Invalid workflow ID format'),
})
const agentMemoryDataSchema = z.object({
role: z.enum(['user', 'assistant', 'system'], {
errorMap: () => ({ message: 'Role must be user, assistant, or system' }),
}),
content: z.string().min(1, 'Content is required'),
})
const genericMemoryDataSchema = z.record(z.unknown())
const memoryPutBodySchema = z.object({
data: z.union([agentMemoryDataSchema, genericMemoryDataSchema], {
errorMap: () => ({ message: 'Invalid memory data structure' }),
}),
workflowId: z.string().uuid('Invalid workflow ID format'),
})
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const runtime = 'nodejs' export const runtime = 'nodejs'
@@ -24,29 +45,36 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const url = new URL(request.url) const url = new URL(request.url)
const workflowId = url.searchParams.get('workflowId') const workflowId = url.searchParams.get('workflowId')
if (!workflowId) { const validation = memoryQuerySchema.safeParse({ workflowId })
logger.warn(`[${requestId}] Missing required parameter: workflowId`)
if (!validation.success) {
const errorMessage = validation.error.errors
.map((err) => `${err.path.join('.')}: ${err.message}`)
.join(', ')
logger.warn(`[${requestId}] Validation error: ${errorMessage}`)
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: { error: {
message: 'workflowId parameter is required', message: errorMessage,
}, },
}, },
{ status: 400 } { status: 400 }
) )
} }
const { workflowId: validatedWorkflowId } = validation.data
// Query the database for the memory // Query the database for the memory
const memories = await db const memories = await db
.select() .select()
.from(memory) .from(memory)
.where(and(eq(memory.key, id), eq(memory.workflowId, workflowId))) .where(and(eq(memory.key, id), eq(memory.workflowId, validatedWorkflowId)))
.orderBy(memory.createdAt) .orderBy(memory.createdAt)
.limit(1) .limit(1)
if (memories.length === 0) { if (memories.length === 0) {
logger.warn(`[${requestId}] Memory not found: ${id} for workflow: ${workflowId}`) logger.warn(`[${requestId}] Memory not found: ${id} for workflow: ${validatedWorkflowId}`)
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
@@ -58,7 +86,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
) )
} }
logger.info(`[${requestId}] Memory retrieved successfully: ${id} for workflow: ${workflowId}`) logger.info(
`[${requestId}] Memory retrieved successfully: ${id} for workflow: ${validatedWorkflowId}`
)
return NextResponse.json( return NextResponse.json(
{ {
success: true, success: true,
@@ -96,28 +126,35 @@ export async function DELETE(
const url = new URL(request.url) const url = new URL(request.url)
const workflowId = url.searchParams.get('workflowId') const workflowId = url.searchParams.get('workflowId')
if (!workflowId) { const validation = memoryQuerySchema.safeParse({ workflowId })
logger.warn(`[${requestId}] Missing required parameter: workflowId`)
if (!validation.success) {
const errorMessage = validation.error.errors
.map((err) => `${err.path.join('.')}: ${err.message}`)
.join(', ')
logger.warn(`[${requestId}] Validation error: ${errorMessage}`)
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: { error: {
message: 'workflowId parameter is required', message: errorMessage,
}, },
}, },
{ status: 400 } { status: 400 }
) )
} }
const { workflowId: validatedWorkflowId } = validation.data
// Verify memory exists before attempting to delete // Verify memory exists before attempting to delete
const existingMemory = await db const existingMemory = await db
.select({ id: memory.id }) .select({ id: memory.id })
.from(memory) .from(memory)
.where(and(eq(memory.key, id), eq(memory.workflowId, workflowId))) .where(and(eq(memory.key, id), eq(memory.workflowId, validatedWorkflowId)))
.limit(1) .limit(1)
if (existingMemory.length === 0) { if (existingMemory.length === 0) {
logger.warn(`[${requestId}] Memory not found: ${id} for workflow: ${workflowId}`) logger.warn(`[${requestId}] Memory not found: ${id} for workflow: ${validatedWorkflowId}`)
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
@@ -130,9 +167,13 @@ export async function DELETE(
} }
// Hard delete the memory // Hard delete the memory
await db.delete(memory).where(and(eq(memory.key, id), eq(memory.workflowId, workflowId))) await db
.delete(memory)
.where(and(eq(memory.key, id), eq(memory.workflowId, validatedWorkflowId)))
logger.info(`[${requestId}] Memory deleted successfully: ${id} for workflow: ${workflowId}`) logger.info(
`[${requestId}] Memory deleted successfully: ${id} for workflow: ${validatedWorkflowId}`
)
return NextResponse.json( return NextResponse.json(
{ {
success: true, success: true,
@@ -163,30 +204,37 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
try { try {
logger.info(`[${requestId}] Processing memory update request for ID: ${id}`) logger.info(`[${requestId}] Processing memory update request for ID: ${id}`)
// Parse request body let validatedData
const body = await request.json() let validatedWorkflowId
const { data, workflowId } = body try {
const body = await request.json()
const validation = memoryPutBodySchema.safeParse(body)
if (!data) { if (!validation.success) {
logger.warn(`[${requestId}] Missing required field: data`) const errorMessage = validation.error.errors
return NextResponse.json( .map((err) => `${err.path.join('.')}: ${err.message}`)
{ .join(', ')
success: false, logger.warn(`[${requestId}] Validation error: ${errorMessage}`)
error: { return NextResponse.json(
message: 'Memory data is required', {
success: false,
error: {
message: `Invalid request body: ${errorMessage}`,
},
}, },
}, { status: 400 }
{ status: 400 } )
) }
}
if (!workflowId) { validatedData = validation.data.data
logger.warn(`[${requestId}] Missing required field: workflowId`) validatedWorkflowId = validation.data.workflowId
} catch (error: any) {
logger.warn(`[${requestId}] Failed to parse request body: ${error.message}`)
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: { error: {
message: 'workflowId is required', message: 'Invalid JSON in request body',
}, },
}, },
{ status: 400 } { status: 400 }
@@ -197,11 +245,11 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const existingMemories = await db const existingMemories = await db
.select() .select()
.from(memory) .from(memory)
.where(and(eq(memory.key, id), eq(memory.workflowId, workflowId))) .where(and(eq(memory.key, id), eq(memory.workflowId, validatedWorkflowId)))
.limit(1) .limit(1)
if (existingMemories.length === 0) { if (existingMemories.length === 0) {
logger.warn(`[${requestId}] Memory not found: ${id} for workflow: ${workflowId}`) logger.warn(`[${requestId}] Memory not found: ${id} for workflow: ${validatedWorkflowId}`)
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
@@ -215,28 +263,19 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const existingMemory = existingMemories[0] const existingMemory = existingMemories[0]
// Validate memory data based on the existing memory type // Additional validation for agent memory type
if (existingMemory.type === 'agent') { if (existingMemory.type === 'agent') {
if (!data.role || !data.content) { const agentValidation = agentMemoryDataSchema.safeParse(validatedData)
logger.warn(`[${requestId}] Missing agent memory fields`) if (!agentValidation.success) {
const errorMessage = agentValidation.error.errors
.map((err) => `${err.path.join('.')}: ${err.message}`)
.join(', ')
logger.warn(`[${requestId}] Agent memory validation error: ${errorMessage}`)
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: { error: {
message: 'Agent memory requires role and content', message: `Invalid agent memory data: ${errorMessage}`,
},
},
{ status: 400 }
)
}
if (!['user', 'assistant', 'system'].includes(data.role)) {
logger.warn(`[${requestId}] Invalid agent role: ${data.role}`)
return NextResponse.json(
{
success: false,
error: {
message: 'Agent role must be user, assistant, or system',
}, },
}, },
{ status: 400 } { status: 400 }
@@ -245,16 +284,20 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
} }
// Update the memory with new data // Update the memory with new data
await db.delete(memory).where(and(eq(memory.key, id), eq(memory.workflowId, workflowId))) await db
.delete(memory)
.where(and(eq(memory.key, id), eq(memory.workflowId, validatedWorkflowId)))
// Fetch the updated memory // Fetch the updated memory
const updatedMemories = await db const updatedMemories = await db
.select() .select()
.from(memory) .from(memory)
.where(and(eq(memory.key, id), eq(memory.workflowId, workflowId))) .where(and(eq(memory.key, id), eq(memory.workflowId, validatedWorkflowId)))
.limit(1) .limit(1)
logger.info(`[${requestId}] Memory updated successfully: ${id} for workflow: ${workflowId}`) logger.info(
`[${requestId}] Memory updated successfully: ${id} for workflow: ${validatedWorkflowId}`
)
return NextResponse.json( return NextResponse.json(
{ {
success: true, success: true,

View File

@@ -13,12 +13,19 @@ import {
} from '@sim/db/schema' } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { requireStripeClient } from '@/lib/billing/stripe-client' import { requireStripeClient } from '@/lib/billing/stripe-client'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('OrganizationInvitation') const logger = createLogger('OrganizationInvitation')
const updateInvitationSchema = z.object({
status: z.enum(['accepted', 'rejected', 'cancelled'], {
errorMap: () => ({ message: 'Invalid status. Must be "accepted", "rejected", or "cancelled"' }),
}),
})
// Get invitation details // Get invitation details
export async function GET( export async function GET(
_req: NextRequest, _req: NextRequest,
@@ -84,15 +91,16 @@ export async function PUT(
} }
try { try {
const { status } = await req.json() const body = await req.json()
if (!status || !['accepted', 'rejected', 'cancelled'].includes(status)) { const validation = updateInvitationSchema.safeParse(body)
return NextResponse.json( if (!validation.success) {
{ error: 'Invalid status. Must be "accepted", "rejected", or "cancelled"' }, const firstError = validation.error.errors[0]
{ status: 400 } return NextResponse.json({ error: firstError.message }, { status: 400 })
)
} }
const { status } = validation.data
const orgInvitation = await db const orgInvitation = await db
.select() .select()
.from(invitation) .from(invitation)

View File

@@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { member, subscription as subscriptionTable, user, userStats } from '@sim/db/schema' import { member, subscription as subscriptionTable, user, userStats } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { getUserUsageData } from '@/lib/billing/core/usage' import { getUserUsageData } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client' import { requireStripeClient } from '@/lib/billing/stripe-client'
@@ -9,6 +10,12 @@ import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('OrganizationMemberAPI') const logger = createLogger('OrganizationMemberAPI')
const updateMemberSchema = z.object({
role: z.enum(['owner', 'admin', 'member'], {
errorMap: () => ({ message: 'Invalid role' }),
}),
})
/** /**
* GET /api/organizations/[id]/members/[memberId] * GET /api/organizations/[id]/members/[memberId]
* Get individual organization member details * Get individual organization member details
@@ -141,14 +148,16 @@ export async function PUT(
} }
const { id: organizationId, memberId } = await params const { id: organizationId, memberId } = await params
const { role } = await request.json() const body = await request.json()
// Validate input const validation = updateMemberSchema.safeParse(body)
if (!role || !['admin', 'member'].includes(role)) { if (!validation.success) {
return NextResponse.json({ error: 'Invalid role' }, { status: 400 }) const firstError = validation.error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
} }
// Verify user has admin access const { role } = validation.data
const userMember = await db const userMember = await db
.select() .select()
.from(member) .from(member)

View File

@@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { member, organization } from '@sim/db/schema' import { member, organization } from '@sim/db/schema'
import { and, eq, ne } from 'drizzle-orm' import { and, eq, ne } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { import {
getOrganizationSeatAnalytics, getOrganizationSeatAnalytics,
@@ -12,6 +13,21 @@ import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('OrganizationAPI') const logger = createLogger('OrganizationAPI')
const updateOrganizationSchema = z.object({
name: z.string().trim().min(1, 'Organization name is required').optional(),
slug: z
.string()
.trim()
.min(1, 'Organization slug is required')
.regex(
/^[a-z0-9-_]+$/,
'Slug can only contain lowercase letters, numbers, hyphens, and underscores'
)
.optional(),
logo: z.string().nullable().optional(),
seats: z.number().int().min(1, 'Invalid seat count').optional(),
})
/** /**
* GET /api/organizations/[id] * GET /api/organizations/[id]
* Get organization details including settings and seat information * Get organization details including settings and seat information
@@ -112,7 +128,14 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const { id: organizationId } = await params const { id: organizationId } = await params
const body = await request.json() const body = await request.json()
const { name, slug, logo, seats } = body
const validation = updateOrganizationSchema.safeParse(body)
if (!validation.success) {
const firstError = validation.error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
}
const { name, slug, logo, seats } = validation.data
// Verify user has admin access // Verify user has admin access
const memberEntry = await db const memberEntry = await db
@@ -134,10 +157,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
// Handle seat count update // Handle seat count update
if (seats !== undefined) { if (seats !== undefined) {
if (typeof seats !== 'number' || seats < 1) {
return NextResponse.json({ error: 'Invalid seat count' }, { status: 400 })
}
const result = await updateOrganizationSeats(organizationId, seats, session.user.id) const result = await updateOrganizationSeats(organizationId, seats, session.user.id)
if (!result.success) { if (!result.success) {
@@ -163,28 +182,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
// Handle settings update // Handle settings update
if (name !== undefined || slug !== undefined || logo !== undefined) { if (name !== undefined || slug !== undefined || logo !== undefined) {
// Validate required fields // Check if slug is already taken by another organization
if (name !== undefined && (!name || typeof name !== 'string' || name.trim().length === 0)) {
return NextResponse.json({ error: 'Organization name is required' }, { status: 400 })
}
if (slug !== undefined && (!slug || typeof slug !== 'string' || slug.trim().length === 0)) {
return NextResponse.json({ error: 'Organization slug is required' }, { status: 400 })
}
// Validate slug format
if (slug !== undefined) { if (slug !== undefined) {
const slugRegex = /^[a-z0-9-_]+$/
if (!slugRegex.test(slug)) {
return NextResponse.json(
{
error: 'Slug can only contain lowercase letters, numbers, hyphens, and underscores',
},
{ status: 400 }
)
}
// Check if slug is already taken by another organization
const existingSlug = await db const existingSlug = await db
.select() .select()
.from(organization) .from(organization)
@@ -198,9 +197,9 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
// Build update object with only provided fields // Build update object with only provided fields
const updateData: any = { updatedAt: new Date() } const updateData: any = { updatedAt: new Date() }
if (name !== undefined) updateData.name = name.trim() if (name !== undefined) updateData.name = name
if (slug !== undefined) updateData.slug = slug.trim() if (slug !== undefined) updateData.slug = slug
if (logo !== undefined) updateData.logo = logo || null if (logo !== undefined) updateData.logo = logo
// Update organization // Update organization
const updatedOrg = await db const updatedOrg = await db

View File

@@ -1,5 +1,6 @@
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid' import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateInternalToken } from '@/lib/auth/internal' import { generateInternalToken } from '@/lib/auth/internal'
import { isDev } from '@/lib/environment' import { isDev } from '@/lib/environment'
@@ -12,6 +13,19 @@ import { getTool, validateRequiredParametersAfterMerge } from '@/tools/utils'
const logger = createLogger('ProxyAPI') const logger = createLogger('ProxyAPI')
const proxyPostSchema = z.object({
toolId: z.string().min(1, 'toolId is required'),
params: z.record(z.any()).optional().default({}),
executionContext: z
.object({
workflowId: z.string().optional(),
workspaceId: z.string().optional(),
executionId: z.string().optional(),
userId: z.string().optional(),
})
.optional(),
})
/** /**
* Creates a minimal set of default headers for proxy requests * Creates a minimal set of default headers for proxy requests
* @returns Record of HTTP headers * @returns Record of HTTP headers
@@ -266,13 +280,19 @@ export async function POST(request: NextRequest) {
throw new Error('Invalid JSON in request body') throw new Error('Invalid JSON in request body')
} }
const { toolId, params, executionContext } = requestBody const validationResult = proxyPostSchema.safeParse(requestBody)
if (!validationResult.success) {
if (!toolId) { logger.error(`[${requestId}] Request validation failed`, {
logger.error(`[${requestId}] Missing toolId in request`) errors: validationResult.error.errors,
throw new Error('Missing toolId in request') })
const errorMessages = validationResult.error.errors
.map((err) => `${err.path.join('.')}: ${err.message}`)
.join(', ')
throw new Error(`Validation failed: ${errorMessages}`)
} }
const { toolId, params } = validationResult.data
logger.info(`[${requestId}] Processing tool: ${toolId}`) logger.info(`[${requestId}] Processing tool: ${toolId}`)
const tool = getTool(toolId) const tool = getTool(toolId)
@@ -306,17 +326,12 @@ export async function POST(request: NextRequest) {
(output) => output.type === 'file' || output.type === 'file[]' (output) => output.type === 'file' || output.type === 'file[]'
) )
// Add userId to execution context for file uploads
const contextWithUser = executionContext
? { ...executionContext, userId: authResult.userId }
: undefined
const result = await executeTool( const result = await executeTool(
toolId, toolId,
params, params,
true, // skipProxy (we're already in the proxy) true, // skipProxy (we're already in the proxy)
!hasFileOutputs, // skipPostProcess (don't skip if tool has file outputs) !hasFileOutputs, // skipPostProcess (don't skip if tool has file outputs)
contextWithUser // pass execution context with userId for file processing undefined // execution context is not available in proxy context
) )
if (!result.success) { if (!result.success) {

View File

@@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { workflow, workflowSchedule } from '@sim/db/schema' import { workflow, workflowSchedule } from '@sim/db/schema'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils' import { getUserEntityPermissions } from '@/lib/permissions/utils'
@@ -11,6 +12,18 @@ const logger = createLogger('ScheduleAPI')
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
const scheduleActionEnum = z.enum(['reactivate', 'disable'])
const scheduleStatusEnum = z.enum(['active', 'disabled'])
const scheduleUpdateSchema = z
.object({
action: scheduleActionEnum.optional(),
status: scheduleStatusEnum.optional(),
})
.refine((data) => data.action || data.status, {
message: 'Either action or status must be provided',
})
/** /**
* Delete a schedule * Delete a schedule
*/ */
@@ -99,7 +112,15 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
} }
const body = await request.json() const body = await request.json()
const { action } = body const validation = scheduleUpdateSchema.safeParse(body)
if (!validation.success) {
const firstError = validation.error.errors[0]
logger.warn(`[${requestId}] Validation error:`, firstError)
return NextResponse.json({ error: firstError.message }, { status: 400 })
}
const { action, status: requestedStatus } = validation.data
const [schedule] = await db const [schedule] = await db
.select({ .select({
@@ -143,7 +164,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Not authorized to modify this schedule' }, { status: 403 }) return NextResponse.json({ error: 'Not authorized to modify this schedule' }, { status: 403 })
} }
if (action === 'reactivate' || (body.status && body.status === 'active')) { if (action === 'reactivate' || (requestedStatus && requestedStatus === 'active')) {
if (schedule.status === 'active') { if (schedule.status === 'active') {
return NextResponse.json({ message: 'Schedule is already active' }, { status: 200 }) return NextResponse.json({ message: 'Schedule is already active' }, { status: 200 })
} }
@@ -169,7 +190,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
}) })
} }
if (action === 'disable' || (body.status && body.status === 'disabled')) { if (action === 'disable' || (requestedStatus && requestedStatus === 'disabled')) {
if (schedule.status === 'disabled') { if (schedule.status === 'disabled') {
return NextResponse.json({ message: 'Schedule is already disabled' }, { status: 200 }) return NextResponse.json({ message: 'Schedule is already disabled' }, { status: 200 })
} }

View File

@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils' import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -7,37 +8,55 @@ const logger = createLogger('ConfluenceCommentAPI')
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
const putCommentSchema = z
.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: z.string().optional(),
commentId: z.string().min(1, 'Comment ID is required'),
comment: z.string().min(1, 'Comment is required'),
})
.refine(
(data) => {
const validation = validateAlphanumericId(data.commentId, 'commentId', 255)
return validation.isValid
},
(data) => {
const validation = validateAlphanumericId(data.commentId, 'commentId', 255)
return { message: validation.error || 'Invalid comment ID', path: ['commentId'] }
}
)
const deleteCommentSchema = z
.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: z.string().optional(),
commentId: z.string().min(1, 'Comment ID is required'),
})
.refine(
(data) => {
const validation = validateAlphanumericId(data.commentId, 'commentId', 255)
return validation.isValid
},
(data) => {
const validation = validateAlphanumericId(data.commentId, 'commentId', 255)
return { message: validation.error || 'Invalid comment ID', path: ['commentId'] }
}
)
// Update a comment // Update a comment
export async function PUT(request: Request) { export async function PUT(request: Request) {
try { try {
const { const body = await request.json()
domain,
accessToken,
cloudId: providedCloudId,
commentId,
comment,
} = await request.json()
if (!domain) { const validation = putCommentSchema.safeParse(body)
return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) if (!validation.success) {
const firstError = validation.error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
} }
if (!accessToken) { const { domain, accessToken, cloudId: providedCloudId, commentId, comment } = validation.data
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!commentId) {
return NextResponse.json({ error: 'Comment ID is required' }, { status: 400 })
}
if (!comment) {
return NextResponse.json({ error: 'Comment is required' }, { status: 400 })
}
const commentIdValidation = validateAlphanumericId(commentId, 'commentId', 255)
if (!commentIdValidation.isValid) {
return NextResponse.json({ error: commentIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
@@ -64,7 +83,7 @@ export async function PUT(request: Request) {
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/footer-comments/${commentId}` const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/footer-comments/${commentId}`
const body = { const updateBody = {
body: { body: {
representation: 'storage', representation: 'storage',
value: comment, value: comment,
@@ -82,7 +101,7 @@ export async function PUT(request: Request) {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
}, },
body: JSON.stringify(body), body: JSON.stringify(updateBody),
}) })
if (!response.ok) { if (!response.ok) {
@@ -111,24 +130,15 @@ export async function PUT(request: Request) {
// Delete a comment // Delete a comment
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
try { try {
const { domain, accessToken, cloudId: providedCloudId, commentId } = await request.json() const body = await request.json()
if (!domain) { const validation = deleteCommentSchema.safeParse(body)
return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) if (!validation.success) {
const firstError = validation.error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
} }
if (!accessToken) { const { domain, accessToken, cloudId: providedCloudId, commentId } = validation.data
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!commentId) {
return NextResponse.json({ error: 'Comment ID is required' }, { status: 400 })
}
const commentIdValidation = validateAlphanumericId(commentId, 'commentId', 255)
if (!commentIdValidation.isValid) {
return NextResponse.json({ error: commentIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))

View File

@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils' import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -7,26 +8,82 @@ const logger = createLogger('ConfluencePageAPI')
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
const postPageSchema = z
.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: z.string().optional(),
pageId: z.string().min(1, 'Page ID is required'),
})
.refine(
(data) => {
const validation = validateAlphanumericId(data.pageId, 'pageId', 255)
return validation.isValid
},
(data) => {
const validation = validateAlphanumericId(data.pageId, 'pageId', 255)
return { message: validation.error || 'Invalid page ID', path: ['pageId'] }
}
)
const putPageSchema = z
.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: z.string().optional(),
pageId: z.string().min(1, 'Page ID is required'),
title: z.string().optional(),
body: z
.object({
value: z.string().optional(),
})
.optional(),
version: z
.object({
message: z.string().optional(),
})
.optional(),
})
.refine(
(data) => {
const validation = validateAlphanumericId(data.pageId, 'pageId', 255)
return validation.isValid
},
(data) => {
const validation = validateAlphanumericId(data.pageId, 'pageId', 255)
return { message: validation.error || 'Invalid page ID', path: ['pageId'] }
}
)
const deletePageSchema = z
.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: z.string().optional(),
pageId: z.string().min(1, 'Page ID is required'),
})
.refine(
(data) => {
const validation = validateAlphanumericId(data.pageId, 'pageId', 255)
return validation.isValid
},
(data) => {
const validation = validateAlphanumericId(data.pageId, 'pageId', 255)
return { message: validation.error || 'Invalid page ID', path: ['pageId'] }
}
)
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const { domain, accessToken, pageId, cloudId: providedCloudId } = await request.json() const body = await request.json()
if (!domain) { const validation = postPageSchema.safeParse(body)
return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) if (!validation.success) {
const firstError = validation.error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
} }
if (!accessToken) { const { domain, accessToken, cloudId: providedCloudId, pageId } = validation.data
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
@@ -91,6 +148,12 @@ export async function PUT(request: Request) {
try { try {
const body = await request.json() const body = await request.json()
const validation = putPageSchema.safeParse(body)
if (!validation.success) {
const firstError = validation.error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
}
const { const {
domain, domain,
accessToken, accessToken,
@@ -99,24 +162,7 @@ export async function PUT(request: Request) {
title, title,
body: pageBody, body: pageBody,
version, version,
} = body } = validation.data
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
@@ -204,24 +250,15 @@ export async function PUT(request: Request) {
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
try { try {
const { domain, accessToken, pageId, cloudId: providedCloudId } = await request.json() const body = await request.json()
if (!domain) { const validation = deletePageSchema.safeParse(body)
return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) if (!validation.success) {
const firstError = validation.error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
} }
if (!accessToken) { const { domain, accessToken, cloudId: providedCloudId, pageId } = validation.data
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))

View File

@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/security/input-validation' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/security/input-validation'
import { getJiraCloudId } from '@/tools/jira/utils' import { getJiraCloudId } from '@/tools/jira/utils'
@@ -7,8 +8,30 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JiraUpdateAPI') const logger = createLogger('JiraUpdateAPI')
const jiraUpdateSchema = z.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
issueKey: z.string().min(1, 'Issue key is required'),
summary: z.string().optional(),
title: z.string().optional(),
description: z.string().optional(),
status: z.string().optional(),
priority: z.string().optional(),
assignee: z.string().optional(),
cloudId: z.string().optional(),
})
export async function PUT(request: Request) { export async function PUT(request: Request) {
try { try {
const body = await request.json()
const validation = jiraUpdateSchema.safeParse(body)
if (!validation.success) {
const firstError = validation.error.errors[0]
logger.error('Validation error:', firstError)
return NextResponse.json({ error: firstError.message }, { status: 400 })
}
const { const {
domain, domain,
accessToken, accessToken,
@@ -20,22 +43,7 @@ export async function PUT(request: Request) {
priority, priority,
assignee, assignee,
cloudId: providedCloudId, cloudId: providedCloudId,
} = await request.json() } = validation.data
if (!domain) {
logger.error('Missing domain in request')
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
logger.error('Missing access token in request')
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!issueKey) {
logger.error('Missing issue key in request')
return NextResponse.json({ error: 'Issue key is required' }, { status: 400 })
}
const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken)) const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken))
logger.info('Using cloud ID:', cloudId) logger.info('Using cloud ID:', cloudId)
@@ -97,7 +105,7 @@ export async function PUT(request: Request) {
} }
} }
const body = { fields } const requestBody = { fields }
const response = await fetch(url, { const response = await fetch(url, {
method: 'PUT', method: 'PUT',
@@ -106,7 +114,7 @@ export async function PUT(request: Request) {
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(body), body: JSON.stringify(requestBody),
}) })
if (!response.ok) { if (!response.ok) {

View File

@@ -958,7 +958,6 @@ The system will substitute actual values when these placeholders are used, keepi
try { try {
logger.info('Attempting to extract structured data using Stagehand extract') logger.info('Attempting to extract structured data using Stagehand extract')
const schemaObj = getSchemaObject(outputSchema) const schemaObj = getSchemaObject(outputSchema)
// Use ensureZodObject to get a proper ZodObject instance
const zodSchema = ensureZodObject(logger, schemaObj) const zodSchema = ensureZodObject(logger, schemaObj)
// Use the extract API to get structured data from whatever page we ended up on // Use the extract API to get structured data from whatever page we ended up on

View File

@@ -1,7 +1,6 @@
import { z } from 'zod' import { z } from 'zod'
import type { Logger } from '@/lib/logs/console/logger' import type { Logger } from '@/lib/logs/console/logger'
// Convert JSON schema to Zod schema (reused from extract route)
function jsonSchemaToZod(logger: Logger, jsonSchema: Record<string, any>): z.ZodTypeAny { function jsonSchemaToZod(logger: Logger, jsonSchema: Record<string, any>): z.ZodTypeAny {
if (!jsonSchema) { if (!jsonSchema) {
logger.error('Invalid schema: Schema is null or undefined') logger.error('Invalid schema: Schema is null or undefined')

View File

@@ -1,4 +1,5 @@
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { getUserUsageLimitInfo, updateUserUsageLimit } from '@/lib/billing' import { getUserUsageLimitInfo, updateUserUsageLimit } from '@/lib/billing'
import { import {
@@ -9,6 +10,18 @@ import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('UnifiedUsageAPI') const logger = createLogger('UnifiedUsageAPI')
const usageContextEnum = z.enum(['user', 'organization'])
const usageUpdateSchema = z
.object({
limit: z.number().min(0, 'Limit must be a positive number'),
context: usageContextEnum.optional().default('user'),
organizationId: z.string().optional(),
})
.refine((data) => data.context !== 'organization' || data.organizationId, {
message: 'Organization ID is required when context is organization',
})
/** /**
* Unified Usage Endpoint * Unified Usage Endpoint
* GET/PUT /api/usage?context=user|organization&userId=<id>&organizationId=<id> * GET/PUT /api/usage?context=user|organization&userId=<id>&organizationId=<id>
@@ -86,48 +99,34 @@ export async function PUT(request: NextRequest) {
} }
const body = await request.json() const body = await request.json()
const limit = body?.limit const validation = usageUpdateSchema.safeParse(body)
const context = body?.context || 'user'
const organizationId = body?.organizationId if (!validation.success) {
const firstError = validation.error.errors[0]
logger.error('Validation error:', firstError)
return NextResponse.json({ error: firstError.message }, { status: 400 })
}
const { limit, context, organizationId } = validation.data
const userId = session.user.id const userId = session.user.id
if (typeof limit !== 'number' || limit < 0) {
return NextResponse.json(
{ error: 'Invalid limit. Must be a positive number' },
{ status: 400 }
)
}
if (!['user', 'organization'].includes(context)) {
return NextResponse.json(
{ error: 'Invalid context. Must be "user" or "organization"' },
{ status: 400 }
)
}
if (context === 'user') { if (context === 'user') {
await updateUserUsageLimit(userId, limit) await updateUserUsageLimit(userId, limit)
} else if (context === 'organization') { } else if (context === 'organization') {
if (!organizationId) { // organizationId is guaranteed to exist by Zod refinement
return NextResponse.json( const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId!)
{ error: 'Organization ID is required when context=organization' },
{ status: 400 }
)
}
const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId)
if (!hasPermission) { if (!hasPermission) {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 }) return NextResponse.json({ error: 'Permission denied' }, { status: 403 })
} }
const { updateOrganizationUsageLimit } = await import('@/lib/billing/core/organization') const { updateOrganizationUsageLimit } = await import('@/lib/billing/core/organization')
const result = await updateOrganizationUsageLimit(organizationId, limit) const result = await updateOrganizationUsageLimit(organizationId!, limit)
if (!result.success) { if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 }) return NextResponse.json({ error: result.error }, { status: 400 })
} }
const updated = await getOrganizationBillingData(organizationId) const updated = await getOrganizationBillingData(organizationId!)
return NextResponse.json({ success: true, context, userId, organizationId, data: updated }) return NextResponse.json({ success: true, context, userId, organizationId, data: updated })
} }

View File

@@ -1,6 +1,7 @@
import { db, workflowDeploymentVersion } from '@sim/db' import { db, workflowDeploymentVersion } from '@sim/db'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils' import { generateRequestId } from '@/lib/utils'
import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils'
@@ -8,6 +9,14 @@ import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/
const logger = createLogger('WorkflowDeploymentVersionAPI') const logger = createLogger('WorkflowDeploymentVersionAPI')
const patchBodySchema = z.object({
name: z
.string()
.trim()
.min(1, 'Name cannot be empty')
.max(100, 'Name must be 100 characters or less'),
})
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const runtime = 'nodejs' export const runtime = 'nodejs'
@@ -73,24 +82,17 @@ export async function PATCH(
} }
const body = await request.json() const body = await request.json()
const { name } = body const validation = patchBodySchema.safeParse(body)
if (typeof name !== 'string') { if (!validation.success) {
return createErrorResponse('Name must be a string', 400) return createErrorResponse(validation.error.errors[0]?.message || 'Invalid request body', 400)
} }
const trimmedName = name.trim() const { name } = validation.data
if (trimmedName.length === 0) {
return createErrorResponse('Name cannot be empty', 400)
}
if (trimmedName.length > 100) {
return createErrorResponse('Name must be 100 characters or less', 400)
}
const [updated] = await db const [updated] = await db
.update(workflowDeploymentVersion) .update(workflowDeploymentVersion)
.set({ name: trimmedName }) .set({ name })
.where( .where(
and( and(
eq(workflowDeploymentVersion.workflowId, id), eq(workflowDeploymentVersion.workflowId, id),
@@ -104,7 +106,7 @@ export async function PATCH(
} }
logger.info( logger.info(
`[${requestId}] Renamed deployment version ${version} for workflow ${id} to "${trimmedName}"` `[${requestId}] Renamed deployment version ${version} for workflow ${id} to "${name}"`
) )
return createSuccessResponse({ name: updated.name }) return createSuccessResponse({ name: updated.name })

View File

@@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { validate as uuidValidate, v4 as uuidv4 } from 'uuid' import { validate as uuidValidate, v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid' import { checkHybridAuth } from '@/lib/auth/hybrid'
import { checkServerSideUsageLimits } from '@/lib/billing' import { checkServerSideUsageLimits } from '@/lib/billing'
import { processInputFileFields } from '@/lib/execution/files' import { processInputFileFields } from '@/lib/execution/files'
@@ -22,6 +23,14 @@ import type { SubflowType } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowExecuteAPI') const logger = createLogger('WorkflowExecuteAPI')
const ExecuteWorkflowSchema = z.object({
selectedOutputs: z.array(z.string()).optional().default([]),
triggerType: z.enum(['api', 'webhook', 'schedule', 'manual', 'chat']).optional(),
stream: z.boolean().optional(),
useDraftState: z.boolean().optional(),
input: z.any().optional(),
})
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -277,16 +286,40 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
logger.warn(`[${requestId}] Failed to parse request body, using defaults`) logger.warn(`[${requestId}] Failed to parse request body, using defaults`)
} }
const validation = ExecuteWorkflowSchema.safeParse(body)
if (!validation.success) {
logger.warn(`[${requestId}] Invalid request body:`, validation.error.errors)
return NextResponse.json(
{
error: 'Invalid request body',
details: validation.error.errors.map((e) => ({
path: e.path.join('.'),
message: e.message,
})),
},
{ status: 400 }
)
}
const defaultTriggerType = auth.authType === 'api_key' ? 'api' : 'manual' const defaultTriggerType = auth.authType === 'api_key' ? 'api' : 'manual'
const { const {
selectedOutputs = [], selectedOutputs,
triggerType = defaultTriggerType, triggerType = defaultTriggerType,
stream: streamParam, stream: streamParam,
useDraftState, useDraftState,
} = body input: validatedInput,
} = validation.data
const input = auth.authType === 'api_key' ? body : body.input // For API key auth, the entire body is the input (except for our control fields)
// For session auth, the input is explicitly provided in the input field
const input =
auth.authType === 'api_key'
? (() => {
const { selectedOutputs, triggerType, stream, useDraftState, ...rest } = body
return Object.keys(rest).length > 0 ? rest : validatedInput
})()
: validatedInput
const shouldUseDraftState = useDraftState ?? auth.authType === 'session' const shouldUseDraftState = useDraftState ?? auth.authType === 'session'

View File

@@ -1,13 +1,33 @@
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { LoggingSession } from '@/lib/logs/execution/logging-session' import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { generateRequestId } from '@/lib/utils' import { generateRequestId } from '@/lib/utils'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware' import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import type { ExecutionResult } from '@/executor/types'
const logger = createLogger('WorkflowLogAPI') const logger = createLogger('WorkflowLogAPI')
const postBodySchema = z.object({
logs: z.array(z.any()).optional(),
executionId: z.string().min(1, 'Execution ID is required').optional(),
result: z
.object({
success: z.boolean(),
error: z.string().optional(),
output: z.any(),
metadata: z
.object({
source: z.string().optional(),
duration: z.number().optional(),
})
.optional(),
})
.optional(),
})
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
@@ -15,16 +35,30 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const { id } = await params const { id } = await params
try { try {
const validation = await validateWorkflowAccess(request, id, false) const accessValidation = await validateWorkflowAccess(request, id, false)
if (validation.error) { if (accessValidation.error) {
logger.warn(`[${requestId}] Workflow access validation failed: ${validation.error.message}`) logger.warn(
return createErrorResponse(validation.error.message, validation.error.status) `[${requestId}] Workflow access validation failed: ${accessValidation.error.message}`
)
return createErrorResponse(accessValidation.error.message, accessValidation.error.status)
} }
const body = await request.json() const body = await request.json()
const { logs, executionId, result } = body const validation = postBodySchema.safeParse(body)
if (!validation.success) {
logger.warn(`[${requestId}] Invalid request body: ${validation.error.message}`)
return createErrorResponse(validation.error.errors[0]?.message || 'Invalid request body', 400)
}
const { logs, executionId, result } = validation.data
if (result) { if (result) {
if (!executionId) {
logger.warn(`[${requestId}] Missing executionId for result logging`)
return createErrorResponse('executionId is required when logging results', 400)
}
logger.info(`[${requestId}] Persisting execution result for workflow: ${id}`, { logger.info(`[${requestId}] Persisting execution result for workflow: ${id}`, {
executionId, executionId,
success: result.success, success: result.success,
@@ -35,8 +69,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const triggerType = isChatExecution ? 'chat' : 'manual' const triggerType = isChatExecution ? 'chat' : 'manual'
const loggingSession = new LoggingSession(id, executionId, triggerType, requestId) const loggingSession = new LoggingSession(id, executionId, triggerType, requestId)
const userId = validation.workflow.userId const userId = accessValidation.workflow.userId
const workspaceId = validation.workflow.workspaceId || '' const workspaceId = accessValidation.workflow.workspaceId || ''
await loggingSession.safeStart({ await loggingSession.safeStart({
userId, userId,
@@ -44,7 +78,12 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
variables: {}, variables: {},
}) })
const { traceSpans, totalDuration } = buildTraceSpans(result) const resultWithOutput = {
...result,
output: result.output ?? {},
}
const { traceSpans, totalDuration } = buildTraceSpans(resultWithOutput as ExecutionResult)
if (result.success === false) { if (result.success === false) {
const message = result.error || 'Workflow execution failed' const message = result.error || 'Workflow execution failed'

View File

@@ -1,7 +1,12 @@
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { PauseResumeManager } from '@/lib/workflows/executor/pause-resume-manager' import { PauseResumeManager } from '@/lib/workflows/executor/pause-resume-manager'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware' import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
const queryParamsSchema = z.object({
status: z.string().optional(),
})
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -20,7 +25,18 @@ export async function GET(
return NextResponse.json({ error: access.error.message }, { status: access.error.status }) return NextResponse.json({ error: access.error.message }, { status: access.error.status })
} }
const statusFilter = request.nextUrl.searchParams.get('status') || undefined const validation = queryParamsSchema.safeParse({
status: request.nextUrl.searchParams.get('status'),
})
if (!validation.success) {
return NextResponse.json(
{ error: validation.error.errors[0]?.message || 'Invalid query parameters' },
{ status: 400 }
)
}
const { status: statusFilter } = validation.data
const pausedExecutions = await PauseResumeManager.listPausedExecutions({ const pausedExecutions = await PauseResumeManager.listPausedExecutions({
workflowId, workflowId,

View File

@@ -354,7 +354,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const userId = session.user.id const userId = session.user.id
// Parse and validate request body
const body = await request.json() const body = await request.json()
const updates = UpdateWorkflowSchema.parse(body) const updates = UpdateWorkflowSchema.parse(body)

View File

@@ -123,7 +123,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const userId = session.user.id const userId = session.user.id
// Parse and validate request body
const body = await request.json() const body = await request.json()
const state = WorkflowStateSchema.parse(body) const state = WorkflowStateSchema.parse(body)

View File

@@ -2,32 +2,44 @@ import { db } from '@sim/db'
import { userStats, workflow } from '@sim/db/schema' import { userStats, workflow } from '@sim/db/schema'
import { eq, sql } from 'drizzle-orm' import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('WorkflowStatsAPI') const logger = createLogger('WorkflowStatsAPI')
const queryParamsSchema = z.object({
runs: z.coerce.number().int().min(1).max(100).default(1),
})
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params const { id } = await params
const searchParams = request.nextUrl.searchParams const searchParams = request.nextUrl.searchParams
const runs = Number.parseInt(searchParams.get('runs') || '1', 10)
if (Number.isNaN(runs) || runs < 1 || runs > 100) { const validation = queryParamsSchema.safeParse({
logger.error(`Invalid number of runs: ${runs}`) runs: searchParams.get('runs'),
})
if (!validation.success) {
logger.error(`Invalid query parameters: ${validation.error.message}`)
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid number of runs. Must be between 1 and 100.' }, {
error:
validation.error.errors[0]?.message ||
'Invalid number of runs. Must be between 1 and 100.',
},
{ status: 400 } { status: 400 }
) )
} }
const { runs } = validation.data
try { try {
// Get workflow record
const [workflowRecord] = await db.select().from(workflow).where(eq(workflow.id, id)).limit(1) const [workflowRecord] = await db.select().from(workflow).where(eq(workflow.id, id)).limit(1)
if (!workflowRecord) { if (!workflowRecord) {
return NextResponse.json({ error: `Workflow ${id} not found` }, { status: 404 }) return NextResponse.json({ error: `Workflow ${id} not found` }, { status: 404 })
} }
// Update workflow runCount
try { try {
await db await db
.update(workflow) .update(workflow)

View File

@@ -1,22 +1,23 @@
import crypto from 'crypto' import crypto from 'crypto'
import { db } from '@sim/db' import { db } from '@sim/db'
import { permissions, type permissionTypeEnum, workspace } from '@sim/db/schema' import { permissions, workspace } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { getUsersWithPermissions, hasWorkspaceAdminAccess } from '@/lib/permissions/utils' import { getUsersWithPermissions, hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
const logger = createLogger('WorkspacesPermissionsAPI') const logger = createLogger('WorkspacesPermissionsAPI')
type PermissionType = (typeof permissionTypeEnum.enumValues)[number] const updatePermissionsSchema = z.object({
updates: z.array(
interface UpdatePermissionsRequest { z.object({
updates: Array<{ userId: z.string().uuid(),
userId: string permissions: z.enum(['admin', 'write', 'read']),
permissions: PermissionType })
}> ),
} })
/** /**
* GET /api/workspaces/[id]/permissions * GET /api/workspaces/[id]/permissions
@@ -92,7 +93,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
) )
} }
const body: UpdatePermissionsRequest = await request.json() const body = updatePermissionsSchema.parse(await request.json())
const workspaceRow = await db const workspaceRow = await db
.select({ billedAccountUserId: workspace.billedAccountUserId }) .select({ billedAccountUserId: workspace.billedAccountUserId })

View File

@@ -1,6 +1,7 @@
import { workflow } from '@sim/db/schema' import { workflow } from '@sim/db/schema'
import { and, eq, inArray } from 'drizzle-orm' import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
@@ -10,6 +11,16 @@ import { db } from '@sim/db'
import { knowledgeBase, permissions, templates, workspace } from '@sim/db/schema' import { knowledgeBase, permissions, templates, workspace } from '@sim/db/schema'
import { getUserEntityPermissions } from '@/lib/permissions/utils' import { getUserEntityPermissions } from '@/lib/permissions/utils'
const patchWorkspaceSchema = z.object({
name: z.string().trim().min(1).optional(),
billedAccountUserId: z.string().uuid().optional(),
allowPersonalApiKeys: z.boolean().optional(),
})
const deleteWorkspaceSchema = z.object({
deleteTemplates: z.boolean().default(false),
})
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params const { id } = await params
const session = await getSession() const session = await getSession()
@@ -100,16 +111,8 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
} }
try { try {
const body = await request.json() const body = patchWorkspaceSchema.parse(await request.json())
const { const { name, billedAccountUserId, allowPersonalApiKeys } = body
name,
billedAccountUserId,
allowPersonalApiKeys,
}: {
name?: string
billedAccountUserId?: string
allowPersonalApiKeys?: boolean
} = body ?? {}
if ( if (
name === undefined && name === undefined &&
@@ -132,11 +135,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const updateData: Record<string, unknown> = {} const updateData: Record<string, unknown> = {}
if (name !== undefined) { if (name !== undefined) {
const trimmedName = name.trim() updateData.name = name
if (!trimmedName) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 })
}
updateData.name = trimmedName
} }
if (allowPersonalApiKeys !== undefined) { if (allowPersonalApiKeys !== undefined) {
@@ -144,11 +143,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
} }
if (billedAccountUserId !== undefined) { if (billedAccountUserId !== undefined) {
const candidateId = billedAccountUserId?.trim() const candidateId = billedAccountUserId
if (!candidateId) {
return NextResponse.json({ error: 'billedAccountUserId is required' }, { status: 400 })
}
const isOwner = candidateId === existingWorkspace.ownerId const isOwner = candidateId === existingWorkspace.ownerId
@@ -219,8 +214,8 @@ export async function DELETE(
} }
const workspaceId = id const workspaceId = id
const body = await request.json().catch(() => ({})) const body = deleteWorkspaceSchema.parse(await request.json().catch(() => ({})))
const { deleteTemplates = false } = body // User's choice: false = keep templates (recommended), true = delete templates const { deleteTemplates } = body // User's choice: false = keep templates (recommended), true = delete templates
// Check if user has admin permissions to delete workspace // Check if user has admin permissions to delete workspace
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)

View File

@@ -2,11 +2,15 @@ import { db } from '@sim/db'
import { permissions, workspace } from '@sim/db/schema' import { permissions, workspace } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils' import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
const logger = createLogger('WorkspaceMemberAPI') const logger = createLogger('WorkspaceMemberAPI')
const deleteMemberSchema = z.object({
workspaceId: z.string().uuid(),
})
// DELETE /api/workspaces/members/[id] - Remove a member from a workspace // DELETE /api/workspaces/members/[id] - Remove a member from a workspace
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
@@ -19,12 +23,8 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
try { try {
// Get the workspace ID from the request body or URL // Get the workspace ID from the request body or URL
const body = await req.json() const body = deleteMemberSchema.parse(await req.json())
const workspaceId = body.workspaceId const { workspaceId } = body
if (!workspaceId) {
return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 })
}
const workspaceRow = await db const workspaceRow = await db
.select({ billedAccountUserId: workspace.billedAccountUserId }) .select({ billedAccountUserId: workspace.billedAccountUserId })

View File

@@ -1,96 +0,0 @@
import { db } from '@sim/db'
import { permissions, type permissionTypeEnum, user } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { hasAdminPermission } from '@/lib/permissions/utils'
const logger = createLogger('WorkspaceMemberAPI')
type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
// Add a member to a workspace
export async function POST(req: Request) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { workspaceId, userEmail, permission = 'read' } = await req.json()
if (!workspaceId || !userEmail) {
return NextResponse.json(
{ error: 'Workspace ID and user email are required' },
{ status: 400 }
)
}
// Validate permission type
const validPermissions: PermissionType[] = ['admin', 'write', 'read']
if (!validPermissions.includes(permission)) {
return NextResponse.json(
{ error: `Invalid permission: must be one of ${validPermissions.join(', ')}` },
{ status: 400 }
)
}
// Check if current user has admin permission for the workspace
const hasAdmin = await hasAdminPermission(session.user.id, workspaceId)
if (!hasAdmin) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
// Find user by email
const targetUser = await db
.select()
.from(user)
.where(eq(user.email, userEmail))
.then((rows) => rows[0])
if (!targetUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
// Check if user already has permissions for this workspace
const existingPermissions = await db
.select()
.from(permissions)
.where(
and(
eq(permissions.userId, targetUser.id),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
if (existingPermissions.length > 0) {
return NextResponse.json(
{ error: 'User already has permissions for this workspace' },
{ status: 400 }
)
}
// Create single permission for the new member
await db.insert(permissions).values({
id: crypto.randomUUID(),
userId: targetUser.id,
entityType: 'workspace' as const,
entityId: workspaceId,
permissionType: permission,
createdAt: new Date(),
updatedAt: new Date(),
})
return NextResponse.json({
success: true,
message: `User added to workspace with ${permission} permission`,
})
} catch (error) {
logger.error('Error adding workspace member:', error)
return NextResponse.json({ error: 'Failed to add workspace member' }, { status: 500 })
}
}

View File

@@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { permissions, workflow, workspace } from '@sim/db/schema' import { permissions, workflow, workspace } from '@sim/db/schema'
import { and, desc, eq, isNull } from 'drizzle-orm' import { and, desc, eq, isNull } from 'drizzle-orm'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers'
@@ -9,6 +10,10 @@ import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
const logger = createLogger('Workspaces') const logger = createLogger('Workspaces')
const createWorkspaceSchema = z.object({
name: z.string().trim().min(1, 'Name is required'),
})
// Get all workspaces for the current user // Get all workspaces for the current user
export async function GET() { export async function GET() {
const session = await getSession() const session = await getSession()
@@ -62,11 +67,7 @@ export async function POST(req: Request) {
} }
try { try {
const { name } = await req.json() const { name } = createWorkspaceSchema.parse(await req.json())
if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 })
}
const newWorkspace = await createWorkspace(session.user.id, name) const newWorkspace = await createWorkspace(session.user.id, name)

View File

@@ -354,7 +354,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
name: file.name, name: file.name,
size: file.size, size: file.size,
type: file.type, type: file.type,
dataUrl: file.dataUrl || (await fileToBase64(file.file)), data: file.dataUrl || (await fileToBase64(file.file)),
})) }))
) )
} }

View File

@@ -91,7 +91,6 @@ export function useChatDeployment() {
deployApiEnabled: !existingChatId, deployApiEnabled: !existingChatId,
} }
// Validate with Zod
chatSchema.parse(payload) chatSchema.parse(payload)
// Determine endpoint and method // Determine endpoint and method

View File

@@ -313,8 +313,11 @@ export function TrainingModal() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
json: JSON.stringify(sanitizedWorkflow), json: JSON.stringify(sanitizedWorkflow),
source_path: liveWorkflowTitle, title: liveWorkflowTitle,
summary: liveWorkflowDescription, tags: [],
metadata: {
summary: liveWorkflowDescription,
},
}), }),
}) })

View File

@@ -191,7 +191,7 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
targetFolderId = folderMap.get(folderPathKey)! targetFolderId = folderMap.get(folderPathKey)!
} }
const workflowName = extractWorkflowName(workflow.content) const workflowName = extractWorkflowName(workflow.content, workflow.name)
const { clearDiff } = useWorkflowDiffStore.getState() const { clearDiff } = useWorkflowDiffStore.getState()
clearDiff() clearDiff()
@@ -252,7 +252,7 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
continue continue
} }
const workflowName = extractWorkflowName(workflow.content) const workflowName = extractWorkflowName(workflow.content, workflow.name)
const { clearDiff } = useWorkflowDiffStore.getState() const { clearDiff } = useWorkflowDiffStore.getState()
clearDiff() clearDiff()

View File

@@ -180,13 +180,26 @@ export async function extractWorkflowsFromFiles(files: File[]): Promise<Imported
return workflows return workflows
} }
export function extractWorkflowName(content: string): string { export function extractWorkflowName(content: string, filename: string): string {
try { try {
const parsed = JSON.parse(content) const parsed = JSON.parse(content)
if (parsed.state?.metadata?.name && typeof parsed.state.metadata.name === 'string') { if (parsed.state?.metadata?.name && typeof parsed.state.metadata.name === 'string') {
return parsed.state.metadata.name.trim() return parsed.state.metadata.name.trim()
} }
} catch {} } catch {
// JSON parse failed, fall through to filename
}
return `Imported Workflow ${new Date().toLocaleString()}` let name = filename.replace(/\.json$/i, '')
name = name.replace(/-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, '')
name = name
.replace(/[-_]/g, ' ')
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
return name.trim() || 'Imported Workflow'
} }

View File

@@ -19,11 +19,10 @@ const createMockExecutionContext = (overrides?: Partial<ExecutionContext>): Exec
workspaceId: 'workspace-456', workspaceId: 'workspace-456',
blockStates: new Map(), blockStates: new Map(),
blockLogs: [], blockLogs: [],
metadata: { startTime: new Date().toISOString(), duration: 0 }, metadata: { duration: 0 },
environmentVariables: {}, environmentVariables: {},
decisions: { router: new Map(), condition: new Map() }, decisions: { router: new Map(), condition: new Map() },
loopIterations: new Map(), loopExecutions: new Map(),
loopItems: new Map(),
completedLoops: new Set(), completedLoops: new Set(),
executedBlocks: new Set(), executedBlocks: new Set(),
activeExecutionPath: new Set(), activeExecutionPath: new Set(),