chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
import { db } from '@sim/db'
import { settings, user } from '@sim/db/schema'
import { getErrorMessage } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { adminMothershipQuerySchema } from '@/lib/api/contracts/mothership-chats'
import { mothershipEnvironmentSchema } from '@/lib/api/contracts/user'
import { searchParamsToObject, validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const ENV_URLS: Record<string, string | undefined> = {
dev: env.MOTHERSHIP_DEV_URL,
staging: env.MOTHERSHIP_STAGING_URL,
prod: env.MOTHERSHIP_PROD_URL,
}
async function getMothershipUrl(environment: string, userId: string): Promise<string | null> {
const parsedEnvironment = mothershipEnvironmentSchema.safeParse(environment)
if (!parsedEnvironment.success) return ENV_URLS[environment] ?? null
return getMothershipBaseURL({
userId,
environment: parsedEnvironment.data,
fallbackUrl: ENV_URLS[environment],
})
}
const ENDPOINT_PATTERN = /^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/
function isValidEndpoint(endpoint: string): boolean {
if (!endpoint) return false
if (endpoint.includes('..')) return false
return ENDPOINT_PATTERN.test(endpoint)
}
async function getAuthorizedAdminUserId() {
const session = await getSession()
if (!session?.user?.id) return null
const [currentUser] = await db
.select({
role: user.role,
superUserModeEnabled: settings.superUserModeEnabled,
})
.from(user)
.leftJoin(settings, eq(settings.userId, user.id))
.where(eq(user.id, session.user.id))
.limit(1)
const authorized = currentUser?.role === 'admin' && (currentUser.superUserModeEnabled ?? false)
return authorized ? session.user.id : null
}
/**
* Proxy to the mothership admin API.
*
* Query params:
* env - "dev" | "staging" | "prod"
* endpoint - the admin endpoint path, e.g. "requests", "licenses", "traces"
*
* The request body (for POST) is forwarded as-is. Additional query params
* (e.g. requestId for GET /traces) are forwarded.
*/
export const POST = withRouteHandler(async (req: NextRequest) => {
const userId = await getAuthorizedAdminUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const adminKey = env.MOTHERSHIP_API_ADMIN_KEY
if (!adminKey) {
return NextResponse.json({ error: 'MOTHERSHIP_API_ADMIN_KEY not configured' }, { status: 500 })
}
const { searchParams } = new URL(req.url)
const queryValidation = adminMothershipQuerySchema.safeParse(searchParamsToObject(searchParams))
if (!queryValidation.success) return validationErrorResponse(queryValidation.error)
const { env: environment, endpoint } = queryValidation.data
if (!isValidEndpoint(endpoint)) {
return NextResponse.json({ error: 'invalid endpoint' }, { status: 400 })
}
const baseUrl = await getMothershipUrl(environment, userId)
if (!baseUrl) {
return NextResponse.json(
{ error: `No URL configured for environment: ${environment}` },
{ status: 400 }
)
}
const targetUrl = `${baseUrl}/api/admin/${endpoint}`
try {
const body = await req.text()
const upstream = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': adminKey,
},
...(body ? { body } : {}),
})
const data = await upstream.json()
return NextResponse.json(data, { status: upstream.status })
} catch (error) {
return NextResponse.json(
{
error: `Failed to reach mothership (${environment}): ${getErrorMessage(error, 'Unknown error')}`,
},
{ status: 502 }
)
}
})
export const GET = withRouteHandler(async (req: NextRequest) => {
const userId = await getAuthorizedAdminUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const adminKey = env.MOTHERSHIP_API_ADMIN_KEY
if (!adminKey) {
return NextResponse.json({ error: 'MOTHERSHIP_API_ADMIN_KEY not configured' }, { status: 500 })
}
const { searchParams } = new URL(req.url)
const queryValidation = adminMothershipQuerySchema.safeParse(searchParamsToObject(searchParams))
if (!queryValidation.success) return validationErrorResponse(queryValidation.error)
const { env: environment, endpoint } = queryValidation.data
if (!isValidEndpoint(endpoint)) {
return NextResponse.json({ error: 'invalid endpoint' }, { status: 400 })
}
const baseUrl = await getMothershipUrl(environment, userId)
if (!baseUrl) {
return NextResponse.json(
{ error: `No URL configured for environment: ${environment}` },
{ status: 400 }
)
}
const forwardParams = new URLSearchParams()
searchParams.forEach((value, key) => {
if (key !== 'env' && key !== 'endpoint') {
forwardParams.set(key, value)
}
})
const qs = forwardParams.toString()
const targetUrl = `${baseUrl}/api/admin/${endpoint}${qs ? `?${qs}` : ''}`
try {
const upstream = await fetch(targetUrl, {
method: 'GET',
headers: { 'x-api-key': adminKey },
})
const data = await upstream.json()
return NextResponse.json(data, { status: upstream.status })
} catch (error) {
return NextResponse.json(
{
error: `Failed to reach mothership (${environment}): ${getErrorMessage(error, 'Unknown error')}`,
},
{ status: 502 }
)
}
})
export const DELETE = withRouteHandler(async (req: NextRequest) => {
const userId = await getAuthorizedAdminUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const adminKey = env.MOTHERSHIP_API_ADMIN_KEY
if (!adminKey) {
return NextResponse.json({ error: 'MOTHERSHIP_API_ADMIN_KEY not configured' }, { status: 500 })
}
const { searchParams } = new URL(req.url)
const queryValidation = adminMothershipQuerySchema.safeParse(searchParamsToObject(searchParams))
if (!queryValidation.success) return validationErrorResponse(queryValidation.error)
const { env: environment, endpoint } = queryValidation.data
if (!isValidEndpoint(endpoint)) {
return NextResponse.json({ error: 'invalid endpoint' }, { status: 400 })
}
const baseUrl = await getMothershipUrl(environment, userId)
if (!baseUrl) {
return NextResponse.json(
{ error: `No URL configured for environment: ${environment}` },
{ status: 400 }
)
}
const forwardParams = new URLSearchParams()
searchParams.forEach((value, key) => {
if (key !== 'env' && key !== 'endpoint') {
forwardParams.set(key, value)
}
})
const qs = forwardParams.toString()
const targetUrl = `${baseUrl}/api/admin/${endpoint}${qs ? `?${qs}` : ''}`
try {
const upstream = await fetch(targetUrl, {
method: 'DELETE',
headers: { 'x-api-key': adminKey },
})
const data = await upstream.json()
return NextResponse.json(data, { status: upstream.status })
} catch (error) {
return NextResponse.json(
{
error: `Failed to reach mothership (${environment}): ${getErrorMessage(error, 'Unknown error')}`,
},
{ status: 502 }
)
}
})
@@ -0,0 +1,169 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetSession,
mockValidateEnterpriseAuditAccess,
mockBuildOrgScopeCondition,
mockGetOrgWorkspaceIds,
mockQueryAuditLogs,
mockBuildFilterConditions,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockValidateEnterpriseAuditAccess: vi.fn(),
mockBuildOrgScopeCondition: vi.fn(),
mockGetOrgWorkspaceIds: vi.fn(),
mockQueryAuditLogs: vi.fn(),
mockBuildFilterConditions: vi.fn(),
}))
vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
getSession: mockGetSession,
}))
vi.mock('@/app/api/v1/audit-logs/auth', () => ({
validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess,
}))
vi.mock('@/app/api/v1/audit-logs/query', () => ({
buildFilterConditions: mockBuildFilterConditions,
buildOrgScopeCondition: mockBuildOrgScopeCondition,
getOrgWorkspaceIds: mockGetOrgWorkspaceIds,
queryAuditLogs: mockQueryAuditLogs,
}))
import { GET } from '@/app/api/audit-logs/export/route'
const ORG_ID = 'org-1'
const MEMBER_IDS = ['admin-1']
const SCOPE_SENTINEL = { type: 'org-scope-sentinel' }
function makeRequest(query = '') {
return createMockRequest(
'GET',
undefined,
{},
`http://localhost:3000/api/audit-logs/export${query}`
)
}
function auditLog(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'log-1',
workspaceId: null,
actorId: 'admin-1',
actorName: 'Ada Lovelace',
actorEmail: 'ada@example.com',
action: 'workflow.created',
resourceType: 'workflow',
resourceId: 'wf-1',
resourceName: 'My workflow',
description: null,
metadata: null,
createdAt: new Date('2026-07-01T00:00:00.000Z'),
...overrides,
}
}
describe('GET /api/audit-logs/export', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: 'admin-1' } })
mockValidateEnterpriseAuditAccess.mockResolvedValue({
success: true,
context: { organizationId: ORG_ID, orgMemberIds: MEMBER_IDS },
})
mockGetOrgWorkspaceIds.mockResolvedValue([])
mockBuildOrgScopeCondition.mockReturnValue(SCOPE_SENTINEL)
mockBuildFilterConditions.mockReturnValue([])
mockQueryAuditLogs.mockResolvedValue({ data: [], nextCursor: undefined })
})
it('returns 401 when unauthenticated', async () => {
mockGetSession.mockResolvedValue(null)
const response = await GET(makeRequest())
expect(response.status).toBe(401)
})
it('returns the enterprise-access-check response when access is denied', async () => {
mockValidateEnterpriseAuditAccess.mockResolvedValue({
success: false,
response: new Response(
JSON.stringify({ error: 'Organization admin or owner role required' }),
{
status: 403,
}
),
})
const response = await GET(makeRequest())
expect(response.status).toBe(403)
expect(mockQueryAuditLogs).not.toHaveBeenCalled()
})
it('returns a CSV with the header row and one line per log', async () => {
mockQueryAuditLogs.mockResolvedValueOnce({ data: [auditLog()], nextCursor: undefined })
const response = await GET(makeRequest())
const csv = await response.text()
const [header, row] = csv.split('\n')
expect(response.headers.get('Content-Type')).toBe('text/csv; charset=utf-8')
expect(response.headers.get('Content-Disposition')).toContain('attachment; filename=')
expect(response.headers.get('X-Export-Truncated')).toBe('0')
expect(header).toBe('Date,Action,Resource Type,Resource Name,Actor,Description')
expect(row).toBe(
'2026-07-01T00:00:00.000Z,workflow.created,workflow,My workflow,ada@example.com,'
)
})
it('falls back to actorName, then "System", when actorEmail is absent', async () => {
mockQueryAuditLogs.mockResolvedValueOnce({
data: [auditLog({ actorEmail: null, actorName: 'Ada Lovelace' })],
nextCursor: undefined,
})
const response = await GET(makeRequest())
const csv = await response.text()
expect(csv).toContain('Ada Lovelace')
})
it('paginates through queryAuditLogs until there is no nextCursor', async () => {
mockQueryAuditLogs
.mockResolvedValueOnce({
data: [auditLog({ id: 'log-1' })],
nextCursor: 'cursor-1',
})
.mockResolvedValueOnce({
data: [auditLog({ id: 'log-2' })],
nextCursor: undefined,
})
const response = await GET(makeRequest())
const csv = await response.text()
expect(mockQueryAuditLogs).toHaveBeenCalledTimes(2)
expect(mockQueryAuditLogs).toHaveBeenNthCalledWith(
2,
expect.anything(),
expect.any(Number),
'cursor-1'
)
expect(csv.split('\n')).toHaveLength(3)
})
it('rejects an actorId that is not a current org member', async () => {
const response = await GET(makeRequest('?actorId=outsider-1'))
expect(response.status).toBe(400)
expect(mockQueryAuditLogs).not.toHaveBeenCalled()
})
})
+151
View File
@@ -0,0 +1,151 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { exportAuditLogsContract } from '@/lib/api/contracts/audit-logs'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { formatCsvValue, toCsvRow } from '@/lib/table/export-format'
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import {
buildFilterConditions,
buildOrgScopeCondition,
getOrgWorkspaceIds,
queryAuditLogs,
} from '@/app/api/v1/audit-logs/query'
const logger = createLogger('AuditLogsExportAPI')
/**
* Circuit breaker, not a UX boundary — an organization's audit trail can
* genuinely grow large over time, unlike a single user's credit ledger, so
* this is sized for "a reasonable audit review window" rather than "should
* never happen." Hitting it truncates (signaled via X-Export-Truncated), it
* doesn't error.
*/
const EXPORT_SAFETY_CAP = 10000
const EXPORT_PAGE_SIZE = 1000
const CSV_HEADER = toCsvRow([
'Date',
'Action',
'Resource Type',
'Resource Name',
'Actor',
'Description',
])
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const authResult = await validateEnterpriseAuditAccess(session.user.id)
if (!authResult.success) {
return authResult.response
}
const { organizationId, orgMemberIds } = authResult.context
const parsed = await parseRequest(
exportAuditLogsContract,
request,
{},
{
validationErrorResponse: (error) =>
NextResponse.json(
{ error: getValidationErrorMessage(error, 'Invalid query parameters') },
{ status: 400 }
),
}
)
if (!parsed.success) return parsed.response
const { search, action, resourceType, actorId, startDate, endDate, includeDeparted } =
parsed.data.query
if (actorId && !orgMemberIds.includes(actorId)) {
return NextResponse.json(
{ error: 'actorId is not a member of your organization' },
{ status: 400 }
)
}
const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId)
const scopeCondition = buildOrgScopeCondition({
organizationId,
orgWorkspaceIds,
orgMemberIds,
includeDeparted,
})
const filterConditions = buildFilterConditions({
action,
resourceType,
actorId,
search,
startDate,
endDate,
})
const conditions = [scopeCondition, ...filterConditions]
const rows: ReturnType<typeof formatAuditLogEntry>[] = []
let cursor: string | undefined
let truncated = false
while (rows.length < EXPORT_SAFETY_CAP) {
const page = await queryAuditLogs(
conditions,
Math.min(EXPORT_PAGE_SIZE, EXPORT_SAFETY_CAP - rows.length),
cursor
)
rows.push(...page.data.map(formatAuditLogEntry))
if (!page.nextCursor) break
truncated = rows.length >= EXPORT_SAFETY_CAP
cursor = page.nextCursor
}
if (truncated) {
logger.warn('Audit log export truncated at safety cap', {
userId: session.user.id,
organizationId,
cap: EXPORT_SAFETY_CAP,
})
}
const csvLines = rows.map((log) =>
toCsvRow([
formatCsvValue(log.createdAt),
formatCsvValue(log.action),
formatCsvValue(log.resourceType),
formatCsvValue(log.resourceName),
formatCsvValue(log.actorEmail || log.actorName || 'System'),
formatCsvValue(log.description),
])
)
const csv = [CSV_HEADER, ...csvLines].join('\n')
const filename = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`
logger.info('Exported audit logs', {
userId: session.user.id,
organizationId,
rowCount: rows.length,
})
return new NextResponse(csv, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'no-cache',
'X-Export-Truncated': truncated ? '1' : '0',
},
})
} catch (error: unknown) {
const message = getErrorMessage(error, 'Unknown error')
logger.error('Audit logs export error', { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
+93
View File
@@ -0,0 +1,93 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { listAuditLogsContract } from '@/lib/api/contracts/audit-logs'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import {
buildFilterConditions,
buildOrgScopeCondition,
getOrgWorkspaceIds,
queryAuditLogs,
} from '@/app/api/v1/audit-logs/query'
const logger = createLogger('AuditLogsAPI')
export const dynamic = 'force-dynamic'
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const authResult = await validateEnterpriseAuditAccess(session.user.id)
if (!authResult.success) {
return authResult.response
}
const { organizationId, orgMemberIds } = authResult.context
const parsed = await parseRequest(
listAuditLogsContract,
request,
{},
{
validationErrorResponse: (error) =>
NextResponse.json(
{ error: getValidationErrorMessage(error, 'Invalid query parameters') },
{ status: 400 }
),
}
)
if (!parsed.success) return parsed.response
const {
search,
action,
resourceType,
actorId,
startDate,
endDate,
includeDeparted,
limit,
cursor,
} = parsed.data.query
const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId)
const scopeCondition = buildOrgScopeCondition({
organizationId,
orgWorkspaceIds,
orgMemberIds,
includeDeparted,
})
const filterConditions = buildFilterConditions({
action,
resourceType,
actorId,
search,
startDate,
endDate,
})
const { data, nextCursor } = await queryAuditLogs(
[scopeCondition, ...filterConditions],
limit,
cursor
)
return NextResponse.json({
success: true,
data: data.map(formatAuditLogEntry),
nextCursor,
})
} catch (error: unknown) {
const message = getErrorMessage(error, 'Unknown error')
logger.error('Audit logs fetch error', { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,139 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handlerMocks = vi.hoisted(() => ({
betterAuthGET: vi.fn(),
betterAuthPOST: vi.fn(),
ensureAnonymousUserExists: vi.fn(),
createAnonymousSession: vi.fn(() => ({
user: { id: 'anon' },
session: { id: 'anon-session' },
})),
isAuthDisabled: false,
}))
vi.mock('better-auth/next-js', () => ({
toNextJsHandler: () => ({
GET: handlerMocks.betterAuthGET,
POST: handlerMocks.betterAuthPOST,
}),
}))
vi.mock('@/lib/auth', () => ({
auth: { handler: {} },
}))
vi.mock('@/lib/auth/anonymous', () => ({
ensureAnonymousUserExists: handlerMocks.ensureAnonymousUserExists,
createAnonymousSession: handlerMocks.createAnonymousSession,
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isAuthDisabled() {
return handlerMocks.isAuthDisabled
},
}))
import { GET, POST } from '@/app/api/auth/[...all]/route'
describe('auth catch-all route (DISABLE_AUTH get-session)', () => {
beforeEach(() => {
vi.clearAllMocks()
handlerMocks.isAuthDisabled = false
})
it('returns anonymous session in better-auth response envelope when auth is disabled', async () => {
handlerMocks.isAuthDisabled = true
const req = createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/auth/get-session'
)
const res = await GET(req as any)
const json = await res.json()
expect(handlerMocks.ensureAnonymousUserExists).toHaveBeenCalledTimes(1)
expect(handlerMocks.betterAuthGET).not.toHaveBeenCalled()
expect(json).toEqual({
user: { id: 'anon' },
session: { id: 'anon-session' },
})
})
it('delegates to better-auth handler when auth is enabled', async () => {
handlerMocks.isAuthDisabled = false
const { NextResponse } = await import('next/server')
handlerMocks.betterAuthGET.mockResolvedValueOnce(
new NextResponse(JSON.stringify({ data: { ok: true } }), {
headers: { 'content-type': 'application/json' },
}) as any
)
const req = createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/auth/get-session'
)
const res = await GET(req as any)
const json = await res.json()
expect(handlerMocks.ensureAnonymousUserExists).not.toHaveBeenCalled()
expect(handlerMocks.betterAuthGET).toHaveBeenCalledTimes(1)
expect(json).toEqual({ data: { ok: true } })
})
})
describe('auth catch-all route organization mutations', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('blocks Better Auth organization mutation endpoints that bypass app lifecycle rules', async () => {
const req = createMockRequest(
'POST',
undefined,
{},
'http://localhost:3000/api/auth/organization/create'
)
const res = await POST(req as any)
const json = await res.json()
expect(res.status).toBe(404)
expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled()
expect(json).toEqual({
error: 'Organization mutations are handled by application API routes.',
})
})
it('allows safe Better Auth organization session endpoints', async () => {
const { NextResponse } = await import('next/server')
handlerMocks.betterAuthPOST.mockResolvedValueOnce(
new NextResponse(JSON.stringify({ data: { ok: true } }), {
headers: { 'content-type': 'application/json' },
}) as any
)
const req = createMockRequest(
'POST',
undefined,
{},
'http://localhost:3000/api/auth/organization/set-active'
)
const res = await POST(req as any)
const json = await res.json()
expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1)
expect(json).toEqual({ data: { ok: true } })
})
})
+44
View File
@@ -0,0 +1,44 @@
import { toNextJsHandler } from 'better-auth/next-js'
import { type NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
import { isAuthDisabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
export const dynamic = 'force-dynamic'
const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler)
const SAFE_ORGANIZATION_POST_PATHS = new Set(['organization/check-slug', 'organization/set-active'])
function getAuthPath(request: NextRequest): string {
const pathname = request.nextUrl?.pathname ?? new URL(request.url).pathname
return pathname.replace('/api/auth/', '')
}
function isBlockedOrganizationMutationPath(path: string): boolean {
return path.startsWith('organization/') && !SAFE_ORGANIZATION_POST_PATHS.has(path)
}
export const GET = withRouteHandler(async (request: NextRequest) => {
const path = getAuthPath(request)
if (path === 'get-session' && isAuthDisabled) {
await ensureAnonymousUserExists()
return NextResponse.json(createAnonymousSession())
}
return betterAuthGET(request)
})
export const POST = withRouteHandler(async (request: NextRequest) => {
const path = getAuthPath(request)
if (isBlockedOrganizationMutationPath(path)) {
return NextResponse.json(
{ error: 'Organization mutations are handled by application API routes.' },
{ status: 404 }
)
}
return betterAuthPOST(request)
})
+61
View File
@@ -0,0 +1,61 @@
import { db } from '@sim/db'
import { account, credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { connectedAccountsQuerySchema } from '@/lib/api/contracts/oauth-connections'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('AuthAccountsAPI')
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const { provider } = connectedAccountsQuerySchema.parse({
provider: searchParams.get('provider') || undefined,
})
const whereConditions = [eq(account.userId, session.user.id)]
if (provider) {
whereConditions.push(eq(account.providerId, provider))
}
const accounts = await db
.select({
id: account.id,
accountId: account.accountId,
providerId: account.providerId,
credentialDisplayName: credential.displayName,
})
.from(account)
.leftJoin(credential, eq(credential.accountId, account.id))
.where(and(...whereConditions))
.orderBy(desc(account.updatedAt))
const seen = new Map<string, (typeof accounts)[number]>()
for (const acc of accounts) {
if (!seen.has(acc.id)) {
seen.set(acc.id, acc)
}
}
const accountsWithDisplayName = Array.from(seen.values()).map((acc) => ({
id: acc.id,
accountId: acc.accountId,
providerId: acc.providerId,
displayName: acc.credentialDisplayName || acc.accountId || acc.providerId,
}))
return NextResponse.json({ accounts: accountsWithDisplayName })
} catch (error) {
logger.error('Failed to fetch accounts', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,170 @@
/**
* Tests for forget password API route
*
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockRequestPasswordReset, mockLogger } = vi.hoisted(() => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
child: vi.fn(),
}
return {
mockRequestPasswordReset: vi.fn(),
mockLogger: logger,
}
})
vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: vi.fn(() => 'https://app.example.com'),
}))
vi.mock('@/lib/auth', () => ({
auth: {
api: {
requestPasswordReset: mockRequestPasswordReset,
},
},
}))
vi.mock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
getRequestContext: () => undefined,
}))
import { POST } from '@/app/api/auth/forget-password/route'
describe('Forget Password API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRequestPasswordReset.mockResolvedValue(undefined)
})
afterEach(() => {
vi.clearAllMocks()
})
it('should send password reset email successfully with same-origin redirectTo', async () => {
const req = createMockRequest('POST', {
email: 'test@example.com',
redirectTo: 'https://app.example.com/reset',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(mockRequestPasswordReset).toHaveBeenCalledWith({
body: {
email: 'test@example.com',
redirectTo: 'https://app.example.com/reset',
},
method: 'POST',
})
})
it('should reject external redirectTo URL', async () => {
const req = createMockRequest('POST', {
email: 'test@example.com',
redirectTo: 'https://evil.com/phishing',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Redirect URL must be a valid same-origin URL')
expect(mockRequestPasswordReset).not.toHaveBeenCalled()
})
it('should send password reset email without redirectTo', async () => {
const req = createMockRequest('POST', {
email: 'test@example.com',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(mockRequestPasswordReset).toHaveBeenCalledWith({
body: {
email: 'test@example.com',
redirectTo: undefined,
},
method: 'POST',
})
})
it('should handle missing email', async () => {
const req = createMockRequest('POST', {})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Email is required')
expect(mockRequestPasswordReset).not.toHaveBeenCalled()
})
it('should handle empty email', async () => {
const req = createMockRequest('POST', {
email: '',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Please provide a valid email address')
expect(mockRequestPasswordReset).not.toHaveBeenCalled()
})
it('should handle auth service error with message', async () => {
const errorMessage = 'User not found'
mockRequestPasswordReset.mockRejectedValue(new Error(errorMessage))
const req = createMockRequest('POST', {
email: 'nonexistent@example.com',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.message).toBe(errorMessage)
expect(mockLogger.error).toHaveBeenCalledWith('Error requesting password reset:', {
error: expect.any(Error),
})
})
it('should handle unknown error', async () => {
mockRequestPasswordReset.mockRejectedValue('Unknown error')
const req = createMockRequest('POST', {
email: 'test@example.com',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.message).toBe('Failed to send password reset email. Please try again later.')
expect(mockLogger.error).toHaveBeenCalled()
})
})
@@ -0,0 +1,78 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { forgetPasswordContract } from '@/lib/api/contracts'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { auth } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
export const dynamic = 'force-dynamic'
const logger = createLogger('ForgetPasswordAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const parsed = await parseRequest(
forgetPasswordContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn('Invalid forget password request data', { errors: error.issues })
return NextResponse.json(
{ message: getValidationErrorMessage(error, 'Invalid request data') },
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const { email, redirectTo } = parsed.data.body
await auth.api.requestPasswordReset({
body: {
email,
redirectTo,
},
method: 'POST',
})
const [existingUser] = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.email, email))
.limit(1)
if (existingUser) {
recordAudit({
actorId: existingUser.id,
actorName: existingUser.name,
actorEmail: existingUser.email,
action: AuditAction.PASSWORD_RESET_REQUESTED,
resourceType: AuditResourceType.PASSWORD,
resourceId: existingUser.id,
resourceName: existingUser.email ?? undefined,
description: `Password reset requested for ${existingUser.email}`,
request,
})
}
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error requesting password reset:', { error })
return NextResponse.json(
{
message:
error instanceof Error
? error.message
: 'Failed to send password reset email. Please try again later.',
},
{ status: 500 }
)
}
})
@@ -0,0 +1,180 @@
/**
* Tests for OAuth connections API route
*
* @vitest-environment node
*/
import {
authMockFns,
createMockRequest,
dbChainMock,
dbChainMockFns,
resetDbChainMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockParseProvider, mockDecodeJwt, mockEq } = vi.hoisted(() => ({
mockParseProvider: vi.fn(),
mockDecodeJwt: vi.fn(),
mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
}))
vi.mock('@sim/db', () => ({
...dbChainMock,
account: { userId: 'userId', providerId: 'providerId' },
user: { email: 'email', id: 'id' },
eq: mockEq,
}))
vi.mock('drizzle-orm', () => ({
eq: mockEq,
}))
vi.mock('jose', () => ({
decodeJwt: mockDecodeJwt,
}))
vi.mock('@/lib/oauth/utils', () => ({
parseProvider: mockParseProvider,
}))
import { GET } from '@/app/api/auth/oauth/connections/route'
describe('OAuth Connections API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockParseProvider.mockImplementation((providerId: string) => ({
baseProvider: providerId.split('-')[0] || providerId,
featureType: providerId.split('-')[1] || 'default',
}))
})
it('should return connections successfully', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
const mockAccounts = [
{
id: 'account-1',
providerId: 'google-email',
accountId: 'test@example.com',
scope: 'email profile',
updatedAt: new Date('2024-01-01'),
idToken: null,
},
{
id: 'account-2',
providerId: 'github',
accountId: 'testuser',
scope: 'repo',
updatedAt: new Date('2024-01-02'),
idToken: null,
},
]
const mockUserRecord = [{ email: 'user@example.com' }]
dbChainMockFns.where.mockResolvedValueOnce(mockAccounts)
dbChainMockFns.limit.mockResolvedValueOnce(mockUserRecord)
const req = createMockRequest('GET')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.connections).toHaveLength(2)
expect(data.connections[0]).toMatchObject({
provider: 'google-email',
baseProvider: 'google',
featureType: 'email',
isConnected: true,
})
expect(data.connections[1]).toMatchObject({
provider: 'github',
baseProvider: 'github',
featureType: 'default',
isConnected: true,
})
})
it('should handle unauthenticated user', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce(null)
const req = createMockRequest('GET')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('User not authenticated')
})
it('should handle user with no connections', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
dbChainMockFns.where.mockResolvedValueOnce([])
dbChainMockFns.limit.mockResolvedValueOnce([])
const req = createMockRequest('GET')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.connections).toHaveLength(0)
})
it('should handle database error', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error'))
const req = createMockRequest('GET')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Internal server error')
})
it('should decode ID token for display name', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
const mockAccounts = [
{
id: 'account-1',
providerId: 'google',
accountId: 'google-user-id',
scope: 'email profile',
updatedAt: new Date('2024-01-01'),
idToken: 'mock-jwt-token',
},
]
mockDecodeJwt.mockReturnValueOnce({
email: 'decoded@example.com',
name: 'Decoded User',
})
dbChainMockFns.where.mockResolvedValueOnce(mockAccounts)
dbChainMockFns.limit.mockResolvedValueOnce([])
const req = createMockRequest('GET')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.connections[0].accounts[0].name).toBe('decoded@example.com')
})
})
@@ -0,0 +1,139 @@
import { account, db, user } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { decodeJwt } from 'jose'
import { type NextRequest, NextResponse } from 'next/server'
import type { OAuthConnection } from '@/lib/api/contracts/oauth-connections'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { OAuthProvider } from '@/lib/oauth'
import { parseProvider } from '@/lib/oauth'
const logger = createLogger('OAuthConnectionsAPI')
interface GoogleIdToken {
email?: string
sub?: string
name?: string
}
/**
* Get all OAuth connections for the current user
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
// Get the session
const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get all accounts for this user
const accounts = await db.select().from(account).where(eq(account.userId, session.user.id))
// Get the user's email for fallback
const userRecord = await db
.select({ email: user.email })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
const userEmail = userRecord.length > 0 ? userRecord[0]?.email : null
// Process accounts to determine connections
const connections: OAuthConnection[] = []
for (const acc of accounts) {
const { baseProvider, featureType } = parseProvider(acc.providerId as OAuthProvider)
const scopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
if (baseProvider) {
// Try multiple methods to get a user-friendly display name
let displayName = ''
// Method 1: Try to extract email from ID token (works for Google, etc.)
if (acc.idToken) {
try {
const decoded = decodeJwt<GoogleIdToken>(acc.idToken)
if (decoded.email) {
displayName = decoded.email
} else if (decoded.name) {
displayName = decoded.name
}
} catch (_error) {
logger.warn(`[${requestId}] Error decoding ID token`, {
accountId: acc.id,
})
}
}
// Method 2: For GitHub, the accountId might be the username
if (!displayName && baseProvider === 'github') {
displayName = `${acc.accountId} (GitHub)`
}
// Method 3: Use the user's email from our database
if (!displayName && userEmail) {
displayName = userEmail
}
// Fallback: Use accountId with provider type as context
if (!displayName) {
displayName = `${acc.accountId} (${baseProvider})`
}
// Create a unique connection key that includes the full provider ID
const connectionKey = acc.providerId
// Find existing connection for this specific provider ID
const existingConnection = connections.find((conn) => conn.provider === connectionKey)
const accountSummary = {
id: acc.id,
name: displayName,
}
if (existingConnection) {
// Add account to existing connection
existingConnection.accounts = existingConnection.accounts || []
existingConnection.accounts.push(accountSummary)
existingConnection.scopes = Array.from(
new Set([...(existingConnection.scopes || []), ...scopes])
)
const existingTimestamp = existingConnection.lastConnected
? new Date(existingConnection.lastConnected).getTime()
: 0
const candidateTimestamp = acc.updatedAt.getTime()
if (candidateTimestamp > existingTimestamp) {
existingConnection.lastConnected = acc.updatedAt.toISOString()
}
} else {
// Create new connection
connections.push({
provider: connectionKey,
baseProvider,
featureType,
isConnected: true,
scopes,
lastConnected: acc.updatedAt.toISOString(),
accounts: [accountSummary],
})
}
}
}
return NextResponse.json({ connections }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching OAuth connections`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,93 @@
/**
* Tests for OAuth credentials API route
*
* @vitest-environment node
*/
import { hybridAuthMockFns, permissionsMock, workflowsUtilsMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/credentials/oauth', () => ({
syncWorkspaceOAuthCredentialsForUser: vi.fn(),
}))
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
import { GET } from '@/app/api/auth/oauth/credentials/route'
describe('OAuth Credentials API Route', () => {
function createMockRequestWithQuery(method = 'GET', queryParams = ''): NextRequest {
const url = `http://localhost:3000/api/auth/oauth/credentials${queryParams}`
return new NextRequest(new URL(url), { method })
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should handle unauthenticated user', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: false,
error: 'Authentication required',
})
const req = createMockRequestWithQuery('GET', '?provider=google')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('User not authenticated')
})
it('should handle missing provider parameter', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
const req = createMockRequestWithQuery('GET')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Provider or credentialId is required')
})
it('should handle no credentials found', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
const req = createMockRequestWithQuery('GET', '?provider=github')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.credentials).toHaveLength(0)
})
it('should return empty credentials when no workspace context', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
const req = createMockRequestWithQuery('GET', '?provider=google-email')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.credentials).toHaveLength(0)
})
})
@@ -0,0 +1,304 @@
import { db } from '@sim/db'
import { account, credential, credentialMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { and, eq, isNotNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { oauthCredentialsQuerySchema } from '@/lib/api/contracts/credentials'
import { getValidationErrorMessage } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCredentialActorContext } from '@/lib/credentials/access'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import {
getCanonicalScopesForProvider,
getServiceAccountProviderForProviderId,
} from '@/lib/oauth/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('OAuthCredentialsAPI')
function toCredentialResponse(
id: string,
displayName: string,
providerId: string,
updatedAt: Date,
scope: string | null,
credentialType: 'oauth' | 'service_account' = 'oauth'
) {
const storedScope = scope?.trim()
// Some providers (e.g. Box) don't return scopes in their token response,
// so the DB column stays empty. Fall back to the configured scopes for
// the provider so the credential-selector doesn't show a false
// "Additional permissions required" banner.
const scopes = storedScope
? storedScope.split(/[\s,]+/).filter(Boolean)
: getCanonicalScopesForProvider(providerId)
const [_, featureType = 'default'] = providerId.split('-')
return {
id,
name: displayName,
provider: providerId,
type: credentialType,
lastUsed: updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes,
}
}
/**
* Get credentials for a specific provider
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const { searchParams } = new URL(request.url)
const rawQuery = {
provider: searchParams.get('provider'),
workflowId: searchParams.get('workflowId'),
workspaceId: searchParams.get('workspaceId'),
credentialId: searchParams.get('credentialId'),
}
const parseResult = oauthCredentialsQuerySchema.safeParse(rawQuery)
if (!parseResult.success) {
const refinementError = parseResult.error.issues.find((err) => err.code === 'custom')
if (refinementError) {
logger.warn(`[${requestId}] Invalid query parameters: ${refinementError.message}`)
return NextResponse.json({ error: refinementError.message }, { status: 400 })
}
logger.warn(`[${requestId}] Invalid query parameters`, {
errors: parseResult.error.issues,
})
return NextResponse.json(
{ error: getValidationErrorMessage(parseResult.error, 'Validation failed') },
{ status: 400 }
)
}
const { provider: providerParam, workflowId, workspaceId, credentialId } = parseResult.data
// Authenticate requester (supports session and internal JWT)
const authResult = await checkSessionOrInternalAuth(request)
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthenticated credentials request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const requesterUserId = authResult.userId
let effectiveWorkspaceId = workspaceId ?? undefined
if (workflowId) {
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId: requesterUserId,
action: 'read',
})
if (!workflowAuthorization.allowed) {
logger.warn(`[${requestId}] Forbidden credentials request for workflow`, {
requesterUserId,
workflowId,
status: workflowAuthorization.status,
})
return NextResponse.json(
{ error: workflowAuthorization.message || 'Forbidden' },
{ status: workflowAuthorization.status }
)
}
effectiveWorkspaceId = workflowAuthorization.workflow?.workspaceId || undefined
}
let requesterCanAdmin = false
if (effectiveWorkspaceId) {
const workspaceAccess = await checkWorkspaceAccess(effectiveWorkspaceId, requesterUserId)
if (!workspaceAccess.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
requesterCanAdmin = workspaceAccess.canAdmin
}
if (credentialId) {
const [platformCredential] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
providerId: credential.providerId,
accountId: credential.accountId,
updatedAt: credential.updatedAt,
accountProviderId: account.providerId,
accountScope: account.scope,
accountUpdatedAt: account.updatedAt,
})
.from(credential)
.leftJoin(account, eq(credential.accountId, account.id))
.where(eq(credential.id, credentialId))
.limit(1)
if (platformCredential) {
if (platformCredential.type === 'service_account') {
if (
workflowId &&
(!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId)
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (!workflowId) {
const access = await getCredentialActorContext(platformCredential.id, requesterUserId)
if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
return NextResponse.json(
{
credentials: [
toCredentialResponse(
platformCredential.id,
platformCredential.displayName,
platformCredential.providerId || 'google-service-account',
platformCredential.updatedAt,
null,
'service_account'
),
],
},
{ status: 200 }
)
}
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}
if (workflowId) {
if (!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
} else {
const access = await getCredentialActorContext(platformCredential.id, requesterUserId)
if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
if (!platformCredential.accountProviderId || !platformCredential.accountUpdatedAt) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}
return NextResponse.json(
{
credentials: [
toCredentialResponse(
platformCredential.id,
platformCredential.displayName,
platformCredential.accountProviderId,
platformCredential.accountUpdatedAt,
platformCredential.accountScope
),
],
},
{ status: 200 }
)
}
}
if (effectiveWorkspaceId && providerParam) {
await syncWorkspaceOAuthCredentialsForUser({
workspaceId: effectiveWorkspaceId,
userId: requesterUserId,
})
const oauthSelect = {
id: credential.id,
displayName: credential.displayName,
providerId: account.providerId,
scope: account.scope,
updatedAt: account.updatedAt,
}
const credentialsData = await db
.select(oauthSelect)
.from(credential)
.innerJoin(account, eq(credential.accountId, account.id))
.leftJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.where(
and(
eq(credential.workspaceId, effectiveWorkspaceId),
eq(credential.type, 'oauth'),
eq(account.providerId, providerParam),
requesterCanAdmin ? undefined : isNotNull(credentialMember.id)
)
)
const results = credentialsData.map((row) =>
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
)
const saProviderId = getServiceAccountProviderForProviderId(providerParam)
if (saProviderId) {
const saSelect = {
id: credential.id,
displayName: credential.displayName,
providerId: credential.providerId,
updatedAt: credential.updatedAt,
}
const serviceAccountCreds = await db
.select(saSelect)
.from(credential)
.leftJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.where(
and(
eq(credential.workspaceId, effectiveWorkspaceId),
eq(credential.type, 'service_account'),
eq(credential.providerId, saProviderId),
requesterCanAdmin ? undefined : isNotNull(credentialMember.id)
)
)
for (const sa of serviceAccountCreds) {
results.push(
toCredentialResponse(
sa.id,
sa.displayName,
sa.providerId || saProviderId,
sa.updatedAt,
null,
'service_account'
)
)
}
}
return NextResponse.json({ credentials: results }, { status: 200 })
}
return NextResponse.json({ credentials: [] }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching OAuth credentials`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,107 @@
/**
* Tests for OAuth disconnect API route
*
* @vitest-environment node
*/
import {
auditMock,
authMockFns,
createMockRequest,
dbChainMock,
dbChainMockFns,
resetDbChainMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@sim/audit', () => auditMock)
import { POST } from '@/app/api/auth/oauth/disconnect/route'
describe('OAuth Disconnect API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
dbChainMockFns.where.mockResolvedValue([])
})
it('should disconnect provider successfully', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
const req = createMockRequest('POST', {
provider: 'google',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
})
it('should disconnect specific provider ID successfully', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
const req = createMockRequest('POST', {
provider: 'google',
providerId: 'google-email',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
})
it('should handle unauthenticated user', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce(null)
const req = createMockRequest('POST', {
provider: 'google',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('User not authenticated')
})
it('should handle missing provider', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
const req = createMockRequest('POST', {})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Provider is required')
})
it('should handle database error', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error'))
const req = createMockRequest('POST', {
provider: 'google',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Internal server error')
})
})
@@ -0,0 +1,125 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { account, credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, like, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { disconnectOAuthContract } from '@/lib/api/contracts/oauth-connections'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { deleteCredential } from '@/lib/credentials/deletion'
import { captureServerEvent } from '@/lib/posthog/server'
export const dynamic = 'force-dynamic'
const logger = createLogger('OAuthDisconnectAPI')
/**
* Disconnect an OAuth provider for the current user
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated disconnect request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const parsed = await parseRequest(
disconnectOAuthContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid disconnect request`, { errors: error.issues })
return NextResponse.json(
{ error: getValidationErrorMessage(error, 'Validation failed') },
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const { provider, providerId, accountId } = parsed.data.body
logger.info(`[${requestId}] Processing OAuth disconnect request`, {
provider,
hasProviderId: !!providerId,
})
// Delete credentials before their accounts so deleteCredential can clear
// stored references first. Otherwise FK CASCADE would orphan them silently.
const accountFilter = accountId
? and(eq(account.userId, session.user.id), eq(account.id, accountId))
: providerId
? and(eq(account.userId, session.user.id), eq(account.providerId, providerId))
: and(
eq(account.userId, session.user.id),
or(eq(account.providerId, provider), like(account.providerId, `${provider}-%`))
)
const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter)
const targetAccountIds = targetAccounts.map((a) => a.id)
if (targetAccountIds.length > 0) {
const credentialsToDelete = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
providerId: credential.providerId,
})
.from(credential)
.where(inArray(credential.accountId, targetAccountIds))
for (const cred of credentialsToDelete) {
await deleteCredential({
credentialId: cred.id,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
reason: 'oauth_disconnect',
request,
})
captureServerEvent(
session.user.id,
'credential_deleted',
{
credential_type: 'oauth',
provider_id: cred.providerId ?? providerId ?? provider,
workspace_id: cred.workspaceId,
},
{ groups: { workspace: cred.workspaceId } }
)
}
await db.delete(account).where(inArray(account.id, targetAccountIds))
}
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.OAUTH_DISCONNECTED,
resourceType: AuditResourceType.OAUTH,
resourceId: providerId ?? provider,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: provider,
description: `Disconnected OAuth provider: ${provider}`,
metadata: { provider, providerId },
request,
})
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error disconnecting OAuth provider`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,123 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { microsoftFileQuerySchema } from '@/lib/api/contracts/selectors/microsoft'
import { getValidationErrorMessage } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('MicrosoftFileAPI')
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const { searchParams } = new URL(request.url)
const parsedQuery = microsoftFileQuerySchema.safeParse({
credentialId: searchParams.get('credentialId') ?? undefined,
fileId: searchParams.get('fileId') ?? undefined,
workflowId: searchParams.get('workflowId') ?? undefined,
})
if (!parsedQuery.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(parsedQuery.error) },
{ status: 400 }
)
}
const { credentialId, fileId, workflowId } = parsedQuery.data
const fileIdValidation = validateMicrosoftGraphId(fileId, 'fileId')
if (!fileIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid file ID: ${fileIdValidation.error}`)
return NextResponse.json({ error: fileIdValidation.error }, { status: 400 })
}
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
const status = authz.error === 'Credential not found' ? 404 : 403
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
}
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accessToken = await refreshAccessTokenIfNeeded(
resolvedCredentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
const response = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/items/${fileId}?$select=id,name,mimeType,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
logger.error(`[${requestId}] Microsoft Graph API error`, {
status: response.status,
error: errorData.error?.message || 'Failed to fetch file from Microsoft OneDrive',
})
return NextResponse.json(
{
error: errorData.error?.message || 'Failed to fetch file from Microsoft OneDrive',
},
{ status: response.status }
)
}
const file = await response.json()
const transformedFile = {
id: file.id,
name: file.name,
mimeType:
file.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
iconLink: file.thumbnails?.[0]?.small?.url,
webViewLink: file.webUrl,
thumbnailLink: file.thumbnails?.[0]?.medium?.url,
createdTime: file.createdDateTime,
modifiedTime: file.lastModifiedDateTime,
size: file.size?.toString(),
owners: file.createdBy
? [
{
displayName: file.createdBy.user?.displayName || 'Unknown',
emailAddress: file.createdBy.user?.email || '',
},
]
: [],
downloadUrl: `https://graph.microsoft.com/v1.0/me/drive/items/${file.id}/content`,
}
return NextResponse.json({ file: transformedFile }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching file from Microsoft OneDrive`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,219 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { microsoftFilesQuerySchema } from '@/lib/api/contracts/selectors/microsoft'
import { getValidationErrorMessage } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validatePathSegment } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils'
import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('MicrosoftFilesAPI')
/**
* Microsoft Graph paginates `search()` results via the `@odata.nextLink`
* absolute URL in the response body. Request the largest page (`$top` caps at
* 999) and drain following nextLink, bounded by a page cap.
* See https://learn.microsoft.com/en-us/graph/paging
*/
const MICROSOFT_FILES_PAGE_SIZE = 999
const MAX_MICROSOFT_FILES_PAGES = 20
interface MicrosoftGraphFile {
id: string
name?: string
mimeType?: string
webUrl?: string
size?: number
createdDateTime?: string
lastModifiedDateTime?: string
thumbnails?: Array<{ small?: { url?: string }; medium?: { url?: string } }>
createdBy?: { user?: { displayName?: string; email?: string } }
}
/**
* The shared `/api/auth/oauth/microsoft/files` route serves both the
* `microsoft.excel` and `microsoft.word` selectors. The two are distinguished
* by the `fileType` query parameter the selector forwards (defaulting to
* `excel` for backward compatibility), which drives both the search-query
* extension hint and the server-side result filter.
*/
const FILE_TYPE_CONFIG = {
excel: {
extension: '.xlsx',
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
},
word: {
extension: '.docx',
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
},
} as const
type MicrosoftFileType = keyof typeof FILE_TYPE_CONFIG
/**
* Get Excel or Word files from Microsoft OneDrive / SharePoint. The
* `fileType` query parameter selects which Office document type to return
* (defaults to `excel`).
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
// Get the credential ID from the query params
const { searchParams } = new URL(request.url)
const parsedQuery = microsoftFilesQuerySchema.safeParse({
credentialId: searchParams.get('credentialId') ?? undefined,
query: searchParams.get('query') ?? undefined,
driveId: searchParams.get('driveId') ?? undefined,
workflowId: searchParams.get('workflowId') ?? undefined,
fileType: searchParams.get('fileType') ?? undefined,
})
if (!parsedQuery.success) {
logger.warn(`[${requestId}] Invalid query parameters`)
return NextResponse.json(
{ error: getValidationErrorMessage(parsedQuery.error) },
{ status: 400 }
)
}
const { credentialId, driveId, workflowId } = parsedQuery.data
const query = parsedQuery.data.query ?? ''
const fileType: MicrosoftFileType = parsedQuery.data.fileType ?? 'excel'
const { extension, mimeType: targetMimeType } = FILE_TYPE_CONFIG[fileType]
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
const status = authz.error === 'Credential not found' ? 404 : 403
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
}
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(
resolvedCredentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Build search query for the requested Office document type
const searchQuery = query ? `${query} ${extension}` : extension
// Build the query parameters for Microsoft Graph API
const searchParams_new = new URLSearchParams()
searchParams_new.append(
'$select',
'id,name,mimeType,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy'
)
searchParams_new.append('$top', String(MICROSOFT_FILES_PAGE_SIZE))
// When driveId is provided (SharePoint), search within that specific drive.
// Otherwise, search the user's personal OneDrive.
if (driveId) {
const driveIdValidation = validatePathSegment(driveId, {
paramName: 'driveId',
customPattern: GRAPH_ID_PATTERN,
})
if (!driveIdValidation.isValid) {
return NextResponse.json({ error: driveIdValidation.error }, { status: 400 })
}
}
const drivePath = driveId ? `drives/${driveId}` : 'me/drive'
const rawFiles: MicrosoftGraphFile[] = []
let nextUrl: string | undefined =
`https://graph.microsoft.com/v1.0/${drivePath}/root/search(q='${encodeURIComponent(searchQuery)}')?${searchParams_new.toString()}`
for (let page = 0; page < MAX_MICROSOFT_FILES_PAGES && nextUrl; page++) {
const response = await fetch(nextUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ error: { message: 'Unknown error' } }))
logger.error(`[${requestId}] Microsoft Graph API error`, {
status: response.status,
error: errorData.error?.message || 'Failed to fetch files from Microsoft OneDrive',
})
return NextResponse.json(
{
error: errorData.error?.message || 'Failed to fetch files from Microsoft OneDrive',
},
{ status: response.status }
)
}
const data = await response.json()
rawFiles.push(...((data.value as MicrosoftGraphFile[]) || []))
const nextLink = getGraphNextPageUrl(data)
nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined
if (nextUrl && page === MAX_MICROSOFT_FILES_PAGES - 1) {
logger.warn(
`[${requestId}] Microsoft files search hit pagination cap; list may be incomplete`,
{ fileType, pages: MAX_MICROSOFT_FILES_PAGES, collected: rawFiles.length }
)
}
}
// Transform Microsoft Graph response and filter to the requested file type
const files = rawFiles
.filter(
(file: MicrosoftGraphFile) =>
file.name?.toLowerCase().endsWith(extension) || file.mimeType === targetMimeType
)
.map((file: MicrosoftGraphFile) => ({
id: file.id,
name: file.name,
mimeType: file.mimeType || targetMimeType,
iconLink: file.thumbnails?.[0]?.small?.url,
webViewLink: file.webUrl,
thumbnailLink: file.thumbnails?.[0]?.medium?.url,
createdTime: file.createdDateTime,
modifiedTime: file.lastModifiedDateTime,
size: file.size?.toString(),
owners: file.createdBy
? [
{
displayName: file.createdBy.user?.displayName || 'Unknown',
emailAddress: file.createdBy.user?.email || '',
},
]
: [],
}))
return NextResponse.json({ files }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching files from Microsoft OneDrive`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,443 @@
/**
* Tests for OAuth token API routes
*
* @vitest-environment node
*/
import {
authOAuthUtilsMock,
authOAuthUtilsMockFns,
createMockRequest,
hybridAuthMockFns,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockAuthorizeCredentialUse } = vi.hoisted(() => ({
mockAuthorizeCredentialUse: vi.fn(),
}))
vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
vi.mock('@/lib/auth/credential-access', () => ({
authorizeCredentialUse: mockAuthorizeCredentialUse,
}))
import { GET, POST } from '@/app/api/auth/oauth/token/route'
describe('OAuth Token API Routes', () => {
beforeEach(() => {
vi.clearAllMocks()
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValue(null)
})
/**
* POST route tests
*/
describe('POST handler', () => {
it('should return access token successfully', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'google',
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({
accessToken: 'fresh-token',
refreshed: false,
})
const req = createMockRequest('POST', {
credentialId: 'credential-id',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(authOAuthUtilsMockFns.mockGetCredential).toHaveBeenCalled()
expect(authOAuthUtilsMockFns.mockRefreshTokenIfNeeded).toHaveBeenCalled()
})
it('should handle workflowId for server-side authentication', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'internal_jwt',
requesterUserId: 'workflow-owner-id',
credentialOwnerUserId: 'workflow-owner-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'google',
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({
accessToken: 'fresh-token',
refreshed: false,
})
const req = createMockRequest('POST', {
credentialId: 'credential-id',
workflowId: 'workflow-id',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(authOAuthUtilsMockFns.mockGetCredential).toHaveBeenCalled()
})
it('should handle missing credentialId', async () => {
const req = createMockRequest('POST', {})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty(
'error',
'Either credentialId or (credentialAccountUserId + providerId) is required'
)
})
it('should handle authentication failure', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: false,
error: 'Authentication required',
})
const req = createMockRequest('POST', {
credentialId: 'credential-id',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty('error')
})
it('should handle workflow not found', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: false, error: 'Workflow not found' })
const req = createMockRequest('POST', {
credentialId: 'credential-id',
workflowId: 'nonexistent-workflow-id',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(403)
})
it('should handle credential not found', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce(undefined)
const req = createMockRequest('POST', {
credentialId: 'nonexistent-credential-id',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(404)
expect(data).toHaveProperty('error')
})
it('should handle token refresh failure', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), // Expired
providerId: 'google',
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockRejectedValueOnce(
new Error('Refresh failure')
)
const req = createMockRequest('POST', {
credentialId: 'credential-id',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'Failed to refresh access token')
})
describe('credentialAccountUserId + providerId path', () => {
it('should reject unauthenticated requests', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: false,
error: 'Authentication required',
})
const req = createMockRequest('POST', {
credentialAccountUserId: 'target-user-id',
providerId: 'google',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'User not authenticated')
expect(authOAuthUtilsMockFns.mockGetOAuthToken).not.toHaveBeenCalled()
})
it('should reject internal JWT authentication', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
authType: 'internal_jwt',
userId: 'test-user-id',
})
const req = createMockRequest('POST', {
credentialAccountUserId: 'test-user-id',
providerId: 'google',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'User not authenticated')
expect(authOAuthUtilsMockFns.mockGetOAuthToken).not.toHaveBeenCalled()
})
it('should reject requests for other users credentials', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'attacker-user-id',
})
const req = createMockRequest('POST', {
credentialAccountUserId: 'victim-user-id',
providerId: 'google',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty('error', 'Unauthorized')
expect(authOAuthUtilsMockFns.mockGetOAuthToken).not.toHaveBeenCalled()
})
it('should allow session-authenticated users to access their own credentials', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
authOAuthUtilsMockFns.mockGetOAuthToken.mockResolvedValueOnce('valid-access-token')
const req = createMockRequest('POST', {
credentialAccountUserId: 'test-user-id',
providerId: 'google',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'valid-access-token')
expect(authOAuthUtilsMockFns.mockGetOAuthToken).toHaveBeenCalledWith(
'test-user-id',
'google'
)
})
it('should return 404 when credential not found for user', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
authOAuthUtilsMockFns.mockGetOAuthToken.mockResolvedValueOnce(null)
const req = createMockRequest('POST', {
credentialAccountUserId: 'test-user-id',
providerId: 'nonexistent-provider',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toContain('No credential found')
})
})
})
/**
* GET route tests
*/
describe('GET handler', () => {
it('should return access token successfully', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'google',
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({
accessToken: 'fresh-token',
refreshed: false,
})
const req = new NextRequest(
'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id'
)
const response = await GET(req as any)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(authOAuthUtilsMockFns.mockGetCredential).toHaveBeenCalled()
expect(authOAuthUtilsMockFns.mockRefreshTokenIfNeeded).toHaveBeenCalled()
})
it('should handle missing credentialId', async () => {
const req = new NextRequest('http://localhost:3000/api/auth/oauth/token')
const response = await GET(req as any)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty('error', 'Credential ID is required')
})
it('should handle authentication failure', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: false,
error: 'Authentication required',
})
const req = new NextRequest(
'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id'
)
const response = await GET(req as any)
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty('error')
})
it('should handle credential not found', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce(undefined)
const req = new NextRequest(
'http://localhost:3000/api/auth/oauth/token?credentialId=nonexistent-credential-id'
)
const response = await GET(req as any)
const data = await response.json()
expect(response.status).toBe(404)
expect(data).toHaveProperty('error')
})
it('should handle missing access token', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: null,
refreshToken: 'refresh-token',
providerId: 'google',
})
const req = new NextRequest(
'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id'
)
const response = await GET(req as any)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty('error')
})
it('should handle token refresh failure', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), // Expired
providerId: 'google',
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockRejectedValueOnce(
new Error('Refresh failure')
)
const req = new NextRequest(
'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id'
)
const response = await GET(req as any)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error')
})
})
})
+384
View File
@@ -0,0 +1,384 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import {
oauthTokenGetContract,
oauthTokenPostContract,
} from '@/lib/api/contracts/oauth-connections'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
getCredential,
getOAuthToken,
refreshTokenIfNeeded,
resolveOAuthAccountId,
resolveServiceAccountToken,
} from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('OAuthTokenAPI')
const SALESFORCE_INSTANCE_URL_REGEX = /__sf_instance__:([^\s]+)/
/**
* Get an access token for a specific credential
* Supports both session-based authentication (for client-side requests)
* and workflow-based authentication (for server-side requests)
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
logger.info(`[${requestId}] OAuth token API POST request received`)
try {
const parsed = await parseRequest(
oauthTokenPostContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid token request`, { errors: error.issues })
return NextResponse.json(
{ error: getValidationErrorMessage(error, 'Validation failed') },
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const {
credentialId,
credentialAccountUserId,
providerId,
workflowId,
scopes,
impersonateEmail,
} = parsed.data.body
const callerUserId = parsed.data.query.userId
if (credentialAccountUserId && providerId) {
logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, {
credentialAccountUserId,
providerId,
})
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || auth.authType !== AuthType.SESSION || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized request for credentialAccountUserId path`, {
success: auth.success,
authType: auth.authType,
})
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
if (auth.userId !== credentialAccountUserId) {
logger.warn(
`[${requestId}] User ${auth.userId} attempted to access credentials for ${credentialAccountUserId}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
try {
const accessToken = await getOAuthToken(credentialAccountUserId, providerId)
if (!accessToken) {
return NextResponse.json(
{
error: `No credential found for user ${credentialAccountUserId} and provider ${providerId}`,
},
{ status: 404 }
)
}
recordAudit({
actorId: auth.userId,
action: AuditAction.CREDENTIAL_ACCESSED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: providerId,
description: `Accessed OAuth credential for provider ${providerId}`,
metadata: {
provider: providerId,
credentialType: 'oauth',
credentialAccountUserId,
},
request,
})
captureServerEvent(auth.userId, 'credential_used', {
credential_type: 'oauth',
provider_id: providerId,
})
return NextResponse.json({ accessToken }, { status: 200 })
} catch (error) {
const message = getErrorMessage(error, 'Failed to get OAuth token')
logger.warn(`[${requestId}] OAuth token error: ${message}`)
return NextResponse.json({ error: message }, { status: 403 })
}
}
if (!credentialId) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const resolved = await resolveOAuthAccountId(credentialId)
if (resolved?.credentialType === 'service_account' && resolved.credentialId) {
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,
requireWorkflowIdForInternal: false,
callerUserId,
})
if (!authz.ok) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const saActorId = authz.requesterUserId
const saWorkspaceId = resolved.workspaceId ?? authz.workspaceId ?? null
const emitServiceAccountAccess = () => {
if (!saActorId) return
recordAudit({
workspaceId: saWorkspaceId,
actorId: saActorId,
action: AuditAction.CREDENTIAL_ACCESSED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: resolved.credentialId ?? credentialId,
description: `Accessed service account credential for provider ${resolved.providerId ?? 'unknown'}`,
metadata: {
provider: resolved.providerId,
credentialType: 'service_account',
},
request,
})
captureServerEvent(
saActorId,
'credential_used',
{
credential_type: 'service_account',
provider_id: resolved.providerId ?? 'unknown',
...(saWorkspaceId ? { workspace_id: saWorkspaceId } : {}),
},
saWorkspaceId ? { groups: { workspace: saWorkspaceId } } : undefined
)
}
try {
const result = await resolveServiceAccountToken(
resolved.credentialId,
resolved.providerId,
scopes ?? [],
impersonateEmail
)
emitServiceAccountAccess()
return NextResponse.json(
{
accessToken: result.accessToken,
cloudId: result.cloudId,
domain: result.domain,
},
{ status: 200 }
)
} catch (error) {
logger.error(`[${requestId}] Service account token error:`, error)
return NextResponse.json({ error: 'Failed to get service account token' }, { status: 401 })
}
}
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,
requireWorkflowIdForInternal: false,
callerUserId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const oauthActorId = authz.requesterUserId
const oauthWorkspaceId = authz.workspaceId ?? null
try {
const { accessToken } = await refreshTokenIfNeeded(
requestId,
credential,
resolvedCredentialId
)
if (oauthActorId) {
recordAudit({
workspaceId: oauthWorkspaceId,
actorId: oauthActorId,
action: AuditAction.CREDENTIAL_ACCESSED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: resolvedCredentialId,
description: `Accessed OAuth credential for provider ${credential.providerId}`,
metadata: {
provider: credential.providerId,
credentialType: 'oauth',
},
request,
})
captureServerEvent(
oauthActorId,
'credential_used',
{
credential_type: 'oauth',
provider_id: credential.providerId,
...(oauthWorkspaceId ? { workspace_id: oauthWorkspaceId } : {}),
},
oauthWorkspaceId ? { groups: { workspace: oauthWorkspaceId } } : undefined
)
}
let instanceUrl: string | undefined
if (credential.providerId === 'salesforce' && credential.scope) {
const instanceMatch = credential.scope.match(SALESFORCE_INSTANCE_URL_REGEX)
if (instanceMatch) {
instanceUrl = instanceMatch[1]
}
}
return NextResponse.json(
{
accessToken,
idToken: credential.idToken || undefined,
...(instanceUrl && { instanceUrl }),
},
{ status: 200 }
)
} catch (error) {
logger.error(`[${requestId}] Failed to refresh access token:`, error)
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
}
} catch (error) {
logger.error(`[${requestId}] Error getting access token`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
/**
* Get the access token for a specific credential
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const parsed = await parseRequest(
oauthTokenGetContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid query parameters`, { errors: error.issues })
return NextResponse.json(
{ error: getValidationErrorMessage(error, 'Validation failed') },
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const { credentialId } = parsed.data.query
const authz = await authorizeCredentialUse(request, {
credentialId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || authz.authType !== AuthType.SESSION || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!credential.accessToken) {
logger.warn(`[${requestId}] No access token available for credential`)
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
}
const actorId = authz.requesterUserId
const workspaceId = authz.workspaceId ?? null
try {
const { accessToken } = await refreshTokenIfNeeded(
requestId,
credential,
resolvedCredentialId
)
if (actorId) {
recordAudit({
workspaceId,
actorId,
action: AuditAction.CREDENTIAL_ACCESSED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: resolvedCredentialId,
description: `Accessed OAuth credential for provider ${credential.providerId}`,
metadata: {
provider: credential.providerId,
credentialType: 'oauth',
},
request,
})
captureServerEvent(
actorId,
'credential_used',
{
credential_type: 'oauth',
provider_id: credential.providerId,
...(workspaceId ? { workspace_id: workspaceId } : {}),
},
workspaceId ? { groups: { workspace: workspaceId } } : undefined
)
}
// For Salesforce, extract instanceUrl from the scope field
let instanceUrl: string | undefined
if (credential.providerId === 'salesforce' && credential.scope) {
const instanceMatch = credential.scope.match(SALESFORCE_INSTANCE_URL_REGEX)
if (instanceMatch) {
instanceUrl = instanceMatch[1]
}
}
return NextResponse.json(
{
accessToken,
idToken: credential.idToken || undefined,
...(instanceUrl && { instanceUrl }),
},
{ status: 200 }
)
} catch (_error) {
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
}
} catch (error) {
logger.error(`[${requestId}] Error fetching access token`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
+334
View File
@@ -0,0 +1,334 @@
/**
* Tests for OAuth utility functions
*
* @vitest-environment node
*/
import { redisConfigMock, redisConfigMockFns } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/oauth/oauth', () => ({
refreshOAuthToken: vi.fn(),
OAUTH_PROVIDERS: {},
}))
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
const { mockDecryptSecret } = vi.hoisted(() => ({ mockDecryptSecret: vi.fn() }))
vi.mock('@/lib/core/security/encryption', () => ({
decryptSecret: mockDecryptSecret,
encryptSecret: vi.fn(async (value: string) => ({ encrypted: value, iv: 'iv' })),
}))
import { db } from '@sim/db'
import { __resetCoalesceLocallyForTests } from '@/lib/concurrency/singleflight'
import { refreshOAuthToken } from '@/lib/oauth'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
SLACK_CUSTOM_BOT_PROVIDER_ID,
} from '@/lib/oauth/types'
import {
getCredential,
refreshAccessTokenIfNeeded,
refreshTokenIfNeeded,
resolveServiceAccountToken,
} from '@/app/api/auth/oauth/utils'
const mockDb = db as any
const mockRefreshOAuthToken = refreshOAuthToken as any
/**
* Creates a chainable mock for db.select() calls.
* Returns a nested chain: select() -> from() -> where() -> limit() / orderBy()
*/
function mockSelectChain(limitResult: unknown[]) {
const mockLimit = vi.fn().mockReturnValue(limitResult)
const mockOrderBy = vi.fn().mockReturnValue(limitResult)
const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit, orderBy: mockOrderBy })
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere })
mockDb.select.mockReturnValueOnce({ from: mockFrom })
return { mockFrom, mockWhere, mockLimit }
}
/**
* Creates a chainable mock for db.update() calls.
* Returns a nested chain: update() -> set() -> where()
*/
function mockUpdateChain() {
const mockWhere = vi.fn().mockResolvedValue({})
const mockSet = vi.fn().mockReturnValue({ where: mockWhere })
mockDb.update.mockReturnValueOnce({ set: mockSet })
return { mockSet, mockWhere }
}
describe('OAuth Utils', () => {
beforeEach(() => {
vi.clearAllMocks()
__resetCoalesceLocallyForTests()
redisConfigMockFns.mockGetRedisClient.mockReturnValue(null)
redisConfigMockFns.mockAcquireLock.mockResolvedValue(true)
redisConfigMockFns.mockReleaseLock.mockResolvedValue(true)
})
afterEach(() => {
vi.clearAllMocks()
})
describe('getCredential', () => {
it('should return credential when found', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'resolved-account-id' }
const mockAccountRow = { id: 'resolved-account-id', userId: 'test-user-id' }
mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
const credential = await getCredential('request-id', 'credential-id', 'test-user-id')
expect(mockDb.select).toHaveBeenCalledTimes(2)
expect(credential).toMatchObject(mockAccountRow)
expect(credential).toMatchObject({ resolvedCredentialId: 'resolved-account-id' })
})
it('should return undefined when credential is not found', async () => {
mockSelectChain([])
mockSelectChain([])
const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id')
expect(credential).toBeUndefined()
})
})
describe('refreshTokenIfNeeded', () => {
it('should return valid token without refresh if not expired', async () => {
const mockCredential = {
id: 'credential-id',
accessToken: 'valid-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'google',
}
const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
expect(mockRefreshOAuthToken).not.toHaveBeenCalled()
expect(result).toEqual({ accessToken: 'valid-token', refreshed: false })
})
it('should refresh token when expired', async () => {
const mockCredential = {
id: 'credential-id',
accessToken: 'expired-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google',
}
mockRefreshOAuthToken.mockResolvedValueOnce({
ok: true,
accessToken: 'new-token',
expiresIn: 3600,
refreshToken: 'new-refresh-token',
})
mockUpdateChain()
const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
expect(mockDb.update).toHaveBeenCalled()
expect(result).toEqual({ accessToken: 'new-token', refreshed: true })
})
it('should handle refresh token error', async () => {
const mockCredential = {
id: 'credential-id',
accessToken: 'expired-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google',
}
mockRefreshOAuthToken.mockResolvedValueOnce({
ok: false,
errorCode: 'invalid_grant',
message: 'Failed',
})
await expect(
refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
).rejects.toThrow('Failed to refresh token')
})
it('should not attempt refresh if no refresh token', async () => {
const mockCredential = {
id: 'credential-id',
accessToken: 'token',
refreshToken: null,
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google',
}
const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
expect(mockRefreshOAuthToken).not.toHaveBeenCalled()
expect(result).toEqual({ accessToken: 'token', refreshed: false })
})
})
describe('refreshAccessTokenIfNeeded', () => {
it('should return valid access token without refresh if not expired', async () => {
const mockResolvedCredential = {
id: 'credential-id',
type: 'oauth',
accountId: 'account-id',
workspaceId: 'workspace-id',
}
const mockAccountRow = {
id: 'account-id',
accessToken: 'valid-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockResolvedCredential])
mockSelectChain([mockAccountRow])
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
expect(mockRefreshOAuthToken).not.toHaveBeenCalled()
expect(token).toBe('valid-token')
})
it('should refresh token when expired', async () => {
const mockResolvedCredential = {
id: 'credential-id',
type: 'oauth',
accountId: 'account-id',
workspaceId: 'workspace-id',
}
const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockResolvedCredential])
mockSelectChain([mockAccountRow])
mockUpdateChain()
mockRefreshOAuthToken.mockResolvedValueOnce({
ok: true,
accessToken: 'new-token',
expiresIn: 3600,
refreshToken: 'new-refresh-token',
})
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
expect(mockDb.update).toHaveBeenCalled()
expect(token).toBe('new-token')
})
it('should return null if credential not found', async () => {
mockSelectChain([])
mockSelectChain([])
const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id')
expect(token).toBeNull()
})
it('should return null if refresh fails', async () => {
const mockResolvedCredential = {
id: 'credential-id',
type: 'oauth',
accountId: 'account-id',
workspaceId: 'workspace-id',
}
const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockResolvedCredential])
mockSelectChain([mockAccountRow])
mockRefreshOAuthToken.mockResolvedValueOnce({
ok: false,
errorCode: 'invalid_grant',
message: 'Failed',
})
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
expect(token).toBeNull()
})
})
describe('resolveServiceAccountToken', () => {
it('throws loudly for an unknown provider (never silently attempts Google)', async () => {
await expect(resolveServiceAccountToken('cred-1', 'mystery-provider')).rejects.toThrow(
/Unsupported service-account provider/
)
})
it('returns the decrypted bot token for a custom Slack bot', async () => {
mockSelectChain([
{
type: 'service_account',
providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
encryptedServiceAccountKey: 'enc',
},
])
mockDecryptSecret.mockResolvedValueOnce({
decrypted: JSON.stringify({ signingSecret: 's', botToken: 'xoxb-tok', teamId: 'T1' }),
})
const result = await resolveServiceAccountToken('cred-1', SLACK_CUSTOM_BOT_PROVIDER_ID)
expect(result.accessToken).toBe('xoxb-tok')
})
it('throws when the Slack bot credential is missing', async () => {
mockSelectChain([])
await expect(
resolveServiceAccountToken('cred-1', SLACK_CUSTOM_BOT_PROVIDER_ID)
).rejects.toThrow(/Slack bot credential not found/)
})
it('returns apiToken + cloudId + domain for Atlassian', async () => {
mockSelectChain([{ encryptedServiceAccountKey: 'enc' }])
mockDecryptSecret.mockResolvedValueOnce({
decrypted: JSON.stringify({
type: 'atlassian_service_account',
apiToken: 'atk',
domain: 'acme.atlassian.net',
cloudId: 'cloud-1',
}),
})
const result = await resolveServiceAccountToken(
'cred-1',
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
)
expect(result).toMatchObject({
accessToken: 'atk',
cloudId: 'cloud-1',
domain: 'acme.atlassian.net',
})
})
it('requires scopes for a Google service account', async () => {
await expect(
resolveServiceAccountToken('cred-1', GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, [])
).rejects.toThrow(/Scopes are required/)
})
})
})
+736
View File
@@ -0,0 +1,736 @@
import { createSign } from 'crypto'
import { db } from '@sim/db'
import { account, credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresErrorCode, toError } from '@sim/utils/errors'
import { and, desc, eq } from 'drizzle-orm'
import { withLeaderLock } from '@/lib/concurrency/leader-lock'
import { coalesceLocally } from '@/lib/concurrency/singleflight'
import { decryptSecret } from '@/lib/core/security/encryption'
import { refreshOAuthToken } from '@/lib/oauth'
import {
getMicrosoftRefreshTokenExpiry,
isMicrosoftProvider,
PROACTIVE_REFRESH_THRESHOLD_DAYS,
} from '@/lib/oauth/microsoft'
import {
getRecentTerminalError,
isTerminalRefreshError,
markCredentialDead,
} from '@/lib/oauth/terminal-errors'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
SLACK_CUSTOM_BOT_PROVIDER_ID,
} from '@/lib/oauth/types'
const logger = createLogger('OAuthUtilsAPI')
export class ServiceAccountTokenError extends Error {
constructor(
public readonly statusCode: number,
public readonly errorDescription: string
) {
super(errorDescription)
this.name = 'ServiceAccountTokenError'
}
}
interface AccountInsertData {
id: string
userId: string
providerId: string
accountId: string
accessToken: string
scope: string
createdAt: Date
updatedAt: Date
refreshToken?: string
idToken?: string
accessTokenExpiresAt?: Date
}
export interface ResolvedCredential {
accountId: string
workspaceId?: string
usedCredentialTable: boolean
credentialType?: string
credentialId?: string
providerId?: string
}
/**
* Resolves a credential ID to its underlying account ID.
* If `credentialId` matches a `credential` row, returns its `accountId` and `workspaceId`.
* For service_account credentials, returns credentialId and type instead of accountId.
* Otherwise assumes `credentialId` is already a raw `account.id` (legacy).
*/
export async function resolveOAuthAccountId(
credentialId: string
): Promise<ResolvedCredential | null> {
const [credentialRow] = await db
.select({
id: credential.id,
type: credential.type,
accountId: credential.accountId,
workspaceId: credential.workspaceId,
providerId: credential.providerId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (credentialRow) {
if (credentialRow.type === 'service_account') {
return {
accountId: '',
credentialId: credentialRow.id,
credentialType: 'service_account',
workspaceId: credentialRow.workspaceId,
providerId: credentialRow.providerId ?? undefined,
usedCredentialTable: true,
}
}
if (credentialRow.type !== 'oauth' || !credentialRow.accountId) {
return null
}
return {
accountId: credentialRow.accountId,
workspaceId: credentialRow.workspaceId,
usedCredentialTable: true,
}
}
return { accountId: credentialId, usedCredentialTable: false }
}
/**
* Userinfo scopes are excluded because service accounts don't represent a user
* and cannot request user identity information. Google rejects token requests
* that include these scopes for service account credentials.
*/
const SA_EXCLUDED_SCOPES = new Set([
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
])
/**
* Generates a short-lived access token for a Google service account credential
* using the two-legged OAuth JWT flow (RFC 7523).
*
* @param impersonateEmail - Optional. Required for Google Workspace APIs (Gmail, Drive, Calendar, etc.)
* where the service account must impersonate a domain user via domain-wide delegation.
* Not needed for project-scoped APIs like BigQuery or Vertex AI where the service account
* authenticates directly with its own IAM permissions.
*/
export async function getServiceAccountToken(
credentialId: string,
scopes: string[],
impersonateEmail?: string
): Promise<string> {
const [credentialRow] = await db
.select({
encryptedServiceAccountKey: credential.encryptedServiceAccountKey,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!credentialRow?.encryptedServiceAccountKey) {
throw new Error('Service account key not found')
}
const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
const keyData = JSON.parse(decrypted) as {
client_email: string
private_key: string
token_uri?: string
}
const filteredScopes = scopes.filter((s) => !SA_EXCLUDED_SCOPES.has(s))
const now = Math.floor(Date.now() / 1000)
const ALLOWED_TOKEN_URIS = new Set(['https://oauth2.googleapis.com/token'])
const tokenUri =
keyData.token_uri && ALLOWED_TOKEN_URIS.has(keyData.token_uri)
? keyData.token_uri
: 'https://oauth2.googleapis.com/token'
const header = { alg: 'RS256', typ: 'JWT' }
const payload: Record<string, unknown> = {
iss: keyData.client_email,
scope: filteredScopes.join(' '),
aud: tokenUri,
iat: now,
exp: now + 3600,
}
if (impersonateEmail) {
payload.sub = impersonateEmail
}
logger.info('Service account JWT payload', {
iss: keyData.client_email,
sub: impersonateEmail || '(none)',
scopes: filteredScopes.join(' '),
aud: tokenUri,
})
const toBase64Url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url')
const signingInput = `${toBase64Url(header)}.${toBase64Url(payload)}`
const signer = createSign('RSA-SHA256')
signer.update(signingInput)
const signature = signer.sign(keyData.private_key, 'base64url')
const jwt = `${signingInput}.${signature}`
const response = await fetch(tokenUri, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt,
}),
})
if (!response.ok) {
const errorBody = await response.text()
logger.error('Service account token exchange failed', {
status: response.status,
body: errorBody,
})
let description = `Token exchange failed: ${response.status}`
try {
const parsed = JSON.parse(errorBody) as { error_description?: string }
if (parsed.error_description) {
const raw = parsed.error_description
if (raw.includes('SignatureException') || raw.includes('Invalid signature')) {
description = 'Invalid account credentials.'
} else {
description = raw
}
}
} catch {
// use default description
}
throw new ServiceAccountTokenError(response.status, description)
}
const tokenData = (await response.json()) as { access_token: string }
return tokenData.access_token
}
export interface SlackBotCredentialSecrets {
signingSecret: string
botToken: string
teamId: string
botUserId?: string
teamName?: string
/** Owning workspace — callers with a user/workflow context must verify it. */
workspaceId: string | null
}
/**
* Decrypt a reusable custom Slack bot credential — a `service_account` credential
* with `providerId='slack-custom-bot'` whose encrypted blob holds the bring-your-own
* app's signing secret + bot token + derived team_id/bot_user_id. Returns null if
* the id is not such a credential (or its blob is incomplete).
*
* @remarks Server-internal. The native custom ingest route authenticates each
* request via the app's signing secret (not a user session), so this reader does
* no per-user authorization; callers with a user context authorize separately.
*/
export async function getSlackBotCredential(
credentialId: string
): Promise<SlackBotCredentialSecrets | null> {
const [row] = await db
.select({
type: credential.type,
providerId: credential.providerId,
encryptedServiceAccountKey: credential.encryptedServiceAccountKey,
workspaceId: credential.workspaceId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (
!row ||
row.type !== 'service_account' ||
row.providerId !== SLACK_CUSTOM_BOT_PROVIDER_ID ||
!row.encryptedServiceAccountKey
) {
return null
}
const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey)
const blob = JSON.parse(decrypted) as Partial<SlackBotCredentialSecrets>
if (!blob.signingSecret || !blob.botToken || !blob.teamId) {
return null
}
return {
signingSecret: blob.signingSecret,
botToken: blob.botToken,
teamId: blob.teamId,
botUserId: blob.botUserId,
teamName: blob.teamName,
workspaceId: row.workspaceId ?? null,
}
}
interface AtlassianServiceAccountSecret {
type: typeof ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE
apiToken: string
domain: string
cloudId: string
atlassianAccountId?: string
}
/**
* Loads the decrypted Atlassian service account secret blob for a credential.
* Throws if the credential is missing or not an Atlassian service account.
*/
export async function getAtlassianServiceAccountSecret(
credentialId: string
): Promise<AtlassianServiceAccountSecret> {
const [credentialRow] = await db
.select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!credentialRow?.encryptedServiceAccountKey) {
throw new Error('Atlassian service account secret not found')
}
const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
const parsed = JSON.parse(decrypted) as AtlassianServiceAccountSecret
if (
parsed.type !== ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE ||
!parsed.apiToken ||
!parsed.cloudId
) {
throw new Error('Stored Atlassian service account secret is malformed')
}
return parsed
}
/**
* For Atlassian service accounts, the API token IS the access token —
* blocks call api.atlassian.com/ex/jira/{cloudId}/... with `Authorization: Bearer {apiToken}`.
* No exchange or refresh is needed; we just decrypt and return the raw token.
*/
export interface ServiceAccountTokenResult {
accessToken: string
/** Atlassian only — the resolved Jira/Confluence cloud id. */
cloudId?: string
/** Atlassian only — the site domain. */
domain?: string
}
/**
* Single dispatch point for turning a `service_account` credential into an
* access token, keyed on `providerId`. Both `refreshAccessTokenIfNeeded` and the
* `POST /api/auth/oauth/token` route go through here, so a new service-account
* provider is one edit and an unknown provider fails loudly instead of silently
* attempting a Google JWT.
*/
export async function resolveServiceAccountToken(
credentialId: string,
providerId: string | null | undefined,
scopes?: string[],
impersonateEmail?: string
): Promise<ServiceAccountTokenResult> {
if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
const secret = await getAtlassianServiceAccountSecret(credentialId)
return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain }
}
if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
const botCredential = await getSlackBotCredential(credentialId)
if (!botCredential) {
throw new Error('Slack bot credential not found')
}
return { accessToken: botCredential.botToken }
}
if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) {
if (!scopes?.length) {
throw new Error('Scopes are required for service account credentials')
}
return { accessToken: await getServiceAccountToken(credentialId, scopes, impersonateEmail) }
}
throw new Error(`Unsupported service-account provider: ${providerId ?? 'unknown'}`)
}
/**
* Safely inserts an account record, handling duplicate constraint violations gracefully.
* If a duplicate is detected (unique constraint violation), logs a warning and returns success.
*/
export async function safeAccountInsert(
data: AccountInsertData,
context: { provider: string; identifier?: string }
): Promise<void> {
try {
await db.insert(account).values(data)
logger.info(`Created new ${context.provider} account for user`, { userId: data.userId })
} catch (error: any) {
if (getPostgresErrorCode(error) === '23505') {
logger.error(`Duplicate ${context.provider} account detected, credential already exists`, {
userId: data.userId,
identifier: context.identifier,
})
} else {
throw error
}
}
}
/**
* Get a credential by resolved account ID and verify it belongs to the user.
*/
async function getCredentialByAccountId(requestId: string, accountId: string, userId: string) {
const credentials = await db
.select()
.from(account)
.where(and(eq(account.id, accountId), eq(account.userId, userId)))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return undefined
}
return {
...credentials[0],
resolvedCredentialId: accountId,
}
}
/**
* Get a credential by ID and verify it belongs to the user.
*/
export async function getCredential(requestId: string, credentialId: string, userId: string) {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
return undefined
}
return getCredentialByAccountId(requestId, resolved.accountId, userId)
}
interface CoalescedRefreshOptions {
accountId: string
providerId: string
refreshToken: string
requestId?: string
userId?: string
}
async function performCoalescedRefresh({
accountId,
providerId,
refreshToken,
requestId,
userId,
}: CoalescedRefreshOptions): Promise<string | null> {
const logContext = {
...(requestId ? { requestId } : {}),
...(userId ? { userId } : {}),
providerId,
accountId,
}
const deadCode = await getRecentTerminalError(accountId)
if (deadCode) {
logger.warn('Skipping refresh: credential recently failed', {
...logContext,
errorCode: deadCode,
})
return null
}
const lockKey = `oauth:refresh:${accountId}`
const refreshPromise = coalesceLocally(lockKey, () =>
withLeaderLock<string>({
key: lockKey,
onLeader: async () => {
try {
const result = await refreshOAuthToken(providerId, refreshToken)
if (!result.ok) {
logger.error('Failed to refresh token', {
...logContext,
errorCode: result.errorCode,
})
if (result.errorCode && isTerminalRefreshError(result.errorCode)) {
await markCredentialDead(accountId, result.errorCode)
}
return null
}
const updateData: Record<string, unknown> = {
accessToken: result.accessToken,
accessTokenExpiresAt: new Date(Date.now() + result.expiresIn * 1000),
updatedAt: new Date(),
}
if (result.refreshToken && result.refreshToken !== refreshToken) {
updateData.refreshToken = result.refreshToken
}
if (isMicrosoftProvider(providerId)) {
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
}
await db.update(account).set(updateData).where(eq(account.id, accountId))
logger.info('Successfully refreshed access token', logContext)
return result.accessToken
} catch (error) {
logger.error('Refresh failed inside leader path', {
...logContext,
error: toError(error).message,
})
return null
}
},
onFollower: async () => {
try {
const [row] = await db
.select({
accessToken: account.accessToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
})
.from(account)
.where(eq(account.id, accountId))
.limit(1)
if (
row?.accessToken &&
row.accessTokenExpiresAt &&
row.accessTokenExpiresAt > new Date()
) {
logger.info('Got fresh access token from coalesced refresh', logContext)
return row.accessToken
}
return null
} catch (error) {
logger.warn('Follower DB read failed during refresh poll', {
...logContext,
error: toError(error).message,
})
return null
}
},
})
)
try {
return await refreshPromise
} catch (error) {
logger.error('Coalesced refresh did not settle', {
...logContext,
error: toError(error).message,
})
return null
}
}
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
const connections = await db
.select({
id: account.id,
accessToken: account.accessToken,
refreshToken: account.refreshToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
idToken: account.idToken,
scope: account.scope,
})
.from(account)
.where(and(eq(account.userId, userId), eq(account.providerId, providerId)))
.orderBy(desc(account.updatedAt))
.limit(1)
if (connections.length === 0) {
logger.warn(`No OAuth token found for user ${userId}, provider ${providerId}`)
return null
}
const credential = connections[0]
// Determine whether we should refresh: missing token OR expired token
const now = new Date()
const tokenExpiry = credential.accessTokenExpiresAt
const shouldAttemptRefresh =
!!credential.refreshToken && (!credential.accessToken || (tokenExpiry && tokenExpiry < now))
if (shouldAttemptRefresh) {
return performCoalescedRefresh({
accountId: credential.id,
providerId,
refreshToken: credential.refreshToken!,
userId,
})
}
if (!credential.accessToken) {
logger.warn(
`Access token is null and no refresh attempted or available for user ${userId}, provider ${providerId}`
)
return null
}
logger.info(`Found valid OAuth token for user ${userId}, provider ${providerId}`)
return credential.accessToken
}
/**
* Refreshes an OAuth token if needed based on credential information.
* Also handles service account credentials by generating a JWT-based token.
* @param credentialId The ID of the credential to check and potentially refresh
* @param userId The user ID who owns the credential (for security verification)
* @param requestId Request ID for log correlation
* @param scopes Optional scopes for service account token generation
* @returns The valid access token or null if refresh fails
*/
export async function refreshAccessTokenIfNeeded(
credentialId: string,
userId: string,
requestId: string,
scopes?: string[],
impersonateEmail?: string
): Promise<string | null> {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return null
}
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
logger.info(`[${requestId}] Using service account token for credential`)
const { accessToken } = await resolveServiceAccountToken(
resolved.credentialId,
resolved.providerId,
scopes,
impersonateEmail
)
return accessToken
}
// Use the already-resolved account ID to avoid a redundant resolveOAuthAccountId query
const credential = await getCredentialByAccountId(requestId, resolved.accountId, userId)
if (!credential) {
return null
}
// Decide if we should refresh: token missing OR expired
const accessTokenExpiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
const now = new Date()
// Check if access token needs refresh (missing or expired)
const accessTokenNeedsRefresh =
!!credential.refreshToken &&
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
// Check if we should proactively refresh to prevent refresh token expiry
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
const proactiveRefreshThreshold = new Date(
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
)
const refreshTokenNeedsProactiveRefresh =
!!credential.refreshToken &&
isMicrosoftProvider(credential.providerId) &&
refreshTokenExpiresAt &&
refreshTokenExpiresAt <= proactiveRefreshThreshold
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
const accessToken = credential.accessToken
if (shouldRefresh) {
const resolvedCredentialId =
(credential as { resolvedCredentialId?: string }).resolvedCredentialId ?? credentialId
const fresh = await performCoalescedRefresh({
accountId: resolvedCredentialId,
providerId: credential.providerId,
refreshToken: credential.refreshToken!,
requestId,
userId: credential.userId,
})
if (fresh) return fresh
// If refresh was only triggered proactively (Microsoft refresh-token aging),
// the still-valid access token is a fine fallback.
if (!accessTokenNeedsRefresh && accessToken) {
logger.info(`[${requestId}] Refresh unavailable; reusing still-valid access token`)
return accessToken
}
return null
}
if (!accessToken) {
// We have no access token and either no refresh token or not eligible to refresh
logger.error(`[${requestId}] Missing access token for credential`)
return null
}
logger.info(`[${requestId}] Access token is valid for credential`)
return accessToken
}
/**
* Enhanced version that returns additional information about the refresh operation
*/
export async function refreshTokenIfNeeded(
requestId: string,
credential: any,
credentialId: string
): Promise<{ accessToken: string; refreshed: boolean }> {
const resolvedCredentialId = credential.resolvedCredentialId ?? credentialId
// Decide if we should refresh: token missing OR expired
const accessTokenExpiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
const now = new Date()
// Check if access token needs refresh (missing or expired)
const accessTokenNeedsRefresh =
!!credential.refreshToken &&
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
// Check if we should proactively refresh to prevent refresh token expiry
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
const proactiveRefreshThreshold = new Date(
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
)
const refreshTokenNeedsProactiveRefresh =
!!credential.refreshToken &&
isMicrosoftProvider(credential.providerId) &&
refreshTokenExpiresAt &&
refreshTokenExpiresAt <= proactiveRefreshThreshold
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
// If token appears valid and present, return it directly
if (!shouldRefresh) {
logger.info(`[${requestId}] Access token is valid`)
return { accessToken: credential.accessToken, refreshed: false }
}
const fresh = await performCoalescedRefresh({
accountId: resolvedCredentialId,
providerId: credential.providerId,
refreshToken: credential.refreshToken!,
requestId,
userId: credential.userId,
})
if (fresh) return { accessToken: fresh, refreshed: true }
if (!accessTokenNeedsRefresh && credential.accessToken) {
logger.info(`[${requestId}] Refresh unavailable; reusing still-valid access token`)
return { accessToken: credential.accessToken, refreshed: false }
}
throw new Error('Failed to refresh token')
}
@@ -0,0 +1,150 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { wealthboxOAuthItemContract } from '@/lib/api/contracts/selectors/wealthbox'
import { parseRequest } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('WealthboxItemAPI')
interface WealthboxItem {
id: string
name: string
type: string
content: string
createdAt: string
updatedAt: string
}
/**
* Get a single item (note, contact, task) from Wealthbox
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const parsed = await parseRequest(wealthboxOAuthItemContract, request, {})
if (!parsed.success) return parsed.response
const { credentialId, itemId, type } = parsed.data.query
const typeValidation = validateEnum(type, ['contact'] as const, 'type')
if (!typeValidation.isValid) {
logger.warn(`[${requestId}] Invalid item type: ${typeValidation.error}`)
return NextResponse.json({ error: typeValidation.error }, { status: 400 })
}
const itemIdValidation = validatePathSegment(itemId, {
paramName: 'itemId',
maxLength: 100,
allowHyphens: true,
allowUnderscores: true,
allowDots: false,
})
if (!itemIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid item ID: ${itemIdValidation.error}`)
return NextResponse.json({ error: itemIdValidation.error }, { status: 400 })
}
const credAccess = await authorizeCredentialUse(request, {
credentialId,
requireWorkflowIdForInternal: false,
})
if (!credAccess.ok || !credAccess.credentialOwnerUserId) {
logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error })
return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
credAccess.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
const endpoints = {
contact: 'contacts',
}
const endpoint = endpoints[type as keyof typeof endpoints]
logger.info(`[${requestId}] Fetching ${type} ${itemId} from Wealthbox`)
const response = await fetch(`https://api.crmworkspace.com/v1/${endpoint}/${itemId}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error(
`[${requestId}] Wealthbox API error: ${response.status} ${response.statusText}`,
{
error: errorText,
endpoint,
itemId,
}
)
if (response.status === 404) {
return NextResponse.json({ error: 'Item not found' }, { status: 404 })
}
return NextResponse.json(
{ error: `Failed to fetch ${type} from Wealthbox` },
{ status: response.status }
)
}
const data = (await response.json()) as Record<string, unknown>
const meta =
data.meta && typeof data.meta === 'object' ? (data.meta as Record<string, unknown>) : null
const totalCount = meta?.total_count ?? 'unknown'
logger.info(`[${requestId}] Wealthbox API response structure`, {
type,
dataKeys: Object.keys(data || {}),
hasContacts: !!data.contacts,
totalCount,
})
let items: WealthboxItem[] = []
if (type === 'contact') {
if (data?.id) {
const firstName = typeof data.first_name === 'string' ? data.first_name : ''
const lastName = typeof data.last_name === 'string' ? data.last_name : ''
const item = {
id: data.id?.toString() || '',
name: `${firstName} ${lastName}`.trim() || `Contact ${data.id}`,
type: 'contact',
content: typeof data.background_info === 'string' ? data.background_info : '',
createdAt: typeof data.created_at === 'string' ? data.created_at : '',
updatedAt: typeof data.updated_at === 'string' ? data.updated_at : '',
}
items = [item]
} else {
logger.warn(`[${requestId}] Unexpected contact response format`, { data })
items = []
}
}
logger.info(
`[${requestId}] Successfully fetched ${items.length} ${type}s from Wealthbox (total: ${totalCount})`
)
return NextResponse.json({ item: items[0] }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching Wealthbox item`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,162 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { wealthboxOAuthItemsContract } from '@/lib/api/contracts/selectors/wealthbox'
import { parseRequest } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validatePathSegment } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('WealthboxItemsAPI')
interface WealthboxItem {
id: string
name: string
type: string
content: string
createdAt: string
updatedAt: string
}
/**
* Get items (notes, contacts, tasks) from Wealthbox
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const parsed = await parseRequest(wealthboxOAuthItemsContract, request, {})
if (!parsed.success) return parsed.response
const { credentialId, type } = parsed.data.query
const query = parsed.data.query.query ?? ''
const credentialIdValidation = validatePathSegment(credentialId, {
paramName: 'credentialId',
maxLength: 100,
allowHyphens: true,
allowUnderscores: true,
allowDots: false,
})
if (!credentialIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid credentialId format: ${credentialId}`)
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
const authz = await authorizeCredentialUse(request, {
credentialId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
const endpoints = {
contact: 'contacts',
}
const endpoint = endpoints[type as keyof typeof endpoints]
const url = new URL(`https://api.crmworkspace.com/v1/${endpoint}`)
logger.info(`[${requestId}] Fetching ${type}s from Wealthbox`, {
endpoint,
url: url.toString(),
hasQuery: !!query.trim(),
})
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error(
`[${requestId}] Wealthbox API error: ${response.status} ${response.statusText}`,
{
error: errorText,
endpoint,
url: url.toString(),
}
)
return NextResponse.json(
{ error: `Failed to fetch ${type}s from Wealthbox` },
{ status: response.status }
)
}
const data = (await response.json()) as { contacts?: Array<Record<string, unknown>> } & Record<
string,
unknown
>
logger.info(`[${requestId}] Wealthbox API response structure`, {
type,
status: response.status,
dataKeys: Object.keys(data || {}),
hasContacts: !!data.contacts,
dataStructure: typeof data === 'object' ? Object.keys(data) : 'not an object',
})
let items: WealthboxItem[] = []
if (type === 'contact') {
const contacts = data.contacts || []
if (!Array.isArray(contacts)) {
logger.warn(`[${requestId}] Contacts is not an array`, {
contacts,
dataType: typeof contacts,
})
return NextResponse.json({ items: [] }, { status: 200 })
}
items = contacts.map((item) => {
const firstName = typeof item.first_name === 'string' ? item.first_name : ''
const lastName = typeof item.last_name === 'string' ? item.last_name : ''
return {
id: item.id?.toString() || '',
name: `${firstName} ${lastName}`.trim() || `Contact ${item.id ?? ''}`,
type: 'contact',
content:
typeof item.background_information === 'string' ? item.background_information : '',
createdAt: typeof item.created_at === 'string' ? item.created_at : '',
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : '',
}
})
}
if (query.trim()) {
const searchTerm = query.trim().toLowerCase()
items = items.filter(
(item) =>
item.name.toLowerCase().includes(searchTerm) ||
item.content.toLowerCase().includes(searchTerm)
)
}
logger.info(`[${requestId}] Successfully fetched ${items.length} ${type}s from Wealthbox`, {
totalItems: items.length,
hasSearchQuery: !!query.trim(),
})
return NextResponse.json({ items }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching Wealthbox items`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,146 @@
import { db } from '@sim/db'
import { pendingCredentialDraft, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, lt } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections'
import { parseRequest } from '@/lib/api/server'
import { auth, getSession } from '@/lib/auth/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getAllOAuthServices } from '@/lib/oauth/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('OAuth2Authorize')
export const dynamic = 'force-dynamic'
const DRAFT_TTL_MS = 15 * 60 * 1000
/**
* Creates the pending credential draft at click time so its TTL starts when the
* user actually initiates the connect. Better Auth's `account.create.after` hook
* consumes this draft to materialize the real credential after the OAuth
* callback; starting the clock here guarantees the draft outlives the (≤5 min)
* OAuth round-trip rather than expiring mid-flow and silently producing no
* credential.
*/
async function createConnectDraft(params: {
userId: string
workspaceId: string
providerId: string
}): Promise<void> {
const { userId, workspaceId, providerId } = params
const service = getAllOAuthServices().find((s) => s.providerId === providerId)
const serviceName = service?.name ?? providerId
let displayName = serviceName
try {
const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId))
if (row?.name) {
displayName = `${row.name}'s ${serviceName}`
}
} catch {
// Fall back to service name only
}
const now = new Date()
const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS)
await db
.delete(pendingCredentialDraft)
.where(
and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now))
)
await db
.insert(pendingCredentialDraft)
.values({
id: generateId(),
userId,
workspaceId,
providerId,
displayName,
expiresAt,
createdAt: now,
})
.onConflictDoUpdate({
target: [
pendingCredentialDraft.userId,
pendingCredentialDraft.providerId,
pendingCredentialDraft.workspaceId,
],
set: { displayName, expiresAt, createdAt: now },
})
logger.info('Created OAuth connect credential draft', { userId, workspaceId, providerId })
}
/**
* Browser-initiated entrypoint for linking a generic OAuth2 account.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const baseUrl = getBaseUrl()
const session = await getSession()
if (!session?.user?.id) {
const loginUrl = new URL('/login', baseUrl)
loginUrl.searchParams.set('callbackUrl', request.nextUrl.pathname + request.nextUrl.search)
return NextResponse.redirect(loginUrl.toString())
}
const userId = session.user.id
const parsed = await parseRequest(authorizeOAuth2Contract, request, {})
if (!parsed.success) return parsed.response
const { providerId, workspaceId, callbackURL: requestedCallback } = parsed.data.query
const callbackURL = requestedCallback?.startsWith(`${baseUrl}/`)
? requestedCallback
: `${baseUrl}/workspace`
try {
const access = await checkWorkspaceAccess(workspaceId, userId)
if (!access.canWrite) {
logger.warn('Workspace write access denied for OAuth2 authorize', {
userId,
workspaceId,
providerId,
})
return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`)
}
// Create the draft before initiating the link so it is guaranteed to exist
// (and freshly clocked) when the OAuth callback's `account.create.after`
// hook runs. If this throws, we never start the OAuth flow.
await createConnectDraft({ userId, workspaceId, providerId })
const linkResponse = await auth.api.oAuth2LinkAccount({
body: { providerId, callbackURL },
headers: request.headers,
asResponse: true,
})
const payload = (await linkResponse.json().catch(() => null)) as { url?: string } | null
if (!linkResponse.ok || !payload?.url) {
logger.error('oAuth2LinkAccount did not return an authorization URL', {
providerId,
status: linkResponse.status,
})
return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`)
}
const response = NextResponse.redirect(payload.url)
// Forward the signed `state` cookie Better Auth set so it lands in the user's
// browser and is present when the provider redirects back to the callback.
const linkHeaders = linkResponse.headers as Headers & {
getSetCookie?: () => string[]
}
for (const cookie of linkHeaders.getSetCookie?.() ?? []) {
response.headers.append('set-cookie', cookie)
}
return response
} catch (error) {
logger.error('Failed to initiate OAuth2 authorization', { providerId, error })
return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`)
}
})
@@ -0,0 +1,169 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import { type NextRequest, NextResponse } from 'next/server'
import {
shopifyCallbackQuerySchema,
shopifyShopDomainSchema,
} from '@/lib/api/contracts/oauth-connections'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('ShopifyCallback')
export const dynamic = 'force-dynamic'
/**
* Validates the HMAC signature from Shopify to ensure the request is authentic
* @see https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens
*/
function validateHmac(searchParams: URLSearchParams, clientSecret: string): boolean {
const hmac = searchParams.get('hmac')
if (!hmac) {
return false
}
const params: Record<string, string> = {}
searchParams.forEach((value, key) => {
if (key !== 'hmac') {
params[key] = value
}
})
const message = Object.keys(params)
.sort()
.map((key) => `${key}=${params[key]}`)
.join('&')
const generatedHmac = hmacSha256Hex(message, clientSecret)
return safeCompare(hmac, generatedHmac)
}
export const GET = withRouteHandler(async (request: NextRequest) => {
const baseUrl = getBaseUrl()
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
}
const { searchParams } = request.nextUrl
const { code, state, shop } = shopifyCallbackQuerySchema.parse({
code: searchParams.get('code') || undefined,
state: searchParams.get('state') || undefined,
shop: searchParams.get('shop') || undefined,
})
const storedState = request.cookies.get('shopify_oauth_state')?.value
const storedShop = request.cookies.get('shopify_shop_domain')?.value
const clientId = env.SHOPIFY_CLIENT_ID
const clientSecret = env.SHOPIFY_CLIENT_SECRET
if (!clientId || !clientSecret) {
logger.error('Shopify credentials not configured')
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_config_error`)
}
if (!validateHmac(searchParams, clientSecret)) {
logger.error('HMAC validation failed in Shopify OAuth callback')
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_hmac_invalid`)
}
if (!state || state !== storedState) {
logger.error('State mismatch in Shopify OAuth callback')
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_state_mismatch`)
}
if (!code) {
logger.error('No code received from Shopify')
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_code`)
}
const shopDomain = shop || storedShop
if (!shopDomain) {
logger.error('No shop domain available')
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_shop`)
}
if (!shopifyShopDomainSchema.safeParse(shopDomain).success) {
logger.error('Invalid shop domain format:', { shopDomain })
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_shop`)
}
const tokenResponse = await fetch(`https://${shopDomain}/admin/oauth/access_token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
code: code,
}),
})
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text()
logger.error('Failed to exchange code for token:', {
status: tokenResponse.status,
body: errorText,
})
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_token_error`)
}
const tokenData = await tokenResponse.json()
const accessToken = tokenData.access_token
const scope = tokenData.scope
logger.info('Shopify token exchange successful:', {
hasAccessToken: !!accessToken,
scope: scope,
})
if (!accessToken) {
logger.error('No access token in response')
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_token`)
}
const storeUrl = new URL(`${baseUrl}/api/auth/oauth2/shopify/store`)
const response = NextResponse.redirect(storeUrl)
response.cookies.set('shopify_pending_token', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60,
path: '/',
})
response.cookies.set('shopify_pending_shop', shopDomain, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60,
path: '/',
})
response.cookies.set('shopify_pending_scope', scope || '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60,
path: '/',
})
response.cookies.delete('shopify_oauth_state')
response.cookies.delete('shopify_shop_domain')
return response
} catch (error) {
logger.error('Error in Shopify OAuth callback:', error)
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_callback_error`)
}
})
@@ -0,0 +1,144 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
shopifyShopDomainSchema,
shopifyStoreCookieSchema,
} from '@/lib/api/contracts/oauth-connections'
import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
const logger = createLogger('ShopifyStore')
export const dynamic = 'force-dynamic'
export const GET = withRouteHandler(async (request: NextRequest) => {
const baseUrl = getBaseUrl()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn('Unauthorized attempt to store Shopify token')
return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
}
const parsedCookies = shopifyStoreCookieSchema.safeParse({
accessToken: request.cookies.get('shopify_pending_token')?.value,
shopDomain: request.cookies.get('shopify_pending_shop')?.value,
scope: request.cookies.get('shopify_pending_scope')?.value || undefined,
returnUrl: request.cookies.get('shopify_return_url')?.value || undefined,
})
if (!parsedCookies.success) {
logger.error('Missing token or shop domain in cookies')
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_missing_data`)
}
const { accessToken, shopDomain, scope, returnUrl } = parsedCookies.data
if (!shopifyShopDomainSchema.safeParse(shopDomain).success) {
logger.error('Invalid shop domain format in cookie', { shopDomain })
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_domain`)
}
const shopResponse = await fetch(`https://${shopDomain}/admin/api/2024-10/shop.json`, {
headers: {
'X-Shopify-Access-Token': accessToken,
'Content-Type': 'application/json',
},
})
if (!shopResponse.ok) {
const errorText = await shopResponse.text()
logger.error('Invalid Shopify token', {
status: shopResponse.status,
error: errorText,
})
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_token`)
}
const shopData = await shopResponse.json()
const shopInfo = shopData.shop
const stableAccountId = shopInfo.id?.toString() || shopDomain
const existing = await db.query.account.findFirst({
where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'shopify'),
eq(account.accountId, stableAccountId)
),
})
const now = new Date()
const accountData = {
accessToken: accessToken,
accountId: stableAccountId,
scope: scope || '',
updatedAt: now,
idToken: shopDomain,
}
if (existing) {
await db.update(account).set(accountData).where(eq(account.id, existing.id))
logger.info('Updated existing Shopify account', { accountId: existing.id })
} else {
await safeAccountInsert(
{
id: `shopify_${session.user.id}_${Date.now()}`,
userId: session.user.id,
providerId: 'shopify',
accountId: accountData.accountId,
accessToken: accountData.accessToken,
scope: accountData.scope,
idToken: accountData.idToken,
createdAt: now,
updatedAt: now,
},
{ provider: 'Shopify', identifier: shopDomain }
)
}
const persisted =
existing ??
(await db.query.account.findFirst({
where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'shopify'),
eq(account.accountId, stableAccountId)
),
}))
if (persisted) {
try {
await processCredentialDraft({
userId: session.user.id,
providerId: 'shopify',
accountId: persisted.id,
})
} catch (error) {
logger.error('Failed to process credential draft for Shopify', { error })
}
}
const redirectUrl = returnUrl && isSameOrigin(returnUrl) ? returnUrl : `${baseUrl}/workspace`
const finalUrl = new URL(redirectUrl)
finalUrl.searchParams.set('shopify_connected', 'true')
const response = NextResponse.redirect(finalUrl.toString())
response.cookies.delete('shopify_pending_token')
response.cookies.delete('shopify_pending_shop')
response.cookies.delete('shopify_pending_scope')
response.cookies.delete('shopify_return_url')
return response
} catch (error) {
logger.error('Error storing Shopify token:', error)
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_store_error`)
}
})
+22
View File
@@ -0,0 +1,22 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getAuthProvidersContract } from '@/lib/api/contracts/auth'
import { parseRequest } from '@/lib/api/server'
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
export const dynamic = 'force-dynamic'
export const GET = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(getAuthProvidersContract, request, {})
if (!parsed.success) return parsed.response
const { githubAvailable, googleAvailable, microsoftAvailable } = await getOAuthProviderStatus()
return NextResponse.json({
githubAvailable,
googleAvailable,
microsoftAvailable,
registrationDisabled: isRegistrationDisabled,
})
})
@@ -0,0 +1,172 @@
/**
* Tests for reset password API route
*
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockResetPassword, mockLogger } = vi.hoisted(() => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
child: vi.fn(),
}
return {
mockResetPassword: vi.fn(),
mockLogger: logger,
}
})
vi.mock('@/lib/auth', () => ({
auth: {
api: {
resetPassword: mockResetPassword,
},
},
}))
vi.mock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
getRequestContext: () => undefined,
}))
import { POST } from '@/app/api/auth/reset-password/route'
describe('Reset Password API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockResetPassword.mockResolvedValue(undefined)
})
afterEach(() => {
vi.clearAllMocks()
})
it('should reset password successfully', async () => {
const req = createMockRequest('POST', {
token: 'valid-reset-token',
newPassword: 'newSecurePassword123!',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(mockResetPassword).toHaveBeenCalledWith({
body: {
token: 'valid-reset-token',
newPassword: 'newSecurePassword123!',
},
method: 'POST',
})
})
it('should handle missing token', async () => {
const req = createMockRequest('POST', {
newPassword: 'newSecurePassword123!',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Token is required')
expect(mockResetPassword).not.toHaveBeenCalled()
})
it('should handle missing new password', async () => {
const req = createMockRequest('POST', {
token: 'valid-reset-token',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Password is required')
expect(mockResetPassword).not.toHaveBeenCalled()
})
it('should handle empty token', async () => {
const req = createMockRequest('POST', {
token: '',
newPassword: 'newSecurePassword123!',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Token is required')
expect(mockResetPassword).not.toHaveBeenCalled()
})
it('should handle empty new password', async () => {
const req = createMockRequest('POST', {
token: 'valid-reset-token',
newPassword: '',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toContain('Password must be at least 8 characters long')
expect(data.message).toContain('Password must contain at least one uppercase letter')
expect(data.message).toContain('Password must contain at least one lowercase letter')
expect(data.message).toContain('Password must contain at least one number')
expect(data.message).toContain('Password must contain at least one special character')
expect(mockResetPassword).not.toHaveBeenCalled()
})
it('should handle auth service error with message', async () => {
const errorMessage = 'Invalid or expired token'
mockResetPassword.mockRejectedValue(new Error(errorMessage))
const req = createMockRequest('POST', {
token: 'invalid-token',
newPassword: 'newSecurePassword123!',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.message).toBe(errorMessage)
expect(mockLogger.error).toHaveBeenCalledWith('Error during password reset:', {
error: expect.any(Error),
})
})
it('should handle unknown error', async () => {
mockResetPassword.mockRejectedValue('Unknown error')
const req = createMockRequest('POST', {
token: 'valid-reset-token',
newPassword: 'newSecurePassword123!',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.message).toBe(
'Failed to reset password. Please try again or request a new reset link.'
)
expect(mockLogger.error).toHaveBeenCalled()
})
})
@@ -0,0 +1,52 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { resetPasswordContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { auth } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
export const dynamic = 'force-dynamic'
const logger = createLogger('PasswordResetAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const parsed = await parseRequest(
resetPasswordContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn('Invalid password reset request data', { errors: error.issues })
const message = error.issues.map((e) => e.message).join(' ')
return NextResponse.json({ message }, { status: 400 })
},
}
)
if (!parsed.success) return parsed.response
const { token, newPassword } = parsed.data.body
await auth.api.resetPassword({
body: {
newPassword,
token,
},
method: 'POST',
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error during password reset:', { error })
return NextResponse.json(
{
message:
error instanceof Error
? error.message
: 'Failed to reset password. Please try again or request a new reset link.',
},
{ status: 500 }
)
}
})
@@ -0,0 +1,226 @@
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import {
shopifyAuthorizeQuerySchema,
shopifyShopDomainSchema,
} from '@/lib/api/contracts/oauth-connections'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getScopesForService } from '@/lib/oauth/utils'
const logger = createLogger('ShopifyAuthorize')
export const dynamic = 'force-dynamic'
const SHOPIFY_SCOPES = getScopesForService('shopify').join(',')
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const clientId = env.SHOPIFY_CLIENT_ID
if (!clientId) {
logger.error('SHOPIFY_CLIENT_ID not configured')
return NextResponse.json({ error: 'Shopify client ID not configured' }, { status: 500 })
}
const query = shopifyAuthorizeQuerySchema.parse({
shop: request.nextUrl.searchParams.get('shop') || undefined,
returnUrl: request.nextUrl.searchParams.get('returnUrl') || undefined,
})
const { shop: shopDomain, returnUrl } = query
if (!shopDomain) {
const safeReturnUrl =
returnUrl && isSameOrigin(returnUrl) ? encodeURIComponent(returnUrl) : ''
const returnUrlJsLiteral = JSON.stringify(safeReturnUrl)
return new NextResponse(
`<!DOCTYPE html>
<html>
<head>
<title>Connect Shopify Store</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #96BF48 0%, #5C8A23 100%);
}
.container {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
text-align: center;
max-width: 400px;
width: 90%;
}
h2 {
color: #111827;
margin: 0 0 0.5rem 0;
}
p {
color: #6b7280;
margin: 0 0 1.5rem 0;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
margin-bottom: 1rem;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #96BF48;
box-shadow: 0 0 0 3px rgba(150, 191, 72, 0.2);
}
button {
width: 100%;
padding: 0.75rem;
background: #96BF48;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
}
button:hover {
background: #7FA93D;
}
.help {
font-size: 0.875rem;
color: #9ca3af;
margin-top: 1rem;
}
</style>
</head>
<body>
<div class="container">
<h2>Connect Your Shopify Store</h2>
<p>Enter your Shopify store domain to continue</p>
<form onsubmit="handleSubmit(event)">
<input
type="text"
id="shop"
placeholder="mystore.myshopify.com"
required
pattern="[a-zA-Z0-9-]+\\.myshopify\\.com"
/>
<button type="submit">Connect Store</button>
</form>
<p class="help">Your store domain looks like: yourstore.myshopify.com</p>
</div>
<script>
const returnUrl = ${returnUrlJsLiteral};
function handleSubmit(e) {
e.preventDefault();
let shop = document.getElementById('shop').value.trim().toLowerCase();
// Clean up the shop domain
shop = shop.replace('https://', '').replace('http://', '');
if (!shop.endsWith('.myshopify.com')) {
shop = shop.replace('.myshopify.com', '') + '.myshopify.com';
}
let url = window.location.pathname + '?shop=' + encodeURIComponent(shop);
if (returnUrl) {
url += '&returnUrl=' + returnUrl;
}
window.location.href = url;
}
</script>
</body>
</html>`,
{
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
}
)
}
let cleanShop = shopDomain.toLowerCase().trim()
cleanShop = cleanShop.replace('https://', '').replace('http://', '')
if (!cleanShop.endsWith('.myshopify.com')) {
cleanShop = `${cleanShop.replace('.myshopify.com', '')}.myshopify.com`
}
if (!shopifyShopDomainSchema.safeParse(cleanShop).success) {
logger.warn('Rejected invalid Shopify shop domain', { shop: shopDomain })
return NextResponse.json({ error: 'Invalid Shopify shop domain' }, { status: 400 })
}
const baseUrl = getBaseUrl()
const redirectUri = `${baseUrl}/api/auth/oauth2/callback/shopify`
const state = generateId()
const oauthUrl =
`https://${cleanShop}/admin/oauth/authorize?` +
new URLSearchParams({
client_id: clientId,
scope: SHOPIFY_SCOPES,
redirect_uri: redirectUri,
state: state,
}).toString()
logger.info('Initiating Shopify OAuth:', {
shop: cleanShop,
requestedScopes: SHOPIFY_SCOPES,
redirectUri,
returnUrl: returnUrl || 'not specified',
})
const response = NextResponse.redirect(oauthUrl)
response.cookies.set('shopify_oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 10,
path: '/',
})
response.cookies.set('shopify_shop_domain', cleanShop, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 10,
path: '/',
})
if (returnUrl && isSameOrigin(returnUrl)) {
response.cookies.set('shopify_return_url', returnUrl, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 10,
path: '/',
})
}
return response
} catch (error) {
logger.error('Error initiating Shopify authorization:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,54 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { headers } from 'next/headers'
import { type NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { isAuthDisabled } from '@/lib/core/config/env-flags'
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('SocketTokenAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
if (isAuthDisabled) {
return NextResponse.json({ token: 'anonymous-socket-token' })
}
const rateLimited = await enforceIpRateLimit('socket-token', request, {
maxTokens: 30,
refillRate: 30,
refillIntervalMs: 60_000,
})
if (rateLimited) return rateLimited
try {
const hdrs = await headers()
const response = await auth.api.generateOneTimeToken({
headers: hdrs,
})
if (!response?.token) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
return NextResponse.json({ token: response.token })
} catch (error) {
// better-auth's sessionMiddleware throws APIError("UNAUTHORIZED") with no message
// when the session is missing/expired — surface this as a 401, not a 500.
if (
error instanceof Error &&
('statusCode' in error || 'status' in error) &&
((error as Record<string, unknown>).statusCode === 401 ||
(error as Record<string, unknown>).status === 'UNAUTHORIZED')
) {
logger.warn('Socket token request with invalid/expired session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
logger.error('Failed to generate socket token', {
error: toError(error).message,
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json({ error: 'Failed to generate token' }, { status: 500 })
}
})
@@ -0,0 +1,107 @@
import { db, member, ssoProvider } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { listSsoProvidersContract } from '@/lib/api/contracts/auth'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('SSOProvidersRoute')
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
const rateLimited = await enforceIpRateLimit('sso-providers', request, {
maxTokens: 20,
refillRate: 20,
refillIntervalMs: 60_000,
})
if (rateLimited) return rateLimited
}
const parsed = await parseRequest(listSsoProvidersContract, request, {})
if (!parsed.success) return parsed.response
const { organizationId } = parsed.data.query
let providers
if (session?.user?.id) {
const userId = session.user.id
let verifiedOrganizationId: string | null = null
if (organizationId) {
const [membership] = await db
.select({ organizationId: member.organizationId, role: member.role })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (membership.role !== 'owner' && membership.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
verifiedOrganizationId = membership.organizationId
}
const whereClause = verifiedOrganizationId
? eq(ssoProvider.organizationId, verifiedOrganizationId)
: eq(ssoProvider.userId, userId)
const results = await db
.select({
id: ssoProvider.id,
providerId: ssoProvider.providerId,
domain: ssoProvider.domain,
issuer: ssoProvider.issuer,
oidcConfig: ssoProvider.oidcConfig,
samlConfig: ssoProvider.samlConfig,
userId: ssoProvider.userId,
organizationId: ssoProvider.organizationId,
})
.from(ssoProvider)
.where(whereClause)
providers = results.map((provider) => {
let oidcConfig = provider.oidcConfig
if (oidcConfig) {
try {
const parsed = JSON.parse(oidcConfig)
parsed.clientSecret = REDACTED_MARKER
oidcConfig = JSON.stringify(parsed)
} catch {
oidcConfig = null
}
}
return {
...provider,
oidcConfig,
providerType: (provider.samlConfig ? 'saml' : 'oidc') as 'oidc' | 'saml',
}
})
} else {
const results = await db
.select({
domain: ssoProvider.domain,
})
.from(ssoProvider)
providers = results.map((provider) => ({
domain: provider.domain,
}))
}
logger.info('Fetched SSO providers', {
userId: session?.user?.id,
authenticated: !!session?.user?.id,
providerCount: providers.length,
})
return NextResponse.json({ providers })
} catch (error) {
logger.error('Failed to fetch SSO providers', { error })
return NextResponse.json({ error: 'Failed to fetch SSO providers' }, { status: 500 })
}
})
@@ -0,0 +1,364 @@
/**
* @vitest-environment node
*/
import { createEnvMock, createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetSession,
mockRegisterSSOProvider,
mockHasSSOAccess,
mockValidateUrlWithDNS,
mockSecureFetchWithPinnedIP,
dbState,
memberTable,
ssoProviderTable,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockRegisterSSOProvider: vi.fn(),
mockHasSSOAccess: vi.fn(),
mockValidateUrlWithDNS: vi.fn(),
mockSecureFetchWithPinnedIP: vi.fn(),
dbState: { members: [] as any[], providers: [] as any[] },
memberTable: {
userId: 'member.userId',
organizationId: 'member.organizationId',
role: 'member.role',
},
ssoProviderTable: {
id: 'sso.id',
providerId: 'sso.providerId',
domain: 'sso.domain',
issuer: 'sso.issuer',
userId: 'sso.userId',
organizationId: 'sso.organizationId',
oidcConfig: 'sso.oidcConfig',
samlConfig: 'sso.samlConfig',
},
}))
function makeBuilder(rows: any[]): any {
const thenable: any = Promise.resolve(rows)
thenable.where = (condition: any) => {
const values = condition?.values
if (Array.isArray(values) && values.length > 0) {
const target = String(values[values.length - 1]).toLowerCase()
return makeBuilder(rows.filter((r) => String(r.domain ?? '').toLowerCase() === target))
}
return makeBuilder(rows)
}
thenable.limit = () => Promise.resolve(rows)
thenable.orderBy = () => Promise.resolve(rows)
return thenable
}
vi.mock('@sim/db', () => ({
db: {
select: () => ({
from: (table: unknown) =>
makeBuilder(table === memberTable ? dbState.members : dbState.providers),
}),
},
member: memberTable,
ssoProvider: ssoProviderTable,
}))
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
auth: { api: { registerSSOProvider: mockRegisterSSOProvider } },
}))
vi.mock('@/lib/billing', () => ({
hasSSOAccess: mockHasSSOAccess,
}))
vi.mock('@/lib/auth/sso/domain', () => ({
normalizeSSODomain: (input: unknown): string | null => {
if (typeof input !== 'string') return null
const value = input.trim().toLowerCase()
return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(value) ? value : null
},
}))
vi.mock('@/lib/core/security/input-validation.server', () => ({
validateUrlWithDNS: mockValidateUrlWithDNS,
secureFetchWithPinnedIP: mockSecureFetchWithPinnedIP,
}))
vi.mock('@/lib/core/config/env', () => createEnvMock({ SSO_ENABLED: 'true' }))
import { POST } from '@/app/api/auth/sso/register/route'
const OIDC_BODY = {
providerType: 'oidc' as const,
providerId: 'acme-oidc',
issuer: 'https://idp.acme.com',
domain: 'acme.com',
clientId: 'client-id',
clientSecret: 'client-secret',
authorizationEndpoint: 'https://idp.acme.com/authorize',
tokenEndpoint: 'https://idp.acme.com/token',
userInfoEndpoint: 'https://idp.acme.com/userinfo',
jwksEndpoint: 'https://idp.acme.com/jwks',
}
function request(body: Record<string, unknown>) {
return createMockRequest('POST', body)
}
describe('POST /api/auth/sso/register', () => {
beforeEach(() => {
vi.clearAllMocks()
dbState.members = []
dbState.providers = []
mockGetSession.mockResolvedValue({ user: { id: 'u1' } })
mockHasSSOAccess.mockResolvedValue(true)
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' })
mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('discovery not mocked for this test'))
mockRegisterSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' })
})
it('rejects callers without an Enterprise plan', async () => {
mockHasSSOAccess.mockResolvedValue(false)
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(403)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('rejects callers who are not an admin/owner of the target org', async () => {
dbState.members = [{ organizationId: 'org1', role: 'member' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(403)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('rejects an invalid domain', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
const res = await POST(request({ ...OIDC_BODY, domain: 'not-a-domain', orgId: 'org1' }))
expect(res.status).toBe(400)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('rejects a domain already registered by another organization', async () => {
dbState.members = [{ organizationId: 'org-attacker', role: 'owner' }]
dbState.providers = [{ domain: 'acme.com', userId: 'u-victim', organizationId: 'org-victim' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org-attacker' }))
const json = await res.json()
expect(res.status).toBe(409)
expect(json.code).toBe('SSO_DOMAIN_ALREADY_REGISTERED')
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('matches conflicts across casing variants', async () => {
dbState.members = [{ organizationId: 'org-attacker', role: 'owner' }]
dbState.providers = [{ domain: 'ACME.com', userId: 'u-victim', organizationId: 'org-victim' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org-attacker' }))
expect(res.status).toBe(409)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('registers when the domain is unclaimed', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
})
it('allows the owning tenant to update its own provider for the same domain', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
dbState.providers = [{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
})
it('lets an org admin adopt their own user-scoped provider for the same domain', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
dbState.providers = [{ domain: 'acme.com', userId: 'u1', organizationId: null }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
})
it("still blocks an org admin from claiming another user's user-scoped domain", async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
dbState.providers = [{ domain: 'acme.com', userId: 'someone-else', organizationId: null }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(409)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('normalizes the domain before persisting it', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
const res = await POST(request({ ...OIDC_BODY, domain: 'ACME.com', orgId: 'org1' }))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.domain).toBe('acme.com')
})
it('passes skipDiscovery since Sim already resolved and validated the OIDC endpoints', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.skipDiscovery).toBe(true)
})
it('omits userInfoEndpoint when skipUserInfoEndpoint is requested, forcing ID token claims', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.userInfoEndpoint).toBeUndefined()
})
it('does not SSRF-validate userInfoEndpoint when skipUserInfoEndpoint is requested', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => {
if (label === 'OIDC userInfoEndpoint') {
return { isValid: false, error: 'resolves to a private IP address' }
}
return { isValid: true, resolvedIP: '1.2.3.4' }
})
const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.userInfoEndpoint).toBeUndefined()
})
it('does not SSRF-validate a discovered userinfo_endpoint when skipUserInfoEndpoint is requested', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => {
if (label === 'OIDC userinfo_endpoint') {
return { isValid: false, error: 'resolves to a private IP address' }
}
return { isValid: true, resolvedIP: '1.2.3.4' }
})
mockSecureFetchWithPinnedIP.mockResolvedValue({
ok: true,
json: async () => ({
authorization_endpoint: 'https://idp.acme.com/authorize',
token_endpoint: 'https://idp.acme.com/token',
userinfo_endpoint: 'http://169.254.169.254/userinfo',
jwks_uri: 'https://idp.acme.com/jwks',
}),
})
const discoveredBody = {
...OIDC_BODY,
authorizationEndpoint: undefined,
tokenEndpoint: undefined,
jwksEndpoint: undefined,
skipUserInfoEndpoint: true,
}
const res = await POST(request({ ...discoveredBody, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.userInfoEndpoint).toBeUndefined()
})
it('keeps userInfoEndpoint when skipUserInfoEndpoint is not requested', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.userInfoEndpoint).toBe('https://idp.acme.com/userinfo')
})
it('selects tokenEndpointAuthentication from the discovery document when endpoints are auto-discovered', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
mockSecureFetchWithPinnedIP.mockResolvedValue({
ok: true,
json: async () => ({
authorization_endpoint: 'https://idp.acme.com/authorize',
token_endpoint: 'https://idp.acme.com/token',
userinfo_endpoint: 'https://idp.acme.com/userinfo',
jwks_uri: 'https://idp.acme.com/jwks',
token_endpoint_auth_methods_supported: ['client_secret_post'],
}),
})
const discoveredBody = {
...OIDC_BODY,
authorizationEndpoint: undefined,
tokenEndpoint: undefined,
jwksEndpoint: undefined,
}
const res = await POST(request({ ...discoveredBody, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
})
it('still selects tokenEndpointAuthentication from discovery when all endpoints are explicit', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
mockSecureFetchWithPinnedIP.mockResolvedValue({
ok: true,
json: async () => ({
token_endpoint_auth_methods_supported: ['client_secret_post'],
}),
})
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
expect(config.oidcConfig.authorizationEndpoint).toBe(OIDC_BODY.authorizationEndpoint)
})
it('registers successfully when discovery is unreachable and all endpoints are explicit', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('ECONNREFUSED'))
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.skipDiscovery).toBe(true)
expect(config.oidcConfig.authorizationEndpoint).toBe(OIDC_BODY.authorizationEndpoint)
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
})
it('prefers client_secret_post over client_secret_basic when an IdP supports both', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
mockSecureFetchWithPinnedIP.mockResolvedValue({
ok: true,
json: async () => ({
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
}),
})
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
})
it('defaults to client_secret_post when discovery advertises no auth methods', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
mockSecureFetchWithPinnedIP.mockResolvedValue({
ok: true,
json: async () => ({}),
})
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
})
it('surfaces the specific discovery failure reason when endpoints are missing', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => {
if (label === 'OIDC discovery URL') {
return { isValid: false, error: 'resolves to a private IP address' }
}
return { isValid: true, resolvedIP: '1.2.3.4' }
})
const discoveredBody = {
...OIDC_BODY,
authorizationEndpoint: undefined,
tokenEndpoint: undefined,
jwksEndpoint: undefined,
}
const res = await POST(request({ ...discoveredBody, orgId: 'org1' }))
const json = await res.json()
expect(res.status).toBe(400)
expect(json.error).toContain('resolves to a private IP address')
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
})
+535
View File
@@ -0,0 +1,535 @@
import { db, member, ssoProvider } from '@sim/db'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { ssoRegistrationContract } from '@/lib/api/contracts/auth'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { auth, getSession } from '@/lib/auth'
import { normalizeSSODomain } from '@/lib/auth/sso/domain'
import { hasSSOAccess } from '@/lib/billing'
import { env } from '@/lib/core/config/env'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('SSORegisterRoute')
type TokenEndpointAuthMethod = 'client_secret_basic' | 'client_secret_post'
/**
* Prefers client_secret_post over client_secret_basic when an IdP supports both:
* better-auth sends client_secret_basic credentials without URL-encoding per
* RFC 6749 §2.3.1, so a '+' in the client secret is decoded as a space, causing
* invalid_client errors. Matches the same default in register-sso-provider.ts.
*/
function selectTokenEndpointAuthMethod(
supportedMethods: unknown,
existing?: TokenEndpointAuthMethod
): TokenEndpointAuthMethod {
if (existing) return existing
if (!Array.isArray(supportedMethods) || supportedMethods.length === 0) {
return 'client_secret_post'
}
if (supportedMethods.includes('client_secret_post')) return 'client_secret_post'
if (supportedMethods.includes('client_secret_basic')) return 'client_secret_basic'
return 'client_secret_post'
}
type DiscoveryResult =
| { ok: true; discovery: Record<string, unknown> }
| { ok: false; error: string }
const OIDC_DISCOVERY_TIMEOUT_MS = 10000
async function fetchOIDCDiscoveryDocument(discoveryUrl: string): Promise<DiscoveryResult> {
const urlValidation = await validateUrlWithDNS(discoveryUrl, 'OIDC discovery URL')
if (!urlValidation.isValid || !urlValidation.resolvedIP) {
return { ok: false, error: urlValidation.error ?? 'SSRF validation failed' }
}
try {
const response = await secureFetchWithPinnedIP(discoveryUrl, urlValidation.resolvedIP, {
headers: { Accept: 'application/json' },
timeout: OIDC_DISCOVERY_TIMEOUT_MS,
})
if (!response.ok) {
return { ok: false, error: `Discovery request failed with status ${response.status}` }
}
return { ok: true, discovery: (await response.json()) as Record<string, unknown> }
} catch (error) {
return { ok: false, error: getErrorMessage(error, 'Unknown error') }
}
}
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
if (!env.SSO_ENABLED) {
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
}
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const hasAccess = await hasSSOAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json({ error: 'SSO requires an Enterprise plan' }, { status: 403 })
}
const parsed = await parseRequest(
ssoRegistrationContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn('Invalid SSO registration request', { errors: error.issues })
return NextResponse.json(
{ error: getValidationErrorMessage(error, 'Validation failed') },
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const body = parsed.data.body
const { providerId, issuer, providerType, mapping, orgId } = body
if (orgId) {
const [membership] = await db
.select({ organizationId: member.organizationId, role: member.role })
.from(member)
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, orgId)))
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (membership.role !== 'owner' && membership.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const domain = normalizeSSODomain(body.domain)
if (!domain) {
return NextResponse.json({ error: 'Enter a valid domain like company.com' }, { status: 400 })
}
const isOwnedByCaller = (provider: {
userId: string | null
organizationId: string | null
}): boolean => {
if (provider.userId === session.user.id && !provider.organizationId) return true
return orgId ? provider.organizationId === orgId : false
}
const findDomainConflict = async () =>
(
await db
.select({
userId: ssoProvider.userId,
organizationId: ssoProvider.organizationId,
})
.from(ssoProvider)
.where(sql`lower(${ssoProvider.domain}) = ${domain}`)
).find((provider) => !isOwnedByCaller(provider))
const domainConflictResponse = () =>
NextResponse.json(
{
error: 'This domain is already registered for SSO by another organization.',
code: 'SSO_DOMAIN_ALREADY_REGISTERED',
},
{ status: 409 }
)
if (await findDomainConflict()) {
logger.warn('Rejected SSO registration for domain owned by another tenant', {
domain,
orgId,
userId: session.user.id,
})
return domainConflictResponse()
}
const headers: Record<string, string> = {}
request.headers.forEach((value, key) => {
headers[key] = value
})
const providerConfig: any = {
providerId,
issuer,
domain,
mapping,
...(orgId ? { organizationId: orgId } : {}),
}
if (providerType === 'oidc') {
const {
clientId,
clientSecret: rawClientSecret,
scopes,
pkce,
authorizationEndpoint,
tokenEndpoint,
userInfoEndpoint,
skipUserInfoEndpoint,
jwksEndpoint,
} = body
let clientSecret = rawClientSecret
if (rawClientSecret === REDACTED_MARKER) {
const ownerClause = orgId
? and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.organizationId, orgId))
: and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.userId, session.user.id))
const [existing] = await db
.select({ oidcConfig: ssoProvider.oidcConfig })
.from(ssoProvider)
.where(ownerClause)
.limit(1)
if (!existing?.oidcConfig) {
return NextResponse.json(
{ error: 'Cannot update: existing provider not found. Re-enter your client secret.' },
{ status: 400 }
)
}
try {
clientSecret = JSON.parse(existing.oidcConfig).clientSecret
} catch {
return NextResponse.json(
{
error: 'Cannot update: failed to read existing secret. Re-enter your client secret.',
},
{ status: 400 }
)
}
}
const oidcConfig: any = {
clientId,
clientSecret,
scopes: Array.isArray(scopes)
? scopes.filter((s: string) => s !== 'offline_access')
: ['openid', 'profile', 'email'].filter((s: string) => s !== 'offline_access'),
pkce: pkce ?? true,
}
oidcConfig.authorizationEndpoint = authorizationEndpoint
oidcConfig.tokenEndpoint = tokenEndpoint
oidcConfig.userInfoEndpoint = userInfoEndpoint
oidcConfig.jwksEndpoint = jwksEndpoint
const userProvidedEndpoints: Record<string, string | undefined> = {
authorizationEndpoint,
tokenEndpoint,
jwksEndpoint,
...(skipUserInfoEndpoint ? {} : { userInfoEndpoint }),
}
for (const [name, endpointUrl] of Object.entries(userProvidedEndpoints)) {
if (endpointUrl) {
const endpointValidation = await validateUrlWithDNS(endpointUrl, `OIDC ${name}`)
if (!endpointValidation.isValid) {
logger.warn('Explicitly provided OIDC endpoint failed SSRF validation', {
endpoint: name,
url: endpointUrl,
error: endpointValidation.error,
})
return NextResponse.json(
{
error: `OIDC ${name} failed security validation: ${endpointValidation.error}`,
},
{ status: 400 }
)
}
}
}
const needsDiscovery =
!oidcConfig.authorizationEndpoint || !oidcConfig.tokenEndpoint || !oidcConfig.jwksEndpoint
const discoveryUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`
const discoveryResult = await fetchOIDCDiscoveryDocument(discoveryUrl)
if (needsDiscovery) {
logger.info('Fetching OIDC discovery document for missing endpoints', {
discoveryUrl,
hasAuthEndpoint: !!oidcConfig.authorizationEndpoint,
hasTokenEndpoint: !!oidcConfig.tokenEndpoint,
hasJwksEndpoint: !!oidcConfig.jwksEndpoint,
})
if (!discoveryResult.ok) {
logger.error('Failed to fetch OIDC discovery document', { discoveryResult })
return NextResponse.json(
{
error: `Failed to fetch OIDC discovery document: ${discoveryResult.error}. Provide all endpoints explicitly or verify the issuer URL.`,
},
{ status: 400 }
)
}
const { discovery } = discoveryResult
const discoveredEndpoints: Record<string, unknown> = {
authorization_endpoint: discovery.authorization_endpoint,
token_endpoint: discovery.token_endpoint,
jwks_uri: discovery.jwks_uri,
...(skipUserInfoEndpoint ? {} : { userinfo_endpoint: discovery.userinfo_endpoint }),
}
for (const [key, value] of Object.entries(discoveredEndpoints)) {
if (typeof value === 'string') {
const endpointValidation = await validateUrlWithDNS(value, `OIDC ${key}`)
if (!endpointValidation.isValid) {
logger.warn('OIDC discovered endpoint failed SSRF validation', {
endpoint: key,
url: value,
error: endpointValidation.error,
})
return NextResponse.json(
{
error: `Discovered OIDC ${key} failed security validation: ${endpointValidation.error}`,
},
{ status: 400 }
)
}
}
}
oidcConfig.authorizationEndpoint =
oidcConfig.authorizationEndpoint || discovery.authorization_endpoint
oidcConfig.tokenEndpoint = oidcConfig.tokenEndpoint || discovery.token_endpoint
oidcConfig.userInfoEndpoint = oidcConfig.userInfoEndpoint || discovery.userinfo_endpoint
oidcConfig.jwksEndpoint = oidcConfig.jwksEndpoint || discovery.jwks_uri
oidcConfig.tokenEndpointAuthentication = selectTokenEndpointAuthMethod(
discovery.token_endpoint_auth_methods_supported,
oidcConfig.tokenEndpointAuthentication
)
logger.info('Merged OIDC endpoints (user-provided + discovery)', {
providerId,
issuer,
authorizationEndpoint: oidcConfig.authorizationEndpoint,
tokenEndpoint: oidcConfig.tokenEndpoint,
userInfoEndpoint: oidcConfig.userInfoEndpoint,
jwksEndpoint: oidcConfig.jwksEndpoint,
tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication,
})
} else {
logger.info('Using explicitly provided OIDC endpoints (all present)', {
providerId,
issuer,
authorizationEndpoint: oidcConfig.authorizationEndpoint,
tokenEndpoint: oidcConfig.tokenEndpoint,
userInfoEndpoint: oidcConfig.userInfoEndpoint,
jwksEndpoint: oidcConfig.jwksEndpoint,
})
if (!discoveryResult.ok) {
logger.info('OIDC discovery unavailable; falling back to the default token auth method', {
providerId,
discoveryUrl,
})
}
oidcConfig.tokenEndpointAuthentication = selectTokenEndpointAuthMethod(
discoveryResult.ok
? discoveryResult.discovery.token_endpoint_auth_methods_supported
: undefined,
oidcConfig.tokenEndpointAuthentication
)
}
if (skipUserInfoEndpoint) {
oidcConfig.userInfoEndpoint = undefined
logger.info('Skipping UserInfo endpoint for provider, claims will come from the ID token', {
providerId,
})
}
if (
!oidcConfig.authorizationEndpoint ||
!oidcConfig.tokenEndpoint ||
!oidcConfig.jwksEndpoint
) {
const missing: string[] = []
if (!oidcConfig.authorizationEndpoint) missing.push('authorizationEndpoint')
if (!oidcConfig.tokenEndpoint) missing.push('tokenEndpoint')
if (!oidcConfig.jwksEndpoint) missing.push('jwksEndpoint')
logger.error('Missing required OIDC endpoints after discovery merge', {
missing,
authorizationEndpoint: oidcConfig.authorizationEndpoint,
tokenEndpoint: oidcConfig.tokenEndpoint,
jwksEndpoint: oidcConfig.jwksEndpoint,
})
return NextResponse.json(
{
error: `Missing required OIDC endpoints: ${missing.join(', ')}. Please provide these explicitly or verify the issuer supports OIDC discovery.`,
},
{ status: 400 }
)
}
oidcConfig.skipDiscovery = true
providerConfig.oidcConfig = oidcConfig
} else if (providerType === 'saml') {
const {
entryPoint,
cert,
callbackUrl,
audience,
wantAssertionsSigned,
signatureAlgorithm,
digestAlgorithm,
identifierFormat,
idpMetadata,
} = body
const computedCallbackUrl =
callbackUrl || `${getBaseUrl()}/api/auth/sso/saml2/callback/${providerId}`
const escapeXml = (str: string) =>
str.replace(/[<>&"']/g, (c) => {
switch (c) {
case '<':
return '&lt;'
case '>':
return '&gt;'
case '&':
return '&amp;'
case '"':
return '&quot;'
case "'":
return '&apos;'
default:
return c
}
})
const spMetadataXml = `<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${escapeXml(getBaseUrl())}">
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${escapeXml(computedCallbackUrl)}" index="1"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>`
const certBase64 = cert
.replace(/-----BEGIN CERTIFICATE-----/g, '')
.replace(/-----END CERTIFICATE-----/g, '')
.replace(/\s/g, '')
const computedIdpMetadataXml =
idpMetadata ||
`<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${escapeXml(issuer)}">
<IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>${certBase64}</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</KeyDescriptor>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${escapeXml(entryPoint)}"/>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="${escapeXml(entryPoint)}"/>
</IDPSSODescriptor>
</EntityDescriptor>`
const samlConfig: any = {
entryPoint,
cert,
callbackUrl: computedCallbackUrl,
spMetadata: {
metadata: spMetadataXml,
},
idpMetadata: {
metadata: computedIdpMetadataXml,
},
}
if (audience) samlConfig.audience = audience
if (wantAssertionsSigned !== undefined) samlConfig.wantAssertionsSigned = wantAssertionsSigned
if (signatureAlgorithm) samlConfig.signatureAlgorithm = signatureAlgorithm
if (digestAlgorithm) samlConfig.digestAlgorithm = digestAlgorithm
if (identifierFormat) samlConfig.identifierFormat = identifierFormat
providerConfig.samlConfig = samlConfig
}
logger.info('Calling Better Auth registerSSOProvider with config:', {
providerId: providerConfig.providerId,
domain: providerConfig.domain,
hasOidcConfig: !!providerConfig.oidcConfig,
hasSamlConfig: !!providerConfig.samlConfig,
samlConfigKeys: providerConfig.samlConfig ? Object.keys(providerConfig.samlConfig) : [],
fullConfig: JSON.stringify(
{
...providerConfig,
oidcConfig: providerConfig.oidcConfig
? {
...providerConfig.oidcConfig,
clientSecret: REDACTED_MARKER,
}
: undefined,
samlConfig: providerConfig.samlConfig
? {
...providerConfig.samlConfig,
cert: REDACTED_MARKER,
}
: undefined,
},
null,
2
),
})
if (await findDomainConflict()) {
logger.warn('Rejected SSO registration: domain was claimed during registration', {
domain,
orgId,
userId: session.user.id,
})
return domainConflictResponse()
}
const registration = await auth.api.registerSSOProvider({
body: providerConfig,
headers,
})
logger.info('SSO provider registered successfully', {
providerId,
providerType,
domain,
})
return NextResponse.json({
success: true,
providerId: registration.providerId,
providerType,
message: `${providerType.toUpperCase()} provider registered successfully`,
})
} catch (error) {
logger.error('Failed to register SSO provider', {
error,
errorMessage: getErrorMessage(error, 'Unknown error'),
errorStack: error instanceof Error ? error.stack : undefined,
errorDetails: JSON.stringify(error),
})
return NextResponse.json(
{
error: 'Failed to register SSO provider',
details: getErrorMessage(error, 'Unknown error'),
},
{ status: 500 }
)
}
})
@@ -0,0 +1,65 @@
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeTrelloContract } from '@/lib/api/contracts/oauth-connections'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
const logger = createLogger('TrelloAuthorize')
export const dynamic = 'force-dynamic'
const TRELLO_STATE_COOKIE = 'trello_oauth_state'
const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello'
const TRELLO_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(authorizeTrelloContract, request, {})
if (!parsed.success) return parsed.response
const apiKey = env.TRELLO_API_KEY
if (!apiKey) {
logger.error('TRELLO_API_KEY not configured')
return NextResponse.json({ error: 'Trello API key not configured' }, { status: 500 })
}
const baseUrl = getBaseUrl()
const state = generateShortId(32)
const returnUrl = new URL('/api/auth/trello/callback', baseUrl)
returnUrl.searchParams.set('state', state)
const scope = getCanonicalScopesForProvider('trello').join(',')
const authUrl = new URL('https://trello.com/1/authorize')
authUrl.searchParams.set('key', apiKey)
authUrl.searchParams.set('name', 'Sim Studio')
authUrl.searchParams.set('expiration', 'never')
authUrl.searchParams.set('callback_method', 'fragment')
authUrl.searchParams.set('response_type', 'token')
authUrl.searchParams.set('scope', scope)
authUrl.searchParams.set('return_url', returnUrl.toString())
const response = NextResponse.redirect(authUrl.toString())
response.cookies.set(TRELLO_STATE_COOKIE, state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS,
path: TRELLO_STATE_COOKIE_PATH,
})
return response
} catch (error) {
logger.error('Error initiating Trello authorization:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,179 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { trelloCallbackContract } from '@/lib/api/contracts/oauth-connections'
import { parseRequest } from '@/lib/api/server'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('TrelloCallback')
export const dynamic = 'force-dynamic'
const TRELLO_STATE_COOKIE = 'trello_oauth_state'
function escapeForJsString(value: string): string {
return value.replace(/[\\'"<>&\r\n\u2028\u2029]/g, (ch) => {
return `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`
})
}
function renderErrorPage(baseUrl: string, redirectQuery: string) {
return new NextResponse(
`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Trello connection failed</title></head><body><script>window.location.href=${JSON.stringify(`${baseUrl}/workspace?${redirectQuery}`)};</script><p>Trello connection failed. Redirecting...</p></body></html>`,
{
status: 400,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
}
)
}
export const GET = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(trelloCallbackContract, request, {})
if (!parsed.success) return parsed.response
const baseUrl = getBaseUrl()
const queryState = parsed.data.query.state
const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value
if (!queryState || !cookieState || queryState !== cookieState) {
logger.warn('Trello callback rejected: state mismatch or missing state', {
hasQueryState: Boolean(queryState),
hasCookieState: Boolean(cookieState),
})
const response = renderErrorPage(baseUrl, 'error=trello_state_mismatch')
response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: '/api/auth/trello' })
return response
}
const safeState = escapeForJsString(queryState)
return new NextResponse(
`<!DOCTYPE html>
<html>
<head>
<title>Connecting to Trello...</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #0052CC 0%, #0079BF 100%);
}
.container {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
text-align: center;
max-width: 400px;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #0052CC;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error {
color: #ef4444;
margin-top: 1rem;
}
h2 {
color: #111827;
margin: 0 0 0.5rem 0;
}
p {
color: #6b7280;
margin: 0;
}
</style>
</head>
<body>
<div class="container">
<div class="spinner"></div>
<h2>Connecting to Trello</h2>
<p id="status">Processing authorization...</p>
<p id="error" class="error" style="display:none;"></p>
</div>
<script>
(function() {
const statusEl = document.getElementById('status');
const errorEl = document.getElementById('error');
try {
const fragment = window.location.hash.substring(1);
const params = new URLSearchParams(fragment);
const token = params.get('token');
const authError = params.get('error');
if (authError) {
throw new Error(authError);
}
if (!token) {
throw new Error('No token received from Trello');
}
statusEl.textContent = 'Saving your connection...';
fetch('${baseUrl}/api/auth/trello/store', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ token: token, state: '${safeState}' })
})
.then(response => response.json())
.then(data => {
if (data.success) {
statusEl.textContent = 'Success! Redirecting...';
setTimeout(function() {
window.location.href = '${baseUrl}/workspace?trello_connected=true';
}, 500);
} else {
throw new Error(data.error || 'Failed to save connection');
}
})
.catch(error => {
errorEl.textContent = error.message || 'Failed to save connection';
errorEl.style.display = 'block';
statusEl.textContent = 'Connection failed';
setTimeout(function() {
window.location.href = '${baseUrl}/workspace?error=trello_failed';
}, 3000);
});
} catch (error) {
errorEl.textContent = error.message || 'Authorization failed';
errorEl.style.display = 'block';
statusEl.textContent = 'Connection failed';
setTimeout(function() {
window.location.href = '${baseUrl}/workspace?error=trello_auth_failed';
}, 3000);
}
})();
</script>
</body>
</html>`,
{
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
}
)
})
+153
View File
@@ -0,0 +1,153 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { storeTrelloTokenContract } from '@/lib/api/contracts/oauth-connections'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
const logger = createLogger('TrelloStore')
export const dynamic = 'force-dynamic'
const TRELLO_STATE_COOKIE = 'trello_oauth_state'
const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello'
function clearStateCookie(response: NextResponse) {
response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: TRELLO_STATE_COOKIE_PATH })
return response
}
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn('Unauthorized attempt to store Trello token')
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(storeTrelloTokenContract, request, {})
if (!parsed.success) return parsed.response
const { token, state } = parsed.data.body
const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value
if (!cookieState || cookieState !== state) {
logger.warn('Trello store rejected: state mismatch', {
hasCookieState: Boolean(cookieState),
userId: session.user.id,
})
return clearStateCookie(
NextResponse.json(
{ success: false, error: 'Invalid or expired authorization state' },
{ status: 400 }
)
)
}
const apiKey = env.TRELLO_API_KEY
if (!apiKey) {
logger.error('TRELLO_API_KEY not configured')
return NextResponse.json({ success: false, error: 'Trello not configured' }, { status: 500 })
}
const scope = getCanonicalScopesForProvider('trello').join(',')
const validationUrl = `https://api.trello.com/1/members/me?key=${apiKey}&token=${token}&fields=id,username,fullName`
const userResponse = await fetch(validationUrl, {
headers: { Accept: 'application/json' },
})
if (!userResponse.ok) {
const errorText = await userResponse.text()
logger.error('Invalid Trello token', {
status: userResponse.status,
error: errorText,
})
return NextResponse.json(
{ success: false, error: `Invalid Trello token: ${errorText}` },
{ status: 400 }
)
}
const trelloUser = (await userResponse.json().catch(() => null)) as { id?: string } | null
if (typeof trelloUser?.id !== 'string' || trelloUser.id.trim().length === 0) {
logger.error('Trello validation response did not include a valid member id', {
response: trelloUser,
})
return NextResponse.json(
{ success: false, error: 'Invalid Trello member response' },
{ status: 502 }
)
}
const existing = await db.query.account.findFirst({
where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'trello'),
eq(account.accountId, trelloUser.id)
),
})
const now = new Date()
if (existing) {
await db
.update(account)
.set({
accessToken: token,
accountId: trelloUser.id,
scope,
updatedAt: now,
})
.where(eq(account.id, existing.id))
} else {
await safeAccountInsert(
{
id: `trello_${session.user.id}_${Date.now()}`,
userId: session.user.id,
providerId: 'trello',
accountId: trelloUser.id,
accessToken: token,
scope,
createdAt: now,
updatedAt: now,
},
{ provider: 'Trello', identifier: trelloUser.id }
)
}
const persisted =
existing ??
(await db.query.account.findFirst({
where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'trello'),
eq(account.accountId, trelloUser.id)
),
}))
if (persisted) {
try {
await processCredentialDraft({
userId: session.user.id,
providerId: 'trello',
accountId: persisted.id,
})
} catch (error) {
logger.error('Failed to process credential draft for Trello', { error })
}
}
return clearStateCookie(NextResponse.json({ success: true }))
} catch (error) {
logger.error('Error storing Trello token:', error)
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,7 @@
import { toNextJsHandler } from 'better-auth/next-js'
import { auth } from '@/lib/auth'
export const dynamic = 'force-dynamic'
// Handle Stripe webhooks through better-auth
export const { GET, POST } = toNextJsHandler(auth.handler)
+82
View File
@@ -0,0 +1,82 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { purchaseCreditsContract } from '@/lib/api/contracts/subscription'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getCreditBalance } from '@/lib/billing/credits/balance'
import { purchaseCredits } from '@/lib/billing/credits/purchase'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CreditsAPI')
export const GET = withRouteHandler(async () => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { balance, entityType, entityId } = await getCreditBalance(session.user.id)
return NextResponse.json({
success: true,
data: { balance, entityType, entityId },
})
} catch (error) {
logger.error('Failed to get credit balance', { error, userId: session.user.id })
return NextResponse.json({ error: 'Failed to get credit balance' }, { status: 500 })
}
})
export const POST = withRouteHandler(async (request: NextRequest) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const parsed = await parseRequest(
purchaseCreditsContract,
request,
{},
{
validationErrorResponse: () =>
NextResponse.json(
{ error: 'Invalid amount. Must be between $10 and $1000' },
{ status: 400 }
),
}
)
if (!parsed.success) return parsed.response
const result = await purchaseCredits({
userId: session.user.id,
amountDollars: parsed.data.body.amount,
requestId: parsed.data.body.requestId,
})
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CREDIT_PURCHASED,
resourceType: AuditResourceType.BILLING,
resourceId: parsed.data.body.requestId,
description: `Purchased $${parsed.data.body.amount} in credits`,
metadata: {
amountDollars: parsed.data.body.amount,
requestId: parsed.data.body.requestId,
},
request,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Failed to purchase credits', { error, userId: session.user.id })
return NextResponse.json({ error: 'Failed to purchase credits' }, { status: 500 })
}
})
@@ -0,0 +1,110 @@
/**
* @vitest-environment node
*/
import { createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing'
import { generateShortId } from '@sim/utils/id'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetSession, mockGetStripeClient, mockStripeInvoicesList } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockGetStripeClient: vi.fn(),
mockStripeInvoicesList: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
getSession: mockGetSession,
}))
vi.mock('@/lib/billing/stripe-client', () => ({
getStripeClient: mockGetStripeClient,
}))
import { GET } from '@/app/api/billing/invoices/route'
function makeInvoice(overrides: Record<string, unknown> = {}) {
return {
id: `in_${generateShortId()}`,
number: 'INV-1',
created: 1700000000,
total: 1000,
amount_paid: 1000,
currency: 'usd',
status: 'paid',
hosted_invoice_url: 'https://stripe.test/invoice',
invoice_pdf: 'https://stripe.test/invoice.pdf',
...overrides,
}
}
describe('GET /api/billing/invoices', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
dbChainMockFns.limit.mockResolvedValue([{ customer: 'cus_1' }])
mockGetStripeClient.mockReturnValue({ invoices: { list: mockStripeInvoicesList } })
})
it('does not surface hasMore when the trailing raw invoice beyond MAX_INVOICES is a draft', async () => {
const finalized = Array.from({ length: 10 }, () => makeInvoice())
mockStripeInvoicesList.mockResolvedValueOnce({
data: [...finalized, makeInvoice({ status: 'draft' })],
has_more: false,
})
const request = createMockRequest('GET')
const response = await GET(request)
const body = await response.json()
expect(body.invoices).toHaveLength(10)
expect(body.hasMore).toBe(false)
})
it('reports hasMore when there are genuinely more finalized invoices', async () => {
const finalized = Array.from({ length: 11 }, () => makeInvoice())
mockStripeInvoicesList.mockResolvedValueOnce({ data: finalized, has_more: false })
const request = createMockRequest('GET')
const response = await GET(request)
const body = await response.json()
expect(body.invoices).toHaveLength(10)
expect(body.hasMore).toBe(true)
})
it('pages through further drafts to confirm hasMore when the first page is inconclusive', async () => {
const firstPage = Array.from({ length: 11 }, () => makeInvoice({ status: 'draft' }))
mockStripeInvoicesList
.mockResolvedValueOnce({ data: firstPage, has_more: true })
.mockResolvedValueOnce({ data: [makeInvoice()], has_more: false })
const request = createMockRequest('GET')
const response = await GET(request)
const body = await response.json()
expect(mockStripeInvoicesList).toHaveBeenCalledTimes(2)
expect(mockStripeInvoicesList).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ starting_after: firstPage.at(-1)?.id })
)
expect(body.invoices).toHaveLength(1)
expect(body.hasMore).toBe(false)
})
it('reports hasMore when the MAX_STRIPE_PAGES safety cap is hit while Stripe still has more', async () => {
mockStripeInvoicesList.mockResolvedValue({
data: Array.from({ length: 11 }, () => makeInvoice({ status: 'draft' })),
has_more: true,
})
const request = createMockRequest('GET')
const response = await GET(request)
const body = await response.json()
expect(mockStripeInvoicesList).toHaveBeenCalledTimes(5)
expect(body.invoices).toHaveLength(0)
expect(body.hasMore).toBe(true)
})
})
+150
View File
@@ -0,0 +1,150 @@
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import type Stripe from 'stripe'
import { getInvoicesContract } from '@/lib/api/contracts/subscription'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { getStripeClient } from '@/lib/billing/stripe-client'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('BillingInvoices')
/** Cap the number of invoices returned to the most recent statements. */
const MAX_INVOICES = 10
/** Stripe page size when scanning for finalized invoices; also bounds the has-more probe. */
const STRIPE_PAGE_SIZE = MAX_INVOICES + 1
/** Safety cap on pagination when a customer has many draft invoices interspersed. */
const MAX_STRIPE_PAGES = 5
interface FinalizedInvoicesPage {
invoices: Stripe.Invoice[]
/**
* Whether Stripe's raw cursor still had more records after the scan
* stopped — either because `invoices.length` became conclusive, or the
* `MAX_STRIPE_PAGES` safety cap was hit first. Callers must OR this into
* `hasMore` so hitting the cap never silently hides a "View all" that a
* customer with many consecutive drafts genuinely has.
*/
stripeHasMore: boolean
}
/**
* Pages through a customer's Stripe invoices, keeping only finalized ones,
* until either more than `MAX_INVOICES` have been collected or Stripe's list
* is exhausted (bounded by `MAX_STRIPE_PAGES`).
*
* Stripe's raw pagination cursor (`has_more`) counts draft invoices, which
* the caller filters out — so a single page can under-report finalized
* invoices while `has_more` is still true. Paging until the finalized count
* is conclusive is what lets the caller derive an accurate `hasMore` for the
* "View all" affordance.
*/
async function collectFinalizedInvoices(
stripe: Stripe,
stripeCustomerId: string
): Promise<FinalizedInvoicesPage> {
const invoices: Stripe.Invoice[] = []
let startingAfter: string | undefined
let stripeHasMore = true
for (
let page = 0;
page < MAX_STRIPE_PAGES && stripeHasMore && invoices.length <= MAX_INVOICES;
page++
) {
const result = await stripe.invoices.list({
customer: stripeCustomerId,
limit: STRIPE_PAGE_SIZE,
starting_after: startingAfter,
})
invoices.push(
...result.data.filter((invoice) => invoice.id && invoice.status && invoice.status !== 'draft')
)
stripeHasMore = result.has_more
startingAfter = result.data.at(-1)?.id
}
return { invoices, stripeHasMore }
}
/**
* Lists finalized Stripe invoices for the caller's billing customer (personal
* or organization-scoped). Returns an empty list when there is no Stripe
* customer yet or when Stripe is not configured, so the UI can simply hide the
* Invoices section instead of surfacing an error.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(getInvoicesContract, request, {})
if (!parsed.success) return parsed.response
const { context, organizationId } = parsed.data.query
if (context === 'organization' && !organizationId) {
return NextResponse.json(
{ error: 'organizationId is required when context=organization' },
{ status: 400 }
)
}
let stripeCustomerId: string | null = null
if (context === 'organization') {
const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId!)
if (!hasPermission) {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 })
}
// Resolve the org's customer via the canonical resolver so we deterministically
// pick the same subscription (most recent entitled, ordered) the rest of the
// billing UI uses — a bare limit(1) here could select a stale row.
const orgSubscription = await getOrganizationSubscription(organizationId!)
stripeCustomerId = orgSubscription?.stripeCustomerId ?? null
} else {
const rows = await db
.select({ customer: user.stripeCustomerId })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
stripeCustomerId = rows.length > 0 ? rows[0].customer || null : null
}
const stripe = getStripeClient()
if (!stripeCustomerId || !stripe) {
return NextResponse.json({ success: true, invoices: [], hasMore: false })
}
try {
const finalized = await collectFinalizedInvoices(stripe, stripeCustomerId)
const hasMore = finalized.invoices.length > MAX_INVOICES || finalized.stripeHasMore
const invoices = finalized.invoices.slice(0, MAX_INVOICES).map((invoice) => ({
id: invoice.id as string,
number: invoice.number ?? null,
created: invoice.created,
total: invoice.total,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
status: invoice.status ?? null,
hostedInvoiceUrl: invoice.hosted_invoice_url ?? null,
invoicePdf: invoice.invoice_pdf ?? null,
}))
return NextResponse.json({ success: true, invoices, hasMore })
} catch (error) {
logger.error('Failed to list invoices', { error, userId: session.user.id, context })
return NextResponse.json({ error: 'Failed to list invoices' }, { status: 500 })
}
})
@@ -0,0 +1,38 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getMyMemberCreditsContract } from '@/lib/api/contracts/organization'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { checkOrgMemberUsageLimit } from '@/lib/billing/calculations/usage-monitor'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
/**
* GET /api/billing/member-credits?workspaceId=...
*
* Returns the caller's OWN per-member usage and cap inside the workspace's
* organization, in DOLLARS (the DB unit) so the client's `formatCredits` does the
* single dollars→credits conversion. Own-data only, so no admin gate (unlike the
* org/member admin route). Reuses {@link checkOrgMemberUsageLimit}, which yields a
* null limit — and the chip falls back to the plan-level view — whenever no
* per-member cap applies: non-hosted, the workspace isn't org-owned, or no cap is
* set for this member.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(getMyMemberCreditsContract, request, {})
if (!parsed.success) return parsed.response
const { workspaceId } = parsed.data.query
const { currentUsage, limit } = await checkOrgMemberUsageLimit(session.user.id, workspaceId)
return NextResponse.json({
success: true,
data: {
usedDollars: currentUsage,
limitDollars: limit,
},
})
})
+80
View File
@@ -0,0 +1,80 @@
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { billingPortalBodySchema } from '@/lib/api/contracts/subscription'
import { getSession } from '@/lib/auth'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('BillingPortal')
export const POST = withRouteHandler(async (request: NextRequest) => {
const session = await getSession()
try {
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json().catch(() => ({}))
const parsedBody = billingPortalBodySchema.safeParse(body)
if (!parsedBody.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const context = parsedBody.data.context
const organizationId = parsedBody.data.organizationId
const returnUrl = parsedBody.data.returnUrl || `${getBaseUrl()}/workspace?billing=updated`
const stripe = requireStripeClient()
let stripeCustomerId: string | null = null
if (context === 'organization') {
if (!organizationId) {
return NextResponse.json({ error: 'organizationId is required' }, { status: 400 })
}
const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId)
if (!hasPermission) {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 })
}
// Canonical resolver: deterministically selects the most recent entitled
// org subscription, matching the rest of the billing UI.
const orgSubscription = await getOrganizationSubscription(organizationId)
stripeCustomerId = orgSubscription?.stripeCustomerId ?? null
} else {
const rows = await db
.select({ customer: user.stripeCustomerId })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
stripeCustomerId = rows.length > 0 ? rows[0].customer || null : null
}
if (!stripeCustomerId) {
logger.error('Stripe customer not found for portal session', {
context,
organizationId,
userId: session.user.id,
})
return NextResponse.json({ error: 'Stripe customer not found' }, { status: 404 })
}
const portal = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: returnUrl,
})
return NextResponse.json({ url: portal.url })
} catch (error) {
logger.error('Failed to create billing portal session', { error })
return NextResponse.json({ error: 'Failed to create billing portal session' }, { status: 500 })
}
})
+183
View File
@@ -0,0 +1,183 @@
import { db, dbReplica } from '@sim/db'
import { member } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { billingQuerySchema } from '@/lib/api/contracts/subscription'
import { getSession } from '@/lib/auth'
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('UnifiedBillingAPI')
/**
* Unified Billing Endpoint
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const session = await getSession()
try {
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const parsedQuery = billingQuerySchema.safeParse({
context: searchParams.get('context') || undefined,
id: searchParams.get('id') || undefined,
includeOrg: searchParams.get('includeOrg') === 'true',
})
if (!parsedQuery.success) {
return NextResponse.json(
{ error: 'Invalid context. Must be "user" or "organization"' },
{ status: 400 }
)
}
const { context, id: contextId, includeOrg } = parsedQuery.data
// For organization context, require contextId
if (context === 'organization' && !contextId) {
return NextResponse.json(
{ error: 'Organization ID is required when context=organization' },
{ status: 400 }
)
}
let billingData
if (context === 'user') {
if (contextId) {
const membership = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, contextId), eq(member.userId, session.user.id)))
.limit(1)
if (membership.length === 0) {
return NextResponse.json(
{ error: 'Access denied - not a member of this organization' },
{ status: 403 }
)
}
}
const [billingResult, billingStatus] = await Promise.all([
getSimplifiedBillingSummary(session.user.id, contextId || undefined, dbReplica),
getEffectiveBillingStatus(session.user.id),
])
billingData = billingResult
billingData = {
...billingData,
billingBlocked: billingStatus.billingBlocked,
billingBlockedReason: billingStatus.billingBlockedReason,
blockedByOrgOwner: billingStatus.blockedByOrgOwner,
}
// Optionally include organization membership and role
if (includeOrg) {
const userMembership = await db
.select({
organizationId: member.organizationId,
role: member.role,
})
.from(member)
.where(eq(member.userId, session.user.id))
.limit(1)
if (userMembership.length > 0) {
billingData = {
...billingData,
organization: {
id: userMembership[0].organizationId,
role: userMembership[0].role as 'owner' | 'admin' | 'member',
},
}
}
}
} else {
// Get user role in organization for permission checks first
const memberRecord = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, contextId!), eq(member.userId, session.user.id)))
.limit(1)
if (memberRecord.length === 0) {
return NextResponse.json(
{ error: 'Access denied - not a member of this organization' },
{ status: 403 }
)
}
// Get organization-specific billing
const rawBillingData = await getOrganizationBillingData(contextId!, dbReplica)
if (!rawBillingData) {
return NextResponse.json(
{ error: 'Organization not found or access denied' },
{ status: 404 }
)
}
billingData = {
organizationId: rawBillingData.organizationId,
organizationName: rawBillingData.organizationName,
subscriptionPlan: rawBillingData.subscriptionPlan,
subscriptionStatus: rawBillingData.subscriptionStatus,
totalSeats: rawBillingData.totalSeats,
usedSeats: rawBillingData.usedSeats,
seatsCount: rawBillingData.seatsCount,
totalCurrentUsage: rawBillingData.totalCurrentUsage,
totalUsageLimit: rawBillingData.totalUsageLimit,
minimumBillingAmount: rawBillingData.minimumBillingAmount,
averageUsagePerMember: rawBillingData.averageUsagePerMember,
billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null,
billingPeriodEnd: rawBillingData.billingPeriodEnd?.toISOString() || null,
members: rawBillingData.members.map((m) => ({
...m,
joinedAt: m.joinedAt.toISOString(),
})),
}
const userRole = memberRecord[0].role
// Get effective billing blocked status (includes org owner check)
const billingStatus = await getEffectiveBillingStatus(session.user.id)
// Merge blocked flag into data for convenience
billingData = {
...billingData,
billingBlocked: billingStatus.billingBlocked,
billingBlockedReason: billingStatus.billingBlockedReason,
blockedByOrgOwner: billingStatus.blockedByOrgOwner,
}
return NextResponse.json({
success: true,
context,
data: billingData,
userRole,
billingBlocked: billingData.billingBlocked,
billingBlockedReason: billingData.billingBlockedReason,
blockedByOrgOwner: billingData.blockedByOrgOwner,
})
}
return NextResponse.json({
success: true,
context,
data: billingData,
})
} catch (error) {
logger.error('Failed to get billing data', {
userId: session?.user?.id,
error,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,212 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { subscription as subscriptionTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { billingSwitchPlanContract } from '@/lib/api/contracts/subscription'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { writeBillingInterval } from '@/lib/billing/core/subscription'
import { getPlanType, isEnterprise } from '@/lib/billing/plan-helpers'
import { getPlanByName } from '@/lib/billing/plans'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import {
hasUsableSubscriptionAccess,
hasUsableSubscriptionStatus,
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('SwitchPlan')
/**
* POST /api/billing/switch-plan
*
* Switches a subscription's tier and/or billing interval via direct Stripe API.
* Covers: Pro <-> Max, monthly <-> annual, and team tier changes.
* Uses proration -- no Billing Portal redirect.
*
* Body:
* targetPlanName: string -- e.g. 'pro_6000', 'team_25000'
* interval?: 'month' | 'year' -- if omitted, keeps the current interval
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const session = await getSession()
try {
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!isBillingEnabled) {
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 400 })
}
const parsed = await parseRequest(billingSwitchPlanContract, request, {})
if (!parsed.success) return parsed.response
const { targetPlanName, interval } = parsed.data.body
const userId = session.user.id
const sub = await getHighestPrioritySubscription(userId)
if (!sub || !sub.stripeSubscriptionId) {
return NextResponse.json({ error: 'No active subscription found' }, { status: 404 })
}
const billingStatus = await getEffectiveBillingStatus(userId)
if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) {
return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 })
}
if (isEnterprise(sub.plan) || isEnterprise(targetPlanName)) {
return NextResponse.json(
{ error: 'Enterprise plan changes must be handled via support' },
{ status: 400 }
)
}
const targetPlan = getPlanByName(targetPlanName)
if (!targetPlan) {
return NextResponse.json({ error: 'Target plan not found' }, { status: 400 })
}
const currentPlanType = getPlanType(sub.plan)
const targetPlanType = getPlanType(targetPlanName)
if (currentPlanType !== targetPlanType) {
return NextResponse.json(
{ error: 'Cannot switch between individual and team plans via this endpoint' },
{ status: 400 }
)
}
if (isOrgScopedSubscription(sub, userId)) {
const hasPermission = await isOrganizationOwnerOrAdmin(userId, sub.referenceId)
if (!hasPermission) {
return NextResponse.json({ error: 'Only team admins can change the plan' }, { status: 403 })
}
}
const stripe = requireStripeClient()
const stripeSubscription = await stripe.subscriptions.retrieve(sub.stripeSubscriptionId)
if (!hasUsableSubscriptionStatus(stripeSubscription.status)) {
return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 })
}
const subscriptionItem = stripeSubscription.items.data[0]
if (!subscriptionItem) {
return NextResponse.json({ error: 'No subscription item found in Stripe' }, { status: 500 })
}
const currentInterval = subscriptionItem.price?.recurring?.interval
const targetInterval = interval ?? currentInterval ?? 'month'
const targetPriceId =
targetInterval === 'year' ? targetPlan.annualDiscountPriceId : targetPlan.priceId
if (!targetPriceId) {
return NextResponse.json(
{ error: `No ${targetInterval} price configured for plan ${targetPlanName}` },
{ status: 400 }
)
}
const alreadyOnStripePrice = subscriptionItem.price?.id === targetPriceId
const alreadyInDb = sub.plan === targetPlanName
if (alreadyOnStripePrice && alreadyInDb) {
return NextResponse.json({ success: true, message: 'Already on this plan and interval' })
}
logger.info('Switching subscription', {
userId,
subscriptionId: sub.id,
stripeSubscriptionId: sub.stripeSubscriptionId,
fromPlan: sub.plan,
toPlan: targetPlanName,
fromInterval: currentInterval,
toInterval: targetInterval,
targetPriceId,
})
if (!alreadyOnStripePrice) {
const currentQuantity = subscriptionItem.quantity ?? 1
await stripe.subscriptions.update(sub.stripeSubscriptionId, {
items: [
{
id: subscriptionItem.id,
price: targetPriceId,
quantity: currentQuantity,
},
],
proration_behavior: 'always_invoice',
})
}
if (!alreadyInDb) {
await db
.update(subscriptionTable)
.set({ plan: targetPlanName })
.where(eq(subscriptionTable.id, sub.id))
}
await writeBillingInterval(sub.id, targetInterval as 'month' | 'year')
logger.info('Subscription switched successfully', {
userId,
subscriptionId: sub.id,
fromPlan: sub.plan,
toPlan: targetPlanName,
interval: targetInterval,
})
captureServerEvent(
userId,
'subscription_changed',
{ from_plan: sub.plan ?? 'unknown', to_plan: targetPlanName, interval: targetInterval },
{ set: { plan: targetPlanName } }
)
if (isOrgScopedSubscription(sub, userId)) {
recordAudit({
actorId: userId,
action: AuditAction.ORG_PLAN_CONVERTED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: sub.referenceId,
description: `Plan converted from ${sub.plan ?? 'unknown'} to ${targetPlanName}`,
metadata: {
organizationId: sub.referenceId,
subscriptionId: sub.id,
fromPlan: sub.plan,
toPlan: targetPlanName,
interval: targetInterval,
},
request,
})
captureServerEvent(userId, 'plan_converted', {
organization_id: sub.referenceId,
from_plan: sub.plan ?? 'unknown',
to_plan: targetPlanName,
})
}
return NextResponse.json({ success: true, plan: targetPlanName, interval: targetInterval })
} catch (error) {
logger.error('Failed to switch subscription', {
userId: session?.user?.id,
error: toError(error).message,
})
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to switch plan') },
{ status: 500 }
)
}
})
@@ -0,0 +1,168 @@
/**
* @vitest-environment node
*/
import { createMockRequest, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockCheckInternalApiKey,
mockRecordUsage,
mockRecordCumulativeUsage,
mockCheckAndBillOverageThreshold,
} = vi.hoisted(() => ({
mockCheckInternalApiKey: vi.fn(),
mockRecordUsage: vi.fn(),
mockRecordCumulativeUsage: vi.fn(),
mockCheckAndBillOverageThreshold: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/copilot/request/http', () => ({
checkInternalApiKey: mockCheckInternalApiKey,
}))
vi.mock('@/lib/copilot/request/otel', () => ({
withIncomingGoSpan: (
_headers: unknown,
_span: unknown,
_attrs: unknown,
fn: (span: { setAttribute: () => void; setAttributes: () => void }) => unknown
) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn() }),
}))
vi.mock('@/lib/billing/core/usage-log', () => ({
recordUsage: mockRecordUsage,
recordCumulativeUsage: mockRecordCumulativeUsage,
}))
vi.mock('@/lib/billing/threshold-billing', () => ({
checkAndBillOverageThreshold: mockCheckAndBillOverageThreshold,
}))
vi.mock('@/lib/core/config/env-flags', () => ({
isBillingEnabled: true,
}))
import { POST } from '@/app/api/billing/update-cost/route'
describe('POST /api/billing/update-cost — workspaceId attribution', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockCheckInternalApiKey.mockReturnValue({ success: true })
mockRecordUsage.mockResolvedValue(undefined)
mockRecordCumulativeUsage.mockResolvedValue({ billed: true, delta: 0.5, total: 0.5 })
mockCheckAndBillOverageThreshold.mockResolvedValue(undefined)
dbChainMockFns.limit.mockResolvedValue([{ id: 'ws-1' }])
})
it('stamps workspaceId onto recorded usage when provided (no idempotency key)', async () => {
const res = await POST(
createMockRequest(
'POST',
{ userId: 'user-1', cost: 0.5, model: 'gpt', source: 'mcp_copilot', workspaceId: 'ws-1' },
{ 'x-api-key': 'internal' }
)
)
expect(res.status).toBe(200)
expect(mockRecordUsage).toHaveBeenCalledTimes(1)
expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({
userId: 'user-1',
workspaceId: 'ws-1',
})
})
it('records cumulative cost via monotonic top-up when an idempotency key is present', async () => {
const res = await POST(
createMockRequest(
'POST',
{
userId: 'user-1',
cost: 0.4662453,
model: 'claude-opus-4.8',
source: 'workspace-chat',
workspaceId: 'ws-1',
idempotencyKey: 'msg-1-billing',
inputTokens: 461371,
outputTokens: 1686,
},
{ 'x-api-key': 'internal' }
)
)
expect(res.status).toBe(200)
expect(mockRecordUsage).not.toHaveBeenCalled()
expect(mockRecordCumulativeUsage).toHaveBeenCalledTimes(1)
expect(mockRecordCumulativeUsage.mock.calls[0][0]).toMatchObject({
userId: 'user-1',
workspaceId: 'ws-1',
source: 'workspace-chat',
model: 'claude-opus-4.8',
cost: 0.4662453,
eventKey: 'update-cost:msg-1-billing',
})
expect(mockCheckAndBillOverageThreshold).toHaveBeenCalledWith('user-1')
})
it('returns 409 and skips overage when the cumulative is not higher (duplicate flush)', async () => {
mockRecordCumulativeUsage.mockResolvedValue({ billed: false, delta: 0, total: 0.4662453 })
const res = await POST(
createMockRequest(
'POST',
{
userId: 'user-1',
cost: 0.4662453,
model: 'claude-opus-4.8',
source: 'workspace-chat',
workspaceId: 'ws-1',
idempotencyKey: 'msg-1-billing',
},
{ 'x-api-key': 'internal' }
)
)
expect(res.status).toBe(409)
expect(mockCheckAndBillOverageThreshold).not.toHaveBeenCalled()
})
it('records unattributed when workspaceId is omitted (headless client)', async () => {
const res = await POST(
createMockRequest(
'POST',
{ userId: 'user-1', cost: 0.5, model: 'gpt', source: 'copilot' },
{ 'x-api-key': 'internal' }
)
)
expect(res.status).toBe(200)
expect(dbChainMockFns.limit).not.toHaveBeenCalled()
expect(mockRecordUsage).toHaveBeenCalledTimes(1)
expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({
userId: 'user-1',
workspaceId: undefined,
})
})
it('records unattributed when the workspace does not exist in this deployment (self-hosted client)', async () => {
dbChainMockFns.limit.mockResolvedValue([])
const res = await POST(
createMockRequest(
'POST',
{
userId: 'user-1',
cost: 0.5,
model: 'claude-opus-4.8',
source: 'workspace-chat',
workspaceId: 'self-hosted-ws',
idempotencyKey: 'msg-1-billing',
},
{ 'x-api-key': 'internal' }
)
)
expect(res.status).toBe(200)
expect(mockRecordCumulativeUsage).toHaveBeenCalledTimes(1)
expect(mockRecordCumulativeUsage.mock.calls[0][0]).toMatchObject({
userId: 'user-1',
workspaceId: undefined,
eventKey: 'update-cost:msg-1-billing',
})
})
})
@@ -0,0 +1,295 @@
import type { Span } from '@opentelemetry/api'
import { db } from '@sim/db'
import { workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresConstraintName, getPostgresErrorCode, toError } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { billingUpdateCostContract } from '@/lib/api/contracts/subscription'
import { parseRequest } from '@/lib/api/server'
import { recordCumulativeUsage, recordUsage } from '@/lib/billing/core/usage-log'
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { BillingRouteOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { checkInternalApiKey } from '@/lib/copilot/request/http'
import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('BillingUpdateCostAPI')
/**
* Resolves the request-supplied workspace to one that exists in this
* deployment. Workspace attribution on the usage ledger is best-effort:
* self-hosted and headless clients bill through this endpoint with workspace
* IDs from their own databases, and `usage_log.workspace_id` carries an FK to
* `workspace`, so stamping a foreign ID would fail the entire flush with an
* FK violation and strand real cost in the caller's dead-letter queue.
* Unknown workspaces are recorded unattributed instead — billing is keyed on
* the user's billing entity and never depends on the workspace.
*/
async function resolveAttributableWorkspaceId(
requestId: string,
workspaceId: string | undefined
): Promise<string | undefined> {
if (!workspaceId) return undefined
const [row] = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (row) return row.id
logger.warn(`[${requestId}] Workspace not found in this deployment; recording unattributed`, {
workspaceId,
})
return undefined
}
/**
* POST /api/billing/update-cost
* Update user cost with a pre-calculated cost value (internal API key auth required)
*
* Parented under the Go-side `sim.update_cost` span via W3C traceparent
* propagation. Every mothership request that bills should therefore show
* the Go client span AND this Sim server span sharing one trace, with
* the actual usage/overage work nested below.
*/
export const POST = withRouteHandler((req: NextRequest) =>
withIncomingGoSpan(
req.headers,
TraceSpan.CopilotBillingUpdateCost,
{
[TraceAttr.HttpMethod]: 'POST',
[TraceAttr.HttpRoute]: '/api/billing/update-cost',
},
async (span) => updateCostInner(req, span)
)
)
async function updateCostInner(req: NextRequest, span: Span): Promise<NextResponse> {
const requestId = generateRequestId()
const startTime = Date.now()
try {
logger.info(`[${requestId}] Update cost request started`)
if (!isBillingEnabled) {
span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.BillingDisabled)
span.setAttribute(TraceAttr.HttpStatusCode, 200)
return NextResponse.json({
success: true,
message: 'Billing disabled, cost update skipped',
data: {
billingEnabled: false,
processedAt: new Date().toISOString(),
requestId,
},
})
}
// Check authentication (internal API key)
const authResult = checkInternalApiKey(req)
if (!authResult.success) {
logger.warn(`[${requestId}] Authentication failed: ${authResult.error}`)
span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.AuthFailed)
span.setAttribute(TraceAttr.HttpStatusCode, 401)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication failed',
},
{ status: 401 }
)
}
const parsed = await parseRequest(
billingUpdateCostContract,
req,
{},
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid request body`, {
errors: error.issues,
})
span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.InvalidBody)
span.setAttribute(TraceAttr.HttpStatusCode, 400)
return NextResponse.json(
{
success: false,
error: 'Invalid request body',
details: error.issues,
},
{ status: 400 }
)
},
invalidJsonResponse: () => {
span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.InvalidBody)
span.setAttribute(TraceAttr.HttpStatusCode, 400)
return NextResponse.json(
{ success: false, error: 'Request body must be valid JSON' },
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const { userId, cost, model, inputTokens, outputTokens, source, idempotencyKey, workspaceId } =
parsed.data.body
const isMcp = source === 'mcp_copilot'
span.setAttributes({
[TraceAttr.UserId]: userId,
[TraceAttr.GenAiRequestModel]: model,
[TraceAttr.BillingSource]: source,
[TraceAttr.BillingCostUsd]: cost,
[TraceAttr.GenAiUsageInputTokens]: inputTokens,
[TraceAttr.GenAiUsageOutputTokens]: outputTokens,
[TraceAttr.BillingIsMcp]: isMcp,
...(idempotencyKey ? { [TraceAttr.BillingIdempotencyKey]: idempotencyKey } : {}),
})
logger.info(`[${requestId}] Processing cost update`, {
userId,
cost,
model,
source,
})
const attributedWorkspaceId = await resolveAttributableWorkspaceId(requestId, workspaceId)
// Go sends the request's CUMULATIVE cost, possibly more than once (a
// mid-loop provider-error flush, then the recovered terminal flush, plus
// abort-race duplicates). Record it as a monotonic top-up: one ledger row
// per request holds the MAX cumulative and we bill only the delta, so
// partial + complete flushes converge to the true total exactly once — no
// under-billing on recovery, no over-billing on duplicates. When there is
// no idempotency key (shouldn't happen for real requests) we fall back to a
// plain append so cost is never silently dropped.
let billed = true
if (idempotencyKey) {
const result = await recordCumulativeUsage({
userId,
workspaceId: attributedWorkspaceId,
source,
model,
cost,
eventKey: `update-cost:${idempotencyKey}`,
metadata: { inputTokens, outputTokens },
})
billed = result.billed
logger.info(`[${requestId}] Cumulative cost top-up`, {
userId,
source,
cumulativeCost: cost,
billedDelta: result.delta,
newTotal: result.total,
billed: result.billed,
})
} else {
await recordUsage({
userId,
workspaceId: attributedWorkspaceId,
entries: [
{
category: 'model',
source,
description: model,
cost,
sourceReference: requestId,
metadata: { inputTokens, outputTokens },
},
],
})
logger.info(`[${requestId}] Recorded usage (no idempotency key)`, {
userId,
addedCost: cost,
source,
})
}
const duration = Date.now() - startTime
// Same-or-lower cumulative than already recorded: nothing new to bill. Tell
// the caller via 409 (its existing "duplicate" outcome) without re-running
// overage billing.
if (!billed) {
logger.info(`[${requestId}] Duplicate/non-increasing cumulative cost; no new charge`, {
idempotencyKey,
userId,
cost,
})
span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.DuplicateIdempotencyKey)
span.setAttribute(TraceAttr.HttpStatusCode, 409)
span.setAttribute(TraceAttr.BillingDurationMs, duration)
return NextResponse.json(
{
success: false,
error: 'Duplicate request: cumulative cost already recorded',
requestId,
},
{ status: 409 }
)
}
// Check if user has hit overage threshold and bill incrementally. Reads the
// (now topped-up) ledger total and is idempotent against billedOverage, so
// it is safe to run on every flush that records new cost.
await checkAndBillOverageThreshold(userId)
logger.info(`[${requestId}] Cost update completed successfully`, {
userId,
duration,
cost,
})
span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.Billed)
span.setAttribute(TraceAttr.HttpStatusCode, 200)
span.setAttribute(TraceAttr.BillingDurationMs, duration)
return NextResponse.json({
success: true,
data: {
userId,
cost,
processedAt: new Date().toISOString(),
requestId,
},
})
} catch (error) {
const duration = Date.now() - startTime
// Surface the underlying Postgres failure (e.g. 23503 FK violation vs a
// lock timeout) — Drizzle's "Failed query" wrapper alone cannot
// distinguish them, which made the dead-workspace incident undiagnosable
// from logs.
const pgCode = getPostgresErrorCode(error)
const pgConstraint = getPostgresConstraintName(error)
logger.error(`[${requestId}] Cost update failed`, {
error: toError(error).message,
...(pgCode && { pgCode }),
...(pgConstraint && { pgConstraint }),
stack: error instanceof Error ? error.stack : undefined,
duration,
})
// The cumulative top-up runs in a single transaction (and a plain append is
// a single insert), so a failure here leaves nothing partially committed —
// a retry re-evaluates the max idempotently. No claim to release.
span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.InternalError)
span.setAttribute(TraceAttr.HttpStatusCode, 500)
span.setAttribute(TraceAttr.BillingDurationMs, duration)
return NextResponse.json(
{
success: false,
error: 'Internal server error',
requestId,
},
{ status: 500 }
)
}
}
@@ -0,0 +1,84 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetSession, mockCheckWorkspaceAccess, mockIsPlatformAdmin, mockGetBlockVisibility } =
vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockCheckWorkspaceAccess: vi.fn(),
mockIsPlatformAdmin: vi.fn(),
mockGetBlockVisibility: vi.fn(),
}))
vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
getSession: mockGetSession,
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
checkWorkspaceAccess: mockCheckWorkspaceAccess,
}))
vi.mock('@/lib/permissions/super-user', () => ({
isPlatformAdmin: mockIsPlatformAdmin,
}))
vi.mock('@/lib/core/config/block-visibility', () => ({
getBlockVisibility: mockGetBlockVisibility,
}))
import { GET } from '@/app/api/blocks/visibility/route'
const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555'
function request(workspaceId = WORKSPACE_ID) {
return new NextRequest(`http://localhost/api/blocks/visibility?workspaceId=${workspaceId}`)
}
describe('GET /api/blocks/visibility', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mockCheckWorkspaceAccess.mockResolvedValue({
hasAccess: true,
workspace: { organizationId: 'org-1' },
})
mockIsPlatformAdmin.mockResolvedValue(false)
mockGetBlockVisibility.mockResolvedValue({
revealed: new Set(['gmail_v2']),
disabled: new Set(['slack']),
previewTagged: new Set(['gmail_v2']),
})
})
it('returns 401 without a session', async () => {
mockGetSession.mockResolvedValue(null)
const response = await GET(request())
expect(response.status).toBe(401)
})
it('returns 403 without workspace access', async () => {
mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: false, workspace: null })
const response = await GET(request())
expect(response.status).toBe(403)
expect(mockGetBlockVisibility).not.toHaveBeenCalled()
})
it('evaluates visibility for the session user, workspace org, and pre-resolved admin', async () => {
mockIsPlatformAdmin.mockResolvedValue(true)
const response = await GET(request())
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
revealed: ['gmail_v2'],
disabled: ['slack'],
previewTagged: ['gmail_v2'],
})
expect(mockGetBlockVisibility).toHaveBeenCalledWith({
userId: 'user-1',
orgId: 'org-1',
isAdmin: true,
})
})
})
@@ -0,0 +1,46 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getBlockVisibilityContract } from '@/lib/api/contracts/block-visibility'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getBlockVisibility } from '@/lib/core/config/block-visibility'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { isPlatformAdmin } from '@/lib/permissions/super-user'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
/**
* Evaluates the viewer's block-visibility projection for a workspace: which
* preview blocks are revealed (and preview-tagged) and which shipped blocks are
* kill-switched. Consumed by the client visibility overlay
* (`BlockVisibilityLoader`); discovery-only — execution is never gated.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(getBlockVisibilityContract, request, {})
if (!parsed.success) return parsed.response
const userId = session.user.id
const { workspaceId } = parsed.data.query
const access = await checkWorkspaceAccess(workspaceId, userId)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const isAdmin = await isPlatformAdmin(userId)
const vis = await getBlockVisibility({
userId,
orgId: access.workspace?.organizationId,
isAdmin,
})
return NextResponse.json({
revealed: [...vis.revealed],
disabled: [...vis.disabled],
previewTagged: [...vis.previewTagged],
})
})
@@ -0,0 +1,883 @@
/**
* Tests for chat OTP API route
*
* @vitest-environment node
*/
import {
redisConfigMock,
redisConfigMockFns,
requestUtilsMockFns,
workflowsApiUtilsMock,
workflowsApiUtilsMockFns,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockRedisSet,
mockRedisGet,
mockRedisDel,
mockRedisTtl,
mockRedisEval,
mockRedisClient,
mockDbSelect,
mockDbInsert,
mockDbDelete,
mockDbUpdate,
mockSendEmail,
mockRenderOTPEmail,
mockSetChatAuthCookie,
mockGetStorageMethod,
mockZodParse,
mockGetEnv,
} = vi.hoisted(() => {
const mockRedisSet = vi.fn()
const mockRedisGet = vi.fn()
const mockRedisDel = vi.fn()
const mockRedisTtl = vi.fn()
const mockRedisEval = vi.fn()
const mockRedisClient = {
set: mockRedisSet,
get: mockRedisGet,
del: mockRedisDel,
ttl: mockRedisTtl,
eval: mockRedisEval,
}
const mockDbSelect = vi.fn()
const mockDbInsert = vi.fn()
const mockDbDelete = vi.fn()
const mockDbUpdate = vi.fn()
const mockSendEmail = vi.fn()
const mockRenderOTPEmail = vi.fn()
const mockSetChatAuthCookie = vi.fn()
const mockGetStorageMethod = vi.fn()
const mockZodParse = vi.fn()
const mockGetEnv = vi.fn()
return {
mockRedisSet,
mockRedisGet,
mockRedisDel,
mockRedisTtl,
mockRedisEval,
mockRedisClient,
mockDbSelect,
mockDbInsert,
mockDbDelete,
mockDbUpdate,
mockSendEmail,
mockRenderOTPEmail,
mockSetChatAuthCookie,
mockGetStorageMethod,
mockZodParse,
mockGetEnv,
}
})
const mockGetRedisClient = redisConfigMockFns.mockGetRedisClient
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
vi.mock('@sim/db', () => ({
db: {
select: mockDbSelect,
insert: mockDbInsert,
delete: mockDbDelete,
update: mockDbUpdate,
transaction: vi.fn(async (callback: (tx: Record<string, unknown>) => unknown) => {
return callback({
select: mockDbSelect,
insert: mockDbInsert,
delete: mockDbDelete,
update: mockDbUpdate,
})
}),
},
}))
vi.mock('drizzle-orm', () => ({
eq: vi.fn((field: string, value: string) => ({ field, value, type: 'eq' })),
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
gt: vi.fn((field: string, value: string) => ({ field, value, type: 'gt' })),
lt: vi.fn((field: string, value: string) => ({ field, value, type: 'lt' })),
isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })),
}))
vi.mock('@/lib/core/storage', () => ({
getStorageMethod: mockGetStorageMethod,
}))
const { mockCheckRateLimitDirect } = vi.hoisted(() => ({
mockCheckRateLimitDirect: vi.fn(),
}))
vi.mock('@/lib/core/rate-limiter', () => ({
RateLimiter: class {
checkRateLimitDirect = mockCheckRateLimitDirect
},
}))
vi.mock('@/lib/messaging/email/mailer', () => ({
sendEmail: mockSendEmail,
}))
vi.mock('@/components/emails', () => ({
renderOTPEmail: mockRenderOTPEmail,
}))
vi.mock('@/lib/core/security/deployment', () => ({
isEmailAllowed: (email: string, allowedEmails: string[]) => {
if (allowedEmails.includes(email)) return true
const atIndex = email.indexOf('@')
if (atIndex > 0) {
const domain = email.substring(atIndex + 1)
if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) return true
}
return false
},
}))
vi.mock('@/app/api/chat/utils', () => ({
setChatAuthCookie: mockSetChatAuthCookie,
}))
vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
vi.mock('@/lib/core/config/env', () => ({
env: {
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
NODE_ENV: 'test',
},
getEnv: mockGetEnv,
isTruthy: vi.fn().mockReturnValue(false),
isFalsy: vi.fn().mockReturnValue(true),
}))
vi.mock('zod', () => {
class ZodError extends Error {
errors: Array<{ message: string }>
constructor(issues: Array<{ message: string }>) {
super('ZodError')
this.errors = issues
}
}
const chainable: Record<string, unknown> = {}
const proxy: Record<string, unknown> = new Proxy(chainable, {
get(target, prop) {
if (prop === 'parse') return mockZodParse
if (prop === 'safeParse') {
return (data: unknown) => ({ success: true, data })
}
if (prop === 'then') return undefined
if (typeof prop === 'symbol') return Reflect.get(target, prop)
if (!(prop in target)) {
target[prop as string] = vi.fn().mockReturnValue(proxy)
}
return target[prop as string]
},
})
const makeChain = vi.fn(() => proxy)
return {
z: new Proxy(
{ ZodError },
{
get(target, prop) {
if (prop === 'ZodError') return ZodError
if (typeof prop === 'symbol') return Reflect.get(target, prop)
return makeChain
},
}
),
}
})
import { POST, PUT } from './route'
describe('Chat OTP API Route', () => {
const mockEmail = 'test@example.com'
const mockChatId = 'chat-123'
const mockIdentifier = 'test-chat'
const mockOTP = '123456'
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(Math, 'random').mockReturnValue(0.123456)
vi.spyOn(Date, 'now').mockReturnValue(1640995200000)
vi.stubGlobal('crypto', {
...crypto,
randomUUID: vi.fn().mockReturnValue('test-uuid-1234'),
})
mockGetRedisClient.mockReturnValue(mockRedisClient)
mockRedisSet.mockResolvedValue('OK')
mockRedisGet.mockResolvedValue(null)
mockRedisDel.mockResolvedValue(1)
mockRedisTtl.mockResolvedValue(600)
const createDbChain = (result: unknown) => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(result),
}),
}),
})
mockDbSelect.mockImplementation(() => createDbChain([]))
mockDbInsert.mockImplementation(() => ({
values: vi.fn().mockResolvedValue(undefined),
}))
mockDbDelete.mockImplementation(() => ({
where: vi.fn().mockResolvedValue(undefined),
}))
mockDbUpdate.mockImplementation(() => ({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
}))
mockGetStorageMethod.mockReturnValue('redis')
mockSendEmail.mockResolvedValue({ success: true })
mockRenderOTPEmail.mockResolvedValue('<html>OTP Email</html>')
mockCreateSuccessResponse.mockImplementation((data: unknown) => ({
json: () => Promise.resolve(data),
status: 200,
}))
mockCreateErrorResponse.mockImplementation((message: string, status: number) => ({
json: () => Promise.resolve({ error: message }),
status,
}))
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('req-123')
requestUtilsMockFns.mockGetClientIp.mockReturnValue('1.2.3.4')
mockCheckRateLimitDirect.mockResolvedValue({
allowed: true,
remaining: 10,
resetAt: new Date(Date.now() + 60_000),
})
mockZodParse.mockImplementation((data: unknown) => data)
mockGetEnv.mockReturnValue('http://localhost:3000')
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('POST - Store OTP (Redis path)', () => {
beforeEach(() => {
mockGetStorageMethod.mockReturnValue('redis')
})
it('should store OTP in Redis when storage method is redis', async () => {
mockDbSelect.mockImplementationOnce(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([
{
id: mockChatId,
authType: 'email',
allowedEmails: [mockEmail],
title: 'Test Chat',
},
]),
}),
}),
}))
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'POST',
body: JSON.stringify({ email: mockEmail }),
})
await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
expect(mockRedisSet).toHaveBeenCalledWith(
`otp:${mockEmail}:${mockChatId}`,
expect.any(String),
'EX',
900 // 15 minutes
)
expect(mockDbInsert).not.toHaveBeenCalled()
})
})
describe('POST - Rate limiting', () => {
const buildDeploymentSelect = () =>
mockDbSelect.mockImplementationOnce(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([
{
id: mockChatId,
authType: 'email',
allowedEmails: [mockEmail],
title: 'Test Chat',
},
]),
}),
}),
}))
it('returns 429 with Retry-After when IP rate limit is exceeded', async () => {
mockCheckRateLimitDirect.mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: new Date(Date.now() + 900_000),
retryAfterMs: 900_000,
})
const headerSet = vi.fn()
mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({
json: () => Promise.resolve({ error: message }),
status,
headers: { set: headerSet },
}))
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'POST',
body: JSON.stringify({ email: mockEmail }),
})
const response = await POST(request, {
params: Promise.resolve({ identifier: mockIdentifier }),
})
expect(response.status).toBe(429)
expect(headerSet).toHaveBeenCalledWith('Retry-After', '900')
expect(mockSendEmail).not.toHaveBeenCalled()
expect(mockDbSelect).not.toHaveBeenCalled()
})
it('returns 429 with Retry-After when email rate limit is exceeded', async () => {
mockCheckRateLimitDirect
.mockResolvedValueOnce({
allowed: true,
remaining: 9,
resetAt: new Date(Date.now() + 60_000),
})
.mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: new Date(Date.now() + 900_000),
retryAfterMs: 900_000,
})
const headerSet = vi.fn()
mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({
json: () => Promise.resolve({ error: message }),
status,
headers: { set: headerSet },
}))
buildDeploymentSelect()
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'POST',
body: JSON.stringify({ email: mockEmail }),
})
const response = await POST(request, {
params: Promise.resolve({ identifier: mockIdentifier }),
})
expect(response.status).toBe(429)
expect(headerSet).toHaveBeenCalledWith('Retry-After', '900')
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('falls back to refill interval when retryAfterMs is missing', async () => {
mockCheckRateLimitDirect.mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: new Date(Date.now() + 900_000),
})
const headerSet = vi.fn()
mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({
json: () => Promise.resolve({ error: message }),
status,
headers: { set: headerSet },
}))
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'POST',
body: JSON.stringify({ email: mockEmail }),
})
await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
expect(headerSet).toHaveBeenCalledWith('Retry-After', '900')
})
it('folds spoofed `unknown` client IPs into a single shared bucket', async () => {
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce('unknown')
buildDeploymentSelect()
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'POST',
body: JSON.stringify({ email: mockEmail }),
})
await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(2)
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
expect.stringMatching(/^chat-otp:ip:.*:unknown$/),
expect.any(Object)
)
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
expect.stringContaining('chat-otp:email:'),
expect.any(Object)
)
})
})
describe('POST - Store OTP (Database path)', () => {
beforeEach(() => {
mockGetStorageMethod.mockReturnValue('database')
mockGetRedisClient.mockReturnValue(null)
})
it('should store OTP in database when storage method is database', async () => {
mockDbSelect.mockImplementationOnce(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([
{
id: mockChatId,
authType: 'email',
allowedEmails: [mockEmail],
title: 'Test Chat',
},
]),
}),
}),
}))
const mockInsertValues = vi.fn().mockResolvedValue(undefined)
mockDbInsert.mockImplementationOnce(() => ({
values: mockInsertValues,
}))
const mockDeleteWhere = vi.fn().mockResolvedValue(undefined)
mockDbDelete.mockImplementation(() => ({
where: mockDeleteWhere,
}))
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'POST',
body: JSON.stringify({ email: mockEmail }),
})
await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
expect(mockDbDelete).toHaveBeenCalled()
expect(mockDbInsert).toHaveBeenCalled()
expect(mockInsertValues).toHaveBeenCalledWith({
id: expect.any(String),
identifier: `chat-otp:${mockChatId}:${mockEmail}`,
value: expect.any(String),
expiresAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
})
expect(mockRedisSet).not.toHaveBeenCalled()
})
})
describe('PUT - Verify OTP (Redis path)', () => {
beforeEach(() => {
mockGetStorageMethod.mockReturnValue('redis')
mockRedisGet.mockResolvedValue(`${mockOTP}:0`)
})
it('should retrieve OTP from Redis and verify successfully', async () => {
mockDbSelect.mockImplementationOnce(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([
{
id: mockChatId,
authType: 'email',
},
]),
}),
}),
}))
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'PUT',
body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
})
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
expect(mockRedisGet).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`)
expect(mockRedisDel).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`)
expect(mockDbSelect).toHaveBeenCalledTimes(1)
})
})
describe('PUT - Verify OTP (authType re-check)', () => {
beforeEach(() => {
mockGetStorageMethod.mockReturnValue('redis')
mockRedisGet.mockResolvedValue(`${mockOTP}:0`)
})
it('rejects verification when the chat has switched away from email auth', async () => {
mockDbSelect.mockImplementationOnce(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([
{
id: mockChatId,
authType: 'password',
password: 'encrypted-password',
},
]),
}),
}),
}))
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'PUT',
body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
})
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
'This chat does not use email authentication',
400
)
expect(mockRedisGet).not.toHaveBeenCalled()
expect(mockSetChatAuthCookie).not.toHaveBeenCalled()
})
})
describe('PUT - Verify OTP (Database path)', () => {
beforeEach(() => {
mockGetStorageMethod.mockReturnValue('database')
mockGetRedisClient.mockReturnValue(null)
})
it('should retrieve OTP from database and verify successfully', async () => {
let selectCallCount = 0
mockDbSelect.mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockImplementation(() => {
selectCallCount++
if (selectCallCount === 1) {
return Promise.resolve([
{
id: mockChatId,
authType: 'email',
},
])
}
return Promise.resolve([
{
value: `${mockOTP}:0`,
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
},
])
}),
}),
}),
}))
const mockDeleteWhere = vi.fn().mockResolvedValue(undefined)
mockDbDelete.mockImplementation(() => ({
where: mockDeleteWhere,
}))
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'PUT',
body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
})
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
expect(mockDbSelect).toHaveBeenCalledTimes(2)
expect(mockDbDelete).toHaveBeenCalled()
expect(mockRedisGet).not.toHaveBeenCalled()
})
it('should reject expired OTP from database', async () => {
let selectCallCount = 0
mockDbSelect.mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockImplementation(() => {
selectCallCount++
if (selectCallCount === 1) {
return Promise.resolve([
{
id: mockChatId,
authType: 'email',
},
])
}
return Promise.resolve([])
}),
}),
}),
}))
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'PUT',
body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
})
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
'No verification code found, request a new one',
400
)
})
})
describe('DELETE OTP (Redis path)', () => {
beforeEach(() => {
mockGetStorageMethod.mockReturnValue('redis')
})
it('should delete OTP from Redis after verification', async () => {
mockRedisGet.mockResolvedValue(`${mockOTP}:0`)
mockDbSelect.mockImplementationOnce(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([
{
id: mockChatId,
authType: 'email',
},
]),
}),
}),
}))
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'PUT',
body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
})
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
expect(mockRedisDel).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`)
expect(mockDbDelete).not.toHaveBeenCalled()
})
})
describe('DELETE OTP (Database path)', () => {
beforeEach(() => {
mockGetStorageMethod.mockReturnValue('database')
mockGetRedisClient.mockReturnValue(null)
})
it('should delete OTP from database after verification', async () => {
let selectCallCount = 0
mockDbSelect.mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockImplementation(() => {
selectCallCount++
if (selectCallCount === 1) {
return Promise.resolve([{ id: mockChatId, authType: 'email' }])
}
return Promise.resolve([
{ value: `${mockOTP}:0`, expiresAt: new Date(Date.now() + 10 * 60 * 1000) },
])
}),
}),
}),
}))
const mockDeleteWhere = vi.fn().mockResolvedValue(undefined)
mockDbDelete.mockImplementation(() => ({
where: mockDeleteWhere,
}))
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'PUT',
body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
})
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
expect(mockDbDelete).toHaveBeenCalled()
expect(mockRedisDel).not.toHaveBeenCalled()
})
})
describe('Brute-force protection', () => {
beforeEach(() => {
mockGetStorageMethod.mockReturnValue('redis')
})
it('should atomically increment attempts on wrong OTP', async () => {
mockRedisGet.mockResolvedValue('654321:0')
mockRedisEval.mockResolvedValue('654321:1')
mockDbSelect.mockImplementationOnce(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: mockChatId, authType: 'email' }]),
}),
}),
}))
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'PUT',
body: JSON.stringify({ email: mockEmail, otp: 'wrong1' }),
})
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
expect(mockRedisEval).toHaveBeenCalledWith(
expect.any(String),
1,
`otp:${mockEmail}:${mockChatId}`,
5
)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Invalid verification code', 400)
})
it('should invalidate OTP and return 429 after max failed attempts', async () => {
mockRedisGet.mockResolvedValue('654321:4')
mockRedisEval.mockResolvedValue('LOCKED')
mockDbSelect.mockImplementationOnce(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: mockChatId, authType: 'email' }]),
}),
}),
}))
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'PUT',
body: JSON.stringify({ email: mockEmail, otp: 'wrong5' }),
})
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
expect(mockRedisEval).toHaveBeenCalled()
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
'Too many failed attempts. Please request a new code.',
429
)
})
it('should store OTP with zero attempts on generation', async () => {
mockDbSelect.mockImplementationOnce(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([
{
id: mockChatId,
authType: 'email',
allowedEmails: [mockEmail],
title: 'Test Chat',
},
]),
}),
}),
}))
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'POST',
body: JSON.stringify({ email: mockEmail }),
})
await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
expect(mockRedisSet).toHaveBeenCalledWith(
`otp:${mockEmail}:${mockChatId}`,
expect.stringMatching(/^\d{6}:0$/),
'EX',
900
)
})
})
describe('Behavior consistency between Redis and Database', () => {
it('should have same behavior for missing OTP in both storage methods', async () => {
mockGetStorageMethod.mockReturnValue('redis')
mockRedisGet.mockResolvedValue(null)
mockDbSelect.mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: mockChatId, authType: 'email' }]),
}),
}),
}))
const requestRedis = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'PUT',
body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
})
await PUT(requestRedis, { params: Promise.resolve({ identifier: mockIdentifier }) })
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
'No verification code found, request a new one',
400
)
})
it('should have same OTP expiry time in both storage methods', async () => {
const OTP_EXPIRY = 15 * 60
mockGetStorageMethod.mockReturnValue('redis')
mockDbSelect.mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([
{
id: mockChatId,
authType: 'email',
allowedEmails: [mockEmail],
title: 'Test Chat',
},
]),
}),
}),
}))
const requestRedis = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'POST',
body: JSON.stringify({ email: mockEmail }),
})
await POST(requestRedis, { params: Promise.resolve({ identifier: mockIdentifier }) })
expect(mockRedisSet).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
'EX',
OTP_EXPIRY
)
})
})
})
@@ -0,0 +1,221 @@
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { renderOTPEmail } from '@/components/emails'
import { requestChatEmailOtpContract, verifyChatEmailOtpContract } from '@/lib/api/contracts/chats'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { isEmailAllowed } from '@/lib/core/security/deployment'
import {
decodeOTPValue,
deleteOTP,
generateOTP,
getOTP,
incrementOTPAttempts,
MAX_OTP_ATTEMPTS,
OTP_EMAIL_RATE_LIMIT,
OTP_IP_RATE_LIMIT,
storeOTP,
} from '@/lib/core/security/otp'
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { setChatAuthCookie } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('ChatOtpAPI')
const rateLimiter = new RateLimiter()
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
const { identifier } = await context.params
const requestId = generateRequestId()
try {
const ip = getClientIp(request)
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
`chat-otp:ip:${identifier}:${ip}`,
OTP_IP_RATE_LIMIT
)
if (!ipRateLimit.allowed) {
logger.warn(`[${requestId}] OTP IP rate limit exceeded for ${identifier} from ${ip}`)
const retryAfter = Math.ceil(
(ipRateLimit.retryAfterMs ?? OTP_IP_RATE_LIMIT.refillIntervalMs) / 1000
)
const response = createErrorResponse('Too many requests. Please try again later.', 429)
response.headers.set('Retry-After', String(retryAfter))
return response
}
const parsed = await parseRequest(requestChatEmailOtpContract, request, context, {
validationErrorResponse: (error) =>
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
})
if (!parsed.success) return parsed.response
const { email } = parsed.data.body
const deploymentResult = await db
.select({
id: chat.id,
authType: chat.authType,
allowedEmails: chat.allowedEmails,
title: chat.title,
})
.from(chat)
.where(
and(eq(chat.identifier, identifier), eq(chat.isActive, true), isNull(chat.archivedAt))
)
.limit(1)
if (deploymentResult.length === 0) {
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
return createErrorResponse('Chat not found', 404)
}
const deployment = deploymentResult[0]
if (deployment.authType !== 'email') {
return createErrorResponse('This chat does not use email authentication', 400)
}
const allowedEmails: string[] = Array.isArray(deployment.allowedEmails)
? deployment.allowedEmails
: []
if (!isEmailAllowed(email, allowedEmails)) {
return createErrorResponse('Email not authorized for this chat', 403)
}
const emailRateLimit = await rateLimiter.checkRateLimitDirect(
`chat-otp:email:${deployment.id}:${email.toLowerCase()}`,
OTP_EMAIL_RATE_LIMIT
)
if (!emailRateLimit.allowed) {
logger.warn(
`[${requestId}] OTP email rate limit exceeded for ${email} on chat ${deployment.id}`
)
const retryAfter = Math.ceil(
(emailRateLimit.retryAfterMs ?? OTP_EMAIL_RATE_LIMIT.refillIntervalMs) / 1000
)
const response = createErrorResponse(
'Too many verification code requests. Please try again later.',
429
)
response.headers.set('Retry-After', String(retryAfter))
return response
}
const otp = generateOTP()
await storeOTP('chat', deployment.id, email, otp)
const emailHtml = await renderOTPEmail(
otp,
email,
'email-verification',
deployment.title || 'Chat'
)
const emailResult = await sendEmail({
to: email,
subject: `Verification code for ${deployment.title || 'Chat'}`,
html: emailHtml,
})
if (!emailResult.success) {
logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message)
return createErrorResponse('Failed to send verification email', 500)
}
logger.info(`[${requestId}] OTP sent to ${email} for chat ${deployment.id}`)
return createSuccessResponse({ message: 'Verification code sent' })
} catch (error) {
logger.error(`[${requestId}] Error processing OTP request:`, error)
return createErrorResponse('Failed to process request', 500)
}
}
)
export const PUT = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
const { identifier } = await context.params
const requestId = generateRequestId()
try {
const parsed = await parseRequest(verifyChatEmailOtpContract, request, context, {
validationErrorResponse: (error) =>
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
})
if (!parsed.success) return parsed.response
const { email, otp } = parsed.data.body
const deploymentResult = await db
.select({
id: chat.id,
title: chat.title,
description: chat.description,
customizations: chat.customizations,
authType: chat.authType,
password: chat.password,
outputConfigs: chat.outputConfigs,
})
.from(chat)
.where(
and(eq(chat.identifier, identifier), eq(chat.isActive, true), isNull(chat.archivedAt))
)
.limit(1)
if (deploymentResult.length === 0) {
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
return createErrorResponse('Chat not found', 404)
}
const deployment = deploymentResult[0]
if (deployment.authType !== 'email') {
return createErrorResponse('This chat does not use email authentication', 400)
}
const storedValue = await getOTP('chat', deployment.id, email)
if (!storedValue) {
return createErrorResponse('No verification code found, request a new one', 400)
}
const { otp: storedOTP, attempts } = decodeOTPValue(storedValue)
if (attempts >= MAX_OTP_ATTEMPTS) {
await deleteOTP('chat', deployment.id, email)
logger.warn(`[${requestId}] OTP already locked out for ${email}`)
return createErrorResponse('Too many failed attempts. Please request a new code.', 429)
}
if (storedOTP !== otp) {
const result = await incrementOTPAttempts('chat', deployment.id, email, storedValue)
if (result === 'locked') {
logger.warn(`[${requestId}] OTP invalidated after max failed attempts for ${email}`)
return createErrorResponse('Too many failed attempts. Please request a new code.', 429)
}
return createErrorResponse('Invalid verification code', 400)
}
await deleteOTP('chat', deployment.id, email)
const response = createSuccessResponse({
id: deployment.id,
title: deployment.title,
description: deployment.description,
customizations: deployment.customizations,
authType: deployment.authType,
outputConfigs: deployment.outputConfigs,
})
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
return response
} catch (error) {
logger.error(`[${requestId}] Error verifying OTP:`, error)
return createErrorResponse('Failed to process request', 500)
}
}
)
@@ -0,0 +1,539 @@
/**
* Tests for chat identifier API route
*
* @vitest-environment node
*/
import {
dbChainMock,
dbChainMockFns,
encryptionMock,
executionPreprocessingMock,
executionPreprocessingMockFns,
loggingSessionMock,
workflowsApiUtilsMock,
workflowsApiUtilsMockFns,
} from '@sim/testing'
import { NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Creates a mock NextRequest with cookies support for testing.
*/
function createMockNextRequest(
method = 'GET',
body?: unknown,
headers: Record<string, string> = {},
url = 'http://localhost:3000/api/test'
): any {
const headersObj = new Headers({
'Content-Type': 'application/json',
...headers,
})
const parsedUrl = new URL(url)
return {
method,
headers: headersObj,
nextUrl: parsedUrl,
cookies: {
get: vi.fn().mockReturnValue(undefined),
},
json:
body !== undefined
? vi.fn().mockResolvedValue(body)
: vi.fn().mockRejectedValue(new Error('No body')),
url,
}
}
const createMockStream = () => {
return new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode('data: {"blockId":"agent-1","chunk":"Hello"}\n\n')
)
controller.enqueue(
new TextEncoder().encode('data: {"blockId":"agent-1","chunk":" world"}\n\n')
)
controller.enqueue(
new TextEncoder().encode('data: {"event":"final","data":{"success":true}}\n\n')
)
controller.close()
},
})
}
const {
mockValidateChatAuth,
mockSetChatAuthCookie,
mockValidateAuthToken,
mockAssertChatEmbedAllowed,
} = vi.hoisted(() => ({
mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }),
mockSetChatAuthCookie: vi.fn(),
mockValidateAuthToken: vi.fn().mockReturnValue(false),
mockAssertChatEmbedAllowed: vi.fn().mockResolvedValue(null),
}))
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
vi.mock('@sim/db', () => ({
...dbChainMock,
chat: {},
workflow: {},
}))
vi.mock('@/lib/core/security/deployment', () => ({
validateAuthToken: mockValidateAuthToken,
setDeploymentAuthCookie: vi.fn(),
isEmailAllowed: vi.fn().mockReturnValue(false),
}))
vi.mock('@/app/api/chat/utils', () => ({
validateChatAuth: mockValidateChatAuth,
setChatAuthCookie: mockSetChatAuthCookie,
assertChatEmbedAllowed: mockAssertChatEmbedAllowed,
}))
vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock)
vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)
vi.mock('@/lib/workflows/streaming/streaming', () => ({
createStreamingResponse: vi.fn().mockImplementation(async () => createMockStream()),
}))
vi.mock('@/lib/workflows/executor/execute-workflow', () => ({
executeWorkflow: vi.fn().mockResolvedValue({ success: true, output: {} }),
}))
vi.mock('@/lib/core/utils/sse', () => ({
SSE_HEADERS: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
}))
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
import { GET, POST } from '@/app/api/chat/[identifier]/route'
describe('Chat Identifier API Route', () => {
const mockChatResult = [
{
id: 'chat-id',
workflowId: 'workflow-id',
userId: 'user-id',
isActive: true,
authType: 'public',
title: 'Test Chat',
description: 'Test chat description',
customizations: {
welcomeMessage: 'Welcome to the test chat',
primaryColor: '#000000',
},
outputConfigs: [{ blockId: 'block-1', path: 'output' }],
},
]
const mockWorkflowResult = [
{
isDeployed: true,
state: {
blocks: {},
edges: [],
loops: {},
parallels: {},
},
deployedState: {
blocks: {},
edges: [],
loops: {},
parallels: {},
},
},
]
beforeEach(() => {
vi.clearAllMocks()
executionPreprocessingMockFns.mockPreprocessExecution.mockResolvedValue({
success: true,
actorUserId: 'test-user-id',
workflowRecord: {
id: 'test-workflow-id',
userId: 'test-user-id',
isDeployed: true,
workspaceId: 'test-workspace-id',
variables: {},
},
userSubscription: {
plan: 'pro',
status: 'active',
},
rateLimitInfo: {
allowed: true,
remaining: 100,
resetAt: new Date(),
},
})
mockValidateChatAuth.mockResolvedValue({ authorized: true })
mockValidateAuthToken.mockReturnValue(false)
mockCreateErrorResponse.mockImplementation((message: string, status: number, code?: string) => {
return new Response(
JSON.stringify({
error: code || 'Error',
message,
}),
{ status }
)
})
mockCreateSuccessResponse.mockImplementation((data: unknown) => {
return new Response(JSON.stringify(data), { status: 200 })
})
dbChainMockFns.select.mockImplementation((fields: Record<string, unknown>) => {
if (fields && fields.isDeployed !== undefined) {
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue(mockWorkflowResult),
}),
}),
}
}
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue(mockChatResult),
}),
}),
}
})
})
describe('GET endpoint', () => {
it('should return chat info for a valid identifier', async () => {
const req = createMockNextRequest('GET')
const params = Promise.resolve({ identifier: 'test-chat' })
const response = await GET(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('id', 'chat-id')
expect(data).toHaveProperty('title', 'Test Chat')
expect(data).toHaveProperty('description', 'Test chat description')
expect(data).toHaveProperty('customizations')
expect(data.customizations).toHaveProperty('welcomeMessage', 'Welcome to the test chat')
})
it('should return 403 when embedding is blocked for a cross-origin caller', async () => {
mockAssertChatEmbedAllowed.mockResolvedValueOnce(
NextResponse.json(
{ error: 'Embedding this chat on external sites requires a paid plan' },
{
status: 403,
}
)
)
const req = createMockNextRequest('GET', undefined, { origin: 'https://evil.example.com' })
const params = Promise.resolve({ identifier: 'test-chat' })
const response = await GET(req, { params })
expect(response.status).toBe(403)
})
it('should return 404 for non-existent identifier', async () => {
dbChainMockFns.select.mockImplementation(() => {
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
}
})
const req = createMockNextRequest('GET')
const params = Promise.resolve({ identifier: 'nonexistent' })
const response = await GET(req, { params })
expect(response.status).toBe(404)
const data = await response.json()
expect(data).toHaveProperty('error')
expect(data).toHaveProperty('message', 'Chat not found')
})
it('should return 403 for inactive chat', async () => {
dbChainMockFns.select.mockImplementation(() => {
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([
{
id: 'chat-id',
isActive: false,
authType: 'public',
},
]),
}),
}),
}
})
const req = createMockNextRequest('GET')
const params = Promise.resolve({ identifier: 'inactive-chat' })
const response = await GET(req, { params })
expect(response.status).toBe(403)
const data = await response.json()
expect(data).toHaveProperty('error')
expect(data).toHaveProperty('message', 'This chat is currently unavailable')
})
it('should return 401 when authentication is required', async () => {
mockValidateChatAuth.mockResolvedValueOnce({
authorized: false,
error: 'auth_required_password',
})
const req = createMockNextRequest('GET')
const params = Promise.resolve({ identifier: 'password-protected-chat' })
const response = await GET(req, { params })
expect(response.status).toBe(401)
const data = await response.json()
expect(data).toHaveProperty('error')
expect(data).toHaveProperty('message', 'auth_required_password')
})
})
describe('POST endpoint', () => {
it('should return 403 when embedding is blocked for a cross-origin caller', async () => {
mockAssertChatEmbedAllowed.mockResolvedValueOnce(
NextResponse.json(
{ error: 'Embedding this chat on external sites requires a paid plan' },
{
status: 403,
}
)
)
const req = createMockNextRequest(
'POST',
{ input: 'Hello' },
{ origin: 'https://evil.example.com' }
)
const params = Promise.resolve({ identifier: 'test-chat' })
const response = await POST(req, { params })
expect(response.status).toBe(403)
})
it('should return chat config on successful authentication', async () => {
const req = createMockNextRequest('POST', { password: 'test-password' })
const params = Promise.resolve({ identifier: 'password-protected-chat' })
const response = await POST(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('id', 'chat-id')
expect(data).toHaveProperty('title', 'Test Chat')
expect(data).toHaveProperty('customizations')
expect(data.customizations).toHaveProperty('welcomeMessage', 'Welcome to the test chat')
expect(mockSetChatAuthCookie).toHaveBeenCalled()
})
it('should return 400 for requests without input', async () => {
const req = createMockNextRequest('POST', {})
const params = Promise.resolve({ identifier: 'test-chat' })
const response = await POST(req, { params })
expect(response.status).toBe(400)
const data = await response.json()
expect(data).toHaveProperty('error')
expect(data).toHaveProperty('message', 'No input provided')
})
it('should return 401 for unauthorized access', async () => {
mockValidateChatAuth.mockResolvedValueOnce({
authorized: false,
error: 'Authentication required',
})
const req = createMockNextRequest('POST', { input: 'Hello' })
const params = Promise.resolve({ identifier: 'protected-chat' })
const response = await POST(req, { params })
expect(response.status).toBe(401)
const data = await response.json()
expect(data).toHaveProperty('error')
expect(data).toHaveProperty('message', 'Authentication required')
})
it('should return 503 when workflow is not available', async () => {
vi.mocked(preprocessExecution).mockResolvedValueOnce({
success: false,
error: {
message: 'Workflow is not deployed',
statusCode: 403,
logCreated: false,
},
})
const req = createMockNextRequest('POST', { input: 'Hello' })
const params = Promise.resolve({ identifier: 'test-chat' })
const response = await POST(req, { params })
expect(response.status).toBe(403)
const data = await response.json()
expect(data).toHaveProperty('error')
expect(data).toHaveProperty('message', 'Workflow is not deployed')
})
it('should return streaming response for valid chat messages', async () => {
const req = createMockNextRequest('POST', {
input: 'Hello world',
conversationId: 'conv-123',
})
const params = Promise.resolve({ identifier: 'test-chat' })
const response = await POST(req, { params })
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('text/event-stream')
expect(response.headers.get('Cache-Control')).toBe('no-cache')
expect(response.headers.get('Connection')).toBe('keep-alive')
expect(createStreamingResponse).toHaveBeenCalledWith(
expect.objectContaining({
executeFn: expect.any(Function),
streamConfig: expect.objectContaining({
isSecureMode: true,
workflowTriggerType: 'chat',
}),
})
)
}, 10000)
it('should handle streaming response body correctly', async () => {
const req = createMockNextRequest('POST', { input: 'Hello world' })
const params = Promise.resolve({ identifier: 'test-chat' })
const response = await POST(req, { params })
expect(response.status).toBe(200)
expect(response.body).toBeInstanceOf(ReadableStream)
if (response.body) {
const reader = response.body.getReader()
const { value, done } = await reader.read()
if (!done && value) {
const chunk = new TextDecoder().decode(value)
expect(chunk).toMatch(/^data: /)
}
reader.releaseLock()
}
})
it('should handle workflow execution errors gracefully', async () => {
vi.mocked(createStreamingResponse).mockImplementationOnce(async () => {
throw new Error('Execution failed')
})
const req = createMockNextRequest('POST', { input: 'Trigger error' })
const params = Promise.resolve({ identifier: 'test-chat' })
const response = await POST(req, { params })
expect(response.status).toBe(500)
const data = await response.json()
expect(data).toHaveProperty('error')
expect(data).toHaveProperty('message', 'Execution failed')
})
it('should handle invalid JSON in request body', async () => {
const req = {
method: 'POST',
headers: new Headers(),
nextUrl: new URL('http://localhost:3000/api/test'),
cookies: { get: vi.fn().mockReturnValue(undefined) },
json: vi.fn().mockRejectedValue(new Error('Invalid JSON')),
} as any
const params = Promise.resolve({ identifier: 'test-chat' })
const response = await POST(req, { params })
expect(response.status).toBe(400)
const data = await response.json()
expect(data).toHaveProperty('error')
expect(data).toHaveProperty('message', 'Invalid request body')
})
it('should pass conversationId to streaming execution when provided', async () => {
const req = createMockNextRequest('POST', {
input: 'Hello world',
conversationId: 'test-conversation-123',
})
const params = Promise.resolve({ identifier: 'test-chat' })
await POST(req, { params })
expect(createStreamingResponse).toHaveBeenCalledWith(
expect.objectContaining({
executeFn: expect.any(Function),
streamConfig: expect.objectContaining({
workflowTriggerType: 'chat',
}),
})
)
})
it('should handle missing conversationId gracefully', async () => {
const req = createMockNextRequest('POST', { input: 'Hello world' })
const params = Promise.resolve({ identifier: 'test-chat' })
await POST(req, { params })
expect(createStreamingResponse).toHaveBeenCalledWith(
expect.objectContaining({
executeFn: expect.any(Function),
})
)
})
})
})
+388
View File
@@ -0,0 +1,388 @@
import { db } from '@sim/db'
import { chat, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { deployedChatPostContract } from '@/lib/api/contracts/chats'
import { parseRequest } from '@/lib/api/server'
import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { env } from '@/lib/core/config/env'
import { validateAuthToken } from '@/lib/core/security/deployment'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { ChatFiles } from '@/lib/uploads'
import { assertChatEmbedAllowed, setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('ChatIdentifierAPI')
interface ChatConfigSource {
id: string
title: string
description: string | null
customizations: unknown
authType: string | null
outputConfigs: unknown
}
function toChatConfigResponse(deployment: ChatConfigSource) {
return {
id: deployment.id,
title: deployment.title,
description: deployment.description,
customizations: deployment.customizations,
authType: deployment.authType,
outputConfigs: deployment.outputConfigs,
}
}
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
const CHAT_MAX_REQUEST_BYTES = Number.parseInt(env.CHAT_MAX_REQUEST_BYTES, 10) || 220 * 1024 * 1024
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
const { identifier } = await context.params
const requestId = generateRequestId()
const ticket = tryAdmit()
if (!ticket) {
return admissionRejectedResponse()
}
try {
const parsed = await parseRequest(deployedChatPostContract, request, context, {
maxBodyBytes: CHAT_MAX_REQUEST_BYTES,
validationErrorResponse: (err) => {
const message = err.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')
return createErrorResponse(`Invalid request body: ${message}`, 400, 'VALIDATION_ERROR')
},
invalidJsonResponse: () => createErrorResponse('Invalid request body', 400),
})
if (!parsed.success) return parsed.response
const parsedBody = parsed.data.body
const deploymentResult = await db
.select({
id: chat.id,
title: chat.title,
description: chat.description,
customizations: chat.customizations,
workflowId: chat.workflowId,
userId: chat.userId,
isActive: chat.isActive,
authType: chat.authType,
password: chat.password,
allowedEmails: chat.allowedEmails,
outputConfigs: chat.outputConfigs,
})
.from(chat)
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
.limit(1)
if (deploymentResult.length === 0) {
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
return createErrorResponse('Chat not found', 404)
}
const deployment = deploymentResult[0]
if (!deployment.isActive) {
logger.warn(`[${requestId}] Chat is not active: ${identifier}`)
const [workflowRecord] = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(and(eq(workflow.id, deployment.workflowId), isNull(workflow.archivedAt)))
.limit(1)
const workspaceId = workflowRecord?.workspaceId
if (!workspaceId) {
logger.warn(
`[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace`
)
return createErrorResponse('This chat is currently unavailable', 403)
}
const executionId = generateId()
const loggingSession = new LoggingSession(
deployment.workflowId,
executionId,
'chat',
requestId
)
await loggingSession.safeStart({
userId: deployment.userId,
workspaceId,
variables: {},
})
await loggingSession.safeCompleteWithError({
error: {
message: 'This chat is currently unavailable. The chat has been disabled.',
stackTrace: undefined,
},
traceSpans: [],
})
return createErrorResponse('This chat is currently unavailable', 403)
}
const embedBlock = await assertChatEmbedAllowed(request, deployment.workflowId, requestId)
if (embedBlock) return embedBlock
const authResult = await validateChatAuth(requestId, deployment, request, parsedBody)
if (!authResult.authorized) {
const response = createErrorResponse(
authResult.error || 'Authentication required',
authResult.status || 401
)
if (authResult.status === 429 && authResult.retryAfterMs !== undefined) {
response.headers.set('Retry-After', String(Math.ceil(authResult.retryAfterMs / 1000)))
}
return response
}
const { input, password, email, conversationId, files } = parsedBody
if ((password || email) && !input) {
const response = createSuccessResponse(toChatConfigResponse(deployment))
if (deployment.authType !== 'sso') {
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
}
return response
}
if (!input && (!files || files.length === 0)) {
return createErrorResponse('No input provided', 400)
}
const executionId = generateId()
const loggingSession = new LoggingSession(
deployment.workflowId,
executionId,
'chat',
requestId
)
const preprocessResult = await preprocessExecution({
workflowId: deployment.workflowId,
userId: deployment.userId,
triggerType: 'chat',
executionId,
requestId,
checkRateLimit: true,
checkDeployment: true,
loggingSession,
})
if (!preprocessResult.success) {
logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`)
return createErrorResponse(
preprocessResult.error?.message || 'Failed to process request',
preprocessResult.error?.statusCode || 500
)
}
const { actorUserId, workflowRecord } = preprocessResult
const workspaceOwnerId = actorUserId!
const workspaceId = workflowRecord?.workspaceId
if (!workspaceId) {
logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`)
// preprocessExecution reserved a billing concurrency slot; release it on
// this early exit since no LoggingSession will finalize to free it.
await releaseExecutionSlot(executionId)
return createErrorResponse('Workflow has no associated workspace', 500)
}
try {
const selectedOutputs: string[] = []
if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) {
for (const config of deployment.outputConfigs) {
const outputId = config.path
? `${config.blockId}_${config.path}`
: `${config.blockId}_content`
selectedOutputs.push(outputId)
}
}
const { createStreamingResponse } = await import('@/lib/workflows/streaming/streaming')
const { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow')
const { SSE_HEADERS } = await import('@/lib/core/utils/sse')
const workflowInput: any = { input, conversationId }
if (files && Array.isArray(files) && files.length > 0) {
const executionContext = {
workspaceId,
workflowId: deployment.workflowId,
executionId,
}
try {
const uploadedFiles = await ChatFiles.processChatFiles(
files,
executionContext,
requestId,
deployment.userId
)
if (uploadedFiles.length > 0) {
workflowInput.files = uploadedFiles
logger.info(`[${requestId}] Successfully processed ${uploadedFiles.length} files`)
}
} catch (fileError: any) {
logger.error(`[${requestId}] Failed to process chat files:`, fileError)
await loggingSession.safeStart({
userId: workspaceOwnerId,
workspaceId,
variables: {},
})
await loggingSession.safeCompleteWithError({
error: {
message: `File upload failed: ${fileError.message || 'Unable to process uploaded files'}`,
stackTrace: fileError.stack,
},
traceSpans: [],
})
throw fileError
}
}
const workflowForExecution = {
id: deployment.workflowId,
userId: deployment.userId,
workspaceId,
isDeployed: workflowRecord?.isDeployed ?? false,
variables: (workflowRecord?.variables as Record<string, unknown>) ?? undefined,
}
const stream = await createStreamingResponse({
requestId,
streamConfig: {
selectedOutputs,
isSecureMode: true,
workflowTriggerType: 'chat',
},
executionId,
workspaceId,
workflowId: deployment.workflowId,
userId: workspaceOwnerId,
executeFn: async ({ onStream, onBlockComplete, abortSignal }) =>
executeWorkflow(
workflowForExecution,
requestId,
workflowInput,
workspaceOwnerId,
{
enabled: true,
selectedOutputs,
isSecureMode: true,
workflowTriggerType: 'chat',
onStream,
onBlockComplete,
skipLoggingComplete: true,
abortSignal,
executionMode: 'stream',
},
executionId
),
})
const streamResponse = new NextResponse(stream, {
status: 200,
headers: SSE_HEADERS,
})
return streamResponse
} catch (error: any) {
logger.error(`[${requestId}] Error processing chat request:`, error)
// Setup failed before the workflow stream took over slot release;
// free the reserved billing slot (idempotent if already released).
await releaseExecutionSlot(executionId)
return createErrorResponse(error.message || 'Failed to process request', 500)
}
} catch (error: any) {
logger.error(`[${requestId}] Error processing chat request:`, error)
return createErrorResponse(error.message || 'Failed to process request', 500)
} finally {
ticket.release()
}
}
)
export const GET = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ identifier: string }> }) => {
const { identifier } = await params
const requestId = generateRequestId()
try {
const deploymentResult = await db
.select({
id: chat.id,
title: chat.title,
description: chat.description,
customizations: chat.customizations,
isActive: chat.isActive,
workflowId: chat.workflowId,
authType: chat.authType,
password: chat.password,
allowedEmails: chat.allowedEmails,
outputConfigs: chat.outputConfigs,
})
.from(chat)
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
.limit(1)
if (deploymentResult.length === 0) {
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
return createErrorResponse('Chat not found', 404)
}
const deployment = deploymentResult[0]
if (!deployment.isActive) {
logger.warn(`[${requestId}] Chat is not active: ${identifier}`)
return createErrorResponse('This chat is currently unavailable', 403)
}
const embedBlock = await assertChatEmbedAllowed(request, deployment.workflowId, requestId)
if (embedBlock) return embedBlock
const cookieName = `chat_auth_${deployment.id}`
const authCookie = request.cookies.get(cookieName)
if (
deployment.authType !== 'public' &&
deployment.authType !== 'sso' &&
authCookie &&
validateAuthToken(authCookie.value, deployment.id, deployment.authType, deployment.password)
) {
return createSuccessResponse(toChatConfigResponse(deployment))
}
const authResult = await validateChatAuth(requestId, deployment, request)
if (!authResult.authorized) {
logger.info(
`[${requestId}] Authentication required for chat: ${identifier}, type: ${deployment.authType}`
)
return createErrorResponse(authResult.error || 'Authentication required', 401)
}
return createSuccessResponse(toChatConfigResponse(deployment))
} catch (error: any) {
logger.error(`[${requestId}] Error fetching chat info:`, error)
return createErrorResponse(error.message || 'Failed to fetch chat information', 500)
}
}
)
@@ -0,0 +1,76 @@
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { chatSSOContract } from '@/lib/api/contracts/chats'
import { parseRequest } from '@/lib/api/server'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { isEmailAllowed } from '@/lib/core/security/deployment'
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('ChatSSOAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
const rateLimiter = new RateLimiter()
const SSO_IP_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 20,
refillRate: 20,
refillIntervalMs: 15 * 60_000,
}
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
const requestId = generateRequestId()
const ip = getClientIp(request)
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
`chat-sso:ip:${ip}`,
SSO_IP_RATE_LIMIT
)
if (!ipRateLimit.allowed) {
logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`)
const retryAfter = Math.ceil(
(ipRateLimit.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs) / 1000
)
const response = createErrorResponse('Too many requests. Please try again later.', 429)
response.headers.set('Retry-After', String(retryAfter))
return response
}
const parsed = await parseRequest(chatSSOContract, request, context)
if (!parsed.success) return parsed.response
const { identifier } = parsed.data.params
const { email } = parsed.data.body
const [deployment] = await db
.select({
authType: chat.authType,
allowedEmails: chat.allowedEmails,
isActive: chat.isActive,
})
.from(chat)
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
.limit(1)
if (!deployment || !deployment.isActive) {
logger.warn(`[${requestId}] SSO check on missing/inactive chat: ${identifier}`)
return createErrorResponse('Chat not found', 404)
}
if (deployment.authType !== 'sso') {
return createErrorResponse('Chat is not configured for SSO authentication', 400)
}
const eligible = isEmailAllowed(email, (deployment.allowedEmails as string[]) || [])
return createSuccessResponse({ eligible })
}
)
@@ -0,0 +1,412 @@
/**
* Tests for chat edit API route
*
* @vitest-environment node
*/
import {
auditMock,
authMockFns,
dbChainMock,
dbChainMockFns,
encryptionMock,
encryptionMockFns,
resetDbChainMock,
workflowsApiUtilsMock,
workflowsApiUtilsMockFns,
workflowsOrchestrationMock,
workflowsOrchestrationMockFns,
workflowsPersistenceUtilsMock,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockCheckChatAccess } = vi.hoisted(() => ({
mockCheckChatAccess: vi.fn(),
}))
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
const mockEncryptSecret = encryptionMockFns.mockEncryptSecret
const mockPerformFullDeploy = workflowsOrchestrationMockFns.mockPerformFullDeploy
const mockPerformChatUndeploy = workflowsOrchestrationMockFns.mockPerformChatUndeploy
const mockNotifySocketDeploymentChanged =
workflowsOrchestrationMockFns.mockNotifySocketDeploymentChanged
vi.mock('@sim/audit', () => auditMock)
vi.mock('@/lib/core/config/env-flags', () => ({
isDev: true,
isHosted: false,
isProd: false,
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
vi.mock('@/lib/core/utils/urls', () => ({
getEmailDomain: vi.fn().mockReturnValue('localhost:3000'),
}))
vi.mock('@/app/api/chat/utils', () => ({
checkChatAccess: mockCheckChatAccess,
}))
vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)
import { DELETE, GET, PATCH } from '@/app/api/chat/manage/[id]/route'
describe('Chat Edit API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockPerformChatUndeploy.mockResolvedValue({ success: true })
mockCreateSuccessResponse.mockImplementation((data) => {
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
})
mockCreateErrorResponse.mockImplementation((message, status = 500) => {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
})
})
mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' })
mockPerformFullDeploy.mockResolvedValue({ success: true, version: 1 })
mockNotifySocketDeploymentChanged.mockResolvedValue(undefined)
})
describe('GET', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123')
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(401)
const data = await response.json()
expect(data.error).toBe('Unauthorized')
})
it('should return 404 when chat not found or access denied', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123')
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(404)
const data = await response.json()
expect(data.error).toBe('Chat not found or access denied')
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
})
it('should return chat details when user has access', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const mockChat = {
id: 'chat-123',
identifier: 'test-chat',
title: 'Test Chat',
description: 'A test chat',
password: 'encrypted-password',
customizations: { primaryColor: '#000000' },
}
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123')
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
const data = await response.json()
expect(data.id).toBe('chat-123')
expect(data.identifier).toBe('test-chat')
expect(data.title).toBe('Test Chat')
expect(data.chatUrl).toBe('http://localhost:3000/chat/test-chat')
expect(data.hasPassword).toBe(true)
})
})
describe('PATCH', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Updated Chat' }),
})
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(401)
const data = await response.json()
expect(data.error).toBe('Unauthorized')
})
it('should return 404 when chat not found or access denied', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Updated Chat' }),
})
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(404)
const data = await response.json()
expect(data.error).toBe('Chat not found or access denied')
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
})
it('should update chat when user has access', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const mockChat = {
id: 'chat-123',
identifier: 'test-chat',
title: 'Test Chat',
authType: 'public',
workflowId: 'workflow-123',
}
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: mockChat,
workspaceId: 'workspace-123',
})
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Updated Chat', description: 'Updated description' }),
})
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(dbChainMockFns.update).toHaveBeenCalled()
const data = await response.json()
expect(data.id).toBe('chat-123')
expect(data.chatUrl).toBe('http://localhost:3000/chat/test-chat')
expect(data.message).toBe('Chat deployment updated successfully')
})
it('should handle identifier conflicts', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const mockChat = {
id: 'chat-123',
identifier: 'test-chat',
title: 'Test Chat',
workflowId: 'workflow-123',
}
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
dbChainMockFns.limit.mockResolvedValueOnce([
{ id: 'other-chat-id', identifier: 'new-identifier' },
])
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ identifier: 'new-identifier' }),
})
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toBe('Identifier already in use')
})
it('should validate password requirement for password auth', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const mockChat = {
id: 'chat-123',
identifier: 'test-chat',
title: 'Test Chat',
authType: 'public',
password: null,
workflowId: 'workflow-123',
}
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ authType: 'password' }),
})
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toBe('Password is required when using password protection')
})
it('should keep the existing password when updating a password-protected chat', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const mockChat = {
id: 'chat-123',
identifier: 'test-chat',
title: 'Test Chat',
authType: 'password',
password: 'encrypted-password',
workflowId: 'workflow-123',
}
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: mockChat,
workspaceId: 'workspace-123',
})
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ authType: 'password', title: 'Updated Chat' }),
})
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockEncryptSecret).not.toHaveBeenCalled()
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({
authType: 'password',
allowedEmails: [],
updatedAt: expect.any(Date),
})
)
const updatePayload = dbChainMockFns.set.mock.calls[0]?.[0]
expect(updatePayload.password).toBeUndefined()
})
it('should allow access when user has workspace admin permission', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'admin-user-id' },
})
const mockChat = {
id: 'chat-123',
identifier: 'test-chat',
title: 'Test Chat',
authType: 'public',
workflowId: 'workflow-123',
}
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: mockChat,
workspaceId: 'workspace-123',
})
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Admin Updated Chat' }),
})
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id')
})
})
describe('DELETE', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'DELETE',
})
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(401)
const data = await response.json()
expect(data.error).toBe('Unauthorized')
})
it('should return 404 when chat not found or access denied', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'DELETE',
})
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(404)
const data = await response.json()
expect(data.error).toBe('Chat not found or access denied')
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
})
it('should delete chat when user has access', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: { title: 'Test Chat', workflowId: 'workflow-123' },
workspaceId: 'workspace-123',
})
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'DELETE',
})
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockPerformChatUndeploy).toHaveBeenCalledWith({
chatId: 'chat-123',
userId: 'user-id',
workspaceId: 'workspace-123',
})
const data = await response.json()
expect(data.message).toBe('Chat deployment deleted successfully')
})
it('should allow deletion when user has workspace admin permission', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'admin-user-id' },
})
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: { title: 'Test Chat', workflowId: 'workflow-123' },
workspaceId: 'workspace-123',
})
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'DELETE',
})
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id')
expect(mockPerformChatUndeploy).toHaveBeenCalledWith({
chatId: 'chat-123',
userId: 'admin-user-id',
workspaceId: 'workspace-123',
})
})
})
})
+292
View File
@@ -0,0 +1,292 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { chatIdParamsSchema, updateChatContract } from '@/lib/api/contracts/chats'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { isDev } from '@/lib/core/config/env-flags'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { performChatUndeploy, performFullDeploy } from '@/lib/workflows/orchestration'
import { checkChatAccess } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
export const dynamic = 'force-dynamic'
export const maxDuration = 120
const logger = createLogger('ChatDetailAPI')
/**
* GET endpoint to fetch a specific chat deployment by ID
*/
export const GET = withRouteHandler(
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const { id } = chatIdParamsSchema.parse(await params)
const chatId = id
try {
const session = await getSession()
if (!session) {
return createErrorResponse('Unauthorized', 401)
}
const { hasAccess, chat: chatRecord } = await checkChatAccess(chatId, session.user.id)
if (!hasAccess || !chatRecord) {
return createErrorResponse('Chat not found or access denied', 404)
}
const { password, ...safeData } = chatRecord
const baseDomain = getEmailDomain()
const protocol = isDev ? 'http' : 'https'
const chatUrl = `${protocol}://${baseDomain}/chat/${chatRecord.identifier}`
const result = {
...safeData,
chatUrl,
hasPassword: !!password,
}
return createSuccessResponse(result)
} catch (error) {
logger.error('Error fetching chat deployment:', error)
return createErrorResponse(getErrorMessage(error, 'Failed to fetch chat deployment'), 500)
}
}
)
/**
* PATCH endpoint to update an existing chat deployment
*/
export const PATCH = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
try {
const session = await getSession()
if (!session) {
return createErrorResponse('Unauthorized', 401)
}
const parsed = await parseRequest(updateChatContract, request, context, {
validationErrorResponse: (error) =>
createErrorResponse(getValidationErrorMessage(error), 400, 'VALIDATION_ERROR'),
})
if (!parsed.success) return parsed.response
const { id: chatId } = parsed.data.params
const validatedData = parsed.data.body
const {
hasAccess,
chat: existingChatRecord,
workspaceId: chatWorkspaceId,
} = await checkChatAccess(chatId, session.user.id)
if (!hasAccess || !existingChatRecord) {
return createErrorResponse('Chat not found or access denied', 404)
}
const existingChat = [existingChatRecord]
const {
workflowId,
identifier,
title,
description,
customizations,
authType,
password,
allowedEmails,
outputConfigs,
} = validatedData
if (workflowId && workflowId !== existingChat[0].workflowId) {
return createErrorResponse('Changing the workflow of a chat deployment is not allowed', 400)
}
if (identifier && identifier !== existingChat[0].identifier) {
const existingIdentifier = await db
.select()
.from(chat)
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
.limit(1)
if (existingIdentifier.length > 0 && existingIdentifier[0].id !== chatId) {
return createErrorResponse('Identifier already in use', 400)
}
}
let encryptedPassword
if (password) {
const { encrypted } = await encryptSecret(password)
encryptedPassword = encrypted
logger.info('Password provided, will be updated')
} else if (authType === 'password' && !password) {
if (existingChat[0].authType !== 'password' || !existingChat[0].password) {
return createErrorResponse('Password is required when using password protection', 400)
}
logger.info('Keeping existing password')
}
// Redeploy the workflow to ensure latest version is active
const deployResult = await performFullDeploy({
workflowId: existingChat[0].workflowId,
userId: session.user.id,
request,
})
if (!deployResult.success) {
logger.warn(`Failed to redeploy workflow for chat update: ${deployResult.error}`)
const status =
deployResult.errorCode === 'validation'
? 400
: deployResult.errorCode === 'not_found'
? 404
: 500
return createErrorResponse(deployResult.error || 'Failed to redeploy workflow', status)
}
logger.info(
`Redeployed workflow ${existingChat[0].workflowId} for chat update (v${deployResult.version})`
)
const updateData: Record<string, unknown> = {
updatedAt: new Date(),
}
if (identifier) updateData.identifier = identifier
if (title) updateData.title = title
if (description !== undefined) updateData.description = description
if (customizations) updateData.customizations = customizations
if (authType) {
updateData.authType = authType
if (authType === 'public') {
updateData.password = null
updateData.allowedEmails = []
} else if (authType === 'password') {
updateData.allowedEmails = []
} else if (authType === 'email' || authType === 'sso') {
updateData.password = null
}
}
if (encryptedPassword) {
updateData.password = encryptedPassword
}
if (allowedEmails) {
updateData.allowedEmails = allowedEmails
}
if (outputConfigs) {
updateData.outputConfigs = outputConfigs
}
const emailCount = Array.isArray(updateData.allowedEmails)
? updateData.allowedEmails.length
: undefined
const outputConfigsCount = Array.isArray(updateData.outputConfigs)
? updateData.outputConfigs.length
: undefined
logger.info('Updating chat deployment with values:', {
chatId,
authType: updateData.authType,
hasPassword: updateData.password !== undefined,
emailCount,
outputConfigsCount,
})
await db.update(chat).set(updateData).where(eq(chat.id, chatId))
const updatedIdentifier = identifier || existingChat[0].identifier
const baseDomain = getEmailDomain()
const protocol = isDev ? 'http' : 'https'
const chatUrl = `${protocol}://${baseDomain}/chat/${updatedIdentifier}`
logger.info(`Chat "${chatId}" updated successfully`)
recordAudit({
workspaceId: chatWorkspaceId || null,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CHAT_UPDATED,
resourceType: AuditResourceType.CHAT,
resourceId: chatId,
resourceName: title || existingChatRecord.title,
description: `Updated chat deployment "${title || existingChatRecord.title}"`,
metadata: {
identifier: updatedIdentifier,
authType: updateData.authType || existingChatRecord.authType,
workflowId: workflowId || existingChatRecord.workflowId,
chatUrl,
},
request,
})
return createSuccessResponse({
id: chatId,
chatUrl,
message: 'Chat deployment updated successfully',
})
} catch (error) {
logger.error('Error updating chat deployment:', error)
return createErrorResponse(getErrorMessage(error, 'Failed to update chat deployment'), 500)
}
}
)
/**
* DELETE endpoint to remove a chat deployment
*/
export const DELETE = withRouteHandler(
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const { id } = chatIdParamsSchema.parse(await params)
const chatId = id
try {
const session = await getSession()
if (!session) {
return createErrorResponse('Unauthorized', 401)
}
const { hasAccess, workspaceId: chatWorkspaceId } = await checkChatAccess(
chatId,
session.user.id
)
if (!hasAccess) {
return createErrorResponse('Chat not found or access denied', 404)
}
const result = await performChatUndeploy({
chatId,
userId: session.user.id,
workspaceId: chatWorkspaceId,
})
if (!result.success) {
return createErrorResponse(result.error || 'Failed to delete chat', 500)
}
return createSuccessResponse({
message: 'Chat deployment deleted successfully',
})
} catch (error) {
logger.error('Error deleting chat deployment:', error)
return createErrorResponse(getErrorMessage(error, 'Failed to delete chat deployment'), 500)
}
}
)
+419
View File
@@ -0,0 +1,419 @@
/**
* Tests for chat API route
*
* @vitest-environment node
*/
import {
authMockFns,
createEnvMock,
dbChainMock,
dbChainMockFns,
workflowsApiUtilsMock,
workflowsApiUtilsMockFns,
workflowsOrchestrationMock,
workflowsOrchestrationMockFns,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockCheckWorkflowAccessForChatCreation } = vi.hoisted(() => ({
mockCheckWorkflowAccessForChatCreation: vi.fn(),
}))
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
const mockPerformChatDeploy = workflowsOrchestrationMockFns.mockPerformChatDeploy
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
vi.mock('@/app/api/chat/utils', () => ({
checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation,
}))
vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)
vi.mock('@/lib/core/config/env', () =>
createEnvMock({
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
})
)
import { GET, POST } from '@/app/api/chat/route'
describe('Chat API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateSuccessResponse.mockImplementation((data) => {
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
})
mockCreateErrorResponse.mockImplementation((message, status = 500) => {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
})
})
mockPerformChatDeploy.mockResolvedValue({
success: true,
chatId: 'test-uuid',
chatUrl: 'http://localhost:3000/chat/test-chat',
})
})
describe('GET', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = new NextRequest('http://localhost:3000/api/chat')
const response = await GET(req)
expect(response.status).toBe(401)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
})
it('should return chat deployments for authenticated user', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const mockDeployments = [{ id: 'deployment-1' }, { id: 'deployment-2' }]
dbChainMockFns.where.mockResolvedValueOnce(mockDeployments)
const req = new NextRequest('http://localhost:3000/api/chat')
const response = await GET(req)
expect(response.status).toBe(200)
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({ deployments: mockDeployments })
expect(dbChainMockFns.where).toHaveBeenCalled()
})
it('should handle errors when fetching deployments', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error'))
const req = new NextRequest('http://localhost:3000/api/chat')
const response = await GET(req)
expect(response.status).toBe(500)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Database error', 500)
})
})
describe('POST', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify({}),
})
const response = await POST(req)
expect(response.status).toBe(401)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
})
it('should validate request data', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const invalidData = { title: 'Test Chat' } // Missing required fields
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(invalidData),
})
const response = await POST(req)
expect(response.status).toBe(400)
})
it('should reject if identifier already exists', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const validData = {
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'existing-chat' }]) // Identifier exists
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const response = await POST(req)
expect(response.status).toBe(400)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Identifier already in use', 400)
})
it('should reject if workflow not found', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const validData = {
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const response = await POST(req)
expect(response.status).toBe(404)
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
'Workflow not found or access denied',
404
)
})
it('should allow chat deployment when user owns workflow directly', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id', email: 'user@example.com' },
})
const validData = {
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: true },
})
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
userId: 'user-id',
identifier: 'test-chat',
})
)
})
it('passes chat customizations and outputConfigs through in the API request shape', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id', email: 'user@example.com' },
})
const validData = {
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
imageUrl: 'https://example.com/icon.png',
},
outputConfigs: [{ blockId: 'agent-1', path: 'content' }],
}
dbChainMockFns.limit.mockResolvedValueOnce([])
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: true },
})
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
identifier: 'test-chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
imageUrl: 'https://example.com/icon.png',
},
outputConfigs: [{ blockId: 'agent-1', path: 'content' }],
})
)
})
it('should allow chat deployment when user has workspace admin permission', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id', email: 'user@example.com' },
})
const validData = {
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: true,
workflow: { userId: 'other-user-id', workspaceId: 'workspace-123', isDeployed: true },
})
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
workspaceId: 'workspace-123',
})
)
})
it('should reject when workflow is in workspace but user lacks admin permission', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const validData = {
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: false,
})
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const response = await POST(req)
expect(response.status).toBe(404)
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
'Workflow not found or access denied',
404
)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
})
it('should handle workspace permission check errors gracefully', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const validData = {
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockRejectedValue(new Error('Permission check failed'))
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const response = await POST(req)
expect(response.status).toBe(500)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
})
it('should call performChatDeploy for undeployed workflow', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id', email: 'user@example.com' },
})
const validData = {
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: false },
})
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
userId: 'user-id',
})
)
})
})
})
+132
View File
@@ -0,0 +1,132 @@
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createChatContract } from '@/lib/api/contracts/chats'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { performChatDeploy } from '@/lib/workflows/orchestration'
import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('ChatAPI')
export const GET = withRouteHandler(async (_request: NextRequest) => {
try {
const session = await getSession()
if (!session) {
return createErrorResponse('Unauthorized', 401)
}
// Get the user's chat deployments
const deployments = await db
.select()
.from(chat)
.where(and(eq(chat.userId, session.user.id), isNull(chat.archivedAt)))
return createSuccessResponse({ deployments })
} catch (error) {
logger.error('Error fetching chat deployments:', error)
return createErrorResponse(getErrorMessage(error, 'Failed to fetch chat deployments'), 500)
}
})
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session) {
return createErrorResponse('Unauthorized', 401)
}
const parsed = await parseRequest(
createChatContract,
request,
{},
{
validationErrorResponse: (error) =>
createErrorResponse(getValidationErrorMessage(error), 400, 'VALIDATION_ERROR'),
}
)
if (!parsed.success) return parsed.response
const {
workflowId,
identifier,
title,
description = '',
customizations,
authType = 'public',
password,
allowedEmails = [],
outputConfigs = [],
} = parsed.data.body
if (authType === 'password' && !password) {
return createErrorResponse('Password is required when using password protection', 400)
}
if (authType === 'email' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) {
return createErrorResponse(
'At least one email or domain is required when using email access control',
400
)
}
if (authType === 'sso' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) {
return createErrorResponse(
'At least one email or domain is required when using SSO access control',
400
)
}
const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([
db
.select()
.from(chat)
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
.limit(1),
checkWorkflowAccessForChatCreation(workflowId, session.user.id),
])
if (existingIdentifier.length > 0) {
return createErrorResponse('Identifier already in use', 400)
}
if (!hasAccess || !workflowRecord) {
return createErrorResponse('Workflow not found or access denied', 404)
}
const result = await performChatDeploy({
workflowId,
userId: session.user.id,
identifier,
title,
description,
customizations,
authType,
password,
allowedEmails,
outputConfigs,
workspaceId: workflowRecord.workspaceId,
})
if (!result.success) {
return createErrorResponse(result.error || 'Failed to deploy chat', 500)
}
return createSuccessResponse({
id: result.chatId,
chatId: result.chatId,
chatUrl: result.chatUrl,
message: 'Chat deployment created successfully',
})
} catch (error) {
logger.error('Error creating chat deployment:', error)
return createErrorResponse(getErrorMessage(error, 'Failed to create chat deployment'), 500)
}
})
+555
View File
@@ -0,0 +1,555 @@
/**
* Tests for chat API utils
*
* @vitest-environment node
*/
import {
dbChainMock,
dbChainMockFns,
encryptionMock,
encryptionMockFns,
loggingSessionMock,
workflowsUtilsMock,
} from '@sim/testing'
import type { NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockMergeSubblockStateWithValues,
mockMergeSubBlockValues,
mockValidateAuthToken,
mockSetDeploymentAuthCookie,
mockIsEmailAllowed,
mockGetSession,
mockCheckRateLimitDirect,
mockIsWorkspaceApiExecutionEntitled,
flagState,
} = vi.hoisted(() => ({
mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
mockMergeSubBlockValues: vi.fn().mockReturnValue({}),
mockValidateAuthToken: vi.fn().mockReturnValue(false),
mockSetDeploymentAuthCookie: vi.fn(),
mockIsEmailAllowed: vi.fn(),
mockGetSession: vi.fn(),
mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }),
mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true),
flagState: { isBillingEnabled: false, isFreeApiDeploymentGateEnabled: true },
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/billing/core/api-access', () => ({
isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled,
}))
vi.mock('@/lib/core/rate-limiter', () => ({
RateLimiter: class {
checkRateLimitDirect = mockCheckRateLimitDirect
},
}))
vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
getSession: mockGetSession,
}))
const mockDecryptSecret = encryptionMockFns.mockDecryptSecret
vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)
vi.mock('@/executor', () => ({
Executor: vi.fn(),
}))
vi.mock('@/serializer', () => ({
Serializer: vi.fn(),
}))
vi.mock('@sim/workflow-persistence/subblocks', () => ({
mergeSubblockStateWithValues: mockMergeSubblockStateWithValues,
mergeSubBlockValues: mockMergeSubBlockValues,
}))
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
vi.mock('@/lib/core/security/deployment', () => ({
validateAuthToken: mockValidateAuthToken,
setDeploymentAuthCookie: mockSetDeploymentAuthCookie,
isEmailAllowed: mockIsEmailAllowed,
deploymentAuthCookieName: (prefix: string, id: string) => `${prefix}_auth_${id}`,
}))
vi.mock('@/lib/core/config/env-flags', () => ({
isDev: true,
isProd: false,
get isBillingEnabled() {
return flagState.isBillingEnabled
},
get isFreeApiDeploymentGateEnabled() {
return flagState.isFreeApiDeploymentGateEnabled
},
}))
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
import { NextRequest } from 'next/server'
import { decryptSecret } from '@/lib/core/security/encryption'
import { assertChatEmbedAllowed, setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils'
function chatRequest(origin?: string): NextRequest {
return new NextRequest('https://www.sim.ai/api/chat/abc', {
headers: origin ? { origin } : undefined,
})
}
describe('Chat API Utils', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('process', {
...process,
env: {
...process.env,
NODE_ENV: 'development',
},
})
})
describe('Auth token utils', () => {
it('should accept valid auth cookie via validateChatAuth', async () => {
mockValidateAuthToken.mockReturnValue(true)
const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password',
}
const mockRequest = {
method: 'POST',
cookies: {
get: vi.fn().mockReturnValue({ value: 'valid-token' }),
},
} as any
const result = await validateChatAuth('request-id', deployment, mockRequest)
expect(mockValidateAuthToken).toHaveBeenCalledWith(
'valid-token',
'chat-id',
'password',
'encrypted-password'
)
expect(result.authorized).toBe(true)
})
it('should reject invalid auth cookie via validateChatAuth', async () => {
mockValidateAuthToken.mockReturnValue(false)
const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password',
}
const mockRequest = {
method: 'GET',
cookies: {
get: vi.fn().mockReturnValue({ value: 'invalid-token' }),
},
} as any
const result = await validateChatAuth('request-id', deployment, mockRequest)
expect(result.authorized).toBe(false)
})
})
describe('Cookie handling', () => {
it('should delegate to setDeploymentAuthCookie', () => {
const mockResponse = {
cookies: { set: vi.fn() },
} as unknown as NextResponse
setChatAuthCookie(mockResponse, 'test-chat-id', 'password')
expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith(
mockResponse,
'chat',
'test-chat-id',
'password',
undefined
)
})
})
describe('Chat auth validation', () => {
beforeEach(() => {
mockDecryptSecret.mockResolvedValue({ decrypted: 'correct-password' })
mockCheckRateLimitDirect.mockResolvedValue({ allowed: true })
})
it('should allow access to public chats', async () => {
const deployment = {
id: 'chat-id',
authType: 'public',
}
const mockRequest = {
cookies: {
get: vi.fn().mockReturnValue(null),
},
} as any
const result = await validateChatAuth('request-id', deployment, mockRequest)
expect(result.authorized).toBe(true)
})
it('should request password auth for GET requests', async () => {
const deployment = {
id: 'chat-id',
authType: 'password',
}
const mockRequest = {
method: 'GET',
cookies: {
get: vi.fn().mockReturnValue(null),
},
} as any
const result = await validateChatAuth('request-id', deployment, mockRequest)
expect(result.authorized).toBe(false)
expect(result.error).toBe('auth_required_password')
})
it('should validate password for POST requests', async () => {
const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password',
}
const mockRequest = {
method: 'POST',
cookies: {
get: vi.fn().mockReturnValue(null),
},
} as any
const parsedBody = {
password: 'correct-password',
}
const result = await validateChatAuth('request-id', deployment, mockRequest, parsedBody)
expect(decryptSecret).toHaveBeenCalledWith('encrypted-password')
expect(result.authorized).toBe(true)
})
it('should reject incorrect password', async () => {
const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password',
}
const mockRequest = {
method: 'POST',
cookies: {
get: vi.fn().mockReturnValue(null),
},
} as any
const parsedBody = {
password: 'wrong-password',
}
const result = await validateChatAuth('request-id', deployment, mockRequest, parsedBody)
expect(result.authorized).toBe(false)
expect(result.error).toBe('Invalid password')
})
it('should return 429 when the password attempt rate limit is exceeded', async () => {
mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 60_000 })
const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password',
}
const mockRequest = {
method: 'POST',
cookies: {
get: vi.fn().mockReturnValue(null),
},
} as any
const result = await validateChatAuth('request-id', deployment, mockRequest, {
password: 'any-guess',
})
expect(result.authorized).toBe(false)
expect(result.status).toBe(429)
expect(result.retryAfterMs).toBe(60_000)
expect(decryptSecret).not.toHaveBeenCalled()
})
it('should request email auth for email-protected chats', async () => {
const deployment = {
id: 'chat-id',
authType: 'email',
allowedEmails: ['user@example.com', '@company.com'],
}
const mockRequest = {
method: 'GET',
cookies: {
get: vi.fn().mockReturnValue(null),
},
} as any
const result = await validateChatAuth('request-id', deployment, mockRequest)
expect(result.authorized).toBe(false)
expect(result.error).toBe('auth_required_email')
})
it('should check allowed emails for email auth', async () => {
const deployment = {
id: 'chat-id',
authType: 'email',
allowedEmails: ['user@example.com', '@company.com'],
}
const mockRequest = {
method: 'POST',
cookies: {
get: vi.fn().mockReturnValue(null),
},
} as any
mockIsEmailAllowed.mockReturnValue(true)
const result1 = await validateChatAuth('request-id', deployment, mockRequest, {
email: 'user@example.com',
})
expect(result1.authorized).toBe(false)
expect(result1.error).toBe('otp_required')
const result2 = await validateChatAuth('request-id', deployment, mockRequest, {
email: 'other@company.com',
})
expect(result2.authorized).toBe(false)
expect(result2.error).toBe('otp_required')
mockIsEmailAllowed.mockReturnValue(false)
const result3 = await validateChatAuth('request-id', deployment, mockRequest, {
email: 'user@unknown.com',
})
expect(result3.authorized).toBe(false)
expect(result3.error).toBe('Email not authorized')
})
describe('SSO auth', () => {
const ssoDeployment = {
id: 'chat-id',
authType: 'sso',
allowedEmails: ['user@example.com', '@company.com'],
}
const postRequest = {
method: 'POST',
cookies: { get: vi.fn().mockReturnValue(null) },
} as any
it('rejects when no session is present', async () => {
mockGetSession.mockResolvedValue(null)
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
input: 'hello',
})
expect(result.authorized).toBe(false)
expect(result.error).toBe('auth_required_sso')
})
it('ignores body-supplied email and uses the session email', async () => {
mockGetSession.mockResolvedValue({ user: { email: 'session@example.com' } })
mockIsEmailAllowed.mockReturnValue(true)
await validateChatAuth('request-id', ssoDeployment, postRequest, {
email: 'attacker@evil.com',
input: 'hello',
})
expect(mockIsEmailAllowed).toHaveBeenCalledWith(
'session@example.com',
ssoDeployment.allowedEmails
)
})
it('authorizes execution when session email is allowlisted', async () => {
mockGetSession.mockResolvedValue({ user: { email: 'user@example.com' } })
mockIsEmailAllowed.mockReturnValue(true)
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
input: 'hello',
})
expect(result.authorized).toBe(true)
})
it('rejects execution when session email is not allowlisted', async () => {
mockGetSession.mockResolvedValue({ user: { email: 'stranger@other.com' } })
mockIsEmailAllowed.mockReturnValue(false)
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
input: 'hello',
})
expect(result.authorized).toBe(false)
expect(result.error).toBe('Your email is not authorized to access this resource')
})
})
})
describe('Execution Result Processing', () => {
it.concurrent('should process logs regardless of overall success status', () => {
const executionResult = {
success: false,
output: {},
logs: [
{
blockId: 'agent1',
startedAt: '2023-01-01T00:00:00Z',
endedAt: '2023-01-01T00:00:01Z',
durationMs: 1000,
success: true,
output: { content: 'Agent 1 succeeded' },
error: undefined,
},
{
blockId: 'agent2',
startedAt: '2023-01-01T00:00:00Z',
endedAt: '2023-01-01T00:00:01Z',
durationMs: 500,
success: false,
output: null,
error: 'Agent 2 failed',
},
],
metadata: { duration: 1000 },
}
expect(executionResult.success).toBe(false)
expect(executionResult.logs).toBeDefined()
expect(executionResult.logs).toHaveLength(2)
expect(executionResult.logs[0].success).toBe(true)
expect(executionResult.logs[0].output?.content).toBe('Agent 1 succeeded')
expect(executionResult.logs[1].success).toBe(false)
expect(executionResult.logs[1].error).toBe('Agent 2 failed')
})
it.concurrent('should handle ExecutionResult vs StreamingExecution types correctly', () => {
const executionResult = {
success: true,
output: { content: 'test' },
logs: [],
metadata: { duration: 100 },
}
const directResult = executionResult
const extractedDirect = directResult
expect(extractedDirect).toBe(executionResult)
const streamingResult = {
stream: new ReadableStream(),
execution: executionResult,
}
const extractedFromStreaming =
streamingResult && typeof streamingResult === 'object' && 'execution' in streamingResult
? streamingResult.execution
: streamingResult
expect(extractedFromStreaming).toBe(executionResult)
})
})
})
describe('assertChatEmbedAllowed', () => {
beforeEach(() => {
vi.clearAllMocks()
flagState.isBillingEnabled = true
flagState.isFreeApiDeploymentGateEnabled = true
mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(true)
dbChainMockFns.limit.mockResolvedValue([{ workspaceId: 'ws-1' }])
})
it('returns 403 for a cross-site origin when the owner is on the free plan', async () => {
mockIsWorkspaceApiExecutionEntitled.mockResolvedValueOnce(false)
const res = await assertChatEmbedAllowed(
chatRequest('https://evil.example.com'),
'wf-1',
'req-1'
)
expect(res?.status).toBe(403)
})
it('allows a cross-site origin when the owner is on a paid plan', async () => {
const res = await assertChatEmbedAllowed(
chatRequest('https://evil.example.com'),
'wf-1',
'req-1'
)
expect(res).toBeNull()
})
it('returns 403 for a cross-site origin when the workflow has no active workspace', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([])
const res = await assertChatEmbedAllowed(
chatRequest('https://evil.example.com'),
'wf-1',
'req-1'
)
expect(res?.status).toBe(403)
expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
})
it('allows a first-party *.sim.ai origin without gating', async () => {
const res = await assertChatEmbedAllowed(chatRequest('https://chat.sim.ai'), 'wf-1', 'req-1')
expect(res).toBeNull()
expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
})
it('allows requests with no Origin header', async () => {
const res = await assertChatEmbedAllowed(chatRequest(), 'wf-1', 'req-1')
expect(res).toBeNull()
expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
})
it('is a no-op when billing is disabled', async () => {
flagState.isBillingEnabled = false
const res = await assertChatEmbedAllowed(
chatRequest('https://evil.example.com'),
'wf-1',
'req-1'
)
expect(res).toBeNull()
expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
})
it('is a no-op when the gate feature flag is disabled', async () => {
flagState.isFreeApiDeploymentGateEnabled = false
const res = await assertChatEmbedAllowed(
chatRequest('https://evil.example.com'),
'wf-1',
'req-1'
)
expect(res).toBeNull()
expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
})
})
+154
View File
@@ -0,0 +1,154 @@
import { db } from '@sim/db'
import { chat, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest, NextResponse } from 'next/server'
import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access'
import { getEnv } from '@/lib/core/config/env'
import { isBillingEnabled, isFreeApiDeploymentGateEnabled } from '@/lib/core/config/env-flags'
import { setDeploymentAuthCookie } from '@/lib/core/security/deployment'
import {
type DeploymentAuthResult,
validateDeploymentAuth,
} from '@/lib/core/security/deployment-auth'
import { createErrorResponse } from '@/app/api/workflows/utils'
const logger = createLogger('ChatAuthUtils')
export function setChatAuthCookie(
response: NextResponse,
chatId: string,
type: string,
encryptedPassword?: string | null
): void {
setDeploymentAuthCookie(response, 'chat', chatId, type, encryptedPassword)
}
/**
* A first-party origin is the app itself or any `*.sim.ai` host (chat subdomains
* + apex). Anything else is a third-party embed. Malformed origins are treated
* as third-party.
*/
function isFirstPartyOrigin(origin: string): boolean {
try {
const host = new URL(origin).hostname.toLowerCase()
if (host === 'sim.ai' || host.endsWith('.sim.ai')) return true
const appUrl = getEnv('NEXT_PUBLIC_APP_URL')
if (appUrl && host === new URL(appUrl).hostname.toLowerCase()) return true
return false
} catch {
return false
}
}
/**
* Gates cross-origin (embedded) chat requests behind a paid plan on hosted.
* Same-origin / SSR / first-party requests — including the chat page rendered in
* a third-party iframe, which calls the API from a `*.sim.ai` origin — are never
* gated. Returns a 403 response to short-circuit the route, or `null` to allow.
*/
export async function assertChatEmbedAllowed(
request: NextRequest,
workflowId: string,
requestId: string
): Promise<NextResponse | null> {
if (!isBillingEnabled || !isFreeApiDeploymentGateEnabled) return null
const origin = request.headers.get('origin')
if (!origin || isFirstPartyOrigin(origin)) return null
const [wf] = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(and(eq(workflow.id, workflowId), isNull(workflow.archivedAt)))
.limit(1)
if (!wf?.workspaceId) {
logger.warn(
`[${requestId}] Chat embed blocked: no active workspace for workflow ${workflowId}, origin=${origin}`
)
return createErrorResponse('This chat is currently unavailable', 403)
}
if (!(await isWorkspaceApiExecutionEntitled(wf.workspaceId))) {
logger.warn(`[${requestId}] Chat embed blocked: workspace on free plan, origin=${origin}`)
return createErrorResponse('Embedding this chat on external sites requires a paid plan', 403)
}
return null
}
/**
* Check if user has permission to create a chat for a specific workflow
*/
export async function checkWorkflowAccessForChatCreation(
workflowId: string,
userId: string
): Promise<{ hasAccess: boolean; workflow?: any }> {
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'admin',
})
if (!authorization.workflow) {
return { hasAccess: false }
}
if (authorization.allowed) {
return { hasAccess: true, workflow: authorization.workflow }
}
return { hasAccess: false }
}
/**
* Check if user has access to view/edit/delete a specific chat
*/
export async function checkChatAccess(
chatId: string,
userId: string
): Promise<{ hasAccess: boolean; chat?: any; workspaceId?: string }> {
const chatData = await db
.select({
chat: chat,
workflowWorkspaceId: workflow.workspaceId,
})
.from(chat)
.innerJoin(workflow, eq(chat.workflowId, workflow.id))
.where(and(eq(chat.id, chatId), isNull(chat.archivedAt)))
.limit(1)
if (chatData.length === 0) {
return { hasAccess: false }
}
const { chat: chatRecord, workflowWorkspaceId } = chatData[0]
if (!workflowWorkspaceId) {
return { hasAccess: false }
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: chatRecord.workflowId,
userId,
action: 'admin',
})
return authorization.allowed
? { hasAccess: true, chat: chatRecord, workspaceId: workflowWorkspaceId }
: { hasAccess: false }
}
/**
* Validates auth for a deployed chat. Thin wrapper over the shared
* {@link validateDeploymentAuth} with the `'chat'` cookie/rate-limit namespace.
*/
export async function validateChatAuth(
requestId: string,
deployment: any,
request: NextRequest,
parsedBody?: any
): Promise<DeploymentAuthResult> {
return validateDeploymentAuth(requestId, deployment, request, parsedBody, 'chat')
}
+59
View File
@@ -0,0 +1,59 @@
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { identifierValidationQuerySchema } from '@/lib/api/contracts/chats'
import { getValidationErrorMessage } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('ChatValidateAPI')
/**
* GET endpoint to validate chat identifier availability
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const { searchParams } = new URL(request.url)
const identifier = searchParams.get('identifier')
const validation = identifierValidationQuerySchema.safeParse({ identifier })
if (!validation.success) {
const errorMessage = getValidationErrorMessage(validation.error, 'Invalid identifier')
logger.warn(`Validation error: ${errorMessage}`)
if (identifier && !/^[a-z0-9-]+$/.test(identifier)) {
return createSuccessResponse({
available: false,
error: errorMessage,
})
}
return createErrorResponse(errorMessage, 400)
}
const { identifier: validatedIdentifier } = validation.data
const existingChat = await db
.select({ id: chat.id })
.from(chat)
.where(and(eq(chat.identifier, validatedIdentifier), isNull(chat.archivedAt)))
.limit(1)
const isAvailable = existingChat.length === 0
logger.debug(
`Identifier "${validatedIdentifier}" availability check: ${isAvailable ? 'available' : 'taken'}`
)
return createSuccessResponse({
available: isAvailable,
error: isAvailable ? null : 'This identifier is already in use',
})
} catch (error: any) {
logger.error('Error validating chat identifier:', error)
return createErrorResponse(error.message || 'Failed to validate identifier', 500)
}
})
+191
View File
@@ -0,0 +1,191 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { renderHelpConfirmationEmail } from '@/components/emails'
import {
getContactTopicLabel,
mapContactTopicToHelpType,
submitContactContract,
} from '@/lib/api/contracts/contact'
import { parseRequest } from '@/lib/api/server'
import { env } from '@/lib/core/config/env'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { isTurnstileConfigured, verifyTurnstileToken } from '@/lib/core/security/turnstile'
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
const logger = createLogger('ContactAPI')
const rateLimiter = new RateLimiter()
const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 10,
refillRate: 5,
refillIntervalMs: 60_000,
}
const CAPTCHA_UNAVAILABLE_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 3,
refillRate: 1,
refillIntervalMs: 60_000,
}
const SUCCESS_RESPONSE = { success: true, message: "Thanks — we'll be in touch soon." }
const TOO_MANY_REQUESTS_RESPONSE = { error: 'Too many requests. Please try again later.' }
/**
* Public contact-form endpoint: per-IP rate limit, honeypot drop, captcha, then a
* help-inbox notification plus a best-effort visitor confirmation.
*
* Captcha is server-authoritative — a valid Turnstile token is the only way past
* the stricter fallback bucket, so a caller cannot opt out of the challenge. A
* missing token (widget could not load) or a Cloudflare transport error falls
* back to the tighter no-captcha bucket rather than a free pass; an outright
* invalid token is rejected. That backstop is enforced `failClosed`, so an
* unavailable limiter rejects token-less submits instead of admitting them. No
* `expectedHostname` is pinned: the site key is already domain-bound in
* Cloudflare, and a single-host pin would reject valid self-hosted/preview/apex
* tokens.
*/
export const POST = withRouteHandler(async (req: NextRequest) => {
const requestId = generateRequestId()
try {
const ip = getClientIp(req)
const storageKey = `public:contact:${ip}`
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
storageKey,
PUBLIC_ENDPOINT_RATE_LIMIT
)
if (!allowed) {
logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, {
status: 429,
headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) },
})
}
const parsed = await parseRequest(submitContactContract, req, {})
if (!parsed.success) {
logger.warn(`[${requestId}] Invalid contact request data`)
return parsed.response
}
const { name, email, company, topic, subject, message, website, captchaToken } =
parsed.data.body
if (typeof website === 'string' && website.trim().length > 0) {
logger.warn(`[${requestId}] Honeypot triggered, discarding`, { ip })
return NextResponse.json(SUCCESS_RESPONSE, { status: 201 })
}
if (isTurnstileConfigured()) {
let captchaVerified = false
const token =
typeof captchaToken === 'string' && captchaToken.length > 0 ? captchaToken : null
if (token) {
const verification = await verifyTurnstileToken({ token, remoteIp: ip })
if (verification.success) {
captchaVerified = true
} else if (!verification.transportError) {
logger.warn(`[${requestId}] Captcha verification failed`, {
ip,
errorCodes: verification.errorCodes,
})
return NextResponse.json(
{ error: 'Captcha verification failed. Please try again.' },
{ status: 400 }
)
} else {
logger.warn(
`[${requestId}] Captcha transport error, falling back to no-captcha rate limit`,
{ ip }
)
}
}
if (!captchaVerified) {
const nocaptchaKey = `public:contact:nocaptcha:${ip}`
const { allowed: nocaptchaAllowed } = await rateLimiter.checkRateLimitDirect(
nocaptchaKey,
CAPTCHA_UNAVAILABLE_RATE_LIMIT,
{ failClosed: true }
)
if (!nocaptchaAllowed) {
logger.warn(`[${requestId}] Rate limit rejected (no-captcha) for IP ${ip}`)
return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { status: 429 })
}
}
}
const topicLabel = getContactTopicLabel(topic)
logger.info(`[${requestId}] Processing contact request`, {
email: `${email.substring(0, 3)}***`,
topic,
})
const emailText = `Contact form submission
Submitted: ${new Date().toISOString()}
Topic: ${topicLabel}
Name: ${name}
Email: ${email}
Company: ${company ?? 'Not provided'}
Subject: ${subject}
Message:
${message}
`
const helpInboxDomain = env.EMAIL_DOMAIN || getEmailDomain()
const emailResult = await sendEmail({
to: [`help@${helpInboxDomain}`],
subject: `[CONTACT:${topic.toUpperCase()}] ${subject}`,
text: emailText,
from: getFromEmailAddress(),
replyTo: email,
emailType: 'transactional',
})
if (!emailResult.success) {
logger.error(`[${requestId}] Error sending contact request email`, emailResult.message)
return NextResponse.json({ error: 'Failed to send message' }, { status: 500 })
}
logger.info(`[${requestId}] Contact request email sent successfully`)
try {
const confirmationHtml = await renderHelpConfirmationEmail(
mapContactTopicToHelpType(topic),
0
)
await sendEmail({
to: [email],
subject: `We've received your message: ${subject}`,
html: confirmationHtml,
from: getFromEmailAddress(),
replyTo: `help@${helpInboxDomain}`,
emailType: 'transactional',
})
} catch (err) {
logger.warn(`[${requestId}] Failed to send contact confirmation email`, err)
}
return NextResponse.json(SUCCESS_RESPONSE, { status: 201 })
} catch (error) {
if (error instanceof Error && error.message.includes('not configured')) {
logger.error(`[${requestId}] Email service configuration error`, error)
return NextResponse.json({ error: 'Email service configuration error.' }, { status: 500 })
}
logger.error(`[${requestId}] Error processing contact request`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,58 @@
import { type NextRequest, NextResponse } from 'next/server'
import { generateCopilotApiKeyContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
export const POST = withRouteHandler(async (req: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const parsed = await parseRequest(generateCopilotApiKeyContract, req, {})
if (!parsed.success) return parsed.response
const { name } = parsed.data.body
const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify({ userId, name }),
spanName: 'sim → go /api/validate-key/generate',
operation: 'generate_api_key',
attributes: { [TraceAttr.UserId]: userId },
})
if (!res.ok) {
return NextResponse.json(
{ error: 'Failed to generate copilot API key' },
{ status: res.status || 500 }
)
}
const data = (await res.json().catch(() => null)) as { apiKey?: string; id?: string } | null
if (!data?.apiKey) {
return NextResponse.json({ error: 'Invalid response from Sim Agent' }, { status: 500 })
}
return NextResponse.json(
{ success: true, key: { id: data?.id || 'new', apiKey: data.apiKey } },
{ status: 201 }
)
} catch (error) {
return NextResponse.json({ error: 'Failed to generate copilot API key' }, { status: 500 })
}
})
@@ -0,0 +1,395 @@
/**
* Tests for copilot api-keys API route
*
* @vitest-environment node
*/
import { authMockFns, createEnvMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockFetch, mockGetMothershipBaseURL } = vi.hoisted(() => ({
mockFetch: vi.fn(),
mockGetMothershipBaseURL: vi.fn(),
}))
vi.mock('@/lib/copilot/constants', () => ({
SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
SIM_AGENT_API_URL: 'https://agent.sim.example.com',
COPILOT_MODES: ['ask', 'build', 'plan'] as const,
COPILOT_REQUEST_MODES: ['ask', 'build', 'plan', 'agent'] as const,
}))
vi.mock('@/lib/copilot/server/agent-url', () => ({
getMothershipBaseURL: mockGetMothershipBaseURL,
}))
vi.mock('@/lib/core/config/env', () => createEnvMock({ COPILOT_API_KEY: 'test-api-key' }))
import { DELETE, GET } from '@/app/api/copilot/api-keys/route'
// `fetchGo` reads `response.status` and `response.headers.get('content-length')`
// to stamp span attributes, so mock responses need both fields or the call
// path throws before the route handler sees the body.
function buildMockResponse(init: {
ok: boolean
status?: number
json: () => Promise<unknown>
}): Record<string, unknown> {
return {
ok: init.ok,
status: init.status ?? (init.ok ? 200 : 500),
headers: new Headers(),
json: init.json,
}
}
describe('Copilot API Keys API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetMothershipBaseURL.mockResolvedValue('https://agent.sim.example.com')
global.fetch = mockFetch
})
describe('GET', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await GET(request)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Unauthorized' })
})
it('should return list of API keys with masked values', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
const mockApiKeys = [
{
id: 'key-1',
apiKey: 'sk-sim-abcdefghijklmnopqrstuv',
name: 'Production Key',
createdAt: '2024-01-01T00:00:00.000Z',
lastUsed: '2024-01-15T00:00:00.000Z',
},
{
id: 'key-2',
apiKey: 'sk-sim-zyxwvutsrqponmlkjihgfe',
name: null,
createdAt: '2024-01-02T00:00:00.000Z',
lastUsed: null,
},
]
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.resolve(mockApiKeys),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await GET(request)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.keys).toHaveLength(2)
expect(responseData.keys[0].id).toBe('key-1')
expect(responseData.keys[0].displayKey).toBe('•••••qrstuv')
expect(responseData.keys[0].name).toBe('Production Key')
expect(responseData.keys[1].displayKey).toBe('•••••jihgfe')
expect(responseData.keys[1].name).toBeNull()
})
it('should return empty array when user has no API keys', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.resolve([]),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await GET(request)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.keys).toEqual([])
})
it('should forward userId to Sim Agent', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.resolve([]),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
await GET(request)
expect(mockFetch).toHaveBeenCalledWith(
'https://agent.sim.example.com/api/validate-key/get-api-keys',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
'x-api-key': 'test-api-key',
}),
body: JSON.stringify({ userId: 'user-123' }),
})
)
})
it('should return error when Sim Agent returns non-ok response', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: false,
status: 503,
json: () => Promise.resolve({ error: 'Service unavailable' }),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await GET(request)
expect(response.status).toBe(503)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Failed to get keys' })
})
it('should return 500 when Sim Agent returns invalid response', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.resolve({ invalid: 'response' }),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await GET(request)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Invalid response from Sim Agent' })
})
it('should handle network errors gracefully', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockRejectedValueOnce(new Error('Network error'))
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await GET(request)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Failed to get keys' })
})
it('should handle API keys with empty apiKey string', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
const mockApiKeys = [
{
id: 'key-1',
apiKey: '',
name: 'Empty Key',
createdAt: '2024-01-01T00:00:00.000Z',
lastUsed: null,
},
]
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.resolve(mockApiKeys),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await GET(request)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.keys[0].displayKey).toBe('•••••')
})
it('should handle JSON parsing errors from Sim Agent', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.reject(new Error('Invalid JSON')),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await GET(request)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Invalid response from Sim Agent' })
})
})
describe('DELETE', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123')
const response = await DELETE(request)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Unauthorized' })
})
it('should return 400 when id parameter is missing', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await DELETE(request)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'id is required' })
})
it('should successfully delete an API key', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.resolve({ success: true }),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123')
const response = await DELETE(request)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({ success: true })
expect(mockFetch).toHaveBeenCalledWith(
'https://agent.sim.example.com/api/validate-key/delete',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
'x-api-key': 'test-api-key',
}),
body: JSON.stringify({ userId: 'user-123', apiKeyId: 'key-123' }),
})
)
})
it('should return error when Sim Agent returns non-ok response', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: false,
status: 404,
json: () => Promise.resolve({ error: 'Key not found' }),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=non-existent')
const response = await DELETE(request)
expect(response.status).toBe(404)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Failed to delete key' })
})
it('should return 500 when Sim Agent returns invalid response', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.resolve({ success: false }),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123')
const response = await DELETE(request)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Invalid response from Sim Agent' })
})
it('should handle network errors gracefully', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockRejectedValueOnce(new Error('Network error'))
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123')
const response = await DELETE(request)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Failed to delete key' })
})
it('should handle JSON parsing errors from Sim Agent on delete', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.reject(new Error('Invalid JSON')),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123')
const response = await DELETE(request)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Invalid response from Sim Agent' })
})
})
})
+105
View File
@@ -0,0 +1,105 @@
import { type NextRequest, NextResponse } from 'next/server'
import { deleteCopilotApiKeyQuerySchema } from '@/lib/api/contracts'
import { getSession } from '@/lib/auth'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/get-api-keys`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify({ userId }),
spanName: 'sim → go /api/validate-key/get-api-keys',
operation: 'get_api_keys',
attributes: { [TraceAttr.UserId]: userId },
})
if (!res.ok) {
return NextResponse.json({ error: 'Failed to get keys' }, { status: res.status || 500 })
}
const apiKeys = (await res.json().catch(() => null)) as
| { id: string; apiKey: string; name?: string; createdAt?: string; lastUsed?: string }[]
| null
if (!Array.isArray(apiKeys)) {
return NextResponse.json({ error: 'Invalid response from Sim Agent' }, { status: 500 })
}
const keys = apiKeys.map((k) => {
const value = typeof k.apiKey === 'string' ? k.apiKey : ''
const last6 = value.slice(-6)
const displayKey = `•••••${last6}`
return {
id: k.id,
displayKey,
name: k.name || null,
createdAt: k.createdAt || null,
lastUsed: k.lastUsed || null,
}
})
return NextResponse.json({ keys }, { status: 200 })
} catch (error) {
return NextResponse.json({ error: 'Failed to get keys' }, { status: 500 })
}
})
export const DELETE = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const queryResult = deleteCopilotApiKeyQuerySchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams)
)
if (!queryResult.success) {
return NextResponse.json({ error: 'id is required' }, { status: 400 })
}
const { id } = queryResult.data
const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify({ userId, apiKeyId: id }),
spanName: 'sim → go /api/validate-key/delete',
operation: 'delete_api_key',
attributes: { [TraceAttr.UserId]: userId, [TraceAttr.ApiKeyId]: id },
})
if (!res.ok) {
return NextResponse.json({ error: 'Failed to delete key' }, { status: res.status || 500 })
}
const data = (await res.json().catch(() => null)) as { success?: boolean } | null
if (!data?.success) {
return NextResponse.json({ error: 'Invalid response from Sim Agent' }, { status: 500 })
}
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
return NextResponse.json({ error: 'Failed to delete key' }, { status: 500 })
}
})
@@ -0,0 +1,114 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockFlags,
mockDbLimit,
mockCheckInternalApiKey,
mockCheckServerSideUsageLimits,
mockCheckOrgMemberUsageLimit,
} = vi.hoisted(() => ({
mockFlags: { isHosted: true },
mockDbLimit: vi.fn(),
mockCheckInternalApiKey: vi.fn(),
mockCheckServerSideUsageLimits: vi.fn(),
mockCheckOrgMemberUsageLimit: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: {
select: () => ({ from: () => ({ where: () => ({ limit: mockDbLimit }) }) }),
},
}))
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
checkServerSideUsageLimits: mockCheckServerSideUsageLimits,
checkOrgMemberUsageLimit: mockCheckOrgMemberUsageLimit,
}))
vi.mock('@/lib/copilot/request/http', () => ({
checkInternalApiKey: mockCheckInternalApiKey,
}))
vi.mock('@/lib/copilot/request/otel', () => ({
withIncomingGoSpan: (
_headers: unknown,
_span: unknown,
_attrs: unknown,
fn: (span: { setAttribute: () => void; setAttributes: () => void }) => unknown
) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn() }),
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isHosted() {
return mockFlags.isHosted
},
}))
import { POST } from '@/app/api/copilot/api-keys/validate/route'
function request(body: Record<string, unknown>) {
return createMockRequest('POST', body, { 'x-api-key': 'internal' })
}
describe('POST /api/copilot/api-keys/validate — per-member enforcement', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFlags.isHosted = true
mockCheckInternalApiKey.mockReturnValue({ success: true })
mockDbLimit.mockResolvedValue([{ id: 'user-1' }])
mockCheckServerSideUsageLimits.mockResolvedValue({
isExceeded: false,
currentUsage: 0,
limit: 100,
})
mockCheckOrgMemberUsageLimit.mockResolvedValue({
isExceeded: false,
currentUsage: 0,
limit: null,
})
})
it('returns 402 when the pooled/personal limit is exceeded (existing behavior)', async () => {
mockCheckServerSideUsageLimits.mockResolvedValue({
isExceeded: true,
currentUsage: 200,
limit: 100,
})
const res = await POST(request({ userId: 'user-1', workspaceId: 'ws-1' }))
expect(res.status).toBe(402)
expect(mockCheckOrgMemberUsageLimit).not.toHaveBeenCalled()
})
it('returns 402 when the per-member org-workspace cap is exceeded', async () => {
mockCheckOrgMemberUsageLimit.mockResolvedValue({
isExceeded: true,
currentUsage: 5,
limit: 4,
})
const res = await POST(request({ userId: 'user-1', workspaceId: 'ws-1' }))
expect(res.status).toBe(402)
expect(mockCheckOrgMemberUsageLimit).toHaveBeenCalledWith('user-1', 'ws-1')
})
it('returns 200 when under both limits', async () => {
const res = await POST(request({ userId: 'user-1', workspaceId: 'ws-1' }))
expect(res.status).toBe(200)
})
it('rejects with 400 when workspaceId is omitted (contract-required, fail closed)', async () => {
const res = await POST(request({ userId: 'user-1' }))
expect(res.status).toBe(400)
expect(mockCheckOrgMemberUsageLimit).not.toHaveBeenCalled()
})
it('skips the per-member check when not hosted', async () => {
mockFlags.isHosted = false
const res = await POST(request({ userId: 'user-1', workspaceId: 'ws-1' }))
expect(res.status).toBe(200)
expect(mockCheckOrgMemberUsageLimit).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,144 @@
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { validateCopilotApiKeyContract } from '@/lib/api/contracts/copilot'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import {
checkOrgMemberUsageLimit,
checkServerSideUsageLimits,
} from '@/lib/billing/calculations/usage-monitor'
import { CopilotValidateOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { checkInternalApiKey } from '@/lib/copilot/request/http'
import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
import { isHosted } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CopilotApiKeysValidate')
/**
* Incoming-from-Go: extracts traceparent so this handler's work shows up as
* a child of the Go-side `sim.validate_api_key` span in the same trace. If
* there's no traceparent (manual curl / browser), the helper falls back to a
* new root span.
*/
export const POST = withRouteHandler((req: NextRequest) =>
withIncomingGoSpan(
req.headers,
TraceSpan.CopilotAuthValidateApiKey,
{
[TraceAttr.HttpMethod]: 'POST',
[TraceAttr.HttpRoute]: '/api/copilot/api-keys/validate',
},
async (span) => {
try {
const auth = checkInternalApiKey(req)
if (!auth.success) {
span.setAttribute(
TraceAttr.CopilotValidateOutcome,
CopilotValidateOutcome.InternalAuthFailed
)
span.setAttribute(TraceAttr.HttpStatusCode, 401)
return new NextResponse(null, { status: 401 })
}
const parsed = await parseRequest(
validateCopilotApiKeyContract,
req,
{},
{
validationErrorResponse: (error) => {
logger.warn('Invalid validation request', { errors: error.issues })
span.setAttribute(
TraceAttr.CopilotValidateOutcome,
CopilotValidateOutcome.InvalidBody
)
span.setAttribute(TraceAttr.HttpStatusCode, 400)
return validationErrorResponse(error, 'userId is required')
},
invalidJsonResponse: () => {
logger.warn('Invalid validation request: invalid JSON')
span.setAttribute(
TraceAttr.CopilotValidateOutcome,
CopilotValidateOutcome.InvalidBody
)
span.setAttribute(TraceAttr.HttpStatusCode, 400)
return NextResponse.json(
{ error: 'userId is required', details: [] },
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const { userId, workspaceId } = parsed.data.body
span.setAttribute(TraceAttr.UserId, userId)
const [existingUser] = await db.select().from(user).where(eq(user.id, userId)).limit(1)
if (!existingUser) {
logger.warn('[API VALIDATION] userId does not exist', { userId })
span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.UserNotFound)
span.setAttribute(TraceAttr.HttpStatusCode, 403)
return NextResponse.json({ error: 'User not found' }, { status: 403 })
}
logger.info('[API VALIDATION] Validating usage limit', { userId })
const { isExceeded, currentUsage, limit } = await checkServerSideUsageLimits(userId)
span.setAttributes({
[TraceAttr.BillingUsageCurrent]: currentUsage,
[TraceAttr.BillingUsageLimit]: limit,
[TraceAttr.BillingUsageExceeded]: isExceeded,
})
logger.info('[API VALIDATION] Usage limit validated', {
userId,
currentUsage,
limit,
isExceeded,
})
if (isExceeded) {
logger.info('[API VALIDATION] Usage exceeded', { userId, currentUsage, limit })
span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.UsageExceeded)
span.setAttribute(TraceAttr.HttpStatusCode, 402)
return new NextResponse(null, { status: 402 })
}
// Per-member org-workspace cap (hosted-only). Blocks the mothership/copilot
// chat request itself when the user is over their personal credit limit for
// the org that owns this workspace, independent of the pooled org limit.
// workspaceId is contract-required, so the gate can't be silently skipped.
if (isHosted) {
const memberCheck = await checkOrgMemberUsageLimit(userId, workspaceId)
if (memberCheck.isExceeded) {
logger.info('[API VALIDATION] Per-member org usage limit exceeded', {
userId,
workspaceId,
currentUsage: memberCheck.currentUsage,
limit: memberCheck.limit,
})
span.setAttribute(
TraceAttr.CopilotValidateOutcome,
CopilotValidateOutcome.UsageExceeded
)
span.setAttribute(TraceAttr.HttpStatusCode, 402)
return new NextResponse(null, { status: 402 })
}
}
span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.Ok)
span.setAttribute(TraceAttr.HttpStatusCode, 200)
return new NextResponse(null, { status: 200 })
} catch (error) {
logger.error('Error validating usage limit', { error })
span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.InternalError)
span.setAttribute(TraceAttr.HttpStatusCode, 500)
return NextResponse.json({ error: 'Failed to validate usage' }, { status: 500 })
}
}
)
)
+121
View File
@@ -0,0 +1,121 @@
import { db } from '@sim/db'
import { settings, user } from '@sim/db/schema'
import { getErrorMessage } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
deleteCopilotByokKeyContract,
listCopilotByokKeysContract,
upsertCopilotByokKeyContract,
} from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
/**
* Enterprise BYOK key management for the current workspace's mothership.
*
* Unlike the cross-environment admin inspector (`/api/admin/mothership`), this
* talks to the SAME copilot the workspace's mothership actually runs on —
* `SIM_AGENT_API_URL` (local in dev, prod copilot in prod) — and authenticates
* with the hosted internal key (`COPILOT_API_KEY`), the exact credential
* mothership chat uses. Copilot requires that key (`SIM_AGENT_API_KEY`) and
* rejects self-hosted callers, so BYOK can only ever be written through our
* hosted Sim. The route is superuser-gated; the workspace id rides in the
* request and is resolved by the caller from the route.
*/
async function getAuthorizedSuperUserId(): Promise<string | null> {
const session = await getSession()
if (!session?.user?.id) return null
const [currentUser] = await db
.select({ role: user.role, superUserModeEnabled: settings.superUserModeEnabled })
.from(user)
.leftJoin(settings, eq(settings.userId, user.id))
.where(eq(user.id, session.user.id))
.limit(1)
const authorized = currentUser?.role === 'admin' && (currentUser.superUserModeEnabled ?? false)
return authorized ? session.user.id : null
}
async function forwardToCopilot(
method: 'GET' | 'POST' | 'DELETE',
query: URLSearchParams,
body?: string
) {
const headers: Record<string, string> = { ...getMothershipSourceEnvHeaders() }
if (env.COPILOT_API_KEY) headers['x-api-key'] = env.COPILOT_API_KEY
if (body !== undefined) headers['Content-Type'] = 'application/json'
const qs = query.toString()
const targetUrl = `${SIM_AGENT_API_URL}/api/admin/byok${qs ? `?${qs}` : ''}`
try {
const upstream = await fetch(targetUrl, {
method,
headers,
...(body !== undefined ? { body } : {}),
})
const text = await upstream.text()
// boundary-raw-fetch: copilot returns JSON; tolerate an empty body.
const data = text ? JSON.parse(text) : {}
return NextResponse.json(data, { status: upstream.status })
} catch (error) {
return NextResponse.json(
{ error: `Failed to reach copilot: ${getErrorMessage(error, 'Unknown error')}` },
{ status: 502 }
)
}
}
export const GET = withRouteHandler(async (req: NextRequest) => {
const userId = await getAuthorizedSuperUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(listCopilotByokKeysContract, req, {})
if (!parsed.success) return parsed.response
return forwardToCopilot(
'GET',
new URLSearchParams({ workspaceId: parsed.data.query.workspaceId })
)
})
export const POST = withRouteHandler(async (req: NextRequest) => {
const userId = await getAuthorizedSuperUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(upsertCopilotByokKeyContract, req, {})
if (!parsed.success) return parsed.response
// Bind the audit field to the authenticated superuser, ignoring any
// client-supplied createdBy so provisioning is always attributable.
const body = JSON.stringify({ ...parsed.data.body, createdBy: userId })
return forwardToCopilot('POST', new URLSearchParams(), body)
})
export const DELETE = withRouteHandler(async (req: NextRequest) => {
const userId = await getAuthorizedSuperUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(deleteCopilotByokKeyContract, req, {})
if (!parsed.success) return parsed.response
return forwardToCopilot(
'DELETE',
new URLSearchParams({
workspaceId: parsed.data.query.workspaceId,
provider: parsed.data.query.provider,
})
)
})
@@ -0,0 +1,68 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { validateCopilotByokContract } from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { checkInternalApiKey } from '@/lib/copilot/request/http'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { verifyEffectiveSuperUser } from '@/lib/permissions/super-user'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CopilotByokValidate')
/**
* Authoritative entitlement gate for enterprise BYOK, called server-to-server by
* the mothership (Go) before it uses a workspace's own provider key. Gated by
* INTERNAL_API_SECRET — never exposed to the browser.
*
* Returns 200 when EITHER:
* - the requesting user is a superuser admin (platform admin with superuser
* mode on), who may use BYOK on any workspace for management/testing; OR
* - the user is a member of the workspace (prevents one org from causing
* another org's stored key to be used) AND the workspace is on an
* enterprise plan.
*
* Any other case returns 403 (not entitled) or 401 (bad internal auth). The Go
* caller fails closed to hosted keys on anything but a 200.
*/
export const POST = withRouteHandler(async (req: NextRequest) => {
const auth = checkInternalApiKey(req)
if (!auth.success) {
return new NextResponse(null, { status: 401 })
}
const parsed = await parseRequest(validateCopilotByokContract, req, {})
if (!parsed.success) return parsed.response
const { workspaceId, userId } = parsed.data.body
try {
// Superuser admins may use BYOK on any workspace (management/testing).
const { effectiveSuperUser } = await verifyEffectiveSuperUser(userId)
if (effectiveSuperUser) {
return new NextResponse(null, { status: 200 })
}
// Everyone else must be a workspace member on an enterprise plan. The
// membership check prevents one org from using another org's stored key.
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permission) {
logger.warn('BYOK validate denied: user is not a member of the workspace', {
workspaceId,
userId,
})
return new NextResponse(null, { status: 403 })
}
const eligible = await isWorkspaceOnEnterprisePlan(workspaceId)
if (!eligible) {
logger.warn('BYOK validate denied: workspace is not on an enterprise plan', { workspaceId })
return new NextResponse(null, { status: 403 })
}
return new NextResponse(null, { status: 200 })
} catch (error) {
logger.error('BYOK validation failed', { error, workspaceId })
return NextResponse.json({ error: 'Failed to validate BYOK entitlement' }, { status: 500 })
}
})
@@ -0,0 +1,170 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { copilotChatAbortBodySchema } from '@/lib/api/contracts/copilot'
import { validationErrorResponse } from '@/lib/api/server'
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
import { CopilotAbortOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http'
import { withCopilotSpan, withIncomingGoSpan } from '@/lib/copilot/request/otel'
import {
abortActiveStream,
releasePendingChatStream,
waitForPendingChatStream,
} from '@/lib/copilot/request/session'
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CopilotChatAbortAPI')
const GO_EXPLICIT_ABORT_TIMEOUT_MS = 3000
const STREAM_ABORT_SETTLE_TIMEOUT_MS = 8000
// POST /api/copilot/chat/abort — fires on user Stop; marks the Go
// side aborted then waits for the prior stream to settle.
export const POST = withRouteHandler((request: NextRequest) =>
withIncomingGoSpan(
request.headers,
TraceSpan.CopilotChatAbortStream,
undefined,
async (rootSpan) => {
const { userId: authenticatedUserId, isAuthenticated } =
await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !authenticatedUserId) {
rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.Unauthorized)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json().catch((err) => {
logger.warn('Abort request body parse failed; continuing with empty object', {
error: getErrorMessage(err),
})
return {}
})
const validation = copilotChatAbortBodySchema.safeParse(body)
if (!validation.success) {
rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.MissingStreamId)
return validationErrorResponse(validation.error, 'Invalid request body')
}
const { streamId, chatId: parsedChatId } = validation.data
let chatId = parsedChatId
if (!streamId) {
rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.MissingStreamId)
return NextResponse.json({ error: 'streamId is required' }, { status: 400 })
}
rootSpan.setAttributes({
[TraceAttr.StreamId]: streamId,
[TraceAttr.UserId]: authenticatedUserId,
})
const run = await getLatestRunForStream(streamId, authenticatedUserId).catch((err) => {
logger.warn('getLatestRunForStream failed while resolving abort context', {
streamId,
error: getErrorMessage(err),
})
return null
})
if (!chatId && run?.chatId) {
chatId = run.chatId
}
const workspaceId = run?.workspaceId ?? undefined
if (chatId) rootSpan.setAttribute(TraceAttr.ChatId, chatId)
const aborted = await abortActiveStream(streamId)
rootSpan.setAttribute(TraceAttr.CopilotAbortLocalAborted, aborted)
let goAbortOk = false
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (env.COPILOT_API_KEY) {
headers['x-api-key'] = env.COPILOT_API_KEY
}
Object.assign(headers, getMothershipSourceEnvHeaders())
const controller = new AbortController()
const timeout = setTimeout(
() => controller.abort('timeout:go_explicit_abort_fetch'),
GO_EXPLICIT_ABORT_TIMEOUT_MS
)
const mothershipBaseURL = await getMothershipBaseURL({ userId: authenticatedUserId })
const response = await fetchGo(`${mothershipBaseURL}/api/streams/explicit-abort`, {
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify({
messageId: streamId,
userId: authenticatedUserId,
...(chatId ? { chatId } : {}),
...(workspaceId ? { workspaceId } : {}),
}),
spanName: 'sim → go /api/streams/explicit-abort',
operation: 'explicit_abort',
attributes: {
[TraceAttr.StreamId]: streamId,
...(chatId ? { [TraceAttr.ChatId]: chatId } : {}),
},
}).finally(() => clearTimeout(timeout))
if (!response.ok) {
throw new Error(`Explicit abort marker request failed: ${response.status}`)
}
goAbortOk = true
} catch (err) {
logger.warn('Explicit abort marker request failed after local abort', {
streamId,
error: getErrorMessage(err),
})
}
rootSpan.setAttribute(TraceAttr.CopilotAbortGoMarkerOk, goAbortOk)
if (chatId) {
const settled = await withCopilotSpan(
TraceSpan.CopilotChatAbortWaitSettle,
{
[TraceAttr.ChatId]: chatId,
[TraceAttr.StreamId]: streamId,
[TraceAttr.SettleTimeoutMs]: STREAM_ABORT_SETTLE_TIMEOUT_MS,
},
async (settleSpan) => {
const start = Date.now()
const ok = await waitForPendingChatStream(
chatId,
STREAM_ABORT_SETTLE_TIMEOUT_MS,
streamId
)
settleSpan.setAttributes({
[TraceAttr.SettleWaitMs]: Date.now() - start,
[TraceAttr.SettleCompleted]: ok,
})
return ok
}
)
if (!settled) {
// The holder didn't settle within the grace window even though the
// user explicitly stopped it and abort markers are written on both
// sides (local + Go). Don't leave the chat hostage to a wedged
// handler: break its stream lock. This is safe by construction —
// releaseLock only deletes when the value still matches this
// streamId (never clobbers a newer stream), and the old handler's
// heartbeat uses extendLock-if-owner, so it observes the loss and
// stops heartbeating rather than re-asserting.
await releasePendingChatStream(chatId, streamId)
logger.warn('Stream did not settle after abort; force-released chat stream lock', {
chatId,
streamId,
})
rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.ForceReleased)
return NextResponse.json({ aborted, settled: false, forceReleased: true })
}
rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.Settled)
return NextResponse.json({ aborted, settled: true })
}
rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.NoChatId)
return NextResponse.json({ aborted })
}
)
)
@@ -0,0 +1,172 @@
/**
* Tests for copilot chat delete API route
*
* @vitest-environment node
*/
import { authMockFns, dbChainMock, dbChainMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetAccessibleCopilotChat, mockGetAccessibleCopilotChatAuth } = vi.hoisted(() => ({
mockGetAccessibleCopilotChat: vi.fn(),
mockGetAccessibleCopilotChatAuth: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/copilot/chat/lifecycle', () => ({
getAccessibleCopilotChat: mockGetAccessibleCopilotChat,
getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChatAuth,
}))
vi.mock('@/lib/copilot/chat-status', () => ({
chatPubSub: { publishStatusChanged: vi.fn() },
}))
import { DELETE } from './route'
function createMockRequest(method: string, body: Record<string, unknown>): NextRequest {
return new NextRequest('http://localhost:3000/api/copilot/chat/delete', {
method,
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
})
}
describe('Copilot Chat Delete API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue(null)
dbChainMockFns.returning.mockResolvedValue([{ workspaceId: 'ws-1' }])
mockGetAccessibleCopilotChat.mockResolvedValue({ id: 'chat-123', userId: 'user-123' })
mockGetAccessibleCopilotChatAuth.mockResolvedValue({ id: 'chat-123', userId: 'user-123' })
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('DELETE', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = createMockRequest('DELETE', {
chatId: 'chat-123',
})
const response = await DELETE(req)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ success: false, error: 'Unauthorized' })
})
it('should successfully delete a chat', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const req = createMockRequest('DELETE', {
chatId: 'chat-123',
})
const response = await DELETE(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({ success: true })
expect(dbChainMockFns.delete).toHaveBeenCalled()
expect(dbChainMockFns.where).toHaveBeenCalled()
})
it('should return 400 for invalid request body - missing chatId', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const req = createMockRequest('DELETE', {})
const response = await DELETE(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toBe('Validation error')
})
it('should return 400 for invalid request body - chatId is not a string', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const req = createMockRequest('DELETE', {
chatId: 12345,
})
const response = await DELETE(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toBe('Validation error')
})
it('should handle database errors gracefully', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
dbChainMockFns.returning.mockRejectedValueOnce(new Error('Database connection failed'))
const req = createMockRequest('DELETE', {
chatId: 'chat-123',
})
const response = await DELETE(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData).toEqual({ success: false, error: 'Failed to delete chat' })
})
it('should handle JSON parsing errors in request body', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const req = new NextRequest('http://localhost:3000/api/copilot/chat/delete', {
method: 'DELETE',
body: '{invalid-json',
headers: {
'Content-Type': 'application/json',
},
})
const response = await DELETE(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to delete chat')
})
it('should delete chat even if it does not exist (idempotent)', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
mockGetAccessibleCopilotChatAuth.mockResolvedValueOnce(null)
const req = createMockRequest('DELETE', {
chatId: 'non-existent-chat',
})
const response = await DELETE(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({ success: true })
})
it('should delete chat with empty string chatId (validation should fail)', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const req = createMockRequest('DELETE', {
chatId: '',
})
const response = await DELETE(req)
expect(response.status).toBe(200)
expect(dbChainMockFns.delete).toHaveBeenCalled()
})
})
})
@@ -0,0 +1,62 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { deleteCopilotChatContract } from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
import { chatPubSub } from '@/lib/copilot/chat-status'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('DeleteChatAPI')
export const DELETE = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const validated = await parseRequest(
deleteCopilotChatContract,
request,
{},
{
invalidJson: 'throw',
}
)
if (!validated.success) return validated.response
const parsed = validated.data.body
const chat = await getAccessibleCopilotChatAuth(parsed.chatId, session.user.id)
if (!chat) {
return NextResponse.json({ success: true })
}
const [deleted] = await db
.delete(copilotChats)
.where(and(eq(copilotChats.id, parsed.chatId), eq(copilotChats.userId, session.user.id)))
.returning({ workspaceId: copilotChats.workspaceId })
if (!deleted) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
logger.info('Chat deleted', { chatId: parsed.chatId })
if (deleted.workspaceId) {
chatPubSub?.publishStatusChanged({
workspaceId: deleted.workspaceId,
chatId: parsed.chatId,
type: 'deleted',
})
}
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error deleting chat:', error)
return NextResponse.json({ success: false, error: 'Failed to delete chat' }, { status: 500 })
}
})
+209
View File
@@ -0,0 +1,209 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { toError } from '@sim/utils/errors'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
import { buildEffectiveChatTranscript } from '@/lib/copilot/chat/effective-transcript'
import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle'
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createForbiddenResponse,
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { readFilePreviewSessions } from '@/lib/copilot/request/session'
import { readEvents } from '@/lib/copilot/request/session/buffer'
import { toStreamBatchEvent } from '@/lib/copilot/request/session/types'
import {
assertActiveWorkspaceAccess,
isWorkspaceAccessDeniedError,
} from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CopilotChatAPI')
function transformChat(chat: {
id: string
title: string | null
model: string | null
messages: unknown
planArtifact?: unknown
config?: unknown
conversationId?: string | null
resources?: unknown
createdAt: Date | null
updatedAt: Date | null
}) {
return {
id: chat.id,
title: chat.title,
model: chat.model,
messages: Array.isArray(chat.messages) ? chat.messages : [],
messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0,
planArtifact: chat.planArtifact || null,
config: chat.config || null,
...('conversationId' in chat ? { activeStreamId: chat.conversationId || null } : {}),
...('resources' in chat
? { resources: Array.isArray(chat.resources) ? chat.resources : [] }
: {}),
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
}
}
type CopilotChatListRow = Pick<
typeof copilotChats.$inferSelect,
'id' | 'title' | 'model' | 'createdAt' | 'updatedAt'
>
function transformChatListItem(chat: CopilotChatListRow) {
return {
id: chat.id,
title: chat.title,
model: chat.model,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
}
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const workflowId = searchParams.get('workflowId')
const workspaceId = searchParams.get('workspaceId')
const chatId = searchParams.get('chatId')
const { userId: authenticatedUserId, isAuthenticated } =
await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !authenticatedUserId) {
return createUnauthorizedResponse()
}
if (chatId) {
const chat = await getAccessibleCopilotChat(chatId, authenticatedUserId)
if (!chat) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
let streamSnapshot: {
events: ReturnType<typeof toStreamBatchEvent>[]
previewSessions: Awaited<ReturnType<typeof readFilePreviewSessions>>
status: string
} | null = null
if (chat.conversationId) {
try {
const [events, previewSessions, run] = await Promise.all([
readEvents(chat.conversationId, '0'),
readFilePreviewSessions(chat.conversationId).catch((error) => {
logger.warn('Failed to read preview sessions for copilot chat', {
chatId,
conversationId: chat.conversationId,
error: toError(error).message,
})
return []
}),
getLatestRunForStream(chat.conversationId, authenticatedUserId).catch((error) => {
logger.warn('Failed to fetch latest run for copilot chat snapshot', {
chatId,
conversationId: chat.conversationId,
error: toError(error).message,
})
return null
}),
])
streamSnapshot = {
events: events.map(toStreamBatchEvent),
previewSessions,
status:
typeof run?.status === 'string'
? run.status
: events.length > 0
? 'active'
: 'unknown',
}
} catch (error) {
logger.warn('Failed to load copilot chat stream snapshot', {
chatId,
conversationId: chat.conversationId,
error: toError(error).message,
})
}
}
const normalizedMessages = Array.isArray(chat.messages)
? chat.messages
.filter((message): message is Record<string, unknown> => Boolean(message))
.map(normalizeMessage)
: []
const effectiveMessages = buildEffectiveChatTranscript({
messages: normalizedMessages,
activeStreamId: chat.conversationId || null,
...(streamSnapshot ? { streamSnapshot } : {}),
})
logger.info(`Retrieved chat ${chatId}`)
return NextResponse.json({
success: true,
chat: {
...transformChat(chat),
messages: effectiveMessages,
...(streamSnapshot ? { streamSnapshot } : {}),
},
})
}
if (!workflowId && !workspaceId) {
return createBadRequestResponse('workflowId, workspaceId, or chatId is required')
}
if (workspaceId) {
await assertActiveWorkspaceAccess(workspaceId, authenticatedUserId)
}
if (workflowId) {
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId: authenticatedUserId,
action: 'read',
})
if (!authorization.allowed) {
return createUnauthorizedResponse()
}
}
const scopeFilter = workflowId
? eq(copilotChats.workflowId, workflowId)
: eq(copilotChats.workspaceId, workspaceId!)
const chats = await db
.select({
id: copilotChats.id,
title: copilotChats.title,
model: copilotChats.model,
createdAt: copilotChats.createdAt,
updatedAt: copilotChats.updatedAt,
})
.from(copilotChats)
.where(and(eq(copilotChats.userId, authenticatedUserId), scopeFilter))
.orderBy(desc(copilotChats.updatedAt))
const scope = workflowId ? `workflow ${workflowId}` : `workspace ${workspaceId}`
logger.info(`Retrieved ${chats.length} chats for ${scope}`)
return NextResponse.json({
success: true,
chats: chats.map(transformChatListItem),
})
} catch (error) {
if (isWorkspaceAccessDeniedError(error)) {
return createForbiddenResponse('Workspace access denied')
}
logger.error('Error fetching copilot chats:', error)
return createInternalServerErrorResponse('Failed to fetch chats')
}
}
@@ -0,0 +1,64 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { renameCopilotChatContract } from '@/lib/api/contracts/copilot'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
import { chatPubSub } from '@/lib/copilot/chat-status'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('RenameChatAPI')
export const PATCH = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(
renameCopilotChatContract,
request,
{},
{
validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'),
}
)
if (!parsed.success) return parsed.response
const { chatId, title } = parsed.data.body
const chat = await getAccessibleCopilotChatAuth(chatId, session.user.id)
if (!chat) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
const now = new Date()
const [updated] = await db
.update(copilotChats)
.set({ title, updatedAt: now, lastSeenAt: now })
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id)))
.returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId })
if (!updated) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
logger.info('Chat renamed', { chatId, title })
if (updated.workspaceId) {
chatPubSub?.publishStatusChanged({
workspaceId: updated.workspaceId,
chatId,
type: 'renamed',
})
}
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error renaming chat:', error)
return NextResponse.json({ success: false, error: 'Failed to rename chat' }, { status: 500 })
}
})
@@ -0,0 +1,201 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
addCopilotChatResourceContract,
removeCopilotChatResourceContract,
reorderCopilotChatResourcesContract,
} from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createInternalServerErrorResponse,
createNotFoundResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import type { ChatResource, ResourceType } from '@/lib/copilot/resources/persistence'
import { GENERIC_RESOURCE_TITLES } from '@/lib/copilot/resources/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CopilotChatResourcesAPI')
const VALID_RESOURCE_TYPES = new Set<ResourceType>([
'table',
'file',
'workflow',
'knowledgebase',
'folder',
'scheduledtask',
'log',
'integration',
])
export const POST = withRouteHandler(async (req: NextRequest) => {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(
addCopilotChatResourceContract,
req,
{},
{
validationErrorResponse: (error) =>
createBadRequestResponse(error.issues.map((e) => e.message).join(', ')),
}
)
if (!parsed.success) return parsed.response
const { chatId, resource } = parsed.data.body
// Ephemeral UI tab (client does not POST this; guard for old clients / bugs).
if (resource.id === 'streaming-file') {
return NextResponse.json({ success: true })
}
if (!VALID_RESOURCE_TYPES.has(resource.type)) {
return createBadRequestResponse(`Invalid resource type: ${resource.type}`)
}
const [chat] = await db
.select({ resources: copilotChats.resources })
.from(copilotChats)
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
.limit(1)
if (!chat) {
return createNotFoundResponse('Chat not found or unauthorized')
}
const existing = Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
const key = `${resource.type}:${resource.id}`
const prev = existing.find((r) => `${r.type}:${r.id}` === key)
let merged: ChatResource[]
if (prev) {
if (GENERIC_RESOURCE_TITLES.has(prev.title) && !GENERIC_RESOURCE_TITLES.has(resource.title)) {
merged = existing.map((r) =>
`${r.type}:${r.id}` === key ? { ...r, title: resource.title } : r
)
} else {
merged = existing
}
} else {
merged = [...existing, resource]
}
await db
.update(copilotChats)
.set({ resources: sql`${JSON.stringify(merged)}::jsonb`, updatedAt: new Date() })
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
logger.info('Added resource to chat', { chatId, resource })
return NextResponse.json({ success: true, resources: merged })
} catch (error) {
logger.error('Error adding chat resource:', error)
return createInternalServerErrorResponse('Failed to add resource')
}
})
export const PATCH = withRouteHandler(async (req: NextRequest) => {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(
reorderCopilotChatResourcesContract,
req,
{},
{
validationErrorResponse: (error) =>
createBadRequestResponse(error.issues.map((e) => e.message).join(', ')),
}
)
if (!parsed.success) return parsed.response
const { chatId, resources: newOrder } = parsed.data.body
const [chat] = await db
.select({ resources: copilotChats.resources })
.from(copilotChats)
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
.limit(1)
if (!chat) {
return createNotFoundResponse('Chat not found or unauthorized')
}
const existing = Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
const existingKeys = new Set(existing.map((r) => `${r.type}:${r.id}`))
const newKeys = new Set(newOrder.map((r) => `${r.type}:${r.id}`))
if (existingKeys.size !== newKeys.size || ![...existingKeys].every((k) => newKeys.has(k))) {
return createBadRequestResponse('Reordered resources must match existing resources')
}
await db
.update(copilotChats)
.set({ resources: sql`${JSON.stringify(newOrder)}::jsonb`, updatedAt: new Date() })
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
logger.info('Reordered resources for chat', { chatId, count: newOrder.length })
return NextResponse.json({ success: true, resources: newOrder })
} catch (error) {
logger.error('Error reordering chat resources:', error)
return createInternalServerErrorResponse('Failed to reorder resources')
}
})
export const DELETE = withRouteHandler(async (req: NextRequest) => {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(
removeCopilotChatResourceContract,
req,
{},
{
validationErrorResponse: (error) =>
createBadRequestResponse(error.issues.map((e) => e.message).join(', ')),
}
)
if (!parsed.success) return parsed.response
const { chatId, resourceType, resourceId } = parsed.data.body
const [updated] = await db
.update(copilotChats)
.set({
resources: sql`COALESCE((
SELECT jsonb_agg(elem)
FROM jsonb_array_elements(${copilotChats.resources}) elem
WHERE NOT (elem->>'type' = ${resourceType} AND elem->>'id' = ${resourceId})
), '[]'::jsonb)`,
updatedAt: new Date(),
})
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
.returning({ resources: copilotChats.resources })
if (!updated) {
return createNotFoundResponse('Chat not found or unauthorized')
}
const merged = Array.isArray(updated.resources) ? (updated.resources as ChatResource[]) : []
logger.info('Removed resource from chat', { chatId, resourceType, resourceId })
return NextResponse.json({ success: true, resources: merged })
} catch (error) {
logger.error('Error removing chat resource:', error)
return createInternalServerErrorResponse('Failed to remove resource')
}
})
+15
View File
@@ -0,0 +1,15 @@
import type { NextRequest } from 'next/server'
import { copilotChatGetContract } from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { handleUnifiedChatPost, maxDuration } from '@/lib/copilot/chat/post'
import { GET as getChat } from '@/app/api/copilot/chat/queries'
export { maxDuration }
export const POST = handleUnifiedChatPost
export async function GET(request: NextRequest) {
const parsed = await parseRequest(copilotChatGetContract, request, {})
if (!parsed.success) return parsed.response
return getChat(request)
}
@@ -0,0 +1,159 @@
/**
* @vitest-environment node
*/
import { authMockFns, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
const { mockAppendCopilotChatMessages, mockPublishStatusChanged } = vi.hoisted(() => ({
mockAppendCopilotChatMessages: vi.fn(),
mockPublishStatusChanged: vi.fn(),
}))
vi.mock('@/lib/copilot/chat/messages-store', () => ({
appendCopilotChatMessages: mockAppendCopilotChatMessages,
}))
vi.mock('@/lib/copilot/chat-status', () => ({
chatPubSub: {
publishStatusChanged: mockPublishStatusChanged,
},
}))
import { POST } from '@/app/api/copilot/chat/stop/route'
function createRequest(body: Record<string, unknown>) {
return new NextRequest('http://localhost:3000/api/copilot/chat/stop', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
})
}
/**
* Sequence the two in-tx reads `finalizeAssistantTurn` issues: the chat row
* (`FOR UPDATE ... LIMIT 1`) and the last-message lookup that drives dedup
* (both terminate on `.limit(1)`).
*/
function mockReads(opts: {
chat: Record<string, unknown> | null
last?: { messageId: string; role: string }
}) {
dbChainMockFns.limit.mockResolvedValueOnce(opts.chat ? [opts.chat] : [])
dbChainMockFns.limit.mockResolvedValueOnce(opts.last ? [opts.last] : [])
}
describe('copilot chat stop route', () => {
beforeEach(() => {
vi.clearAllMocks()
// Drain the once-queue (clearAllMocks/resetDbChainMock don't), then restore defaults.
dbChainMockFns.limit.mockReset()
resetDbChainMock()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
})
it('returns 401 when unauthenticated', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce(null)
const response = await POST(
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: '' })
)
expect(response.status).toBe(401)
expect(await response.json()).toEqual({ error: 'Unauthorized' })
})
it('is a no-op when the chat is missing', async () => {
mockReads({ chat: null })
const response = await POST(
createRequest({ chatId: 'missing-chat', streamId: 'stream-1', content: '' })
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ success: true })
expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled()
})
it('appends a stopped assistant message even with no content', async () => {
mockReads({
chat: { workspaceId: 'ws-1', conversationId: 'stream-1', model: null },
last: { messageId: 'stream-1', role: 'user' },
})
const response = await POST(
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: '' })
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ success: true })
const setArg = dbChainMockFns.set.mock.calls[0]?.[0] as Record<string, unknown>
expect(setArg.conversationId).toBeNull()
expect(Object.hasOwn(setArg, 'messages')).toBe(false)
expect(mockAppendCopilotChatMessages).toHaveBeenCalledTimes(1)
const [, appended] = mockAppendCopilotChatMessages.mock.calls[0]
expect(appended[0]).toMatchObject({
role: 'assistant',
content: '',
contentBlocks: [{ type: 'complete', status: 'cancelled' }],
})
expect(mockPublishStatusChanged).toHaveBeenCalledWith({
workspaceId: 'ws-1',
chatId: 'chat-1',
type: 'completed',
streamId: 'stream-1',
})
})
it('appends a stopped assistant message if the stream marker was already cleared', async () => {
mockReads({
chat: { workspaceId: 'ws-1', conversationId: null, model: null },
last: { messageId: 'stream-1', role: 'user' },
})
const response = await POST(
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: 'partial' })
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ success: true })
expect(mockAppendCopilotChatMessages).toHaveBeenCalledTimes(1)
const [, appended] = mockAppendCopilotChatMessages.mock.calls[0]
expect(appended[0]).toMatchObject({ role: 'assistant', content: 'partial' })
expect(mockPublishStatusChanged).toHaveBeenCalledWith({
workspaceId: 'ws-1',
chatId: 'chat-1',
type: 'completed',
streamId: 'stream-1',
})
})
it('republishes completed status when the assistant was already persisted', async () => {
mockReads({
chat: { workspaceId: 'ws-1', conversationId: null, model: null },
last: { messageId: 'assistant-1', role: 'assistant' },
})
const response = await POST(
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: 'partial' })
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ success: true })
expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled()
expect(dbChainMockFns.set).not.toHaveBeenCalled()
expect(mockPublishStatusChanged).toHaveBeenCalledWith({
workspaceId: 'ws-1',
chatId: 'chat-1',
type: 'completed',
streamId: 'stream-1',
})
})
})
+102
View File
@@ -0,0 +1,102 @@
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { copilotChatStopContract } from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import {
normalizeMessage,
type PersistedMessage,
withStoppedContentBlock,
} from '@/lib/copilot/chat/persisted-message'
import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state'
import { chatPubSub } from '@/lib/copilot/chat-status'
import {
CopilotChatFinalizeOutcome,
CopilotStopOutcome,
} from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CopilotChatStopAPI')
// POST /api/copilot/chat/stop — persists partial assistant content
// when the user stops mid-stream. Lock release is handled by the
// aborted server stream unwinding, not this handler.
export const POST = withRouteHandler((req: NextRequest) =>
withIncomingGoSpan(req.headers, TraceSpan.CopilotChatStopStream, undefined, async (span) => {
try {
const session = await getSession()
if (!session?.user?.id) {
span.setAttribute(TraceAttr.CopilotStopOutcome, CopilotStopOutcome.Unauthorized)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(copilotChatStopContract, req, {})
if (!parsed.success) {
span.setAttribute(TraceAttr.CopilotStopOutcome, CopilotStopOutcome.ValidationError)
return parsed.response
}
const { chatId, streamId, content, contentBlocks, requestId } = parsed.data.body
span.setAttributes({
[TraceAttr.ChatId]: chatId,
[TraceAttr.StreamId]: streamId,
[TraceAttr.UserId]: session.user.id,
[TraceAttr.CopilotStopContentLength]: content.length,
[TraceAttr.CopilotStopBlocksCount]: contentBlocks?.length ?? 0,
...(requestId ? { [TraceAttr.RequestId]: requestId } : {}),
})
const hasContent = content.trim().length > 0
const hasBlocks = Array.isArray(contentBlocks) && contentBlocks.length > 0
const assistantBlocks = hasBlocks
? contentBlocks
: hasContent
? [{ type: 'text', channel: 'assistant', content }]
: []
const assistantMessage: PersistedMessage = withStoppedContentBlock(
normalizeMessage({
id: generateId(),
role: 'assistant',
content,
timestamp: new Date().toISOString(),
contentBlocks: assistantBlocks,
...(requestId ? { requestId } : {}),
})
)
const result = await finalizeAssistantTurn({
chatId,
userId: session.user.id,
userMessageId: streamId,
assistantMessage,
streamMarkerPolicy: 'active-or-cleared',
})
span.setAttribute(TraceAttr.CopilotStopAppendedAssistant, result.appendedAssistant)
const stopOutcome = !result.found
? CopilotStopOutcome.ChatNotFound
: result.updated || result.outcome === CopilotChatFinalizeOutcome.AssistantAlreadyPersisted
? CopilotStopOutcome.Persisted
: CopilotStopOutcome.NoMatchingRow
const shouldPublishCompleted =
result.updated || result.outcome === CopilotChatFinalizeOutcome.AssistantAlreadyPersisted
if (shouldPublishCompleted && result.workspaceId) {
chatPubSub?.publishStatusChanged({
workspaceId: result.workspaceId,
chatId,
type: 'completed',
streamId,
})
}
span.setAttribute(TraceAttr.CopilotStopOutcome, stopOutcome)
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error stopping chat stream:', error)
span.setAttribute(TraceAttr.CopilotStopOutcome, CopilotStopOutcome.InternalError)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
)
@@ -0,0 +1,203 @@
/**
* @vitest-environment node
*/
import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
} from '@/lib/copilot/generated/mothership-stream-v1'
const { getLatestRunForStream, readEvents, readFilePreviewSessions, checkForReplayGap } =
vi.hoisted(() => ({
getLatestRunForStream: vi.fn(),
readEvents: vi.fn(),
readFilePreviewSessions: vi.fn(),
checkForReplayGap: vi.fn(),
}))
vi.mock('@/lib/copilot/async-runs/repository', () => ({
getLatestRunForStream,
}))
vi.mock('@/lib/copilot/request/session', () => ({
readEvents,
readFilePreviewSessions,
checkForReplayGap,
createEvent: (event: Record<string, unknown>) => ({
stream: {
streamId: event.streamId,
cursor: event.cursor,
},
seq: event.seq,
trace: { requestId: event.requestId ?? '' },
type: event.type,
payload: event.payload,
}),
encodeSSEEnvelope: (event: Record<string, unknown>) =>
new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`),
encodeSSEComment: (comment: string) => new TextEncoder().encode(`: ${comment}\n\n`),
SSE_RESPONSE_HEADERS: {
'Content-Type': 'text/event-stream',
},
}))
vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)
import { GET } from './route'
async function readAllChunks(response: Response): Promise<string[]> {
const reader = response.body?.getReader()
expect(reader).toBeTruthy()
const chunks: string[] = []
while (true) {
const { done, value } = await reader!.read()
if (done) {
break
}
chunks.push(new TextDecoder().decode(value))
}
return chunks
}
describe('copilot chat stream replay route', () => {
beforeEach(() => {
vi.clearAllMocks()
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
userId: 'user-1',
isAuthenticated: true,
})
readEvents.mockResolvedValue([])
readFilePreviewSessions.mockResolvedValue([])
checkForReplayGap.mockResolvedValue(null)
})
it('returns preview sessions in batch mode', async () => {
getLatestRunForStream.mockResolvedValue({
status: 'active',
executionId: 'exec-1',
id: 'run-1',
})
readFilePreviewSessions.mockResolvedValue([
{
schemaVersion: 1,
id: 'preview-1',
streamId: 'stream-1',
toolCallId: 'preview-1',
status: 'streaming',
fileName: 'draft.md',
previewText: 'hello',
previewVersion: 2,
updatedAt: '2026-04-10T00:00:00.000Z',
},
])
const response = await GET(
new NextRequest(
'http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0&batch=true'
)
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
success: true,
previewSessions: [
expect.objectContaining({
id: 'preview-1',
previewText: 'hello',
previewVersion: 2,
}),
],
status: 'active',
})
})
it('stops replay polling when run becomes cancelled', async () => {
getLatestRunForStream
.mockResolvedValueOnce({
status: 'active',
executionId: 'exec-1',
id: 'run-1',
})
.mockResolvedValueOnce({
status: 'cancelled',
executionId: 'exec-1',
id: 'run-1',
})
const response = await GET(
new NextRequest('http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0')
)
const chunks = await readAllChunks(response)
expect(chunks[0]).toBe(': accepted\n\n')
expect(chunks.join('')).toContain(
JSON.stringify({
status: MothershipStreamV1CompletionStatus.cancelled,
reason: 'terminal_status',
})
)
expect(getLatestRunForStream).toHaveBeenCalledTimes(2)
})
it('emits structured terminal replay error when run metadata disappears', async () => {
getLatestRunForStream
.mockResolvedValueOnce({
status: 'active',
executionId: 'exec-1',
id: 'run-1',
})
.mockResolvedValueOnce(null)
const response = await GET(
new NextRequest('http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0')
)
const chunks = await readAllChunks(response)
const body = chunks.join('')
expect(body).toContain(`"type":"${MothershipStreamV1EventType.error}"`)
expect(body).toContain('"code":"resume_run_unavailable"')
expect(body).toContain(`"type":"${MothershipStreamV1EventType.complete}"`)
})
it('uses the latest live request id for synthetic terminal replay events', async () => {
getLatestRunForStream
.mockResolvedValueOnce({
status: 'active',
executionId: 'exec-1',
id: 'run-1',
})
.mockResolvedValueOnce({
status: 'cancelled',
executionId: 'exec-1',
id: 'run-1',
})
readEvents
.mockResolvedValueOnce([
{
stream: { streamId: 'stream-1', cursor: '1' },
seq: 1,
trace: { requestId: 'req-live-123' },
type: MothershipStreamV1EventType.text,
payload: {
channel: 'assistant',
text: 'hello',
},
},
])
.mockResolvedValueOnce([])
const response = await GET(
new NextRequest('http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0')
)
const chunks = await readAllChunks(response)
const terminalChunk = chunks[chunks.length - 1] ?? ''
expect(terminalChunk).toContain(`"type":"${MothershipStreamV1EventType.complete}"`)
expect(terminalChunk).toContain('"requestId":"req-live-123"')
expect(terminalChunk).toContain('"status":"cancelled"')
})
})
@@ -0,0 +1,483 @@
import { type Context, context as otelContext, type Span, trace } from '@opentelemetry/api'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { type NextRequest, NextResponse } from 'next/server'
import { copilotChatStreamContract } from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
} from '@/lib/copilot/generated/mothership-stream-v1'
import {
CopilotResumeOutcome,
CopilotTransport,
} from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { contextFromRequestHeaders } from '@/lib/copilot/request/go/propagation'
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http'
import { getCopilotTracer, markSpanForError } from '@/lib/copilot/request/otel'
import {
checkForReplayGap,
createEvent,
encodeSSEComment,
encodeSSEEnvelope,
readEvents,
readFilePreviewSessions,
SSE_RESPONSE_HEADERS,
} from '@/lib/copilot/request/session'
import { toStreamBatchEvent } from '@/lib/copilot/request/session/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
export const maxDuration = 3600
const logger = createLogger('CopilotChatStreamAPI')
const POLL_INTERVAL_MS = 250
const REPLAY_KEEPALIVE_INTERVAL_MS = 15_000
const MAX_STREAM_MS = 60 * 60 * 1000
function extractCanonicalRequestId(value: unknown): string {
return typeof value === 'string' && value.length > 0 ? value : ''
}
function extractRunRequestId(run: { requestContext?: unknown } | null | undefined): string {
if (!run || typeof run.requestContext !== 'object' || run.requestContext === null) {
return ''
}
const requestContext = run.requestContext as Record<string, unknown>
return (
extractCanonicalRequestId(requestContext.requestId) ||
extractCanonicalRequestId(requestContext.simRequestId)
)
}
function extractEnvelopeRequestId(envelope: { trace?: { requestId?: unknown } }): string {
return extractCanonicalRequestId(envelope.trace?.requestId)
}
function isTerminalStatus(
status: string | null | undefined
): status is MothershipStreamV1CompletionStatus {
return (
status === MothershipStreamV1CompletionStatus.complete ||
status === MothershipStreamV1CompletionStatus.error ||
status === MothershipStreamV1CompletionStatus.cancelled
)
}
function buildResumeTerminalEnvelopes(options: {
streamId: string
afterCursor: string
status: MothershipStreamV1CompletionStatus
message?: string
code: string
reason?: string
requestId?: string
}) {
const baseSeq = Number(options.afterCursor || '0')
const seq = Number.isFinite(baseSeq) ? baseSeq : 0
const envelopes: ReturnType<typeof createEvent>[] = []
const rid = options.requestId ?? ''
if (options.status === MothershipStreamV1CompletionStatus.error) {
envelopes.push(
createEvent({
streamId: options.streamId,
cursor: String(seq + 1),
seq: seq + 1,
requestId: rid,
type: MothershipStreamV1EventType.error,
payload: {
message: options.message || 'Stream recovery failed before completion.',
code: options.code,
},
})
)
}
envelopes.push(
createEvent({
streamId: options.streamId,
cursor: String(seq + envelopes.length + 1),
seq: seq + envelopes.length + 1,
requestId: rid,
type: MothershipStreamV1EventType.complete,
payload: {
status: options.status,
...(options.reason ? { reason: options.reason } : {}),
},
})
)
return envelopes
}
export const GET = withRouteHandler(async (request: NextRequest) => {
const { userId: authenticatedUserId, isAuthenticated } =
await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !authenticatedUserId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(copilotChatStreamContract, request, {})
if (!parsed.success) return parsed.response
const { streamId, after: afterCursor, batch: batchMode } = parsed.data.query
if (!streamId) {
return NextResponse.json({ error: 'streamId is required' }, { status: 400 })
}
// Root span for the whole resume/reconnect request. In stream mode the
// work happens inside `ReadableStream.start`, which the Node runtime
// invokes after this function returns and OUTSIDE the AsyncLocalStorage
// scope installed by `startActiveSpan`. We therefore start the span
// manually, capture its context, and re-enter that context inside the
// stream callback so every nested `withCopilotSpan` / `withDbSpan` call
// attaches to this root.
//
// `contextFromRequestHeaders` extracts the W3C `traceparent` the
// client echoed (set via `streamTraceparentRef` on Sim's chat POST
// response), so the resume span becomes a child of the original
// chat's `gen_ai.agent.execute` trace instead of a disconnected
// new root. On reconnects after page reload (client ref was wiped)
// the header is absent and extraction leaves the ambient context
// alone → the resume span becomes its own root. Same as pre-
// linking behavior; no regression.
const incomingContext = contextFromRequestHeaders(request.headers)
const rootSpan = getCopilotTracer().startSpan(
TraceSpan.CopilotResumeRequest,
{
attributes: {
[TraceAttr.CopilotTransport]: batchMode ? CopilotTransport.Batch : CopilotTransport.Stream,
[TraceAttr.StreamId]: streamId,
[TraceAttr.UserId]: authenticatedUserId,
[TraceAttr.CopilotResumeAfterCursor]: afterCursor || '0',
},
},
incomingContext
)
const rootContext = trace.setSpan(incomingContext, rootSpan)
try {
return await otelContext.with(rootContext, () =>
handleResumeRequestBody({
request,
streamId,
afterCursor,
batchMode,
authenticatedUserId,
rootSpan,
rootContext,
})
)
} catch (err) {
markSpanForError(rootSpan, err)
rootSpan.end()
throw err
}
})
async function handleResumeRequestBody({
request,
streamId,
afterCursor,
batchMode,
authenticatedUserId,
rootSpan,
rootContext,
}: {
request: NextRequest
streamId: string
afterCursor: string
batchMode: boolean
authenticatedUserId: string
rootSpan: Span
rootContext: Context
}) {
const run = await getLatestRunForStream(streamId, authenticatedUserId).catch((err) => {
logger.warn('Failed to fetch latest run for stream', {
streamId,
error: getErrorMessage(err),
})
return null
})
logger.info('[Resume] Stream lookup', {
streamId,
afterCursor,
batchMode,
hasRun: !!run,
runStatus: run?.status,
})
if (!run) {
rootSpan.setAttribute(TraceAttr.CopilotResumeOutcome, CopilotResumeOutcome.StreamNotFound)
rootSpan.end()
return NextResponse.json({ error: 'Stream not found' }, { status: 404 })
}
rootSpan.setAttribute(TraceAttr.CopilotRunStatus, run.status)
if (batchMode) {
const afterSeq = afterCursor || '0'
const [events, previewSessions] = await Promise.all([
readEvents(streamId, afterSeq),
readFilePreviewSessions(streamId).catch((error) => {
logger.warn('Failed to read preview sessions for stream batch', {
streamId,
error: getErrorMessage(error),
})
return []
}),
])
const batchEvents = events.map(toStreamBatchEvent)
logger.info('[Resume] Batch response', {
streamId,
afterCursor: afterSeq,
eventCount: batchEvents.length,
previewSessionCount: previewSessions.length,
runStatus: run.status,
})
rootSpan.setAttributes({
[TraceAttr.CopilotResumeOutcome]: CopilotResumeOutcome.BatchDelivered,
[TraceAttr.CopilotResumeEventCount]: batchEvents.length,
[TraceAttr.CopilotResumePreviewSessionCount]: previewSessions.length,
})
rootSpan.end()
return NextResponse.json({
success: true,
events: batchEvents,
previewSessions,
status: run.status,
...(run.chatId ? { chatId: run.chatId } : {}),
})
}
const startTime = Date.now()
let totalEventsFlushed = 0
let pollIterations = 0
const stream = new ReadableStream({
async start(controller) {
// Re-enter the root OTel context so any `withCopilotSpan` call below
// (inside flushEvents/checkForReplayGap/etc.) parents under
// copilot.resume.request instead of becoming an orphan.
return otelContext.with(rootContext, () => startInner(controller))
},
})
async function startInner(controller: ReadableStreamDefaultController) {
let cursor = afterCursor || '0'
let controllerClosed = false
let sawTerminalEvent = false
let currentRequestId = extractRunRequestId(run)
let lastWriteTime = Date.now()
// Stamp the logical request id + chat id on the resume root as soon
// as we resolve them from the run row, so TraceQL joins work on
// resume legs the same way they do on the original POST.
if (currentRequestId) {
rootSpan.setAttribute(TraceAttr.RequestId, currentRequestId)
rootSpan.setAttribute(TraceAttr.SimRequestId, currentRequestId)
}
if (run?.chatId) {
rootSpan.setAttribute(TraceAttr.ChatId, run.chatId)
}
const closeController = () => {
if (controllerClosed) return
controllerClosed = true
try {
controller.close()
} catch {
// Controller already closed by runtime/client
}
}
const enqueueEvent = (payload: unknown) => {
if (controllerClosed) return false
try {
controller.enqueue(encodeSSEEnvelope(payload))
lastWriteTime = Date.now()
return true
} catch {
controllerClosed = true
return false
}
}
const enqueueComment = (comment: string) => {
if (controllerClosed) return false
try {
controller.enqueue(encodeSSEComment(comment))
lastWriteTime = Date.now()
return true
} catch {
controllerClosed = true
return false
}
}
const abortListener = () => {
controllerClosed = true
}
request.signal.addEventListener('abort', abortListener, { once: true })
const flushEvents = async () => {
const events = await readEvents(streamId, cursor)
if (events.length > 0) {
logger.debug('[Resume] Flushing events', {
streamId,
afterCursor: cursor,
eventCount: events.length,
})
}
for (const envelope of events) {
if (!enqueueEvent(envelope)) {
break
}
totalEventsFlushed += 1
cursor = envelope.stream.cursor ?? String(envelope.seq)
currentRequestId = extractEnvelopeRequestId(envelope) || currentRequestId
if (envelope.type === MothershipStreamV1EventType.complete) {
sawTerminalEvent = true
}
}
}
const emitTerminalIfMissing = (
status: MothershipStreamV1CompletionStatus,
options?: { message?: string; code: string; reason?: string }
) => {
if (controllerClosed || sawTerminalEvent) {
return
}
for (const envelope of buildResumeTerminalEnvelopes({
streamId,
afterCursor: cursor,
status,
message: options?.message,
code: options?.code ?? 'resume_terminal',
reason: options?.reason,
requestId: currentRequestId,
})) {
if (!enqueueEvent(envelope)) {
break
}
cursor = envelope.stream.cursor ?? String(envelope.seq)
if (envelope.type === MothershipStreamV1EventType.complete) {
sawTerminalEvent = true
}
}
}
try {
enqueueComment('accepted')
const gap = await checkForReplayGap(streamId, afterCursor, currentRequestId)
if (gap) {
for (const envelope of gap.envelopes) {
if (!enqueueEvent(envelope)) {
break
}
cursor = envelope.stream.cursor ?? String(envelope.seq)
currentRequestId = extractEnvelopeRequestId(envelope) || currentRequestId
if (envelope.type === MothershipStreamV1EventType.complete) {
sawTerminalEvent = true
}
}
return
}
await flushEvents()
while (!controllerClosed && Date.now() - startTime < MAX_STREAM_MS) {
pollIterations += 1
const currentRun = await getLatestRunForStream(streamId, authenticatedUserId).catch(
(err) => {
logger.warn('Failed to poll latest run for stream', {
streamId,
error: getErrorMessage(err),
})
return null
}
)
if (!currentRun) {
emitTerminalIfMissing(MothershipStreamV1CompletionStatus.error, {
message: 'The stream could not be recovered because its run metadata is unavailable.',
code: 'resume_run_unavailable',
reason: 'run_unavailable',
})
break
}
currentRequestId = extractRunRequestId(currentRun) || currentRequestId
await flushEvents()
if (controllerClosed) {
break
}
if (isTerminalStatus(currentRun.status)) {
emitTerminalIfMissing(currentRun.status, {
message:
currentRun.status === MothershipStreamV1CompletionStatus.error
? typeof currentRun.error === 'string'
? currentRun.error
: 'The recovered stream ended with an error.'
: undefined,
code: 'resume_terminal_status',
reason: 'terminal_status',
})
break
}
if (request.signal.aborted) {
controllerClosed = true
break
}
if (Date.now() - lastWriteTime >= REPLAY_KEEPALIVE_INTERVAL_MS) {
enqueueComment('keepalive')
}
await sleep(POLL_INTERVAL_MS)
}
if (!controllerClosed && Date.now() - startTime >= MAX_STREAM_MS) {
emitTerminalIfMissing(MothershipStreamV1CompletionStatus.error, {
message: 'The stream recovery timed out before completion.',
code: 'resume_timeout',
reason: 'timeout',
})
}
} catch (error) {
if (!controllerClosed && !request.signal.aborted) {
logger.warn('Stream replay failed', {
streamId,
error: getErrorMessage(error),
})
emitTerminalIfMissing(MothershipStreamV1CompletionStatus.error, {
message: 'The stream replay failed before completion.',
code: 'resume_internal',
reason: 'stream_replay_failed',
})
}
markSpanForError(rootSpan, error)
} finally {
request.signal.removeEventListener('abort', abortListener)
closeController()
rootSpan.setAttributes({
[TraceAttr.CopilotResumeOutcome]: sawTerminalEvent
? CopilotResumeOutcome.TerminalDelivered
: controllerClosed
? CopilotResumeOutcome.ClientDisconnected
: CopilotResumeOutcome.EndedWithoutTerminal,
[TraceAttr.CopilotResumeEventCount]: totalEventsFlushed,
[TraceAttr.CopilotResumePollIterations]: pollIterations,
[TraceAttr.CopilotResumeDurationMs]: Date.now() - startTime,
})
rootSpan.end()
}
}
return new Response(stream, { headers: SSE_RESPONSE_HEADERS })
}
@@ -0,0 +1,574 @@
/**
* Tests for copilot chat update-messages API route
*
* @vitest-environment node
*/
import { authMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockSelect,
mockFrom,
mockWhere,
mockLimit,
mockUpdate,
mockSet,
mockUpdateWhere,
mockReturning,
mockReplaceCopilotChatMessages,
} = vi.hoisted(() => ({
mockSelect: vi.fn(),
mockFrom: vi.fn(),
mockWhere: vi.fn(),
mockLimit: vi.fn(),
mockUpdate: vi.fn(),
mockSet: vi.fn(),
mockUpdateWhere: vi.fn(),
mockReturning: vi.fn(),
mockReplaceCopilotChatMessages: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
update: mockUpdate,
transaction: async (
cb: (tx: { update: typeof mockUpdate; select: typeof mockSelect }) => unknown
) => cb({ update: mockUpdate, select: mockSelect }),
},
}))
vi.mock('@/lib/copilot/chat/messages-store', () => ({
replaceCopilotChatMessages: mockReplaceCopilotChatMessages,
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
}))
import { POST } from '@/app/api/copilot/chat/update-messages/route'
function createMockRequest(method: string, body: Record<string, unknown>): NextRequest {
return new NextRequest('http://localhost:3000/api/copilot/chat/update-messages', {
method,
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
})
}
describe('Copilot Chat Update Messages API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue(null)
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({ limit: mockLimit })
mockLimit.mockResolvedValue([])
mockUpdate.mockReturnValue({ set: mockSet })
mockSet.mockReturnValue({ where: mockUpdateWhere })
mockUpdateWhere.mockReturnValue({ returning: mockReturning })
mockReturning.mockResolvedValue([{ model: 'gpt-4' }])
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('POST', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = createMockRequest('POST', {
chatId: 'chat-123',
messages: [
{
id: 'msg-1',
role: 'user',
content: 'Hello',
timestamp: '2024-01-01T00:00:00.000Z',
},
],
})
const response = await POST(req)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Unauthorized' })
})
it('should return 400 for invalid request body - missing chatId', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const req = createMockRequest('POST', {
messages: [
{
id: 'msg-1',
role: 'user',
content: 'Hello',
timestamp: '2024-01-01T00:00:00.000Z',
},
],
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toBe('Validation error')
})
it('should return 400 for invalid request body - missing messages', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const req = createMockRequest('POST', {
chatId: 'chat-123',
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toBe('Validation error')
})
it('should return 400 for invalid message structure - missing required fields', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const req = createMockRequest('POST', {
chatId: 'chat-123',
messages: [
{
id: 'msg-1',
},
],
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toBe('Validation error')
})
it('should return 400 for invalid message role', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const req = createMockRequest('POST', {
chatId: 'chat-123',
messages: [
{
id: 'msg-1',
role: 'invalid-role',
content: 'Hello',
timestamp: '2024-01-01T00:00:00.000Z',
},
],
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toBe('Validation error')
})
it('should return 404 when chat is not found', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
mockLimit.mockResolvedValueOnce([])
const req = createMockRequest('POST', {
chatId: 'non-existent-chat',
messages: [
{
id: 'msg-1',
role: 'user',
content: 'Hello',
timestamp: '2024-01-01T00:00:00.000Z',
},
],
})
const response = await POST(req)
expect(response.status).toBe(404)
const responseData = await response.json()
expect(responseData.error).toBe('Chat not found or unauthorized')
})
it('should return 404 when chat belongs to different user', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
mockLimit.mockResolvedValueOnce([])
const req = createMockRequest('POST', {
chatId: 'other-user-chat',
messages: [
{
id: 'msg-1',
role: 'user',
content: 'Hello',
timestamp: '2024-01-01T00:00:00.000Z',
},
],
})
const response = await POST(req)
expect(response.status).toBe(404)
const responseData = await response.json()
expect(responseData.error).toBe('Chat not found or unauthorized')
})
it('should successfully update chat messages', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const existingChat = {
id: 'chat-123',
userId: 'user-123',
messages: [],
}
mockLimit.mockResolvedValueOnce([existingChat])
const messages = [
{
id: 'msg-1',
role: 'user',
content: 'Hello, how are you?',
timestamp: '2024-01-01T10:00:00.000Z',
},
{
id: 'msg-2',
role: 'assistant',
content: 'I am doing well, thank you!',
timestamp: '2024-01-01T10:01:00.000Z',
},
]
const req = createMockRequest('POST', {
chatId: 'chat-123',
messages,
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
messageCount: 2,
})
expect(mockSelect).toHaveBeenCalled()
expect(mockUpdate).toHaveBeenCalled()
expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) })
expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith(
'chat-123',
messages,
{ chatModel: 'gpt-4' },
expect.anything()
)
})
it('should successfully update chat messages with optional fields', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const existingChat = {
id: 'chat-456',
userId: 'user-123',
messages: [],
}
mockLimit.mockResolvedValueOnce([existingChat])
const messages = [
{
id: 'msg-1',
role: 'user',
content: 'Hello',
timestamp: '2024-01-01T10:00:00.000Z',
},
{
id: 'msg-2',
role: 'assistant',
content: 'Hi there!',
timestamp: '2024-01-01T10:01:00.000Z',
toolCalls: [
{
id: 'tool-1',
name: 'get_weather',
arguments: { location: 'NYC' },
},
],
contentBlocks: [
{
type: 'text',
content: 'Here is the weather information',
},
],
},
]
const req = createMockRequest('POST', {
chatId: 'chat-456',
messages,
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
messageCount: 2,
})
expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) })
expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith(
'chat-456',
[
{
id: 'msg-1',
role: 'user',
content: 'Hello',
timestamp: '2024-01-01T10:00:00.000Z',
},
{
id: 'msg-2',
role: 'assistant',
content: 'Hi there!',
timestamp: '2024-01-01T10:01:00.000Z',
contentBlocks: [
{
type: 'text',
content: 'Here is the weather information',
},
{
type: 'tool',
phase: 'call',
toolCall: {
id: 'tool-1',
name: 'get_weather',
state: 'pending',
},
},
],
},
],
{ chatModel: 'gpt-4' },
expect.anything()
)
})
it('should handle empty messages array', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const existingChat = {
id: 'chat-789',
userId: 'user-123',
messages: [],
}
mockLimit.mockResolvedValueOnce([existingChat])
const req = createMockRequest('POST', {
chatId: 'chat-789',
messages: [],
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
messageCount: 0,
})
expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) })
expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith(
'chat-789',
[],
{ chatModel: 'gpt-4' },
expect.anything()
)
})
it('should handle database errors during chat lookup', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
mockLimit.mockRejectedValueOnce(new Error('Database connection failed'))
const req = createMockRequest('POST', {
chatId: 'chat-123',
messages: [
{
id: 'msg-1',
role: 'user',
content: 'Hello',
timestamp: '2024-01-01T00:00:00.000Z',
},
],
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to update chat messages')
})
it('should handle database errors during update operation', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const existingChat = {
id: 'chat-123',
userId: 'user-123',
messages: [],
}
mockLimit.mockResolvedValueOnce([existingChat])
mockSet.mockReturnValueOnce({
where: vi.fn().mockRejectedValue(new Error('Update operation failed')),
})
const req = createMockRequest('POST', {
chatId: 'chat-123',
messages: [
{
id: 'msg-1',
role: 'user',
content: 'Hello',
timestamp: '2024-01-01T00:00:00.000Z',
},
],
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to update chat messages')
})
it('should handle JSON parsing errors in request body', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const req = new NextRequest('http://localhost:3000/api/copilot/chat/update-messages', {
method: 'POST',
body: '{invalid-json',
headers: {
'Content-Type': 'application/json',
},
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to update chat messages')
})
it('should handle large message arrays', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const existingChat = {
id: 'chat-large',
userId: 'user-123',
messages: [],
}
mockLimit.mockResolvedValueOnce([existingChat])
const messages = Array.from({ length: 100 }, (_, i) => ({
id: `msg-${i + 1}`,
role: i % 2 === 0 ? 'user' : 'assistant',
content: `Message ${i + 1}`,
timestamp: new Date(2024, 0, 1, 10, i).toISOString(),
}))
const req = createMockRequest('POST', {
chatId: 'chat-large',
messages,
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
messageCount: 100,
})
expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) })
expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith(
'chat-large',
messages,
{ chatModel: 'gpt-4' },
expect.anything()
)
})
it('should handle messages with both user and assistant roles', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const existingChat = {
id: 'chat-mixed',
userId: 'user-123',
messages: [],
}
mockLimit.mockResolvedValueOnce([existingChat])
const messages = [
{
id: 'msg-1',
role: 'user',
content: 'What is the weather like?',
timestamp: '2024-01-01T10:00:00.000Z',
},
{
id: 'msg-2',
role: 'assistant',
content: 'Let me check the weather for you.',
timestamp: '2024-01-01T10:01:00.000Z',
toolCalls: [
{
id: 'tool-weather',
name: 'get_weather',
arguments: { location: 'current' },
},
],
},
{
id: 'msg-3',
role: 'assistant',
content: 'The weather is sunny and 75°F.',
timestamp: '2024-01-01T10:02:00.000Z',
},
{
id: 'msg-4',
role: 'user',
content: 'Thank you!',
timestamp: '2024-01-01T10:03:00.000Z',
},
]
const req = createMockRequest('POST', {
chatId: 'chat-mixed',
messages,
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
messageCount: 4,
})
})
})
})
@@ -0,0 +1,118 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { updateCopilotMessagesContract } from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
import { replaceCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message'
import {
authenticateCopilotRequestSessionOnly,
createInternalServerErrorResponse,
createNotFoundResponse,
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CopilotChatUpdateAPI')
export const POST = withRouteHandler(async (req: NextRequest) => {
const tracker = createRequestTracker()
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(
updateCopilotMessagesContract,
req,
{},
{
invalidJson: 'throw',
}
)
if (!parsed.success) return parsed.response
const { chatId, messages, planArtifact, config } = parsed.data.body
const lastMsg = messages[messages.length - 1]
if (lastMsg?.role === 'assistant') {
logger.info(`[${tracker.requestId}] Received messages to save`, {
messageCount: messages.length,
lastMsgId: lastMsg.id,
lastMsgContentLength: lastMsg.content?.length || 0,
lastMsgContentBlockCount: lastMsg.contentBlocks?.length || 0,
lastMsgContentBlockTypes: lastMsg.contentBlocks?.map((b: any) => b?.type) || [],
})
}
const normalizedMessages: PersistedMessage[] = messages.map((message) =>
normalizeMessage(message as Record<string, unknown>)
)
// Debug: Log what we're about to save
const lastMsgParsed = normalizedMessages[normalizedMessages.length - 1]
if (lastMsgParsed?.role === 'assistant') {
logger.info(`[${tracker.requestId}] Parsed messages to save`, {
messageCount: normalizedMessages.length,
lastMsgId: lastMsgParsed.id,
lastMsgContentLength: lastMsgParsed.content?.length || 0,
lastMsgContentBlockCount: lastMsgParsed.contentBlocks?.length || 0,
lastMsgContentBlockTypes: lastMsgParsed.contentBlocks?.map((b: any) => b?.type) || [],
})
}
// Verify that the chat belongs to the user
const chat = await getAccessibleCopilotChatAuth(chatId, userId)
if (!chat) {
return createNotFoundResponse('Chat not found or unauthorized')
}
const updateData: Record<string, unknown> = {
updatedAt: new Date(),
}
if (planArtifact !== undefined) {
updateData.planArtifact = planArtifact
}
if (config !== undefined) {
updateData.config = config
}
await db.transaction(async (tx) => {
const [updated] = await tx
.update(copilotChats)
.set(updateData)
.where(eq(copilotChats.id, chatId))
.returning({ model: copilotChats.model })
if (!updated) return
await replaceCopilotChatMessages(
chatId,
normalizedMessages,
{ chatModel: updated.model ?? null },
tx
)
})
logger.info(`[${tracker.requestId}] Successfully updated chat`, {
chatId,
newMessageCount: normalizedMessages.length,
hasPlanArtifact: !!planArtifact,
hasConfig: !!config,
})
return NextResponse.json({
success: true,
messageCount: normalizedMessages.length,
})
} catch (error) {
logger.error(`[${tracker.requestId}] Error updating chat messages:`, error)
return createInternalServerErrorResponse('Failed to update chat messages')
}
})
@@ -0,0 +1,240 @@
/**
* Tests for copilot chats list API route
*
* @vitest-environment node
*/
import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockSelectDistinctOn, mockFrom, mockLeftJoin, mockWhere, mockOrderBy } = vi.hoisted(() => ({
mockSelectDistinctOn: vi.fn(),
mockFrom: vi.fn(),
mockLeftJoin: vi.fn(),
mockWhere: vi.fn(),
mockOrderBy: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: {
selectDistinctOn: mockSelectDistinctOn,
},
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
or: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'or' })),
inArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'inArray' })),
isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })),
desc: vi.fn((field: unknown) => ({ field, type: 'desc' })),
sql: vi.fn(),
}))
vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)
vi.mock('@/lib/workspaces/utils', () => ({
listAccessibleWorkspaceRowsForUser: vi
.fn()
.mockResolvedValue([
{ workspace: { id: 'workspace-123', createdAt: new Date() }, permissionType: 'admin' },
]),
}))
import { GET } from '@/app/api/copilot/chats/route'
describe('Copilot Chats List API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSelectDistinctOn.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ leftJoin: mockLeftJoin })
mockLeftJoin.mockReturnValue({ leftJoin: mockLeftJoin, where: mockWhere })
mockWhere.mockReturnValue({ orderBy: mockOrderBy })
mockOrderBy.mockResolvedValue([])
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('GET', () => {
it('should return 401 when user is not authenticated', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: null,
isAuthenticated: false,
})
const request = new Request('http://localhost:3000/api/copilot/chats')
const response = await GET(request as any)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Unauthorized' })
})
it('should return empty chats array when user has no chats', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
mockOrderBy.mockResolvedValueOnce([])
const request = new Request('http://localhost:3000/api/copilot/chats')
const response = await GET(request as any)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
chats: [],
})
})
it('should return list of chats for authenticated user', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const mockChats = [
{
id: 'chat-1',
title: 'First Chat',
workflowId: 'workflow-1',
updatedAt: new Date('2024-01-02'),
},
{
id: 'chat-2',
title: 'Second Chat',
workflowId: 'workflow-2',
updatedAt: new Date('2024-01-01'),
},
]
mockOrderBy.mockResolvedValueOnce(mockChats)
const request = new Request('http://localhost:3000/api/copilot/chats')
const response = await GET(request as any)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(responseData.chats).toHaveLength(2)
expect(responseData.chats[0].id).toBe('chat-1')
expect(responseData.chats[0].title).toBe('First Chat')
expect(responseData.chats[1].id).toBe('chat-2')
})
it('should return chats ordered by updatedAt descending', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const mockChats = [
{
id: 'newest-chat',
title: 'Newest',
workflowId: 'workflow-1',
updatedAt: new Date('2024-01-10'),
},
{
id: 'older-chat',
title: 'Older',
workflowId: 'workflow-2',
updatedAt: new Date('2024-01-05'),
},
{
id: 'oldest-chat',
title: 'Oldest',
workflowId: 'workflow-3',
updatedAt: new Date('2024-01-01'),
},
]
mockOrderBy.mockResolvedValueOnce(mockChats)
const request = new Request('http://localhost:3000/api/copilot/chats')
const response = await GET(request as any)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.chats[0].id).toBe('newest-chat')
expect(responseData.chats[2].id).toBe('oldest-chat')
})
it('should handle chats with null workflowId', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const mockChats = [
{
id: 'chat-no-workflow',
title: 'Chat without workflow',
workflowId: null,
updatedAt: new Date('2024-01-01'),
},
]
mockOrderBy.mockResolvedValueOnce(mockChats)
const request = new Request('http://localhost:3000/api/copilot/chats')
const response = await GET(request as any)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.chats[0].workflowId).toBeNull()
})
it('should handle database errors gracefully', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
mockOrderBy.mockRejectedValueOnce(new Error('Database connection failed'))
const request = new Request('http://localhost:3000/api/copilot/chats')
const response = await GET(request as any)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to fetch user chats')
})
it('should only return chats belonging to authenticated user', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const mockChats = [
{
id: 'my-chat',
title: 'My Chat',
workflowId: 'workflow-1',
updatedAt: new Date('2024-01-01'),
},
]
mockOrderBy.mockResolvedValueOnce(mockChats)
const request = new Request('http://localhost:3000/api/copilot/chats')
await GET(request as any)
expect(mockSelectDistinctOn).toHaveBeenCalled()
expect(mockWhere).toHaveBeenCalled()
})
it('should return 401 when userId is null despite isAuthenticated being true', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: null,
isAuthenticated: true,
})
const request = new Request('http://localhost:3000/api/copilot/chats')
const response = await GET(request as any)
expect(response.status).toBe(401)
})
})
})
+151
View File
@@ -0,0 +1,151 @@
import { db } from '@sim/db'
import { copilotChats, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createWorkflowCopilotChatContract } from '@/lib/api/contracts/copilot'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle'
import { chatPubSub } from '@/lib/copilot/chat-status'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createForbiddenResponse,
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
assertActiveWorkspaceAccess,
isWorkspaceAccessDeniedError,
} from '@/lib/workspaces/permissions/utils'
import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils'
const logger = createLogger('CopilotChatsListAPI')
const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6'
export const GET = withRouteHandler(async (_request: NextRequest) => {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
// Active accessible workspaces (explicit + org-derived). Using the active
// scope keeps the archived-workspace exclusion the old join-based query had.
const accessibleRows = await listAccessibleWorkspaceRowsForUser(userId)
const accessibleWorkspaceIds = accessibleRows.map((row) => row.workspace.id)
const inAccessibleWorkspace =
accessibleWorkspaceIds.length > 0
? or(
inArray(workflow.workspaceId, accessibleWorkspaceIds),
and(
isNull(copilotChats.workflowId),
inArray(copilotChats.workspaceId, accessibleWorkspaceIds)
)
)
: undefined
const visibleChats = await db
.selectDistinctOn([copilotChats.id], {
id: copilotChats.id,
title: copilotChats.title,
workflowId: copilotChats.workflowId,
workspaceId: copilotChats.workspaceId,
activeStreamId: copilotChats.conversationId,
updatedAt: copilotChats.updatedAt,
})
.from(copilotChats)
.leftJoin(workflow, eq(copilotChats.workflowId, workflow.id))
.where(
and(
eq(copilotChats.userId, userId),
or(
and(isNull(copilotChats.workflowId), isNull(copilotChats.workspaceId)),
inAccessibleWorkspace
),
or(isNull(workflow.id), isNull(workflow.archivedAt))
)
)
.orderBy(copilotChats.id, desc(copilotChats.updatedAt))
const sorted = [...visibleChats].sort(
(a, b) => new Date(b.updatedAt!).getTime() - new Date(a.updatedAt!).getTime()
)
logger.info(`Retrieved ${sorted.length} chats for user ${userId}`)
return NextResponse.json({ success: true, chats: sorted })
} catch (error) {
logger.error('Error fetching user copilot chats:', error)
return createInternalServerErrorResponse('Failed to fetch user chats')
}
})
/**
* POST /api/copilot/chats
* Creates an empty workflow-scoped copilot chat (same lifecycle as {@link resolveOrCreateChat}).
* Matches mothership's POST /api/mothership/chats pattern so the client always selects a real row id.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(
createWorkflowCopilotChatContract,
request,
{},
{
validationErrorResponse: (error) =>
validationErrorResponse(error, 'workspaceId and workflowId are required'),
}
)
if (!parsed.success) return parsed.response
const { workspaceId, workflowId } = parsed.data.body
await assertActiveWorkspaceAccess(workspaceId, userId)
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'read',
})
if (!authorization.allowed || !authorization.workflow) {
return NextResponse.json(
{ success: false, error: authorization.message ?? 'Forbidden' },
{ status: authorization.status }
)
}
if (authorization.workflow.workspaceId !== workspaceId) {
return createBadRequestResponse('workflow does not belong to this workspace')
}
const result = await resolveOrCreateChat({
userId,
workflowId,
workspaceId,
model: DEFAULT_COPILOT_MODEL,
type: 'copilot',
})
if (!result.chatId) {
return createInternalServerErrorResponse('Failed to create chat')
}
chatPubSub?.publishStatusChanged({ workspaceId, chatId: result.chatId, type: 'created' })
return NextResponse.json({ success: true, id: result.chatId })
} catch (error) {
if (isWorkspaceAccessDeniedError(error)) {
return createForbiddenResponse('Workspace access denied')
}
logger.error('Error creating workflow copilot chat:', error)
return createInternalServerErrorResponse('Failed to create chat')
}
})
@@ -0,0 +1,801 @@
/**
* Tests for copilot checkpoints revert API route
*
* @vitest-environment node
*/
import { authMockFns, workflowAuthzMockFns, workflowsUtilsMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockSelect,
mockFrom,
mockWhere,
mockThen,
mockDelete,
mockDeleteWhere,
mockGetAccessibleCopilotChat,
} = vi.hoisted(() => ({
mockSelect: vi.fn(),
mockFrom: vi.fn(),
mockWhere: vi.fn(),
mockThen: vi.fn(),
mockDelete: vi.fn(),
mockDeleteWhere: vi.fn(),
mockGetAccessibleCopilotChat: vi.fn(),
}))
vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: vi.fn(() => 'http://localhost:3000'),
getInternalApiBaseUrl: vi.fn(() => 'http://localhost:3000'),
getBaseDomain: vi.fn(() => 'localhost:3000'),
getEmailDomain: vi.fn(() => 'localhost:3000'),
}))
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
vi.mock('@/lib/copilot/chat/lifecycle', () => ({
getAccessibleCopilotChat: mockGetAccessibleCopilotChat,
getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChat,
}))
vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
delete: mockDelete,
},
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
}))
import { POST } from '@/app/api/copilot/checkpoints/revert/route'
describe('Copilot Checkpoints Revert API Route', () => {
/** Queued results for successive `.then()` calls in the db select chain */
let thenResults: unknown[]
beforeEach(() => {
vi.clearAllMocks()
thenResults = []
authMockFns.mockGetSession.mockResolvedValue(null)
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
})
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({ then: mockThen })
// Drizzle's .then() is a thenable: it receives a callback like (rows) => rows[0].
// We invoke the callback with our mock rows array so the route gets the expected value.
mockThen.mockImplementation((callback: (rows: unknown[]) => unknown) => {
const result = thenResults.shift()
if (result instanceof Error) {
return Promise.reject(result)
}
const rows = result === undefined ? [] : [result]
return Promise.resolve(callback(rows))
})
// Mock delete chain
mockDelete.mockReturnValue({ where: mockDeleteWhere })
mockDeleteWhere.mockResolvedValue(undefined)
mockGetAccessibleCopilotChat.mockResolvedValue({ id: 'chat-123', userId: 'user-123' })
global.fetch = vi.fn()
vi.spyOn(Date, 'now').mockReturnValue(1640995200000)
const originalDate = Date
const buildDate = (args: any[]): Date => {
if (args.length === 0) {
return new originalDate('2024-01-01T00:00:00.000Z')
}
if (args.length === 1) {
return new originalDate(args[0])
}
return new originalDate(args[0], args[1], args[2], args[3], args[4], args[5], args[6])
}
vi.spyOn(global, 'Date').mockImplementation(
class {
constructor(...args: any[]) {
// biome-ignore lint/correctness/noConstructorReturn: vitest 4 constructs mocks via Reflect.construct; returning a real Date overrides the instance so `new Date(...)` yields a genuine Date the route can call .toISOString()/.getTime() on
return buildDate(args)
}
} as any
)
})
afterEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks()
})
/** Helper to set authenticated state */
function setAuthenticated(user = { id: 'user-123', email: 'test@example.com' }) {
authMockFns.mockGetSession.mockResolvedValue({ user })
}
/** Helper to set unauthenticated state */
function setUnauthenticated() {
authMockFns.mockGetSession.mockResolvedValue(null)
}
describe('POST', () => {
it('should return 401 when user is not authenticated', async () => {
setUnauthenticated()
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
})
const response = await POST(req)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Unauthorized' })
})
it('should return 400 for invalid request body - missing checkpointId', async () => {
setAuthenticated()
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(typeof responseData.error).toBe('string')
})
it('should return 400 for empty checkpointId', async () => {
setAuthenticated()
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: '' }),
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(typeof responseData.error).toBe('string')
})
it('should return 404 when checkpoint is not found', async () => {
setAuthenticated()
// Mock checkpoint not found
thenResults.push(undefined)
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'non-existent-checkpoint' }),
})
const response = await POST(req)
expect(response.status).toBe(404)
const responseData = await response.json()
expect(responseData.error).toBe('Checkpoint not found or access denied')
})
it('should return 404 when checkpoint belongs to different user', async () => {
setAuthenticated()
// Mock checkpoint not found (due to user mismatch in query)
thenResults.push(undefined)
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'other-user-checkpoint' }),
})
const response = await POST(req)
expect(response.status).toBe(404)
const responseData = await response.json()
expect(responseData.error).toBe('Checkpoint not found or access denied')
})
it('should return 404 when workflow is not found', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-123',
workflowId: 'a1b2c3d4-e5f6-4a78-b9c0-d1e2f3a4b5c6',
userId: 'user-123',
workflowState: { blocks: {}, edges: [] },
}
thenResults.push(mockCheckpoint) // Checkpoint found
thenResults.push(undefined) // Workflow not found
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
})
const response = await POST(req)
expect(response.status).toBe(404)
const responseData = await response.json()
expect(responseData.error).toBe('Workflow not found')
})
it('should return 401 when workflow belongs to different user', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-123',
workflowId: 'b2c3d4e5-f6a7-4b89-a0d1-e2f3a4b5c6d7',
userId: 'user-123',
workflowState: { blocks: {}, edges: [] },
}
const mockWorkflow = {
id: 'b2c3d4e5-f6a7-4b89-a0d1-e2f3a4b5c6d7',
userId: 'different-user',
}
thenResults.push(mockCheckpoint) // Checkpoint found
thenResults.push(mockWorkflow) // Workflow found but different user
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
allowed: false,
status: 403,
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
})
const response = await POST(req)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Unauthorized' })
})
it('should successfully revert checkpoint with basic workflow state', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-123',
workflowId: 'c3d4e5f6-a7b8-4c09-a1e2-f3a4b5c6d7e8',
userId: 'user-123',
workflowState: {
blocks: { block1: { type: 'start' } },
edges: [{ from: 'block1', to: 'block2' }],
loops: {},
parallels: {},
isDeployed: true,
},
}
const mockWorkflow = {
id: 'c3d4e5f6-a7b8-4c09-a1e2-f3a4b5c6d7e8',
userId: 'user-123',
}
thenResults.push(mockCheckpoint) // Checkpoint found
thenResults.push(mockWorkflow) // Workflow found
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: 'session=test-session',
},
body: JSON.stringify({
checkpointId: 'checkpoint-123',
}),
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
workflowId: 'c3d4e5f6-a7b8-4c09-a1e2-f3a4b5c6d7e8',
checkpointId: 'checkpoint-123',
revertedAt: '2024-01-01T00:00:00.000Z',
checkpoint: {
id: 'checkpoint-123',
workflowState: {
blocks: { block1: { type: 'start' } },
edges: [{ from: 'block1', to: 'block2' }],
loops: {},
parallels: {},
isDeployed: true,
lastSaved: 1640995200000,
},
},
})
// Verify fetch was called with correct parameters
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3000/api/workflows/c3d4e5f6-a7b8-4c09-a1e2-f3a4b5c6d7e8/state',
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Cookie: 'session=test-session',
},
body: JSON.stringify({
blocks: { block1: { type: 'start' } },
edges: [{ from: 'block1', to: 'block2' }],
loops: {},
parallels: {},
isDeployed: true,
lastSaved: 1640995200000,
}),
}
)
})
it('should handle checkpoint state with valid deployedAt date', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-with-date',
workflowId: 'd4e5f6a7-b8c9-4d10-a2e3-a4b5c6d7e8f9',
userId: 'user-123',
workflowState: {
blocks: {},
edges: [],
deployedAt: '2024-01-01T12:00:00.000Z',
isDeployed: true,
},
}
const mockWorkflow = {
id: 'd4e5f6a7-b8c9-4d10-a2e3-a4b5c6d7e8f9',
userId: 'user-123',
}
thenResults.push(mockCheckpoint)
thenResults.push(mockWorkflow)
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-with-date' }),
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.checkpoint.workflowState.deployedAt).toBeDefined()
expect(responseData.checkpoint.workflowState.deployedAt).toEqual('2024-01-01T12:00:00.000Z')
})
it('should handle checkpoint state with invalid deployedAt date', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-invalid-date',
workflowId: 'e5f6a7b8-c9d0-4e11-a3f4-b5c6d7e8f9a0',
userId: 'user-123',
workflowState: {
blocks: {},
edges: [],
deployedAt: 'invalid-date',
isDeployed: true,
},
}
const mockWorkflow = {
id: 'e5f6a7b8-c9d0-4e11-a3f4-b5c6d7e8f9a0',
userId: 'user-123',
}
thenResults.push(mockCheckpoint)
thenResults.push(mockWorkflow)
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-invalid-date' }),
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
// Invalid date should be filtered out
expect(responseData.checkpoint.workflowState.deployedAt).toBeUndefined()
})
it('should handle checkpoint state with null/undefined values', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-null-values',
workflowId: 'f6a7b8c9-d0e1-4f23-a4b5-c6d7e8f9a0b1',
userId: 'user-123',
workflowState: {
blocks: null,
edges: undefined,
loops: null,
parallels: undefined,
},
}
const mockWorkflow = {
id: 'f6a7b8c9-d0e1-4f23-a4b5-c6d7e8f9a0b1',
userId: 'user-123',
}
thenResults.push(mockCheckpoint)
thenResults.push(mockWorkflow)
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-null-values' }),
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
// Null/undefined values should be replaced with defaults
expect(responseData.checkpoint.workflowState).toEqual({
blocks: {},
edges: [],
loops: {},
parallels: {},
isDeployed: false,
lastSaved: 1640995200000,
})
})
it('should return 500 when state API call fails', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-123',
workflowId: 'a7b8c9d0-e1f2-4a34-b5c6-d7e8f9a0b1c2',
userId: 'user-123',
workflowState: { blocks: {}, edges: [] },
}
const mockWorkflow = {
id: 'a7b8c9d0-e1f2-4a34-b5c6-d7e8f9a0b1c2',
userId: 'user-123',
}
thenResults.push(mockCheckpoint)
thenResults.push(mockWorkflow)
;(global.fetch as any).mockResolvedValue({
ok: false,
text: () => Promise.resolve('State validation failed'),
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to revert workflow to checkpoint')
})
it('should handle database errors during checkpoint lookup', async () => {
setAuthenticated()
// Mock database error
thenResults.push(new Error('Database connection failed'))
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to revert to checkpoint')
})
it('should handle database errors during workflow lookup', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-123',
workflowId: 'b8c9d0e1-f2a3-4b45-a6d7-e8f9a0b1c2d3',
userId: 'user-123',
workflowState: { blocks: {}, edges: [] },
}
thenResults.push(mockCheckpoint) // Checkpoint found
thenResults.push(new Error('Database error during workflow lookup')) // Workflow lookup fails
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to revert to checkpoint')
})
it('should handle fetch network errors', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-123',
workflowId: 'c9d0e1f2-a3b4-4c56-a7e8-f9a0b1c2d3e4',
userId: 'user-123',
workflowState: { blocks: {}, edges: [] },
}
const mockWorkflow = {
id: 'c9d0e1f2-a3b4-4c56-a7e8-f9a0b1c2d3e4',
userId: 'user-123',
}
thenResults.push(mockCheckpoint)
thenResults.push(mockWorkflow)
;(global.fetch as any).mockRejectedValue(new Error('Network error'))
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to revert to checkpoint')
})
it('should handle JSON parsing errors in request body', async () => {
setAuthenticated()
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
body: '{invalid-json',
headers: {
'Content-Type': 'application/json',
},
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to revert to checkpoint')
})
it('should forward cookies to state API call', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-123',
workflowId: 'd0e1f2a3-b4c5-4d67-a8f9-a0b1c2d3e4f5',
userId: 'user-123',
workflowState: { blocks: {}, edges: [] },
}
const mockWorkflow = {
id: 'd0e1f2a3-b4c5-4d67-a8f9-a0b1c2d3e4f5',
userId: 'user-123',
}
thenResults.push(mockCheckpoint)
thenResults.push(mockWorkflow)
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: 'session=test-session; auth=token123',
},
body: JSON.stringify({
checkpointId: 'checkpoint-123',
}),
})
await POST(req)
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3000/api/workflows/d0e1f2a3-b4c5-4d67-a8f9-a0b1c2d3e4f5/state',
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Cookie: 'session=test-session; auth=token123',
},
body: expect.any(String),
}
)
})
it('should handle missing cookies gracefully', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-123',
workflowId: 'e1f2a3b4-c5d6-4e78-a9a0-b1c2d3e4f5a6',
userId: 'user-123',
workflowState: { blocks: {}, edges: [] },
}
const mockWorkflow = {
id: 'e1f2a3b4-c5d6-4e78-a9a0-b1c2d3e4f5a6',
userId: 'user-123',
}
thenResults.push(mockCheckpoint)
thenResults.push(mockWorkflow)
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// No Cookie header
},
body: JSON.stringify({
checkpointId: 'checkpoint-123',
}),
})
const response = await POST(req)
expect(response.status).toBe(200)
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3000/api/workflows/e1f2a3b4-c5d6-4e78-a9a0-b1c2d3e4f5a6/state',
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Cookie: '', // Empty string when no cookies
},
body: expect.any(String),
}
)
})
it('should handle complex checkpoint state with all fields', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-complex',
workflowId: 'f2a3b4c5-d6e7-4f89-a0b1-c2d3e4f5a6b7',
userId: 'user-123',
workflowState: {
blocks: {
start: { type: 'start', config: {} },
http: { type: 'http', config: { url: 'https://api.example.com' } },
end: { type: 'end', config: {} },
},
edges: [
{ from: 'start', to: 'http' },
{ from: 'http', to: 'end' },
],
loops: {
loop1: { condition: 'true', iterations: 3 },
},
parallels: {
parallel1: { branches: ['branch1', 'branch2'] },
},
isDeployed: true,
deployedAt: '2024-01-01T10:00:00.000Z',
},
}
const mockWorkflow = {
id: 'f2a3b4c5-d6e7-4f89-a0b1-c2d3e4f5a6b7',
userId: 'user-123',
}
thenResults.push(mockCheckpoint)
thenResults.push(mockWorkflow)
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-complex' }),
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.checkpoint.workflowState).toEqual({
blocks: {
start: { type: 'start', config: {} },
http: { type: 'http', config: { url: 'https://api.example.com' } },
end: { type: 'end', config: {} },
},
edges: [
{ from: 'start', to: 'http' },
{ from: 'http', to: 'end' },
],
loops: {
loop1: { condition: 'true', iterations: 3 },
},
parallels: {
parallel1: { branches: ['branch1', 'branch2'] },
},
isDeployed: true,
deployedAt: '2024-01-01T10:00:00.000Z',
lastSaved: 1640995200000,
})
})
})
})
@@ -0,0 +1,176 @@
import { db } from '@sim/db'
import { workflowCheckpoints, workflow as workflowTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { revertCopilotCheckpointContract } from '@/lib/api/contracts/copilot'
import type { CleanedWorkflowState } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
import {
authenticateCopilotRequestSessionOnly,
createInternalServerErrorResponse,
createNotFoundResponse,
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { isUuidV4 } from '@/executor/constants'
const logger = createLogger('CheckpointRevertAPI')
/**
* POST /api/copilot/checkpoints/revert
* Revert workflow to a specific checkpoint state
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const tracker = createRequestTracker()
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(
revertCopilotCheckpointContract,
request,
{},
{
invalidJson: 'throw',
}
)
if (!parsed.success) return parsed.response
const { checkpointId } = parsed.data.body
logger.info(`[${tracker.requestId}] Reverting to checkpoint ${checkpointId}`)
const checkpoint = await db
.select()
.from(workflowCheckpoints)
.where(and(eq(workflowCheckpoints.id, checkpointId), eq(workflowCheckpoints.userId, userId)))
.then((rows) => rows[0])
if (!checkpoint) {
return createNotFoundResponse('Checkpoint not found or access denied')
}
const chat = await getAccessibleCopilotChatAuth(checkpoint.chatId, userId)
if (!chat) {
return createNotFoundResponse('Checkpoint not found or access denied')
}
const workflowData = await db
.select()
.from(workflowTable)
.where(eq(workflowTable.id, checkpoint.workflowId))
.then((rows) => rows[0])
if (!workflowData) {
return createNotFoundResponse('Workflow not found')
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: checkpoint.workflowId,
userId,
action: 'write',
})
if (!authorization.allowed) {
return createUnauthorizedResponse()
}
const checkpointState: Record<string, unknown> =
checkpoint.workflowState && typeof checkpoint.workflowState === 'object'
? (checkpoint.workflowState as Record<string, unknown>)
: {}
const rawBlocks = checkpointState.blocks
const rawEdges = checkpointState.edges
const rawLoops = checkpointState.loops
const rawParallels = checkpointState.parallels
const rawDeployedAt = checkpointState.deployedAt
const parsedDeployedAt =
rawDeployedAt === null || rawDeployedAt === undefined
? null
: new Date(rawDeployedAt as string | number | Date)
const cleanedState: CleanedWorkflowState = {
blocks: (rawBlocks ?? {}) as Record<string, unknown>,
edges: (rawEdges ?? []) as unknown[],
loops: (rawLoops ?? {}) as Record<string, unknown>,
parallels: (rawParallels ?? {}) as Record<string, unknown>,
isDeployed: Boolean(checkpointState.isDeployed),
lastSaved: Date.now(),
...(parsedDeployedAt && !Number.isNaN(parsedDeployedAt.getTime())
? { deployedAt: parsedDeployedAt }
: {}),
}
logger.info(`[${tracker.requestId}] Applying cleaned checkpoint state`, {
blocksCount: Object.keys(cleanedState.blocks).length,
edgesCount: cleanedState.edges.length,
hasDeployedAt: !!cleanedState.deployedAt,
isDeployed: cleanedState.isDeployed,
})
if (!isUuidV4(checkpoint.workflowId)) {
logger.error(`[${tracker.requestId}] Invalid workflow ID format`)
return NextResponse.json({ error: 'Invalid workflow ID format' }, { status: 400 })
}
const stateResponse = await fetch(
`${getInternalApiBaseUrl()}/api/workflows/${checkpoint.workflowId}/state`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Cookie: request.headers.get('Cookie') || '',
},
body: JSON.stringify(cleanedState),
}
)
if (!stateResponse.ok) {
const errorData = await stateResponse.text()
logger.error(`[${tracker.requestId}] Failed to apply checkpoint state: ${errorData}`)
return NextResponse.json(
{ error: 'Failed to revert workflow to checkpoint' },
{ status: 500 }
)
}
const result = await stateResponse.json()
logger.info(
`[${tracker.requestId}] Successfully reverted workflow ${checkpoint.workflowId} to checkpoint ${checkpointId}`
)
// Delete the checkpoint after successfully reverting to it
try {
await db.delete(workflowCheckpoints).where(eq(workflowCheckpoints.id, checkpointId))
logger.info(`[${tracker.requestId}] Deleted checkpoint after reverting`, { checkpointId })
} catch (deleteError) {
logger.warn(`[${tracker.requestId}] Failed to delete checkpoint after revert`, {
checkpointId,
error: deleteError,
})
// Don't fail the request if deletion fails - the revert was successful
}
return NextResponse.json({
success: true,
workflowId: checkpoint.workflowId,
checkpointId,
revertedAt: new Date().toISOString(),
checkpoint: {
id: checkpoint.id,
workflowState: cleanedState,
},
})
} catch (error) {
logger.error(`[${tracker.requestId}] Error reverting to checkpoint:`, error)
return createInternalServerErrorResponse('Failed to revert to checkpoint')
}
})
@@ -0,0 +1,388 @@
/**
* Tests for copilot checkpoints API route
*
* @vitest-environment node
*/
import { authMockFns, workflowAuthzMockFns, workflowsUtilsMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockSelect,
mockFrom,
mockWhere,
mockLimit,
mockOrderBy,
mockInsert,
mockValues,
mockReturning,
mockGetAccessibleCopilotChat,
} = vi.hoisted(() => ({
mockSelect: vi.fn(),
mockFrom: vi.fn(),
mockWhere: vi.fn(),
mockLimit: vi.fn(),
mockOrderBy: vi.fn(),
mockInsert: vi.fn(),
mockValues: vi.fn(),
mockReturning: vi.fn(),
mockGetAccessibleCopilotChat: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
insert: mockInsert,
},
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
desc: vi.fn((field: unknown) => ({ field, type: 'desc' })),
}))
vi.mock('@/lib/copilot/chat/lifecycle', () => ({
getAccessibleCopilotChat: mockGetAccessibleCopilotChat,
getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChat,
}))
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
import { GET, POST } from './route'
function createMockRequest(method: string, body: Record<string, unknown>): NextRequest {
return new NextRequest('http://localhost:3000/api/copilot/checkpoints', {
method,
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
})
}
describe('Copilot Checkpoints API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue(null)
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({
orderBy: mockOrderBy,
limit: mockLimit,
})
mockOrderBy.mockResolvedValue([])
mockLimit.mockResolvedValue([])
mockInsert.mockReturnValue({ values: mockValues })
mockValues.mockReturnValue({ returning: mockReturning })
mockGetAccessibleCopilotChat.mockResolvedValue({
id: 'chat-123',
userId: 'user-123',
workflowId: 'workflow-123',
})
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('POST', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = createMockRequest('POST', {
workflowId: 'workflow-123',
chatId: 'chat-123',
workflowState: '{"blocks": []}',
})
const response = await POST(req)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Unauthorized' })
})
it('should return 400 for invalid request body', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const req = createMockRequest('POST', {
workflowId: 'workflow-123',
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(typeof responseData.error).toBe('string')
})
it('should return 400 when chat not found or unauthorized', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
mockGetAccessibleCopilotChat.mockResolvedValueOnce(null)
const req = createMockRequest('POST', {
workflowId: 'workflow-123',
chatId: 'chat-123',
workflowState: '{"blocks": []}',
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toBe('Chat not found or unauthorized')
})
it('should return 400 for invalid workflow state JSON', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const req = createMockRequest('POST', {
workflowId: 'workflow-123',
chatId: 'chat-123',
workflowState: 'invalid-json',
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toBe('Invalid workflow state JSON')
})
it('should successfully create a checkpoint', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const checkpoint = {
id: 'checkpoint-123',
userId: 'user-123',
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: 'message-123',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
}
mockReturning.mockResolvedValue([checkpoint])
const workflowState = { blocks: [], connections: [] }
const req = createMockRequest('POST', {
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: 'message-123',
workflowState: JSON.stringify(workflowState),
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
checkpoint: {
id: 'checkpoint-123',
userId: 'user-123',
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: 'message-123',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
})
expect(mockInsert).toHaveBeenCalled()
expect(mockValues).toHaveBeenCalledWith({
userId: 'user-123',
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: 'message-123',
workflowState: workflowState,
})
})
it('should create checkpoint without messageId', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const checkpoint = {
id: 'checkpoint-123',
userId: 'user-123',
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: undefined,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
}
mockReturning.mockResolvedValue([checkpoint])
const workflowState = { blocks: [] }
const req = createMockRequest('POST', {
workflowId: 'workflow-123',
chatId: 'chat-123',
workflowState: JSON.stringify(workflowState),
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(responseData.checkpoint.messageId).toBeUndefined()
})
it('should handle database errors during checkpoint creation', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
mockReturning.mockRejectedValue(new Error('Database insert failed'))
const req = createMockRequest('POST', {
workflowId: 'workflow-123',
chatId: 'chat-123',
workflowState: '{"blocks": []}',
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to create checkpoint')
})
it('should handle database errors during chat lookup', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
mockGetAccessibleCopilotChat.mockRejectedValueOnce(new Error('Database query failed'))
const req = createMockRequest('POST', {
workflowId: 'workflow-123',
chatId: 'chat-123',
workflowState: '{"blocks": []}',
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to create checkpoint')
})
})
describe('GET', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123')
const response = await GET(req)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Unauthorized' })
})
it('should return 400 when chatId is missing', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints')
const response = await GET(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toBe('chatId is required')
})
it('should return checkpoints for authenticated user and chat', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const mockCheckpoints = [
{
id: 'checkpoint-1',
userId: 'user-123',
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: 'message-1',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
},
{
id: 'checkpoint-2',
userId: 'user-123',
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: 'message-2',
createdAt: new Date('2024-01-02'),
updatedAt: new Date('2024-01-02'),
},
]
mockOrderBy.mockResolvedValue(mockCheckpoints)
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123')
const response = await GET(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
checkpoints: [
{
id: 'checkpoint-1',
userId: 'user-123',
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: 'message-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'checkpoint-2',
userId: 'user-123',
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: 'message-2',
createdAt: '2024-01-02T00:00:00.000Z',
updatedAt: '2024-01-02T00:00:00.000Z',
},
],
})
expect(mockSelect).toHaveBeenCalled()
expect(mockWhere).toHaveBeenCalled()
expect(mockOrderBy).toHaveBeenCalled()
})
it('should handle database errors when fetching checkpoints', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
mockOrderBy.mockRejectedValue(new Error('Database query failed'))
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123')
const response = await GET(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to fetch checkpoints')
})
it('should return empty array when no checkpoints found', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
mockOrderBy.mockResolvedValue([])
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123')
const response = await GET(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
checkpoints: [],
})
})
})
})
@@ -0,0 +1,192 @@
import { db } from '@sim/db'
import { workflowCheckpoints } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
createCopilotCheckpointContract,
listCopilotCheckpointsContract,
} from '@/lib/api/contracts/copilot'
import { getValidationErrorMessage, parseRequest, validationErrorResponse } from '@/lib/api/server'
import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createInternalServerErrorResponse,
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('WorkflowCheckpointsAPI')
/**
* POST /api/copilot/checkpoints
* Create a new checkpoint with JSON workflow state
*/
export const POST = withRouteHandler(async (req: NextRequest) => {
const tracker = createRequestTracker()
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(
createCopilotCheckpointContract,
req,
{},
{
validationErrorResponse: (error) =>
validationErrorResponse(
error,
getValidationErrorMessage(error, 'Invalid checkpoint payload')
),
}
)
if (!parsed.success) return parsed.response
const { workflowId, chatId, messageId, workflowState } = parsed.data.body
logger.info(`[${tracker.requestId}] Creating workflow checkpoint`, {
userId,
workflowId,
chatId,
messageId,
parsedData: { workflowId, chatId, messageId },
messageIdType: typeof messageId,
messageIdExists: !!messageId,
})
// Verify that the chat belongs to the user
const chat = await getAccessibleCopilotChatAuth(chatId, userId)
if (!chat) {
return createBadRequestResponse('Chat not found or unauthorized')
}
if (chat.workflowId !== workflowId) {
return createBadRequestResponse('Chat does not belong to the requested workflow')
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'write',
})
if (!authorization.allowed) {
return createUnauthorizedResponse()
}
// Parse the workflow state to validate it's valid JSON
let parsedWorkflowState
try {
parsedWorkflowState = JSON.parse(workflowState)
} catch (error) {
return createBadRequestResponse('Invalid workflow state JSON')
}
// Create checkpoint with JSON workflow state
const [checkpoint] = await db
.insert(workflowCheckpoints)
.values({
userId,
workflowId,
chatId,
messageId,
workflowState: parsedWorkflowState, // Store as JSON object
})
.returning()
logger.info(`[${tracker.requestId}] Workflow checkpoint created successfully`, {
checkpointId: checkpoint.id,
savedData: {
checkpointId: checkpoint.id,
userId: checkpoint.userId,
workflowId: checkpoint.workflowId,
chatId: checkpoint.chatId,
messageId: checkpoint.messageId,
createdAt: checkpoint.createdAt,
},
})
return NextResponse.json({
success: true,
checkpoint: {
id: checkpoint.id,
userId: checkpoint.userId,
workflowId: checkpoint.workflowId,
chatId: checkpoint.chatId,
messageId: checkpoint.messageId,
createdAt: checkpoint.createdAt,
updatedAt: checkpoint.updatedAt,
},
})
} catch (error) {
logger.error(`[${tracker.requestId}] Failed to create workflow checkpoint:`, error)
return createInternalServerErrorResponse('Failed to create checkpoint')
}
})
/**
* GET /api/copilot/checkpoints?chatId=xxx
* Retrieve workflow checkpoints for a chat
*/
export const GET = withRouteHandler(async (req: NextRequest) => {
const tracker = createRequestTracker()
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(
listCopilotCheckpointsContract,
req,
{},
{
validationErrorResponse: (error) =>
validationErrorResponse(error, getValidationErrorMessage(error)),
}
)
if (!parsed.success) return parsed.response
const { chatId } = parsed.data.query
logger.info(`[${tracker.requestId}] Fetching workflow checkpoints for chat`, {
userId,
chatId,
})
const chat = await getAccessibleCopilotChatAuth(chatId, userId)
if (!chat) {
return createBadRequestResponse('Chat not found or unauthorized')
}
// Fetch checkpoints for this user and chat
const checkpoints = await db
.select({
id: workflowCheckpoints.id,
userId: workflowCheckpoints.userId,
workflowId: workflowCheckpoints.workflowId,
chatId: workflowCheckpoints.chatId,
messageId: workflowCheckpoints.messageId,
createdAt: workflowCheckpoints.createdAt,
updatedAt: workflowCheckpoints.updatedAt,
})
.from(workflowCheckpoints)
.where(and(eq(workflowCheckpoints.chatId, chatId), eq(workflowCheckpoints.userId, userId)))
.orderBy(desc(workflowCheckpoints.createdAt))
logger.info(`[${tracker.requestId}] Retrieved ${checkpoints.length} workflow checkpoints`)
return NextResponse.json({
success: true,
checkpoints,
})
} catch (error) {
logger.error(`[${tracker.requestId}] Failed to fetch workflow checkpoints:`, error)
return createInternalServerErrorResponse('Failed to fetch checkpoints')
}
})
@@ -0,0 +1,224 @@
/**
* @vitest-environment node
*/
import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
getAsyncToolCall,
getRunSegment,
upsertAsyncToolCall,
completeAsyncToolCall,
publishToolConfirmation,
} = vi.hoisted(() => ({
getAsyncToolCall: vi.fn(),
getRunSegment: vi.fn(),
upsertAsyncToolCall: vi.fn(),
completeAsyncToolCall: vi.fn(),
publishToolConfirmation: vi.fn(),
}))
vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)
vi.mock('@/lib/copilot/async-runs/repository', () => ({
getAsyncToolCall,
getRunSegment,
upsertAsyncToolCall,
completeAsyncToolCall,
}))
vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({
publishToolConfirmation,
}))
import { POST } from './route'
describe('Copilot Confirm API Route', () => {
const existingRow = {
toolCallId: 'tool-call-123',
runId: 'run-1',
checkpointId: 'checkpoint-1',
toolName: 'client_tool',
args: { foo: 'bar' },
}
beforeEach(() => {
vi.clearAllMocks()
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
userId: 'user-1',
isAuthenticated: true,
})
getAsyncToolCall.mockResolvedValue(existingRow)
getRunSegment.mockResolvedValue({ id: 'run-1', userId: 'user-1' })
upsertAsyncToolCall.mockResolvedValue(existingRow)
completeAsyncToolCall.mockResolvedValue(existingRow)
})
function createMockPostRequest(body: Record<string, unknown>): NextRequest {
return new NextRequest('http://localhost:3000/api/copilot/confirm', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
})
}
it('returns 401 when the session is unauthenticated', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
userId: null,
isAuthenticated: false,
})
const response = await POST(
createMockPostRequest({
toolCallId: 'tool-call-123',
status: 'success',
})
)
expect(response.status).toBe(401)
expect(await response.json()).toEqual({ error: 'Unauthorized' })
})
it('returns 404 when the tool call row does not exist', async () => {
getAsyncToolCall.mockResolvedValue(null)
const response = await POST(
createMockPostRequest({
toolCallId: 'missing-tool',
status: 'success',
})
)
expect(response.status).toBe(404)
expect(await response.json()).toEqual({ error: 'Tool call not found' })
})
it('returns 403 when the tool call belongs to a different user', async () => {
getRunSegment.mockResolvedValue({ id: 'run-1', userId: 'user-2' })
const response = await POST(
createMockPostRequest({
toolCallId: 'tool-call-123',
status: 'success',
})
)
expect(response.status).toBe(403)
expect(await response.json()).toEqual({ error: 'Forbidden' })
})
it('persists terminal confirmations through completeAsyncToolCall', async () => {
const response = await POST(
createMockPostRequest({
toolCallId: 'tool-call-123',
status: 'success',
message: 'Tool executed successfully',
data: { ok: true },
})
)
expect(response.status).toBe(200)
expect(completeAsyncToolCall).toHaveBeenCalledWith({
toolCallId: 'tool-call-123',
status: 'completed',
result: { ok: true },
error: null,
})
expect(upsertAsyncToolCall).not.toHaveBeenCalled()
expect(publishToolConfirmation).toHaveBeenCalledWith(
expect.objectContaining({
toolCallId: 'tool-call-123',
status: 'success',
data: { ok: true },
})
)
})
it('accepts primitive terminal confirmation data', async () => {
const response = await POST(
createMockPostRequest({
toolCallId: 'tool-call-123',
status: 'success',
message: 'Tool executed successfully',
data: 'done',
})
)
expect(response.status).toBe(200)
expect(completeAsyncToolCall).toHaveBeenCalledWith({
toolCallId: 'tool-call-123',
status: 'completed',
result: 'done',
error: null,
})
expect(publishToolConfirmation).toHaveBeenCalledWith(
expect.objectContaining({
toolCallId: 'tool-call-123',
status: 'success',
data: 'done',
})
)
})
it('keeps background as a live pending detach confirmation', async () => {
const response = await POST(
createMockPostRequest({
toolCallId: 'tool-call-123',
status: 'background',
})
)
expect(response.status).toBe(200)
expect(upsertAsyncToolCall).not.toHaveBeenCalled()
expect(completeAsyncToolCall).not.toHaveBeenCalled()
expect(publishToolConfirmation).toHaveBeenCalledWith(
expect.objectContaining({
toolCallId: 'tool-call-123',
status: 'background',
})
)
})
it('rejects unsupported accepted and rejected confirmation statuses', async () => {
const acceptedResponse = await POST(
createMockPostRequest({
toolCallId: 'tool-call-123',
status: 'accepted',
})
)
expect(acceptedResponse.status).toBe(400)
expect(await acceptedResponse.json()).toMatchObject({
error: 'Invalid request data: Invalid notification status',
details: expect.any(Array),
})
const rejectedResponse = await POST(
createMockPostRequest({
toolCallId: 'tool-call-123',
status: 'rejected',
})
)
expect(rejectedResponse.status).toBe(400)
expect(await rejectedResponse.json()).toMatchObject({
error: 'Invalid request data: Invalid notification status',
details: expect.any(Array),
})
})
it('returns 500 when the durable write fails before publish', async () => {
completeAsyncToolCall.mockRejectedValueOnce(new Error('db down'))
const response = await POST(
createMockPostRequest({
toolCallId: 'tool-call-123',
status: 'success',
})
)
expect(response.status).toBe(500)
expect(publishToolConfirmation).not.toHaveBeenCalled()
})
})
+217
View File
@@ -0,0 +1,217 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { copilotConfirmContract } from '@/lib/api/contracts/copilot'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import {
ASYNC_TOOL_CONFIRMATION_STATUS,
ASYNC_TOOL_STATUS,
type AsyncCompletionData,
type AsyncConfirmationStatus,
} from '@/lib/copilot/async-runs/lifecycle'
import {
completeAsyncToolCall,
getAsyncToolCall,
getRunSegment,
upsertAsyncToolCall,
} from '@/lib/copilot/async-runs/repository'
import { CopilotConfirmOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { publishToolConfirmation } from '@/lib/copilot/persistence/tool-confirm'
import {
authenticateCopilotRequestSessionOnly,
createInternalServerErrorResponse,
createNotFoundResponse,
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CopilotConfirmAPI')
/**
* Persist terminal durable tool status, then publish a wakeup event.
*
* `background` remains a live detach signal in the current browser workflow
* runtime, so it should not rewrite the durable async row.
*/
async function updateToolCallStatus(
existing: NonNullable<Awaited<ReturnType<typeof getAsyncToolCall>>>,
status: AsyncConfirmationStatus,
message?: string,
data?: AsyncCompletionData
): Promise<boolean> {
const toolCallId = existing.toolCallId
if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) {
publishToolConfirmation({
toolCallId,
status,
message: message || undefined,
timestamp: new Date().toISOString(),
data,
})
return true
}
const durableStatus =
status === 'success'
? ASYNC_TOOL_STATUS.completed
: status === 'cancelled'
? ASYNC_TOOL_STATUS.cancelled
: status === 'error'
? ASYNC_TOOL_STATUS.failed
: ASYNC_TOOL_STATUS.pending
try {
if (
durableStatus === ASYNC_TOOL_STATUS.completed ||
durableStatus === ASYNC_TOOL_STATUS.failed ||
durableStatus === ASYNC_TOOL_STATUS.cancelled
) {
await completeAsyncToolCall({
toolCallId,
status: durableStatus,
result: data ?? null,
error: status === 'success' ? null : message || status,
})
} else if (existing.runId) {
await upsertAsyncToolCall({
runId: existing.runId,
checkpointId: existing.checkpointId ?? null,
toolCallId,
toolName: existing.toolName || 'client_tool',
args: (existing.args as Record<string, unknown> | null) ?? {},
status: durableStatus,
})
}
publishToolConfirmation({
toolCallId,
status,
message: message || undefined,
timestamp: new Date().toISOString(),
data,
})
return true
} catch (error) {
logger.error('Failed to update tool call status', {
toolCallId,
status,
error: toError(error).message,
})
return false
}
}
// POST /api/copilot/confirm — delivery path for client-executed tool
// results. Correlate via `toolCallId` when the awaiting chat stream
// stalls.
export const POST = withRouteHandler((req: NextRequest) => {
const tracker = createRequestTracker()
return withIncomingGoSpan(
req.headers,
TraceSpan.CopilotConfirmToolResult,
{ [TraceAttr.RequestId]: tracker.requestId },
async (span) => {
try {
const { userId: authenticatedUserId, isAuthenticated } =
await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !authenticatedUserId) {
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.Unauthorized)
return createUnauthorizedResponse()
}
const parsed = await parseRequest(
copilotConfirmContract,
req,
{},
{
validationErrorResponse: (error) => {
span.setAttribute(
TraceAttr.CopilotConfirmOutcome,
CopilotConfirmOutcome.ValidationError
)
return validationErrorResponse(
error,
`Invalid request data: ${error.issues.map((e) => e.message).join(', ')}`
)
},
}
)
if (!parsed.success) return parsed.response
const { toolCallId, status, message, data } = parsed.data.body
span.setAttributes({
[TraceAttr.ToolCallId]: toolCallId,
[TraceAttr.ToolConfirmationStatus]: status,
[TraceAttr.UserId]: authenticatedUserId,
})
const existing = await getAsyncToolCall(toolCallId).catch((err) => {
logger.warn('Failed to fetch async tool call', {
toolCallId,
error: getErrorMessage(err),
})
return null
})
if (!existing) {
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.ToolCallNotFound)
return createNotFoundResponse('Tool call not found')
}
if (existing.toolName) span.setAttribute(TraceAttr.ToolName, existing.toolName)
if (existing.runId) span.setAttribute(TraceAttr.RunId, existing.runId)
const run = await getRunSegment(existing.runId).catch((err) => {
logger.warn('Failed to fetch run segment', {
runId: existing.runId,
error: getErrorMessage(err),
})
return null
})
if (!run) {
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.RunNotFound)
return createNotFoundResponse('Tool call run not found')
}
if (run.userId !== authenticatedUserId) {
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.Forbidden)
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const updated = await updateToolCallStatus(existing, status, message, data)
if (!updated) {
logger.error(`[${tracker.requestId}] Failed to update tool call status`, {
userId: authenticatedUserId,
toolCallId,
status,
internalStatus: status,
message,
})
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.UpdateFailed)
// DB write failed — 500, not 400. 400 is a client-shape error.
return createInternalServerErrorResponse('Failed to update tool call status')
}
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.Delivered)
return NextResponse.json({
success: true,
message: message || `Tool call ${toolCallId} has been ${status.toLowerCase()}`,
toolCallId,
status,
})
} catch (error) {
const duration = tracker.getDuration()
logger.error(`[${tracker.requestId}] Unexpected error:`, {
duration,
error: getErrorMessage(error, 'Unknown error'),
stack: error instanceof Error ? error.stack : undefined,
})
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.InternalError)
return createInternalServerErrorResponse(getErrorMessage(error, 'Internal server error'))
}
}
)
})
@@ -0,0 +1,35 @@
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { copilotCredentialsContract } from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http'
import { routeExecution } from '@/lib/copilot/tools/server/router'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
/**
* GET /api/copilot/credentials
* Returns connected OAuth credentials for the authenticated user.
* Used by the copilot store for credential masking.
*/
export const GET = withRouteHandler(async (req: NextRequest) => {
const parsed = await parseRequest(copilotCredentialsContract, req, {})
if (!parsed.success) return parsed.response
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const result = await routeExecution('get_credentials', {}, { userId })
return NextResponse.json({ success: true, result })
} catch (error) {
return NextResponse.json(
{
success: false,
error: getErrorMessage(error, 'Failed to load credentials'),
},
{ status: 500 }
)
}
})
@@ -0,0 +1,405 @@
/**
* Tests for copilot feedback API route
*
* @vitest-environment node
*/
import {
copilotHttpMock,
copilotHttpMockFns,
dbChainMock,
dbChainMockFns,
resetDbChainMock,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)
import { GET, POST } from '@/app/api/copilot/feedback/route'
function createMockRequest(method: string, body: Record<string, unknown>): NextRequest {
return new NextRequest('http://localhost:3000/api/copilot/feedback', {
method,
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
})
}
describe('Copilot Feedback API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('POST', () => {
it('should return 401 when user is not authenticated', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: null,
isAuthenticated: false,
})
const req = createMockRequest('POST', {
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'How do I create a workflow?',
agentResponse: 'You can create a workflow by...',
isPositiveFeedback: true,
})
const response = await POST(req)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Unauthorized' })
})
it('should successfully submit positive feedback', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const feedbackRecord = {
feedbackId: 'feedback-123',
userId: 'user-123',
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'How do I create a workflow?',
agentResponse: 'You can create a workflow by...',
isPositive: true,
feedback: null,
workflowYaml: null,
createdAt: new Date('2024-01-01'),
}
dbChainMockFns.returning.mockResolvedValueOnce([feedbackRecord])
const req = createMockRequest('POST', {
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'How do I create a workflow?',
agentResponse: 'You can create a workflow by...',
isPositiveFeedback: true,
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(responseData.feedbackId).toBe('feedback-123')
expect(responseData.message).toBe('Feedback submitted successfully')
})
it('should successfully submit negative feedback with text', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const feedbackRecord = {
feedbackId: 'feedback-456',
userId: 'user-123',
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'How do I deploy?',
agentResponse: 'Here is how to deploy...',
isPositive: false,
feedback: 'The response was not helpful',
workflowYaml: null,
createdAt: new Date('2024-01-01'),
}
dbChainMockFns.returning.mockResolvedValueOnce([feedbackRecord])
const req = createMockRequest('POST', {
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'How do I deploy?',
agentResponse: 'Here is how to deploy...',
isPositiveFeedback: false,
feedback: 'The response was not helpful',
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(responseData.feedbackId).toBe('feedback-456')
})
it('should successfully submit feedback with workflow YAML', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const workflowYaml = `
blocks:
- id: starter
type: starter
- id: agent
type: agent
edges:
- source: starter
target: agent
`
const feedbackRecord = {
feedbackId: 'feedback-789',
userId: 'user-123',
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'Build a simple agent workflow',
agentResponse: 'I created a workflow for you.',
isPositive: true,
feedback: null,
workflowYaml: workflowYaml,
createdAt: new Date('2024-01-01'),
}
dbChainMockFns.returning.mockResolvedValueOnce([feedbackRecord])
const req = createMockRequest('POST', {
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'Build a simple agent workflow',
agentResponse: 'I created a workflow for you.',
isPositiveFeedback: true,
workflowYaml: workflowYaml,
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(dbChainMockFns.values).toHaveBeenCalledWith(
expect.objectContaining({
workflowYaml: workflowYaml,
})
)
})
it('should return 400 for invalid chatId format', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const req = createMockRequest('POST', {
chatId: 'not-a-uuid',
userQuery: 'How do I create a workflow?',
agentResponse: 'You can create a workflow by...',
isPositiveFeedback: true,
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toContain('Invalid request data')
})
it('should return 400 for empty userQuery', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const req = createMockRequest('POST', {
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: '',
agentResponse: 'You can create a workflow by...',
isPositiveFeedback: true,
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toContain('Invalid request data')
})
it('should return 400 for empty agentResponse', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const req = createMockRequest('POST', {
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'How do I create a workflow?',
agentResponse: '',
isPositiveFeedback: true,
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toContain('Invalid request data')
})
it('should return 400 for missing isPositiveFeedback', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const req = createMockRequest('POST', {
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'How do I create a workflow?',
agentResponse: 'You can create a workflow by...',
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toContain('Invalid request data')
})
it('should handle database errors gracefully', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
dbChainMockFns.returning.mockRejectedValueOnce(new Error('Database connection failed'))
const req = createMockRequest('POST', {
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'How do I create a workflow?',
agentResponse: 'You can create a workflow by...',
isPositiveFeedback: true,
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to submit feedback')
})
it('should handle JSON parsing errors in request body', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const req = new NextRequest('http://localhost:3000/api/copilot/feedback', {
method: 'POST',
body: '{invalid-json',
headers: {
'Content-Type': 'application/json',
},
})
const response = await POST(req)
expect(response.status).toBe(500)
})
})
describe('GET', () => {
it('should return 401 when user is not authenticated', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: null,
isAuthenticated: false,
})
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Unauthorized' })
})
it('should return empty feedback array when no feedback exists', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
dbChainMockFns.where.mockResolvedValueOnce([])
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(responseData.feedback).toEqual([])
})
it('should only return feedback records for the authenticated user', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const mockFeedback = [
{
feedbackId: 'feedback-1',
userId: 'user-123',
chatId: 'chat-1',
userQuery: 'Query 1',
agentResponse: 'Response 1',
isPositive: true,
feedback: null,
workflowYaml: null,
createdAt: new Date('2024-01-01'),
},
]
dbChainMockFns.where.mockResolvedValueOnce(mockFeedback)
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(responseData.feedback).toHaveLength(1)
expect(responseData.feedback[0].feedbackId).toBe('feedback-1')
expect(responseData.feedback[0].userId).toBe('user-123')
const { eq } = await import('drizzle-orm')
expect(dbChainMockFns.where).toHaveBeenCalled()
expect(eq).toHaveBeenCalledWith('userId', 'user-123')
})
it('should handle database errors gracefully', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
dbChainMockFns.where.mockRejectedValueOnce(new Error('Database connection failed'))
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to retrieve feedback')
})
it('should return metadata with response', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
dbChainMockFns.where.mockResolvedValueOnce([])
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.metadata).toBeDefined()
expect(responseData.metadata.requestId).toBeDefined()
expect(responseData.metadata.duration).toBeDefined()
})
})
})
+162
View File
@@ -0,0 +1,162 @@
import { db } from '@sim/db'
import { copilotFeedback } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { submitCopilotFeedbackContract } from '@/lib/api/contracts'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import {
authenticateCopilotRequestSessionOnly,
createInternalServerErrorResponse,
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('CopilotFeedbackAPI')
/**
* POST /api/copilot/feedback
* Submit feedback for a copilot interaction
*/
export const POST = withRouteHandler(async (req: NextRequest) => {
const tracker = createRequestTracker()
try {
// Authenticate user using the same pattern as other copilot routes
const { userId: authenticatedUserId, isAuthenticated } =
await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !authenticatedUserId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(
submitCopilotFeedbackContract,
req,
{},
{
invalidJson: 'throw',
validationErrorResponse: (error) => {
logger.error(`[${tracker.requestId}] Validation error:`, {
duration: tracker.getDuration(),
errors: error.issues,
})
return validationErrorResponse(error, 'Invalid request data')
},
}
)
if (!parsed.success) return parsed.response
const { chatId, userQuery, agentResponse, isPositiveFeedback, feedback, workflowYaml } =
parsed.data.body
logger.info(`[${tracker.requestId}] Processing copilot feedback submission`, {
userId: authenticatedUserId,
chatId,
isPositiveFeedback,
userQueryLength: userQuery.length,
agentResponseLength: agentResponse.length,
hasFeedback: !!feedback,
hasWorkflowYaml: !!workflowYaml,
workflowYamlLength: workflowYaml?.length || 0,
})
// Insert feedback into the database
const [feedbackRecord] = await db
.insert(copilotFeedback)
.values({
userId: authenticatedUserId,
chatId,
userQuery,
agentResponse,
isPositive: isPositiveFeedback,
feedback: feedback || null,
workflowYaml: workflowYaml || null,
})
.returning()
logger.info(`[${tracker.requestId}] Successfully saved copilot feedback`, {
feedbackId: feedbackRecord.feedbackId,
userId: authenticatedUserId,
isPositive: isPositiveFeedback,
duration: tracker.getDuration(),
})
captureServerEvent(authenticatedUserId, 'copilot_feedback_submitted', {
is_positive: isPositiveFeedback,
has_text_feedback: !!feedback,
has_workflow_yaml: !!workflowYaml,
})
return NextResponse.json({
success: true,
feedbackId: feedbackRecord.feedbackId,
message: 'Feedback submitted successfully',
metadata: {
requestId: tracker.requestId,
duration: tracker.getDuration(),
},
})
} catch (error) {
const duration = tracker.getDuration()
logger.error(`[${tracker.requestId}] Error submitting copilot feedback:`, {
duration,
error: getErrorMessage(error, 'Unknown error'),
stack: error instanceof Error ? error.stack : undefined,
})
return createInternalServerErrorResponse('Failed to submit feedback')
}
})
/**
* GET /api/copilot/feedback
* Get feedback records for the authenticated user
*/
export const GET = withRouteHandler(async (req: NextRequest) => {
const tracker = createRequestTracker()
try {
// Authenticate user
const { userId: authenticatedUserId, isAuthenticated } =
await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !authenticatedUserId) {
return createUnauthorizedResponse()
}
// Get feedback records for the authenticated user only
const feedbackRecords = await db
.select({
feedbackId: copilotFeedback.feedbackId,
userId: copilotFeedback.userId,
chatId: copilotFeedback.chatId,
userQuery: copilotFeedback.userQuery,
agentResponse: copilotFeedback.agentResponse,
isPositive: copilotFeedback.isPositive,
feedback: copilotFeedback.feedback,
workflowYaml: copilotFeedback.workflowYaml,
createdAt: copilotFeedback.createdAt,
})
.from(copilotFeedback)
.where(eq(copilotFeedback.userId, authenticatedUserId))
logger.info(`[${tracker.requestId}] Retrieved ${feedbackRecords.length} feedback records`)
return NextResponse.json({
success: true,
feedback: feedbackRecords,
metadata: {
requestId: tracker.requestId,
duration: tracker.getDuration(),
},
})
} catch (error) {
logger.error(`[${tracker.requestId}] Error retrieving copilot feedback:`, error)
return createInternalServerErrorResponse('Failed to retrieve feedback')
}
})
@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest'
describe('copilot methods route placeholder', () => {
it('loads test suite', () => {
expect(true).toBe(true)
})
})
+100
View File
@@ -0,0 +1,100 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { copilotModelsContract } from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http'
import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
interface AvailableModel {
id: string
friendlyName: string
provider: string
}
const logger = createLogger('CopilotModelsAPI')
interface RawAvailableModel {
id: string
friendlyName?: string
displayName?: string
provider?: string
}
function isRawAvailableModel(item: unknown): item is RawAvailableModel {
return (
typeof item === 'object' &&
item !== null &&
'id' in item &&
typeof (item as { id: unknown }).id === 'string'
)
}
export const GET = withRouteHandler(async (req: NextRequest) => {
const parsed = await parseRequest(copilotModelsContract, req, {})
if (!parsed.success) return parsed.response
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (env.COPILOT_API_KEY) {
headers['x-api-key'] = env.COPILOT_API_KEY
}
try {
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const response = await fetchGo(`${mothershipBaseURL}/api/get-available-models`, {
method: 'GET',
headers,
cache: 'no-store',
spanName: 'sim → go /api/get-available-models',
operation: 'get_available_models',
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
logger.warn('Failed to fetch available models from copilot backend', {
status: response.status,
})
return NextResponse.json(
{
success: false,
error: payload?.error || 'Failed to fetch available models',
models: [],
},
{ status: response.status }
)
}
const rawModels = Array.isArray(payload?.models) ? payload.models : []
const models: AvailableModel[] = rawModels
.filter((item: unknown): item is RawAvailableModel => isRawAvailableModel(item))
.map((item: RawAvailableModel) => ({
id: item.id,
friendlyName: item.friendlyName || item.displayName || item.id,
provider: item.provider || 'unknown',
}))
return NextResponse.json({ success: true, models })
} catch (error) {
logger.error('Error fetching available models', {
error: toError(error).message,
})
return NextResponse.json(
{
success: false,
error: 'Failed to fetch available models',
models: [],
},
{ status: 500 }
)
}
})
@@ -0,0 +1,82 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { copilotTrainingExampleContract } from '@/lib/api/contracts/copilot'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/copilot/request/http'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CopilotTrainingExamplesAPI')
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const POST = withRouteHandler(async (request: NextRequest) => {
const auth = checkInternalApiKey(request)
if (!auth.success) {
return createUnauthorizedResponse()
}
const baseUrl = env.AGENT_INDEXER_URL
if (!baseUrl) {
logger.error('Missing AGENT_INDEXER_URL environment variable')
return NextResponse.json({ error: 'Missing AGENT_INDEXER_URL env' }, { status: 500 })
}
const apiKey = env.AGENT_INDEXER_API_KEY
if (!apiKey) {
logger.error('Missing AGENT_INDEXER_API_KEY environment variable')
return NextResponse.json({ error: 'Missing AGENT_INDEXER_API_KEY env' }, { status: 500 })
}
try {
const parsed = await parseRequest(
copilotTrainingExampleContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn('Invalid training example format', { errors: error.issues })
return validationErrorResponse(error, 'Invalid training example format')
},
}
)
if (!parsed.success) return parsed.response
const validatedData = parsed.data.body
logger.info('Sending workflow example to agent indexer', {
hasJsonField: typeof validatedData.json === 'string',
title: validatedData.title,
})
const upstream = await fetch(`${baseUrl}/examples/add`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
},
body: JSON.stringify(validatedData),
})
if (!upstream.ok) {
const errorText = await upstream.text()
logger.error('Agent indexer rejected the example', {
status: upstream.status,
error: errorText,
})
return NextResponse.json({ error: errorText }, { status: upstream.status })
}
const data = await upstream.json()
logger.info('Successfully sent workflow example to agent indexer')
return NextResponse.json(data, {
headers: { 'content-type': 'application/json' },
})
} catch (err) {
const errorMessage = getErrorMessage(err, 'Failed to add example')
logger.error('Failed to send workflow example', { error: err })
return NextResponse.json({ error: errorMessage }, { status: 502 })
}
})
@@ -0,0 +1,94 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { copilotTrainingDataContract } from '@/lib/api/contracts/copilot'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/copilot/request/http'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CopilotTrainingAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
const auth = checkInternalApiKey(request)
if (!auth.success) {
return createUnauthorizedResponse()
}
try {
const baseUrl = env.AGENT_INDEXER_URL
if (!baseUrl) {
logger.error('Missing AGENT_INDEXER_URL environment variable')
return NextResponse.json({ error: 'Agent indexer not configured' }, { status: 500 })
}
const apiKey = env.AGENT_INDEXER_API_KEY
if (!apiKey) {
logger.error('Missing AGENT_INDEXER_API_KEY environment variable')
return NextResponse.json(
{ error: 'Agent indexer authentication not configured' },
{ status: 500 }
)
}
const parsed = await parseRequest(
copilotTrainingDataContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn('Invalid training data format', { errors: error.issues })
return validationErrorResponse(error, 'Invalid training data format')
},
}
)
if (!parsed.success) return parsed.response
const { title, prompt, input, output, operations } = parsed.data.body
logger.info('Sending training data to agent indexer', {
title,
operationsCount: operations.length,
})
const upstreamUrl = `${baseUrl}/operations/add`
const upstreamResponse = await fetch(upstreamUrl, {
method: 'POST',
headers: {
'x-api-key': apiKey,
'content-type': 'application/json',
},
body: JSON.stringify({
title,
prompt,
input,
output,
operations: { operations },
}),
})
const responseData = await upstreamResponse.json()
if (!upstreamResponse.ok) {
logger.error('Agent indexer rejected the data', {
status: upstreamResponse.status,
response: responseData,
})
return NextResponse.json(responseData, { status: upstreamResponse.status })
}
logger.info('Successfully sent training data to agent indexer', {
title,
response: responseData,
})
return NextResponse.json(responseData)
} catch (error) {
logger.error('Failed to send training data to agent indexer', { error })
return NextResponse.json(
{
error: getErrorMessage(error, 'Failed to send training data'),
},
{ status: 502 }
)
}
})
@@ -0,0 +1,406 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { credential, credentialMember, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
upsertWorkspaceCredentialMemberContract,
type WorkspaceCredentialMember,
} from '@/lib/api/contracts/credentials'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { deriveCredentialAdmin, isSharedCredentialType } from '@/lib/credentials/access'
import { captureServerEvent } from '@/lib/posthog/server'
import {
getUserEntityPermissions,
getUsersWithPermissions,
} from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CredentialMembersAPI')
interface RouteContext {
params: Promise<{ id: string }>
}
async function requireCredentialAdmin(credentialId: string, userId: string) {
const [cred] = await db
.select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!cred) return null
const perm = await getUserEntityPermissions(userId, 'workspace', cred.workspaceId)
if (perm === null) return null
const [membership] = await db
.select({ role: credentialMember.role, status: credentialMember.status })
.from(credentialMember)
.where(
and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId))
)
.limit(1)
const isAdmin = deriveCredentialAdmin({
credentialType: cred.type,
memberRole: membership?.status === 'active' ? membership.role : null,
workspaceCanAdmin: perm === 'admin',
})
if (!isAdmin) {
return null
}
return { credentialType: cred.type, workspaceId: cred.workspaceId }
}
export const GET = withRouteHandler(async (_request: NextRequest, context: RouteContext) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const [cred] = await db
.select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!cred) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const callerPerm = await getUserEntityPermissions(
session.user.id,
'workspace',
cred.workspaceId
)
if (callerPerm === null) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const explicitMembers = await db
.select({
id: credentialMember.id,
userId: credentialMember.userId,
role: credentialMember.role,
status: credentialMember.status,
joinedAt: credentialMember.joinedAt,
userName: user.name,
userEmail: user.email,
})
.from(credentialMember)
.innerJoin(user, eq(credentialMember.userId, user.id))
.where(eq(credentialMember.credentialId, credentialId))
const byUser = new Map<string, WorkspaceCredentialMember>(
explicitMembers.map((m) => [
m.userId,
{
id: m.id,
userId: m.userId,
role: m.role,
status: m.status,
joinedAt: m.joinedAt ? m.joinedAt.toISOString() : null,
userName: m.userName,
userEmail: m.userEmail,
roleSource: 'explicit' as const,
},
])
)
if (isSharedCredentialType(cred.type)) {
const workspaceMembers = await getUsersWithPermissions(cred.workspaceId)
for (const wsMember of workspaceMembers) {
if (wsMember.permissionType !== 'admin') continue
const existing = byUser.get(wsMember.userId)
if (existing) {
existing.role = 'admin'
existing.status = 'active'
existing.roleSource = 'workspace-admin'
} else {
byUser.set(wsMember.userId, {
id: `workspace-admin-${wsMember.userId}`,
userId: wsMember.userId,
role: 'admin',
status: 'active',
joinedAt: null,
userName: wsMember.name,
userEmail: wsMember.email,
roleSource: 'workspace-admin',
})
}
}
}
const members = Array.from(byUser.values())
return NextResponse.json({ members })
} catch (error) {
logger.error('Failed to fetch credential members', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const admin = await requireCredentialAdmin(credentialId, session.user.id)
if (!admin) {
logger.warn('Credential member share denied', {
credentialId,
actorId: session.user.id,
reason: 'not-admin',
})
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
}
if (!isSharedCredentialType(admin.credentialType)) {
logger.warn('Credential member share denied', {
credentialId,
actorId: session.user.id,
reason: 'env_personal-cannot-be-shared',
})
return NextResponse.json({ error: 'Personal secrets cannot be shared' }, { status: 400 })
}
const parsed = await parseRequest(upsertWorkspaceCredentialMemberContract, request, context)
if (!parsed.success) return parsed.response
const { userId, role } = parsed.data.body
const targetWorkspacePerm = await getUserEntityPermissions(
userId,
'workspace',
admin.workspaceId
)
if (targetWorkspacePerm === 'admin' && role !== 'admin') {
return NextResponse.json(
{ error: 'Workspace admins are automatically credential admins and cannot be demoted' },
{ status: 400 }
)
}
const now = new Date()
const [existing] = await db
.select({ id: credentialMember.id, status: credentialMember.status })
.from(credentialMember)
.where(
and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId))
)
.limit(1)
if (existing) {
const result = await db.transaction(async (tx) => {
const [current] = await tx
.select({ role: credentialMember.role, status: credentialMember.status })
.from(credentialMember)
.where(eq(credentialMember.id, existing.id))
.limit(1)
.for('update')
if (
!isSharedCredentialType(admin.credentialType) &&
current?.role === 'admin' &&
current?.status === 'active' &&
role !== 'admin'
) {
const activeAdmins = await tx
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.role, 'admin'),
eq(credentialMember.status, 'active')
)
)
.for('update')
if (activeAdmins.length <= 1) return { ok: false as const }
}
await tx
.update(credentialMember)
.set({ role, status: 'active', updatedAt: now })
.where(eq(credentialMember.id, existing.id))
return { ok: true as const, fromRole: current?.role }
})
if (!result.ok) {
return NextResponse.json({ error: 'Cannot demote the last admin' }, { status: 400 })
}
recordAudit({
workspaceId: admin.workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CREDENTIAL_MEMBER_ROLE_CHANGED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: credentialId,
description: `Changed credential member role to "${role}"`,
metadata: { targetUserId: userId, fromRole: result.fromRole, toRole: role },
request,
})
return NextResponse.json({ success: true })
}
await db.insert(credentialMember).values({
id: generateId(),
credentialId,
userId,
role,
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
captureServerEvent(session.user.id, 'credential_shared', {
credential_type: admin.credentialType,
role,
workspace_id: admin.workspaceId,
})
recordAudit({
workspaceId: admin.workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CREDENTIAL_MEMBER_ADDED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: credentialId,
description: `Shared credential with member as "${role}"`,
metadata: { targetUserId: userId, role },
request,
})
return NextResponse.json({ success: true }, { status: 201 })
} catch (error) {
logger.error('Failed to add credential member', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const targetUserId = new URL(request.url).searchParams.get('userId')
if (!targetUserId) {
return NextResponse.json({ error: 'userId query parameter required' }, { status: 400 })
}
const admin = await requireCredentialAdmin(credentialId, session.user.id)
if (!admin) {
logger.warn('Credential member removal denied', {
credentialId,
actorId: session.user.id,
reason: 'not-admin',
})
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
}
const [target] = await db
.select({
id: credentialMember.id,
role: credentialMember.role,
})
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.userId, targetUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!target) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
if (isSharedCredentialType(admin.credentialType)) {
const targetWorkspacePerm = await getUserEntityPermissions(
targetUserId,
'workspace',
admin.workspaceId
)
if (targetWorkspacePerm === 'admin') {
return NextResponse.json(
{ error: 'Workspace admins are automatically credential admins and cannot be removed' },
{ status: 400 }
)
}
}
const revoked = await db.transaction(async (tx) => {
if (!isSharedCredentialType(admin.credentialType) && target.role === 'admin') {
const activeAdmins = await tx
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.role, 'admin'),
eq(credentialMember.status, 'active')
)
)
.for('update')
if (activeAdmins.length <= 1) {
return false
}
}
await tx
.update(credentialMember)
.set({ status: 'revoked', updatedAt: new Date() })
.where(eq(credentialMember.id, target.id))
return true
})
if (!revoked) {
return NextResponse.json({ error: 'Cannot remove the last admin' }, { status: 400 })
}
captureServerEvent(session.user.id, 'credential_unshared', {
credential_type: admin.credentialType,
workspace_id: admin.workspaceId,
})
recordAudit({
workspaceId: admin.workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CREDENTIAL_MEMBER_REMOVED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: credentialId,
description: 'Removed credential member',
metadata: { targetUserId },
request,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Failed to remove credential member', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
+155
View File
@@ -0,0 +1,155 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { updateWorkspaceCredentialContract } from '@/lib/api/contracts/credentials'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { type CredentialActorContext, getCredentialActorContext } from '@/lib/credentials/access'
import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration'
const logger = createLogger('CredentialByIdAPI')
function formatCredentialResponse(access: CredentialActorContext) {
const cred = access.credential
if (!cred) return null
return {
id: cred.id,
workspaceId: cred.workspaceId,
type: cred.type,
displayName: cred.displayName,
description: cred.description,
providerId: cred.providerId,
accountId: cred.accountId,
envKey: cred.envKey,
envOwnerUserId: cred.envOwnerUserId,
createdBy: cred.createdBy,
createdAt: cred.createdAt,
updatedAt: cred.updatedAt,
role: access.isAdmin ? 'admin' : (access.member?.role ?? null),
status: access.member?.status ?? (access.isAdmin ? 'active' : null),
}
}
export const GET = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
return NextResponse.json({ credential: formatCredentialResponse(access) }, { status: 200 })
} catch (error) {
logger.error('Failed to fetch credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
export const PUT = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const parsed = await parseRequest(updateWorkspaceCredentialContract, request, context, {
validationErrorResponse: (error) =>
NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
})
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const body = parsed.data.body
const result = await performUpdateCredential({
credentialId: id,
userId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
displayName: body.displayName,
description: body.description,
serviceAccountJson: body.serviceAccountJson,
signingSecret: body.signingSecret,
botToken: body.botToken,
apiToken: body.apiToken,
domain: body.domain,
request,
})
if (!result.success) {
const status =
result.errorCode === 'not_found'
? 404
: result.errorCode === 'forbidden'
? 403
: result.errorCode === 'conflict'
? 409
: result.errorCode === 'validation'
? 400
: 500
return NextResponse.json(
{
error: result.error,
...(result.providerErrorCode ? { code: result.providerErrorCode } : {}),
},
{ status }
)
}
const access = await getCredentialActorContext(id, session.user.id)
return NextResponse.json({ credential: formatCredentialResponse(access) }, { status: 200 })
} catch (error) {
logger.error('Failed to update credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
export const DELETE = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const result = await performDeleteCredential({
credentialId: id,
userId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
request,
})
if (!result.success) {
const status =
result.errorCode === 'not_found'
? 404
: result.errorCode === 'forbidden'
? 403
: result.errorCode === 'validation'
? 400
: 500
return NextResponse.json({ error: result.error }, { status })
}
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to delete credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,95 @@
import { db } from '@sim/db'
import { pendingCredentialDraft } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, lt } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createCredentialDraftContract } from '@/lib/api/contracts/credentials'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCredentialActorContext } from '@/lib/credentials/access'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CredentialDraftAPI')
const DRAFT_TTL_MS = 15 * 60 * 1000
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(createCredentialDraftContract, request, {})
if (!parsed.success) return parsed.response
const { workspaceId, providerId, displayName, description, credentialId } = parsed.data.body
const userId = session.user.id
const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId)
if (!workspaceAccess.canWrite) {
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
}
if (credentialId) {
const access = await getCredentialActorContext(credentialId, userId, { workspaceAccess })
if (!access.credential || access.credential.workspaceId !== workspaceId || !access.isAdmin) {
return NextResponse.json(
{ error: 'Admin access required on the target credential' },
{ status: 403 }
)
}
}
const now = new Date()
await db
.delete(pendingCredentialDraft)
.where(
and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now))
)
await db
.insert(pendingCredentialDraft)
.values({
id: generateId(),
userId,
workspaceId,
providerId,
displayName,
description: description || null,
credentialId: credentialId || null,
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
createdAt: now,
})
.onConflictDoUpdate({
target: [
pendingCredentialDraft.userId,
pendingCredentialDraft.providerId,
pendingCredentialDraft.workspaceId,
],
set: {
displayName,
description: description || null,
credentialId: credentialId || null,
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
createdAt: now,
},
})
logger.info('Credential draft saved', {
userId,
workspaceId,
providerId,
displayName,
credentialId: credentialId || null,
})
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to save credential draft', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})

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