Files
sim/apps/sim/app/api/templates/route.ts
Waleed a680cec78f fix(core): consolidate ID generation to prevent HTTP self-hosted crashes (#3977)
* fix(core): consolidate ID generation to prevent HTTP self-hosted crashes

crypto.randomUUID() requires a secure context (HTTPS) in browsers,
causing white-screen crashes on self-hosted HTTP deployments. This
replaces all direct usage of crypto.randomUUID(), nanoid, and the uuid
package with a central utility that falls back to crypto.getRandomValues()
which works in all contexts.

- Add generateId(), generateShortId(), isValidUuid() in @/lib/core/utils/uuid
- Replace crypto.randomUUID() imports across ~220 server + client files
- Replace nanoid imports with generateShortId()
- Replace uuid package validate with isValidUuid()
- Remove nanoid dependency from apps/sim and packages/testing
- Remove browser polyfill script from layout.tsx
- Update test mocks to target @/lib/core/utils/uuid
- Update CLAUDE.md, AGENTS.md, cursor rules, claude rules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* update bunlock

* fix(core): remove UUID_REGEX shim, use isValidUuid directly

* fix(core): remove deprecated uuid mock helpers that use vi.doMock

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 11:28:54 -07:00

372 lines
12 KiB
TypeScript

import { db } from '@sim/db'
import {
templateCreators,
templateStars,
templates,
workflow,
workflowDeploymentVersion,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, ilike, or, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { generateId } from '@/lib/core/utils/uuid'
import { canAccessTemplate, verifyEffectiveSuperUser } from '@/lib/templates/permissions'
import {
extractRequiredCredentials,
sanitizeCredentials,
} from '@/lib/workflows/credentials/credential-extractor'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
const logger = createLogger('TemplatesAPI')
export const revalidate = 0
// Function to sanitize sensitive data from workflow state
// Now uses the more comprehensive sanitizeCredentials from credential-extractor
function sanitizeWorkflowState(state: any): any {
return sanitizeCredentials(state)
}
// Schema for creating a template
const CreateTemplateSchema = z.object({
workflowId: z.string().min(1, 'Workflow ID is required'),
name: z.string().min(1, 'Name is required').max(100, 'Name must be less than 100 characters'),
details: z
.object({
tagline: z.string().max(500, 'Tagline must be less than 500 characters').optional(),
about: z.string().optional(), // Markdown long description
})
.optional(),
creatorId: z.string().min(1, 'Creator profile is required'),
tags: z.array(z.string()).max(10, 'Maximum 10 tags allowed').optional().default([]),
})
// Schema for query parameters
const QueryParamsSchema = z.object({
limit: z.coerce.number().optional().default(50),
offset: z.coerce.number().optional().default(0),
search: z.string().optional(),
workflowId: z.string().optional(),
status: z.enum(['pending', 'approved', 'rejected']).optional(),
includeAllStatuses: z.coerce.boolean().optional().default(false), // For super users
})
// GET /api/templates - Retrieve templates
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized templates access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries()))
// Check if user is a super user
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
const isSuperUser = effectiveSuperUser
// Build query conditions
const conditions = []
// Apply workflow filter if provided (for getting template by workflow)
// When fetching by workflowId, we want to get the template regardless of status
// This is used by the deploy modal to check if a template exists
if (params.workflowId) {
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: params.workflowId,
userId: session.user.id,
action: 'write',
})
if (!authorization.allowed) {
return NextResponse.json(
{
data: [],
pagination: {
total: 0,
limit: params.limit,
offset: params.offset,
page: 1,
totalPages: 0,
},
},
{ status: 200 }
)
}
conditions.push(eq(templates.workflowId, params.workflowId))
} else {
// Apply status filter - only approved templates for non-super users
if (params.status) {
if (!isSuperUser && params.status !== 'approved') {
return NextResponse.json(
{
data: [],
pagination: {
total: 0,
limit: params.limit,
offset: params.offset,
page: 1,
totalPages: 0,
},
},
{ status: 200 }
)
}
conditions.push(eq(templates.status, params.status))
} else if (!isSuperUser || !params.includeAllStatuses) {
// Non-super users and super users without includeAllStatuses flag see only approved templates
conditions.push(eq(templates.status, 'approved'))
}
}
// Apply search filter if provided
if (params.search) {
const searchTerm = `%${params.search}%`
conditions.push(
or(
ilike(templates.name, searchTerm),
sql`${templates.details}->>'tagline' ILIKE ${searchTerm}`
)
)
}
// Combine conditions
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
// Apply ordering, limit, and offset with star information
const results = await db
.select({
id: templates.id,
workflowId: templates.workflowId,
name: templates.name,
details: templates.details,
creatorId: templates.creatorId,
creator: templateCreators,
views: templates.views,
stars: templates.stars,
status: templates.status,
tags: templates.tags,
requiredCredentials: templates.requiredCredentials,
state: templates.state,
createdAt: templates.createdAt,
updatedAt: templates.updatedAt,
isStarred: sql<boolean>`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`,
isSuperUser: sql<boolean>`${isSuperUser}`, // Include super user status in response
})
.from(templates)
.leftJoin(
templateStars,
and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id))
)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(whereCondition)
.orderBy(desc(templates.views), desc(templates.createdAt))
.limit(params.limit)
.offset(params.offset)
// Get total count for pagination
const totalCount = await db
.select({ count: sql<number>`count(*)` })
.from(templates)
.where(whereCondition)
const total = totalCount[0]?.count || 0
const visibleResults =
params.workflowId && !isSuperUser
? (
await Promise.all(
results.map(async (template) => {
if (template.status === 'approved') {
return template
}
const access = await canAccessTemplate(template.id, session.user.id)
return access.allowed ? template : null
})
)
).filter((template): template is (typeof results)[number] => template !== null)
: results
logger.info(`[${requestId}] Successfully retrieved ${visibleResults.length} templates`)
return NextResponse.json({
data: visibleResults,
pagination: {
total: params.workflowId && !isSuperUser ? visibleResults.length : total,
limit: params.limit,
offset: params.offset,
page: Math.floor(params.offset / params.limit) + 1,
totalPages: Math.ceil(
(params.workflowId && !isSuperUser ? visibleResults.length : total) / params.limit
),
},
})
} catch (error: any) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid query parameters`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid query parameters', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error fetching templates`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// POST /api/templates - Create a new template
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized template creation attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = CreateTemplateSchema.parse(body)
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId: data.workflowId,
userId: session.user.id,
action: 'write',
})
if (!workflowAuthorization.workflow) {
logger.warn(`[${requestId}] Workflow not found: ${data.workflowId}`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
if (!workflowAuthorization.allowed) {
logger.warn(`[${requestId}] User denied permission to template workflow ${data.workflowId}`)
return NextResponse.json(
{ error: workflowAuthorization.message || 'Access denied' },
{ status: workflowAuthorization.status || 403 }
)
}
const { verifyCreatorPermission } = await import('@/lib/templates/permissions')
const { hasPermission, error: permissionError } = await verifyCreatorPermission(
session.user.id,
data.creatorId,
'member'
)
if (!hasPermission) {
logger.warn(`[${requestId}] User cannot use creator profile: ${data.creatorId}`)
return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 })
}
const templateId = generateId()
const now = new Date()
// Get the active deployment version for the workflow to copy its state
const activeVersion = await db
.select({
id: workflowDeploymentVersion.id,
state: workflowDeploymentVersion.state,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, data.workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
if (activeVersion.length === 0) {
logger.warn(
`[${requestId}] No active deployment version found for workflow: ${data.workflowId}`
)
return NextResponse.json(
{ error: 'Workflow must be deployed before creating a template' },
{ status: 400 }
)
}
// Ensure the state includes workflow variables (if not already included)
let stateWithVariables = activeVersion[0].state as any
if (stateWithVariables && !stateWithVariables.variables) {
// Fetch workflow variables if not in deployment version
const [workflowRecord] = await db
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, data.workflowId))
.limit(1)
stateWithVariables = {
...stateWithVariables,
variables: workflowRecord?.variables || undefined,
}
}
// Extract credential requirements before sanitizing
const requiredCredentials = extractRequiredCredentials(stateWithVariables)
// Sanitize the workflow state to remove all credential values
const sanitizedState = sanitizeWorkflowState(stateWithVariables)
const newTemplate = {
id: templateId,
workflowId: data.workflowId,
name: data.name,
details: data.details || null,
creatorId: data.creatorId,
views: 0,
stars: 0,
status: 'pending' as const, // All new templates start as pending
tags: data.tags || [],
requiredCredentials: requiredCredentials, // Store the extracted credential requirements
state: sanitizedState, // Store the sanitized state without credential values
createdAt: now,
updatedAt: now,
}
await db.insert(templates).values(newTemplate)
logger.info(`[${requestId}] Successfully created template: ${templateId}`)
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.TEMPLATE_CREATED,
resourceType: AuditResourceType.TEMPLATE,
resourceId: templateId,
resourceName: data.name,
description: `Created template "${data.name}"`,
request,
})
return NextResponse.json(
{
id: templateId,
message: 'Template submitted for approval successfully',
},
{ status: 201 }
)
} catch (error: any) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid template data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid template data', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error creating template`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}