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
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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 } })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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`)
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 '<'
|
||||
case '>':
|
||||
return '>'
|
||||
case '&':
|
||||
return '&'
|
||||
case '"':
|
||||
return '"'
|
||||
case "'":
|
||||
return '''
|
||||
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',
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user