chore: import upstream snapshot with attribution
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) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { apiKey } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { and, eq, not } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateWorkspaceApiKeyContract } from '@/lib/api/contracts/api-keys'
|
||||
import { 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 { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceApiKeyAPI')
|
||||
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; keyId: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const { id: workspaceId, keyId } = await context.params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace API key update attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateWorkspaceApiKeyContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { name } = parsed.data.body
|
||||
|
||||
const existingKey = await db
|
||||
.select()
|
||||
.from(apiKey)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.workspaceId, workspaceId),
|
||||
eq(apiKey.id, keyId),
|
||||
eq(apiKey.type, 'workspace')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingKey.length === 0) {
|
||||
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const conflictingKey = await db
|
||||
.select()
|
||||
.from(apiKey)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.workspaceId, workspaceId),
|
||||
eq(apiKey.name, name),
|
||||
eq(apiKey.type, 'workspace'),
|
||||
not(eq(apiKey.id, keyId))
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (conflictingKey.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A workspace API key with this name already exists' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const [updatedKey] = await db
|
||||
.update(apiKey)
|
||||
.set({
|
||||
name,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.workspaceId, workspaceId),
|
||||
eq(apiKey.id, keyId),
|
||||
eq(apiKey.type, 'workspace')
|
||||
)
|
||||
)
|
||||
.returning({
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
createdAt: apiKey.createdAt,
|
||||
updatedAt: apiKey.updatedAt,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.API_KEY_UPDATED,
|
||||
resourceType: AuditResourceType.API_KEY,
|
||||
resourceId: keyId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: name,
|
||||
description: `Renamed workspace API key from "${existingKey[0].name}" to "${name}"`,
|
||||
metadata: {
|
||||
keyType: 'workspace',
|
||||
previousName: existingKey[0].name,
|
||||
newName: name,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Updated workspace API key: ${keyId} in workspace ${workspaceId}`)
|
||||
return NextResponse.json({ key: updatedKey })
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Workspace API key PUT error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to update workspace API key') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string; keyId: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const { id: workspaceId, keyId } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace API key deletion attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const deletedRows = await db
|
||||
.delete(apiKey)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.workspaceId, workspaceId),
|
||||
eq(apiKey.id, keyId),
|
||||
eq(apiKey.type, 'workspace')
|
||||
)
|
||||
)
|
||||
.returning({ id: apiKey.id, name: apiKey.name, lastUsed: apiKey.lastUsed })
|
||||
|
||||
if (deletedRows.length === 0) {
|
||||
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const deletedKey = deletedRows[0]
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'api_key_revoked',
|
||||
{ workspace_id: workspaceId, key_name: deletedKey.name },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.API_KEY_REVOKED,
|
||||
resourceType: AuditResourceType.API_KEY,
|
||||
resourceId: keyId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: deletedKey.name,
|
||||
description: `Revoked workspace API key: ${deletedKey.name}`,
|
||||
metadata: {
|
||||
keyType: 'workspace',
|
||||
keyName: deletedKey.name,
|
||||
lastUsed: deletedKey.lastUsed?.toISOString() ?? null,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Deleted workspace API key: ${keyId} from workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Workspace API key DELETE error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to delete workspace API key') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,216 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { apiKey } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
createWorkspaceApiKeyContract,
|
||||
deleteWorkspaceApiKeysContract,
|
||||
} from '@/lib/api/contracts/api-keys'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getApiKeyDisplayFormat } from '@/lib/api-key/auth'
|
||||
import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceApiKeysAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const workspaceId = (await params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace API keys access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const ws = await getWorkspaceById(workspaceId)
|
||||
if (!ws) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const workspaceKeys = await db
|
||||
.select({
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
key: apiKey.key,
|
||||
createdAt: apiKey.createdAt,
|
||||
lastUsed: apiKey.lastUsed,
|
||||
expiresAt: apiKey.expiresAt,
|
||||
createdBy: apiKey.createdBy,
|
||||
})
|
||||
.from(apiKey)
|
||||
.where(and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.type, 'workspace')))
|
||||
.orderBy(apiKey.createdAt)
|
||||
|
||||
const formattedWorkspaceKeys = await Promise.all(
|
||||
workspaceKeys.map(async (key) => {
|
||||
const displayFormat = await getApiKeyDisplayFormat(key.key)
|
||||
return {
|
||||
...key,
|
||||
key: key.key,
|
||||
displayKey: displayFormat,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
keys: formattedWorkspaceKeys,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Workspace API keys GET error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to load API keys') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const workspaceId = (await context.params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace API key creation attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(createWorkspaceApiKeyContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { name, source } = parsed.data.body
|
||||
|
||||
const result = await performCreateWorkspaceApiKey({
|
||||
workspaceId,
|
||||
userId,
|
||||
name,
|
||||
source,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
})
|
||||
if (!result.success || !result.key) {
|
||||
const status = result.errorCode === 'conflict' ? 409 : 500
|
||||
return NextResponse.json({ error: result.error }, { status })
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'api_key_created',
|
||||
{ workspace_id: workspaceId, key_name: name, source },
|
||||
{
|
||||
groups: { workspace: workspaceId },
|
||||
setOnce: { first_api_key_created_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
key: result.key,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Workspace API key POST error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to create workspace API key') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const workspaceId = (await context.params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace API key deletion attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(deleteWorkspaceApiKeysContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { keys } = parsed.data.body
|
||||
|
||||
const deletedCount = await db
|
||||
.delete(apiKey)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.workspaceId, workspaceId),
|
||||
eq(apiKey.type, 'workspace'),
|
||||
inArray(apiKey.id, keys)
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
for (const keyId of keys) {
|
||||
PlatformEvents.apiKeyRevoked({
|
||||
userId: userId,
|
||||
keyId: keyId,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Telemetry should not fail the operation
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Deleted ${deletedCount} workspace API keys from workspace ${workspaceId}`
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
actorName: session?.user?.name,
|
||||
actorEmail: session?.user?.email,
|
||||
action: AuditAction.API_KEY_REVOKED,
|
||||
resourceType: AuditResourceType.API_KEY,
|
||||
description: `Revoked ${deletedCount} workspace API key(s)`,
|
||||
metadata: { keyIds: keys, deletedCount, keyType: 'workspace' },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, deletedCount })
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Workspace API key DELETE error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to delete workspace API keys') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
import { db } from '@sim/db'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getWorkspaceBackgroundWorkContract } from '@/lib/api/contracts/workspace-fork'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { listSurfacedBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
|
||||
import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getWorkspaceBackgroundWorkContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
const { cursor, limit } = parsed.data.query
|
||||
|
||||
// The fork Activity feed is a fork feature: gate it behind the same forking-enabled +
|
||||
// workspace-admin check the other fork routes use, instead of a bare access check.
|
||||
await assertWorkspaceAdminAccess(id, session.user.id)
|
||||
|
||||
const { rows, nextCursor } = await listSurfacedBackgroundWork(db, id, { cursor, limit })
|
||||
return NextResponse.json({
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
workspaceId: row.workspaceId,
|
||||
workflowId: row.workflowId,
|
||||
kind: row.kind,
|
||||
status: row.status,
|
||||
message: row.message,
|
||||
error: row.error,
|
||||
metadata: row.metadata ?? null,
|
||||
startedAt: row.startedAt.toISOString(),
|
||||
completedAt: row.completedAt ? row.completedAt.toISOString() : null,
|
||||
})),
|
||||
nextCursor,
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { auditMock, createMockRequest } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockDbState,
|
||||
mockGetSession,
|
||||
mockGetUserEntityPermissions,
|
||||
mockGetWorkspaceById,
|
||||
mockEncryptSecret,
|
||||
mockDecryptSecret,
|
||||
mockUpdateSet,
|
||||
mockInsertValues,
|
||||
mockDeleteWhere,
|
||||
} = vi.hoisted(() => {
|
||||
const state = {
|
||||
selectResults: [] as unknown[][],
|
||||
insertReturning: [] as unknown[],
|
||||
deleteReturning: [] as unknown[],
|
||||
}
|
||||
return {
|
||||
mockDbState: state,
|
||||
mockGetSession: vi.fn(),
|
||||
mockGetUserEntityPermissions: vi.fn(),
|
||||
mockGetWorkspaceById: vi.fn(),
|
||||
mockEncryptSecret: vi.fn(),
|
||||
mockDecryptSecret: vi.fn(),
|
||||
mockUpdateSet: vi.fn(() => ({ where: vi.fn(() => Promise.resolve()) })),
|
||||
mockInsertValues: vi.fn(() => ({
|
||||
returning: vi.fn(() => Promise.resolve(state.insertReturning)),
|
||||
})),
|
||||
mockDeleteWhere: vi.fn(() => ({
|
||||
returning: vi.fn(() => Promise.resolve(state.deleteReturning)),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@sim/db', () => {
|
||||
const dbMock: Record<string, unknown> = {
|
||||
select: vi.fn(() => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
chain.from = vi.fn().mockReturnValue(chain)
|
||||
chain.where = vi.fn().mockImplementation(() => {
|
||||
const result: any = Promise.resolve(mockDbState.selectResults.shift() ?? [])
|
||||
result.limit = vi.fn(() => result)
|
||||
result.orderBy = vi.fn(() => result)
|
||||
return result
|
||||
})
|
||||
return chain
|
||||
}),
|
||||
update: vi.fn(() => ({ set: mockUpdateSet })),
|
||||
insert: vi.fn(() => ({ values: mockInsertValues })),
|
||||
delete: vi.fn(() => ({ where: mockDeleteWhere })),
|
||||
execute: vi.fn(() => Promise.resolve([])),
|
||||
}
|
||||
dbMock.transaction = vi.fn(async (callback: (tx: unknown) => unknown) => callback(dbMock))
|
||||
return { db: dbMock }
|
||||
})
|
||||
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/encryption', () => ({
|
||||
encryptSecret: mockEncryptSecret,
|
||||
decryptSecret: mockDecryptSecret,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/posthog/server', () => ({
|
||||
captureServerEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getUserEntityPermissions: mockGetUserEntityPermissions,
|
||||
getWorkspaceById: mockGetWorkspaceById,
|
||||
}))
|
||||
|
||||
import { DELETE, GET, POST } from '@/app/api/workspaces/[id]/byok-keys/route'
|
||||
|
||||
const WORKSPACE_ID = 'workspace-1'
|
||||
const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) }
|
||||
|
||||
const storedKeyRow = (id: string, name: string | null = null) => ({
|
||||
id,
|
||||
providerId: 'openai',
|
||||
encryptedApiKey: `encrypted-${id}`,
|
||||
name,
|
||||
createdBy: 'user-1',
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
})
|
||||
|
||||
describe('workspace BYOK keys route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDbState.selectResults = []
|
||||
mockDbState.insertReturning = []
|
||||
mockDbState.deleteReturning = []
|
||||
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
mockGetWorkspaceById.mockResolvedValue({ id: WORKSPACE_ID })
|
||||
mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-value', iv: 'iv' })
|
||||
mockDecryptSecret.mockImplementation(async (encrypted: string) => ({
|
||||
decrypted: encrypted.replace('encrypted-', 'sk-decrypted-value-'),
|
||||
}))
|
||||
})
|
||||
|
||||
describe('GET', () => {
|
||||
it('lists every stored key with name and masked value', async () => {
|
||||
mockDbState.selectResults = [[storedKeyRow('key-1', 'Production'), storedKeyRow('key-2')]]
|
||||
|
||||
const res = await GET(createMockRequest('GET'), routeContext)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.keys).toHaveLength(2)
|
||||
expect(body.keys[0]).toMatchObject({ id: 'key-1', name: 'Production', providerId: 'openai' })
|
||||
expect(body.keys[0].maskedKey).toBe('sk-dec...ey-1')
|
||||
expect(body.keys[1]).toMatchObject({ id: 'key-2', name: null })
|
||||
})
|
||||
|
||||
it('returns 401 when the user has no workspace permission', async () => {
|
||||
mockGetUserEntityPermissions.mockResolvedValue(null)
|
||||
|
||||
const res = await GET(createMockRequest('GET'), routeContext)
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST', () => {
|
||||
it('returns 403 when the user is not a workspace admin', async () => {
|
||||
mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
|
||||
const res = await POST(
|
||||
createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-new' }),
|
||||
routeContext
|
||||
)
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
expect(mockInsertValues).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds a new key even when the provider already has keys', async () => {
|
||||
mockDbState.selectResults = [[{ keyCount: 2 }]]
|
||||
mockDbState.insertReturning = [
|
||||
{ id: 'key-3', providerId: 'openai', name: 'Backup', createdAt: new Date() },
|
||||
]
|
||||
|
||||
const res = await POST(
|
||||
createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-new-key', name: 'Backup' }),
|
||||
routeContext
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.key).toMatchObject({ id: 'key-3', name: 'Backup' })
|
||||
expect(mockInsertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
providerId: 'openai',
|
||||
encryptedApiKey: 'encrypted-value',
|
||||
name: 'Backup',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('stores a null name when none is provided', async () => {
|
||||
mockDbState.selectResults = [[{ keyCount: 0 }]]
|
||||
mockDbState.insertReturning = [
|
||||
{ id: 'key-1', providerId: 'openai', name: null, createdAt: new Date() },
|
||||
]
|
||||
|
||||
const res = await POST(
|
||||
createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-new-key' }),
|
||||
routeContext
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockInsertValues).toHaveBeenCalledWith(expect.objectContaining({ name: null }))
|
||||
})
|
||||
|
||||
it('rejects adding a key beyond the per-provider cap', async () => {
|
||||
mockDbState.selectResults = [[{ keyCount: 10 }]]
|
||||
|
||||
const res = await POST(
|
||||
createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-new-key' }),
|
||||
routeContext
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error).toContain('at most 10 keys')
|
||||
expect(mockInsertValues).not.toHaveBeenCalled()
|
||||
expect(mockEncryptSecret).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates the targeted key in place when keyId is provided', async () => {
|
||||
mockDbState.selectResults = [[{ id: 'key-2', name: 'Old name' }]]
|
||||
|
||||
const res = await POST(
|
||||
createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-rotated', keyId: 'key-2' }),
|
||||
routeContext
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.key).toMatchObject({ id: 'key-2', name: 'Old name' })
|
||||
expect(mockUpdateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ encryptedApiKey: 'encrypted-value', name: 'Old name' })
|
||||
)
|
||||
expect(mockInsertValues).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the name when updating with an empty name', async () => {
|
||||
mockDbState.selectResults = [[{ id: 'key-2', name: 'Old name' }]]
|
||||
|
||||
const res = await POST(
|
||||
createMockRequest('POST', {
|
||||
providerId: 'openai',
|
||||
apiKey: 'sk-rotated',
|
||||
keyId: 'key-2',
|
||||
name: '',
|
||||
}),
|
||||
routeContext
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockUpdateSet).toHaveBeenCalledWith(expect.objectContaining({ name: null }))
|
||||
})
|
||||
|
||||
it('returns 404 when the keyId does not exist in the workspace', async () => {
|
||||
mockDbState.selectResults = [[]]
|
||||
|
||||
const res = await POST(
|
||||
createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-rotated', keyId: 'missing' }),
|
||||
routeContext
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
expect(mockUpdateSet).not.toHaveBeenCalled()
|
||||
expect(mockEncryptSecret).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an empty apiKey', async () => {
|
||||
const res = await POST(
|
||||
createMockRequest('POST', { providerId: 'openai', apiKey: '' }),
|
||||
routeContext
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('deletes a single key when keyId is provided', async () => {
|
||||
mockDbState.deleteReturning = [{ id: 'key-2' }]
|
||||
|
||||
const res = await DELETE(
|
||||
createMockRequest('DELETE', { providerId: 'openai', keyId: 'key-2' }),
|
||||
routeContext
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('returns 404 when keyId is provided but no key matches', async () => {
|
||||
mockDbState.deleteReturning = []
|
||||
|
||||
const res = await DELETE(
|
||||
createMockRequest('DELETE', { providerId: 'openai', keyId: 'missing' }),
|
||||
routeContext
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('deletes all provider keys when keyId is omitted', async () => {
|
||||
mockDbState.deleteReturning = [{ id: 'key-1' }, { id: 'key-2' }]
|
||||
|
||||
const res = await DELETE(createMockRequest('DELETE', { providerId: 'openai' }), routeContext)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('succeeds when keyId is omitted and the provider has no keys', async () => {
|
||||
mockDbState.deleteReturning = []
|
||||
|
||||
const res = await DELETE(createMockRequest('DELETE', { providerId: 'openai' }), routeContext)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('returns 403 when the user is not a workspace admin', async () => {
|
||||
mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
|
||||
const res = await DELETE(createMockRequest('DELETE', { providerId: 'openai' }), routeContext)
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
expect(mockDeleteWhere).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,380 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { workspaceBYOKKeys } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { and, asc, count, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
deleteByokKeyContract,
|
||||
MAX_BYOK_KEYS_PER_PROVIDER,
|
||||
upsertByokKeyContract,
|
||||
} from '@/lib/api/contracts/byok-keys'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceBYOKKeysAPI')
|
||||
|
||||
/**
|
||||
* Bounds the per-provider BYOK advisory-lock wait so a stuck holder fails fast
|
||||
* (SQLSTATE 55P03) rather than hanging, even if the deployment lacks a
|
||||
* server-side `lock_timeout`. Transaction-scoped via `set_config(..., true)`.
|
||||
*/
|
||||
const WORKSPACE_BYOK_LOCK_TIMEOUT_MS = 5_000
|
||||
|
||||
function maskApiKey(key: string): string {
|
||||
if (key.length <= 8) {
|
||||
return '•'.repeat(8)
|
||||
}
|
||||
if (key.length <= 12) {
|
||||
return `${key.slice(0, 4)}...${key.slice(-4)}`
|
||||
}
|
||||
return `${key.slice(0, 6)}...${key.slice(-4)}`
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const workspaceId = (await params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized BYOK keys access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const ws = await getWorkspaceById(workspaceId)
|
||||
if (!ws) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const byokKeys = await db
|
||||
.select({
|
||||
id: workspaceBYOKKeys.id,
|
||||
providerId: workspaceBYOKKeys.providerId,
|
||||
encryptedApiKey: workspaceBYOKKeys.encryptedApiKey,
|
||||
name: workspaceBYOKKeys.name,
|
||||
createdBy: workspaceBYOKKeys.createdBy,
|
||||
createdAt: workspaceBYOKKeys.createdAt,
|
||||
updatedAt: workspaceBYOKKeys.updatedAt,
|
||||
})
|
||||
.from(workspaceBYOKKeys)
|
||||
.where(eq(workspaceBYOKKeys.workspaceId, workspaceId))
|
||||
.orderBy(
|
||||
asc(workspaceBYOKKeys.providerId),
|
||||
asc(workspaceBYOKKeys.createdAt),
|
||||
asc(workspaceBYOKKeys.id)
|
||||
)
|
||||
|
||||
const formattedKeys = await Promise.all(
|
||||
byokKeys.map(async (key) => {
|
||||
try {
|
||||
const { decrypted } = await decryptSecret(key.encryptedApiKey)
|
||||
return {
|
||||
id: key.id,
|
||||
providerId: key.providerId,
|
||||
name: key.name,
|
||||
maskedKey: maskApiKey(decrypted),
|
||||
createdBy: key.createdBy,
|
||||
createdAt: key.createdAt,
|
||||
updatedAt: key.updatedAt,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to decrypt BYOK key for provider ${key.providerId}`,
|
||||
{
|
||||
error,
|
||||
}
|
||||
)
|
||||
return {
|
||||
id: key.id,
|
||||
providerId: key.providerId,
|
||||
name: key.name,
|
||||
maskedKey: '••••••••',
|
||||
createdBy: key.createdBy,
|
||||
createdAt: key.createdAt,
|
||||
updatedAt: key.updatedAt,
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({ keys: formattedKeys })
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] BYOK keys GET error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to load BYOK keys') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const workspaceId = (await context.params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized BYOK key creation attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only workspace admins can manage BYOK keys' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(upsertByokKeyContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { providerId, apiKey, keyId, name } = parsed.data.body
|
||||
|
||||
if (keyId) {
|
||||
const [existingKey] = await db
|
||||
.select({ id: workspaceBYOKKeys.id, name: workspaceBYOKKeys.name })
|
||||
.from(workspaceBYOKKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceBYOKKeys.id, keyId),
|
||||
eq(workspaceBYOKKeys.workspaceId, workspaceId),
|
||||
eq(workspaceBYOKKeys.providerId, providerId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingKey) {
|
||||
return NextResponse.json({ error: 'BYOK key not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { encrypted } = await encryptSecret(apiKey)
|
||||
const updatedName = name === undefined ? existingKey.name : name || null
|
||||
const updatedAt = new Date()
|
||||
|
||||
await db
|
||||
.update(workspaceBYOKKeys)
|
||||
.set({
|
||||
encryptedApiKey: encrypted,
|
||||
name: updatedName,
|
||||
updatedAt,
|
||||
})
|
||||
.where(eq(workspaceBYOKKeys.id, existingKey.id))
|
||||
|
||||
logger.info(`[${requestId}] Updated BYOK key for ${providerId} in workspace ${workspaceId}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
actorName: session?.user?.name,
|
||||
actorEmail: session?.user?.email,
|
||||
action: AuditAction.BYOK_KEY_UPDATED,
|
||||
resourceType: AuditResourceType.BYOK_KEY,
|
||||
resourceId: existingKey.id,
|
||||
resourceName: providerId,
|
||||
description: `Updated BYOK key for ${providerId}`,
|
||||
metadata: { providerId, keyId: existingKey.id },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
key: {
|
||||
id: existingKey.id,
|
||||
providerId,
|
||||
name: updatedName,
|
||||
maskedKey: maskApiKey(apiKey),
|
||||
updatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const newKey = await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`SELECT set_config('lock_timeout', ${`${WORKSPACE_BYOK_LOCK_TIMEOUT_MS}ms`}, true)`
|
||||
)
|
||||
await tx.execute(
|
||||
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`byok:${workspaceId}:${providerId}`}, 0))`
|
||||
)
|
||||
|
||||
const [{ keyCount }] = await tx
|
||||
.select({ keyCount: count() })
|
||||
.from(workspaceBYOKKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceBYOKKeys.workspaceId, workspaceId),
|
||||
eq(workspaceBYOKKeys.providerId, providerId)
|
||||
)
|
||||
)
|
||||
|
||||
if (keyCount >= MAX_BYOK_KEYS_PER_PROVIDER) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { encrypted } = await encryptSecret(apiKey)
|
||||
|
||||
const [inserted] = await tx
|
||||
.insert(workspaceBYOKKeys)
|
||||
.values({
|
||||
id: generateShortId(),
|
||||
workspaceId,
|
||||
providerId,
|
||||
encryptedApiKey: encrypted,
|
||||
name: name || null,
|
||||
createdBy: userId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning({
|
||||
id: workspaceBYOKKeys.id,
|
||||
providerId: workspaceBYOKKeys.providerId,
|
||||
name: workspaceBYOKKeys.name,
|
||||
createdAt: workspaceBYOKKeys.createdAt,
|
||||
})
|
||||
|
||||
return inserted
|
||||
})
|
||||
|
||||
if (!newKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `A workspace can store at most ${MAX_BYOK_KEYS_PER_PROVIDER} keys per provider`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Created BYOK key for ${providerId} in workspace ${workspaceId}`)
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'byok_key_added',
|
||||
{ workspace_id: workspaceId, provider_id: providerId },
|
||||
{
|
||||
groups: { workspace: workspaceId },
|
||||
setOnce: { first_byok_key_added_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
actorName: session?.user?.name,
|
||||
actorEmail: session?.user?.email,
|
||||
action: AuditAction.BYOK_KEY_CREATED,
|
||||
resourceType: AuditResourceType.BYOK_KEY,
|
||||
resourceId: newKey.id,
|
||||
resourceName: providerId,
|
||||
description: `Added BYOK key for ${providerId}`,
|
||||
metadata: { providerId, keyId: newKey.id },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
key: {
|
||||
...newKey,
|
||||
maskedKey: maskApiKey(apiKey),
|
||||
},
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] BYOK key POST error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to save BYOK key') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const workspaceId = (await context.params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized BYOK key deletion attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only workspace admins can manage BYOK keys' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(deleteByokKeyContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { providerId, keyId } = parsed.data.body
|
||||
|
||||
const providerScope = and(
|
||||
eq(workspaceBYOKKeys.workspaceId, workspaceId),
|
||||
eq(workspaceBYOKKeys.providerId, providerId)
|
||||
)
|
||||
|
||||
const deletedKeys = await db
|
||||
.delete(workspaceBYOKKeys)
|
||||
.where(keyId ? and(providerScope, eq(workspaceBYOKKeys.id, keyId)) : providerScope)
|
||||
.returning({ id: workspaceBYOKKeys.id })
|
||||
|
||||
if (keyId && deletedKeys.length === 0) {
|
||||
return NextResponse.json({ error: 'BYOK key not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Deleted BYOK key for ${providerId} from workspace ${workspaceId}`)
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'byok_key_removed',
|
||||
{ workspace_id: workspaceId, provider_id: providerId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
actorName: session?.user?.name,
|
||||
actorEmail: session?.user?.email,
|
||||
action: AuditAction.BYOK_KEY_DELETED,
|
||||
resourceType: AuditResourceType.BYOK_KEY,
|
||||
resourceName: providerId,
|
||||
description: `Removed BYOK key for ${providerId}`,
|
||||
metadata: { providerId, deletedKeyIds: deletedKeys.map((key) => key.id) },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] BYOK key DELETE error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to delete BYOK key') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockGetSession,
|
||||
mockGetWorkspaceById,
|
||||
mockGetUserEntityPermissions,
|
||||
mockGetPersonalAndWorkspaceEnv,
|
||||
mockGetWorkspaceEnvKeyAdminAccess,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockGetWorkspaceById: vi.fn(),
|
||||
mockGetUserEntityPermissions: vi.fn(),
|
||||
mockGetPersonalAndWorkspaceEnv: vi.fn(),
|
||||
mockGetWorkspaceEnvKeyAdminAccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
auth: { api: { getSession: vi.fn() } },
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getWorkspaceById: mockGetWorkspaceById,
|
||||
getUserEntityPermissions: mockGetUserEntityPermissions,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/environment/utils', () => ({
|
||||
getPersonalAndWorkspaceEnv: mockGetPersonalAndWorkspaceEnv,
|
||||
invalidateEffectiveDecryptedEnvCache: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credentials/environment', () => ({
|
||||
getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess,
|
||||
createWorkspaceEnvCredentials: vi.fn(),
|
||||
deleteWorkspaceEnvCredentials: vi.fn(),
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/workspaces/[id]/environment/route'
|
||||
|
||||
const WORKSPACE_ID = 'ws-1'
|
||||
|
||||
function buildParams() {
|
||||
return { params: Promise.resolve({ id: WORKSPACE_ID }) }
|
||||
}
|
||||
|
||||
async function callGet() {
|
||||
const request = createMockRequest('GET')
|
||||
const response = await GET(request, buildParams())
|
||||
return { status: response.status, body: await response.json() }
|
||||
}
|
||||
|
||||
describe('GET /api/workspaces/[id]/environment', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'u-1' } })
|
||||
mockGetWorkspaceById.mockResolvedValue({ id: WORKSPACE_ID })
|
||||
mockGetPersonalAndWorkspaceEnv.mockResolvedValue({
|
||||
workspaceDecrypted: { OPENAI_API_KEY: 'sk-secret', DATABASE_URL: 'postgres://secret' },
|
||||
personalDecrypted: { PERSONAL: { value: 'p' } },
|
||||
conflicts: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 when the caller has no workspace permission', async () => {
|
||||
mockGetUserEntityPermissions.mockResolvedValue(null)
|
||||
|
||||
const { status, body } = await callGet()
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body.error).toBe('Unauthorized')
|
||||
expect(mockGetPersonalAndWorkspaceEnv).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('masks workspace secret values for a read-only member', async () => {
|
||||
mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({
|
||||
adminKeys: new Set<string>(),
|
||||
knownKeys: new Set(['OPENAI_API_KEY', 'DATABASE_URL']),
|
||||
})
|
||||
|
||||
const { status, body } = await callGet()
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(Object.keys(body.data.workspace).sort()).toEqual(['DATABASE_URL', 'OPENAI_API_KEY'])
|
||||
expect(body.data.workspace.OPENAI_API_KEY).toBe('')
|
||||
expect(body.data.workspace.DATABASE_URL).toBe('')
|
||||
})
|
||||
|
||||
it('reveals only the workspace values the caller is a credential admin of', async () => {
|
||||
mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({
|
||||
adminKeys: new Set(['OPENAI_API_KEY']),
|
||||
knownKeys: new Set(['OPENAI_API_KEY', 'DATABASE_URL']),
|
||||
})
|
||||
|
||||
const { body } = await callGet()
|
||||
|
||||
expect(body.data.workspace.OPENAI_API_KEY).toBe('sk-secret')
|
||||
expect(body.data.workspace.DATABASE_URL).toBe('')
|
||||
})
|
||||
|
||||
it('reveals legacy keys (no per-secret ACL) only to workspace admins', async () => {
|
||||
mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({
|
||||
adminKeys: new Set<string>(),
|
||||
knownKeys: new Set<string>(),
|
||||
})
|
||||
|
||||
const { body } = await callGet()
|
||||
|
||||
expect(body.data.workspace.OPENAI_API_KEY).toBe('sk-secret')
|
||||
expect(body.data.workspace.DATABASE_URL).toBe('postgres://secret')
|
||||
})
|
||||
|
||||
it('does not reveal legacy keys to a non-admin member', async () => {
|
||||
mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({
|
||||
adminKeys: new Set<string>(),
|
||||
knownKeys: new Set<string>(),
|
||||
})
|
||||
|
||||
const { body } = await callGet()
|
||||
|
||||
expect(body.data.workspace.OPENAI_API_KEY).toBe('')
|
||||
expect(body.data.workspace.DATABASE_URL).toBe('')
|
||||
})
|
||||
|
||||
it('always returns personal values untouched', async () => {
|
||||
mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({
|
||||
adminKeys: new Set<string>(),
|
||||
knownKeys: new Set(['OPENAI_API_KEY', 'DATABASE_URL']),
|
||||
})
|
||||
|
||||
const { body } = await callGet()
|
||||
|
||||
expect(body.data.personal).toEqual({ PERSONAL: { value: 'p' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,412 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { workspaceEnvironment } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
removeWorkspaceEnvironmentContract,
|
||||
upsertWorkspaceEnvironmentContract,
|
||||
} from '@/lib/api/contracts/environment'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
createWorkspaceEnvCredentials,
|
||||
deleteWorkspaceEnvCredentials,
|
||||
getWorkspaceEnvKeyAdminAccess,
|
||||
} from '@/lib/credentials/environment'
|
||||
import {
|
||||
getPersonalAndWorkspaceEnv,
|
||||
invalidateEffectiveDecryptedEnvCache,
|
||||
} from '@/lib/environment/utils'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
getUserEntityPermissions,
|
||||
getWorkspaceById,
|
||||
type PermissionType,
|
||||
} from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceEnvironmentAPI')
|
||||
|
||||
/**
|
||||
* Bounds the workspace-environment advisory-lock wait so a stuck holder fails
|
||||
* fast (SQLSTATE 55P03) rather than hanging, even if the deployment lacks a
|
||||
* server-side `lock_timeout`. Transaction-scoped via `set_config(..., true)`.
|
||||
*/
|
||||
const WORKSPACE_ENV_LOCK_TIMEOUT_MS = 5_000
|
||||
|
||||
/**
|
||||
* Restricts decrypted workspace env values to administrators. Members (including
|
||||
* read-only) receive the variable names with empty values so editor autocomplete
|
||||
* and conflict detection keep working without leaking secret values. A value is
|
||||
* revealed when the caller is a workspace admin (which includes organization
|
||||
* admins) or a per-secret credential admin of that key. Mirrors the per-key edit
|
||||
* gating in PUT/DELETE: if you can administer a secret, you can read it.
|
||||
*/
|
||||
async function maskWorkspaceEnvForViewer({
|
||||
workspaceDecrypted,
|
||||
workspaceId,
|
||||
userId,
|
||||
permission,
|
||||
}: {
|
||||
workspaceDecrypted: Record<string, string>
|
||||
workspaceId: string
|
||||
userId: string
|
||||
permission: PermissionType
|
||||
}): Promise<Record<string, string>> {
|
||||
const workspaceKeys = Object.keys(workspaceDecrypted)
|
||||
const { adminKeys } = await getWorkspaceEnvKeyAdminAccess({
|
||||
workspaceId,
|
||||
envKeys: workspaceKeys,
|
||||
userId,
|
||||
})
|
||||
|
||||
const masked: Record<string, string> = {}
|
||||
for (const key of workspaceKeys) {
|
||||
const canViewValue = permission === 'admin' || adminKeys.has(key)
|
||||
masked[key] = canViewValue ? workspaceDecrypted[key] : ''
|
||||
}
|
||||
return masked
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const workspaceId = (await params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace env access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const ws = await getWorkspaceById(workspaceId)
|
||||
if (!ws) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { workspaceDecrypted, personalDecrypted, conflicts } = await getPersonalAndWorkspaceEnv(
|
||||
userId,
|
||||
workspaceId
|
||||
)
|
||||
|
||||
const workspace = await maskWorkspaceEnvForViewer({
|
||||
workspaceDecrypted,
|
||||
workspaceId,
|
||||
userId,
|
||||
permission,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: {
|
||||
workspace,
|
||||
personal: personalDecrypted,
|
||||
conflicts,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Workspace env GET error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to load environment') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Upserts workspace environment variables under tiered authorization: the caller
|
||||
* needs some workspace permission, editing an existing secret requires
|
||||
* credential-admin on that key, and adding a brand-new key requires workspace
|
||||
* write/admin.
|
||||
*/
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const workspaceId = (await context.params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace env update attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const parsed = await parseRequest(upsertWorkspaceEnvironmentContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { variables } = parsed.data.body
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const incomingKeys = Object.keys(variables)
|
||||
if (incomingKeys.length === 0) {
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
const { adminKeys, knownKeys } = await getWorkspaceEnvKeyAdminAccess({
|
||||
workspaceId,
|
||||
envKeys: incomingKeys,
|
||||
userId,
|
||||
})
|
||||
const isKeyAdmin = (key: string) => permission === 'admin' || adminKeys.has(key)
|
||||
const forbiddenExisting = incomingKeys.filter((k) => knownKeys.has(k) && !isKeyAdmin(k))
|
||||
if (forbiddenExisting.length > 0) {
|
||||
logger.warn(`[${requestId}] Workspace env update denied`, {
|
||||
workspaceId,
|
||||
userId,
|
||||
reason: 'not-secret-admin',
|
||||
keys: forbiddenExisting,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'You must be an admin of these secrets to edit them' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
if (
|
||||
incomingKeys.some((k) => !knownKeys.has(k)) &&
|
||||
permission !== 'admin' &&
|
||||
permission !== 'write'
|
||||
) {
|
||||
logger.warn(`[${requestId}] Workspace env update denied`, {
|
||||
workspaceId,
|
||||
userId,
|
||||
reason: 'write-access-required',
|
||||
keys: incomingKeys.filter((k) => !knownKeys.has(k)),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Write access is required to add new secrets' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const encryptedIncoming = await Promise.all(
|
||||
Object.entries(variables).map(async ([key, value]) => {
|
||||
const { encrypted } = await encryptSecret(value)
|
||||
return [key, encrypted] as const
|
||||
})
|
||||
).then((entries) => Object.fromEntries(entries))
|
||||
|
||||
const { existingEncrypted, merged } = await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`SELECT set_config('lock_timeout', ${`${WORKSPACE_ENV_LOCK_TIMEOUT_MS}ms`}, true)`
|
||||
)
|
||||
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${workspaceId}, 0))`)
|
||||
|
||||
const [existingRow] = await tx
|
||||
.select()
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
const existing = ((existingRow?.variables as Record<string, string>) ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
const mergedVars = { ...existing, ...encryptedIncoming }
|
||||
|
||||
await tx
|
||||
.insert(workspaceEnvironment)
|
||||
.values({
|
||||
id: generateId(),
|
||||
workspaceId,
|
||||
variables: mergedVars,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [workspaceEnvironment.workspaceId],
|
||||
set: { variables: mergedVars, updatedAt: new Date() },
|
||||
})
|
||||
|
||||
return { existingEncrypted: existing, merged: mergedVars }
|
||||
})
|
||||
|
||||
invalidateEffectiveDecryptedEnvCache({ workspaceId })
|
||||
const newKeys = Object.keys(variables).filter((k) => !(k in existingEncrypted))
|
||||
await createWorkspaceEnvCredentials({ workspaceId, newKeys, actingUserId: userId })
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
actorName: session?.user?.name,
|
||||
actorEmail: session?.user?.email,
|
||||
action: AuditAction.ENVIRONMENT_UPDATED,
|
||||
resourceType: AuditResourceType.ENVIRONMENT,
|
||||
resourceId: workspaceId,
|
||||
description: `Updated ${Object.keys(variables).length} workspace environment variable(s)`,
|
||||
metadata: {
|
||||
variableCount: Object.keys(variables).length,
|
||||
updatedKeys: Object.keys(variables),
|
||||
totalKeysAfterUpdate: Object.keys(merged).length,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(userId, 'environment_updated', {
|
||||
workspace_id: workspaceId,
|
||||
key_count: Object.keys(variables).length,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Workspace env PUT error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to update environment') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Removes workspace environment variables. Deleting an existing secret requires
|
||||
* credential-admin on that key; a key with no credential yet (legacy) falls back
|
||||
* to workspace write/admin.
|
||||
*/
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const workspaceId = (await context.params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace env delete attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const parsed = await parseRequest(removeWorkspaceEnvironmentContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { keys } = parsed.data.body
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { adminKeys, knownKeys } = await getWorkspaceEnvKeyAdminAccess({
|
||||
workspaceId,
|
||||
envKeys: keys,
|
||||
userId,
|
||||
})
|
||||
const isKeyAdmin = (key: string) => permission === 'admin' || adminKeys.has(key)
|
||||
const forbiddenExisting = keys.filter((k) => knownKeys.has(k) && !isKeyAdmin(k))
|
||||
if (forbiddenExisting.length > 0) {
|
||||
logger.warn(`[${requestId}] Workspace env delete denied`, {
|
||||
workspaceId,
|
||||
userId,
|
||||
reason: 'not-secret-admin',
|
||||
keys: forbiddenExisting,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'You must be an admin of these secrets to delete them' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
if (keys.some((k) => !knownKeys.has(k)) && permission !== 'admin' && permission !== 'write') {
|
||||
logger.warn(`[${requestId}] Workspace env delete denied`, {
|
||||
workspaceId,
|
||||
userId,
|
||||
reason: 'write-access-required',
|
||||
keys: keys.filter((k) => !knownKeys.has(k)),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Write access is required to remove these secrets' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`SELECT set_config('lock_timeout', ${`${WORKSPACE_ENV_LOCK_TIMEOUT_MS}ms`}, true)`
|
||||
)
|
||||
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${workspaceId}, 0))`)
|
||||
|
||||
const [existingRow] = await tx
|
||||
.select()
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!existingRow) return null
|
||||
|
||||
const current: Record<string, string> =
|
||||
(existingRow.variables as Record<string, string>) ?? {}
|
||||
let modified = false
|
||||
for (const k of keys) {
|
||||
if (k in current) {
|
||||
delete current[k]
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!modified) return null
|
||||
|
||||
await tx
|
||||
.update(workspaceEnvironment)
|
||||
.set({ variables: current, updatedAt: new Date() })
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
|
||||
return { remainingKeysCount: Object.keys(current).length }
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
invalidateEffectiveDecryptedEnvCache({ workspaceId })
|
||||
await deleteWorkspaceEnvCredentials({ workspaceId, removedKeys: keys })
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
actorName: session?.user?.name,
|
||||
actorEmail: session?.user?.email,
|
||||
action: AuditAction.ENVIRONMENT_DELETED,
|
||||
resourceType: AuditResourceType.ENVIRONMENT,
|
||||
resourceId: workspaceId,
|
||||
description: `Removed ${keys.length} workspace environment variable(s)`,
|
||||
metadata: {
|
||||
removedKeys: keys,
|
||||
remainingKeysCount: result.remainingKeysCount,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(userId, 'environment_deleted', {
|
||||
workspace_id: workspaceId,
|
||||
key_count: keys.length,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Workspace env DELETE error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to remove environment keys') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { workspaceFileCompiledCheckContract } from '@/lib/api/contracts/workspace-files'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile'
|
||||
import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc'
|
||||
import { isE2BDocEnabled } from '@/lib/core/config/env-flags'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
|
||||
import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task'
|
||||
import { validateMermaidSource } from '@/lib/mermaid/validate'
|
||||
import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const logger = createLogger('WorkspaceFileCompiledCheckAPI')
|
||||
|
||||
/**
|
||||
* GET /api/workspaces/[id]/files/[fileId]/compiled-check
|
||||
*
|
||||
* Compiles or validates the saved source for generated document-like files and
|
||||
* returns whether it succeeds. Used by the file agent to self-verify generated
|
||||
* code or diagram syntax before finalising an edit.
|
||||
*
|
||||
* Returns:
|
||||
* 200 { ok: true }
|
||||
* 200 { ok: false, error: string, errorName: string } — user code error
|
||||
* 4xx on auth / missing file / unsupported extension
|
||||
* 500 on system (sandbox infra) failure
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const parsed = await parseRequest(workspaceFileCompiledCheckContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, fileId } = parsed.data.params
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const membership = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!fileRecord) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const ext = fileRecord.name.split('.').pop()?.toLowerCase() ?? ''
|
||||
// In the E2B regime ALL four formats compile in the doc sandbox (Node for
|
||||
// pptx/docx, Python for pdf/xlsx). Gate on the flag (not the stored MIME) so
|
||||
// a stale file can't trigger an E2B compile when the sandbox is disabled.
|
||||
const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(fileRecord.name) : null
|
||||
const taskId = BINARY_DOC_TASKS[ext]
|
||||
const isMermaidFile = ext === 'mmd' || ext === 'mermaid'
|
||||
if (!e2bFmt && !taskId && !isMermaidFile) {
|
||||
return NextResponse.json(
|
||||
{ error: `Compiled check only supports .docx, .pptx, .pdf, .xlsx, and .mmd files` },
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
let buffer: Buffer
|
||||
try {
|
||||
buffer = await fetchWorkspaceFileBuffer(fileRecord)
|
||||
} catch (err) {
|
||||
logger.error('Failed to download file for compiled check', {
|
||||
fileId,
|
||||
error: toError(err).message,
|
||||
})
|
||||
return NextResponse.json({ error: 'Failed to read file' }, { status: 500 })
|
||||
}
|
||||
|
||||
const code = buffer.toString('utf-8')
|
||||
|
||||
if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) {
|
||||
return NextResponse.json({ error: 'File source exceeds maximum size' }, { status: 413 })
|
||||
}
|
||||
|
||||
if (isMermaidFile) {
|
||||
return NextResponse.json(await validateMermaidSource(code))
|
||||
}
|
||||
|
||||
if (e2bFmt) {
|
||||
// Loads the compile-once artifact if present, else compiles via E2B once
|
||||
// (and recalc-scans xlsx formulas). Only a script error is { ok: false };
|
||||
// infra failures rethrow → 500, so the agent isn't told to "fix its script"
|
||||
// during an E2B/S3 outage.
|
||||
const result = await runE2BCompiledCheck({
|
||||
source: code,
|
||||
fileName: fileRecord.name,
|
||||
workspaceId,
|
||||
ext,
|
||||
})
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
|
||||
try {
|
||||
if (!taskId) {
|
||||
return NextResponse.json({ error: 'Unsupported compiled check target' }, { status: 422 })
|
||||
}
|
||||
await runSandboxTask(taskId, { code, workspaceId }, { ownerKey: `user:${session.user.id}` })
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxUserCodeError) {
|
||||
logger.info('Compiled check failed with user code error', {
|
||||
fileId,
|
||||
taskId,
|
||||
error: toError(err).message,
|
||||
errorName: err.name,
|
||||
})
|
||||
return NextResponse.json({ ok: false, error: toError(err).message, errorName: err.name })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateWorkspaceFileContentContract } from '@/lib/api/contracts/workspace-files'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkspaceFileContentAPI')
|
||||
|
||||
/**
|
||||
* PUT /api/workspaces/[id]/files/[fileId]/content
|
||||
* Update a workspace file's text content (requires write permission)
|
||||
*/
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateWorkspaceFileContentContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, fileId } = parsed.data.params
|
||||
const { content, encoding } = parsed.data.body
|
||||
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceId
|
||||
)
|
||||
if (userPermission !== 'admin' && userPermission !== 'write') {
|
||||
logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`)
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const buffer =
|
||||
encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8')
|
||||
|
||||
const maxFileSizeBytes = 50 * 1024 * 1024
|
||||
if (buffer.length > maxFileSizeBytes) {
|
||||
return NextResponse.json(
|
||||
{ error: `File size exceeds ${maxFileSizeBytes / 1024 / 1024}MB limit` },
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
|
||||
const updatedFile = await updateWorkspaceFileContent(
|
||||
workspaceId,
|
||||
fileId,
|
||||
session.user.id,
|
||||
buffer
|
||||
)
|
||||
|
||||
logger.info(`Updated content for workspace file: ${updatedFile.name}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
action: AuditAction.FILE_UPDATED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: fileId,
|
||||
resourceName: updatedFile.name,
|
||||
description: `Updated content of file "${updatedFile.name}"`,
|
||||
metadata: { contentSize: buffer.length },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
file: updatedFile,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = toError(error).message || 'Failed to update file content'
|
||||
const isNotFound = errorMessage.includes('File not found')
|
||||
const isQuotaExceeded = errorMessage.includes('Storage limit exceeded')
|
||||
const status = isNotFound ? 404 : isQuotaExceeded ? 402 : 500
|
||||
|
||||
if (status === 500) {
|
||||
logger.error('Error updating file content:', error)
|
||||
} else {
|
||||
logger.warn(errorMessage)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, error: errorMessage }, { status })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getWorkspaceCsvPreviewContract } from '@/lib/api/contracts/workspace-file-table'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getCsvPreviewSlice } from '@/lib/file-parsers/csv-preview-slice'
|
||||
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceCsvPreviewAPI')
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
const userId = authResult.userId
|
||||
|
||||
const parsed = await parseRequest(getWorkspaceCsvPreviewContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, fileId } = parsed.data.params
|
||||
const { key } = parsed.data.query
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Resolve the file record (active, in this workspace) and read from its authoritative key —
|
||||
// never the client-supplied one. This rejects archived/deleted files and keys with no live
|
||||
// row, matching the access guarantees of /api/files/serve.
|
||||
const record = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!record || record.key !== key) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const slice = await getCsvPreviewSlice({
|
||||
key: record.key,
|
||||
context: 'workspace',
|
||||
signal: request.signal,
|
||||
})
|
||||
|
||||
logger.info('CSV preview served', {
|
||||
workspaceId,
|
||||
rows: slice.rows.length,
|
||||
truncated: slice.truncated,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, ...slice })
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
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 { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files'
|
||||
import { getValidationErrorMessage } 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 { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkspaceFileDownloadAPI')
|
||||
|
||||
/**
|
||||
* POST /api/workspaces/[id]/files/[fileId]/download
|
||||
* Return authenticated file serve URL (requires read permission)
|
||||
* Uses /api/files/serve endpoint which enforces authentication and context
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const paramsResult = workspaceFileParamsSchema.safeParse(await params)
|
||||
if (!paramsResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { id: workspaceId, fileId } = paramsResult.data
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!userPermission) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!fileRecord) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { getBaseUrl } = await import('@/lib/core/utils/urls')
|
||||
const serveUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(fileRecord.key)}?context=workspace`
|
||||
const viewerUrl = `${getBaseUrl()}/workspace/${workspaceId}/files/${fileId}`
|
||||
|
||||
logger.info(`[${requestId}] Generated download URL for workspace file: ${fileRecord.name}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.FILE_DOWNLOADED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: fileId,
|
||||
resourceName: fileRecord.name,
|
||||
description: `Downloaded file "${fileRecord.name}"`,
|
||||
metadata: { fileId, fileName: fileRecord.name, bytes: fileRecord.size },
|
||||
request,
|
||||
})
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'file_downloaded',
|
||||
{ workspace_id: workspaceId, is_bulk: false, file_count: 1 },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
downloadUrl: serveUrl,
|
||||
viewerUrl: viewerUrl,
|
||||
fileName: fileRecord.name,
|
||||
expiresIn: null,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error generating download URL:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Failed to generate download URL'),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files'
|
||||
import { getValidationErrorMessage } 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 { performRestoreWorkspaceFile } from '@/lib/workspace-files/orchestration'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('RestoreWorkspaceFileAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const paramsResult = workspaceFileParamsSchema.safeParse(await params)
|
||||
if (!paramsResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { id: workspaceId, fileId } = paramsResult.data
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceId
|
||||
)
|
||||
if (userPermission !== 'admin' && userPermission !== 'write') {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = await performRestoreWorkspaceFile({
|
||||
workspaceId,
|
||||
fileId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error },
|
||||
{ status: result.errorCode === 'conflict' ? 409 : 500 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Restored workspace file ${fileId}`)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error restoring workspace file ${fileId}`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Internal server error') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
renameWorkspaceFileContract,
|
||||
workspaceFileParamsSchema,
|
||||
} from '@/lib/api/contracts/workspace-files'
|
||||
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 { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
performDeleteWorkspaceFileItems,
|
||||
performRenameWorkspaceFile,
|
||||
} from '@/lib/workspace-files/orchestration'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkspaceFileAPI')
|
||||
|
||||
/**
|
||||
* PATCH /api/workspaces/[id]/files/[fileId]
|
||||
* Rename a workspace file (requires write permission)
|
||||
*/
|
||||
export const PATCH = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(renameWorkspaceFileContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, fileId } = parsed.data.params
|
||||
const { name } = parsed.data.body
|
||||
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceId
|
||||
)
|
||||
if (userPermission !== 'admin' && userPermission !== 'write') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = await performRenameWorkspaceFile({
|
||||
workspaceId,
|
||||
fileId,
|
||||
name,
|
||||
userId: session.user.id,
|
||||
})
|
||||
if (!result.success || !result.file) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: result.error },
|
||||
{ status: result.errorCode === 'conflict' ? 409 : 500 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${result.file.name}"`)
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'file_renamed',
|
||||
{ workspace_id: workspaceId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
file: result.file,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error renaming workspace file:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Failed to rename file'),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /api/workspaces/[id]/files/[fileId]
|
||||
* Archive a workspace file (requires write permission)
|
||||
*/
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const paramsResult = workspaceFileParamsSchema.safeParse(await params)
|
||||
if (!paramsResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { id: workspaceId, fileId } = paramsResult.data
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check workspace permissions (requires write)
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceId
|
||||
)
|
||||
if (userPermission !== 'admin' && userPermission !== 'write') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = await performDeleteWorkspaceFileItems({
|
||||
workspaceId,
|
||||
userId: session.user.id,
|
||||
fileIds: [fileId],
|
||||
})
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: result.error },
|
||||
{
|
||||
status:
|
||||
result.errorCode === 'validation'
|
||||
? 400
|
||||
: result.errorCode === 'not_found'
|
||||
? 404
|
||||
: 500,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Archived workspace file: ${fileId}`)
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'file_deleted',
|
||||
{ workspace_id: workspaceId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting workspace file:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Failed to delete file'),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { auditMock, authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetWorkspaceFile, mockGetShareForResource, mockUpsertFileShare, mockValidateSharing } =
|
||||
vi.hoisted(() => ({
|
||||
mockGetWorkspaceFile: vi.fn(),
|
||||
mockGetShareForResource: vi.fn(),
|
||||
mockUpsertFileShare: vi.fn(),
|
||||
mockValidateSharing: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace', () => ({
|
||||
getWorkspaceFile: mockGetWorkspaceFile,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/public-shares/share-manager', () => {
|
||||
class ShareValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ShareValidationError'
|
||||
}
|
||||
}
|
||||
return {
|
||||
getShareForResource: mockGetShareForResource,
|
||||
upsertFileShare: mockUpsertFileShare,
|
||||
ShareValidationError,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/ee/access-control/utils/permission-check', () => {
|
||||
class PublicFileSharingNotAllowedError extends Error {
|
||||
constructor() {
|
||||
super('Public file sharing is not allowed based on your permission group settings')
|
||||
this.name = 'PublicFileSharingNotAllowedError'
|
||||
}
|
||||
}
|
||||
return { validatePublicFileSharing: mockValidateSharing, PublicFileSharingNotAllowedError }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
|
||||
const FILE_ID = 'wf_abc'
|
||||
|
||||
import { ShareValidationError } from '@/lib/public-shares/share-manager'
|
||||
import { GET, PUT } from '@/app/api/workspaces/[id]/files/[fileId]/share/route'
|
||||
|
||||
const params = (id = WS, fileId = FILE_ID) => ({ params: Promise.resolve({ id, fileId }) })
|
||||
|
||||
const putRequest = (body: unknown) =>
|
||||
new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const getRequest = () =>
|
||||
new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`)
|
||||
|
||||
const SHARE = {
|
||||
id: 'sh_1',
|
||||
token: 'tok_1',
|
||||
url: 'https://sim.ai/f/tok_1',
|
||||
isActive: true,
|
||||
resourceType: 'file' as const,
|
||||
resourceId: FILE_ID,
|
||||
}
|
||||
|
||||
describe('share route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authMockFns.mockGetSession.mockResolvedValue({
|
||||
user: { id: 'user-1', name: 'User One', email: 'u@example.com' },
|
||||
})
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
mockGetWorkspaceFile.mockResolvedValue({ id: FILE_ID, name: 'report.pdf' })
|
||||
mockGetShareForResource.mockResolvedValue(SHARE)
|
||||
mockUpsertFileShare.mockResolvedValue(SHARE)
|
||||
mockValidateSharing.mockResolvedValue(undefined) // policy allows by default
|
||||
})
|
||||
|
||||
describe('GET', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValueOnce(null)
|
||||
const res = await GET(getRequest(), params())
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 when the caller has no workspace access', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce(null)
|
||||
const res = await GET(getRequest(), params())
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns the share for a member', async () => {
|
||||
const res = await GET(getRequest(), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ share: SHARE })
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT', () => {
|
||||
it('returns 403 for a read-only member', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read')
|
||||
const res = await PUT(putRequest({ isActive: true }), params())
|
||||
expect(res.status).toBe(403)
|
||||
expect(mockUpsertFileShare).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps a ShareValidationError to 400, not 500', async () => {
|
||||
mockUpsertFileShare.mockRejectedValueOnce(
|
||||
new ShareValidationError('Password is required for password-protected shares')
|
||||
)
|
||||
const res = await PUT(putRequest({ isActive: true, authType: 'password' }), params())
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()).error).toBe('Password is required for password-protected shares')
|
||||
})
|
||||
|
||||
it('returns 404 when the file is not in the workspace', async () => {
|
||||
mockGetWorkspaceFile.mockResolvedValueOnce(null)
|
||||
const res = await PUT(putRequest({ isActive: true }), params())
|
||||
expect(res.status).toBe(404)
|
||||
expect(mockUpsertFileShare).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('enables the share for a writer', async () => {
|
||||
const res = await PUT(putRequest({ isActive: true }), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockUpsertFileShare).toHaveBeenCalledWith({
|
||||
workspaceId: WS,
|
||||
fileId: FILE_ID,
|
||||
userId: 'user-1',
|
||||
isActive: true,
|
||||
})
|
||||
expect(await res.json()).toEqual({ share: SHARE })
|
||||
})
|
||||
|
||||
it('returns 403 when org access-control disables public sharing (enable)', async () => {
|
||||
const { PublicFileSharingNotAllowedError } = await import(
|
||||
'@/ee/access-control/utils/permission-check'
|
||||
)
|
||||
mockValidateSharing.mockRejectedValueOnce(new PublicFileSharingNotAllowedError())
|
||||
const res = await PUT(putRequest({ isActive: true }), params())
|
||||
expect(res.status).toBe(403)
|
||||
expect(mockUpsertFileShare).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows disabling a share even when policy disallows enabling', async () => {
|
||||
mockValidateSharing.mockRejectedValue(new Error('should not be called for disable'))
|
||||
const res = await PUT(putRequest({ isActive: false }), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockValidateSharing).not.toHaveBeenCalled()
|
||||
expect(mockUpsertFileShare).toHaveBeenCalledWith({
|
||||
workspaceId: WS,
|
||||
fileId: FILE_ID,
|
||||
userId: 'user-1',
|
||||
isActive: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a missing isActive body', async () => {
|
||||
const res = await PUT(putRequest({}), params())
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
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 { getFileShareContract, upsertFileShareContract } from '@/lib/api/contracts/public-shares'
|
||||
import { 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 {
|
||||
getShareForResource,
|
||||
ShareValidationError,
|
||||
upsertFileShare,
|
||||
} from '@/lib/public-shares/share-manager'
|
||||
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
PublicFileSharingNotAllowedError,
|
||||
validatePublicFileSharing,
|
||||
} from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkspaceFileShareAPI')
|
||||
|
||||
/**
|
||||
* GET /api/workspaces/[id]/files/[fileId]/share
|
||||
* Fetch the public share state for a file (requires workspace membership).
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getFileShareContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, fileId } = parsed.data.params
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (permission === null) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks access to workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const file = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const share = await getShareForResource('file', fileId)
|
||||
return NextResponse.json({ share })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching file share:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to fetch share') },
|
||||
{
|
||||
status: 500,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /api/workspaces/[id]/files/[fileId]/share
|
||||
* Enable or disable the public share for a file (requires write permission).
|
||||
*/
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(upsertFileShareContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, fileId } = parsed.data.params
|
||||
const { isActive, authType, password, allowedEmails, token } = parsed.data.body
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (permission !== 'admin' && permission !== 'write') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const file = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Enabling a share is gated by the org's access-control policy (both the
|
||||
// master on/off and the per-auth-type allow-list); disabling is always
|
||||
// allowed so users can still un-share after the policy is turned on.
|
||||
if (isActive) {
|
||||
try {
|
||||
await validatePublicFileSharing(session.user.id, workspaceId, authType ?? 'public')
|
||||
} catch (error) {
|
||||
if (error instanceof PublicFileSharingNotAllowedError) {
|
||||
logger.warn(`[${requestId}] Public file sharing disabled for workspace ${workspaceId}`)
|
||||
return NextResponse.json({ error: error.message }, { status: 403 })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const share = await upsertFileShare({
|
||||
workspaceId,
|
||||
fileId,
|
||||
userId: session.user.id,
|
||||
isActive,
|
||||
authType,
|
||||
password,
|
||||
allowedEmails,
|
||||
token,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] ${isActive ? 'Enabled' : 'Disabled'} share for file ${fileId}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: fileId,
|
||||
resourceName: file.name,
|
||||
description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`,
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ share })
|
||||
} catch (error) {
|
||||
if (error instanceof ShareValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error(`[${requestId}] Error updating file share:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to update share') },
|
||||
{
|
||||
status: 500,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { workspaceFileStyleContract } from '@/lib/api/contracts/workspace-files'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const logger = createLogger('WorkspaceFileStyleAPI')
|
||||
|
||||
/**
|
||||
* GET /api/workspaces/[id]/files/[fileId]/style
|
||||
* Extract a compact JSON style summary from an uploaded .docx, .pptx, or .pdf file.
|
||||
* OOXML files return theme colors, font pair, and named styles.
|
||||
* PDF files return page dimensions and embedded font names.
|
||||
*/
|
||||
const MAX_STYLE_FILE_BYTES = 100 * 1024 * 1024 // 100 MB
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(workspaceFileStyleContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, fileId } = parsed.data.params
|
||||
|
||||
const membership = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!fileRecord) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const rawExt = fileRecord.name.split('.').pop()?.toLowerCase()
|
||||
if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Style extraction supports .docx, .pptx, and .pdf files' },
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
const ext: 'docx' | 'pptx' | 'pdf' = rawExt
|
||||
|
||||
if (fileRecord.size > MAX_STYLE_FILE_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: 'File is too large for style extraction (limit: 100 MB)' },
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
let buffer: Buffer
|
||||
try {
|
||||
buffer = await fetchWorkspaceFileBuffer(fileRecord)
|
||||
} catch (err) {
|
||||
logger.error('Failed to download file for style extraction', {
|
||||
fileId,
|
||||
error: toError(err).message,
|
||||
})
|
||||
return NextResponse.json({ error: 'Failed to read file' }, { status: 500 })
|
||||
}
|
||||
|
||||
const summary = await extractDocumentStyle(buffer, ext)
|
||||
if (!summary) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Could not extract style — file may be encrypted, corrupt, image-only, or contain no parseable style information',
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Extracted style summary via API', { fileId, format: ext })
|
||||
|
||||
return NextResponse.json(summary, {
|
||||
headers: { 'Cache-Control': 'private, max-age=300' },
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { bulkArchiveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceFileBulkArchiveAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(bulkArchiveWorkspaceFileItemsContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { fileIds, folderIds } = parsed.data.body
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (permission !== 'admin' && permission !== 'write') {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await performDeleteWorkspaceFileItems({
|
||||
workspaceId,
|
||||
userId: session.user.id,
|
||||
fileIds,
|
||||
folderIds,
|
||||
})
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: result.error },
|
||||
{
|
||||
status:
|
||||
result.errorCode === 'validation'
|
||||
? 400
|
||||
: result.errorCode === 'not_found'
|
||||
? 404
|
||||
: 500,
|
||||
}
|
||||
)
|
||||
}
|
||||
if (!result.deletedItems) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete workspace file items' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'file_bulk_deleted',
|
||||
{ workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, deletedItems: result.deletedItems })
|
||||
} catch (error) {
|
||||
logger.error('Failed to bulk archive workspace file items:', error)
|
||||
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import JSZip from 'jszip'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
buildWorkspaceFileFolderPathMap,
|
||||
fetchWorkspaceFileBuffer,
|
||||
listWorkspaceFileFolders,
|
||||
listWorkspaceFiles,
|
||||
} from '@/lib/uploads/contexts/workspace'
|
||||
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceFilesDownloadAPI')
|
||||
const MAX_ZIP_DOWNLOAD_FILES = 100
|
||||
const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024
|
||||
|
||||
function safeZipPath(path: string): string {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => {
|
||||
const cleaned = segment.trim().replace(/[<>:"\\|?*\x00-\x1f]/g, '_')
|
||||
return cleaned === '.' || cleaned === '..' ? '_' : cleaned
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('/')
|
||||
}
|
||||
|
||||
function withZipPathSuffix(path: string, suffix: number): string {
|
||||
const slashIndex = path.lastIndexOf('/')
|
||||
const directory = slashIndex >= 0 ? `${path.slice(0, slashIndex + 1)}` : ''
|
||||
const filename = slashIndex >= 0 ? path.slice(slashIndex + 1) : path
|
||||
const dotIndex = filename.lastIndexOf('.')
|
||||
|
||||
return dotIndex > 0
|
||||
? `${directory}${filename.slice(0, dotIndex)} (${suffix})${filename.slice(dotIndex)}`
|
||||
: `${directory}${filename} (${suffix})`
|
||||
}
|
||||
|
||||
function collectDescendantFolderIds(
|
||||
selectedFolderIds: string[],
|
||||
folders: Array<{ id: string; parentId: string | null }>
|
||||
): Set<string> {
|
||||
const folderIds = new Set(selectedFolderIds)
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (const folder of folders) {
|
||||
if (folder.parentId && folderIds.has(folder.parentId) && !folderIds.has(folder.id)) {
|
||||
folderIds.add(folder.id)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return folderIds
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(downloadWorkspaceFileItemsContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { fileIds, folderIds } = parsed.data.query
|
||||
|
||||
const permission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!permission) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const [files, folders] = await Promise.all([
|
||||
listWorkspaceFiles(workspaceId, { hydrateFolderPaths: false }),
|
||||
listWorkspaceFileFolders(workspaceId),
|
||||
])
|
||||
const folderPaths = buildWorkspaceFileFolderPathMap(folders)
|
||||
const selectedFolderIds = collectDescendantFolderIds(folderIds, folders)
|
||||
const requestedFileIds = new Set(fileIds)
|
||||
const filesToZip = files.filter(
|
||||
(file) =>
|
||||
requestedFileIds.has(file.id) || (file.folderId && selectedFolderIds.has(file.folderId))
|
||||
)
|
||||
|
||||
if (filesToZip.length === 0) {
|
||||
return NextResponse.json({ error: 'No files selected for download' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (filesToZip.length > MAX_ZIP_DOWNLOAD_FILES) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Too many files selected for download. Select ${MAX_ZIP_DOWNLOAD_FILES} or fewer files.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const totalBytes = filesToZip.reduce((sum, file) => sum + file.size, 0)
|
||||
if (totalBytes > MAX_ZIP_DOWNLOAD_BYTES) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Selected files total ${formatFileSize(totalBytes)}, which exceeds the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const buffers = await Promise.all(filesToZip.map((file) => fetchWorkspaceFileBuffer(file)))
|
||||
|
||||
// Assemble zip synchronously so path deduplication is deterministic.
|
||||
const zip = new JSZip()
|
||||
const usedPaths = new Set<string>()
|
||||
for (let i = 0; i < filesToZip.length; i++) {
|
||||
const file = filesToZip[i]
|
||||
const buffer = buffers[i]
|
||||
const folderPath = file.folderId ? folderPaths.get(file.folderId) : null
|
||||
const basePath =
|
||||
safeZipPath(folderPath ? `${folderPath}/${file.name}` : file.name) ||
|
||||
safeZipPath(file.name) ||
|
||||
file.id
|
||||
let zipPath = basePath
|
||||
let suffix = 2
|
||||
while (usedPaths.has(zipPath)) {
|
||||
zipPath = withZipPathSuffix(basePath, suffix)
|
||||
suffix++
|
||||
}
|
||||
usedPaths.add(zipPath)
|
||||
zip.file(zipPath, buffer)
|
||||
}
|
||||
|
||||
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.FILE_DOWNLOADED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
description: `Downloaded ${filesToZip.length} file${filesToZip.length === 1 ? '' : 's'} as zip`,
|
||||
metadata: { fileCount: filesToZip.length, totalBytes },
|
||||
request,
|
||||
})
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'file_downloaded',
|
||||
{ workspace_id: workspaceId, is_bulk: true, file_count: filesToZip.length },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
return new NextResponse(new Uint8Array(zipBuffer), {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': 'attachment; filename="workspace-files.zip"',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to download workspace file selection:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { restoreWorkspaceFileFolderContract } from '@/lib/api/contracts/workspace-file-folders'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
performRestoreWorkspaceFileFolder,
|
||||
workspaceFilesOrchestrationStatus,
|
||||
} from '@/lib/workspace-files/orchestration'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceFileFolderRestoreAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(restoreWorkspaceFileFolderContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, folderId } = parsed.data.params
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (permission !== 'admin' && permission !== 'write') {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await performRestoreWorkspaceFileFolder({
|
||||
workspaceId,
|
||||
folderId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: result.error },
|
||||
{ status: workspaceFilesOrchestrationStatus(result.errorCode) }
|
||||
)
|
||||
}
|
||||
const { folder, restoredItems } = result
|
||||
if (!folder || !restoredItems) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to restore workspace file folder' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`Restored workspace file folder: ${folderId}`)
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'folder_restored',
|
||||
{ folder_id: folderId, workspace_id: workspaceId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
return NextResponse.json({ success: true, folder, restoredItems })
|
||||
} catch (error) {
|
||||
logger.error('Failed to restore workspace file folder:', error)
|
||||
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
deleteWorkspaceFileFolderContract,
|
||||
updateWorkspaceFileFolderContract,
|
||||
} from '@/lib/api/contracts/workspace-file-folders'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
performDeleteWorkspaceFileItems,
|
||||
performUpdateWorkspaceFileFolder,
|
||||
workspaceFilesOrchestrationStatus,
|
||||
} from '@/lib/workspace-files/orchestration'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceFileFolderAPI')
|
||||
|
||||
async function assertWritePermission(userId: string, workspaceId: string) {
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
return permission === 'admin' || permission === 'write'
|
||||
}
|
||||
|
||||
export const PATCH = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateWorkspaceFileFolderContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, folderId } = parsed.data.params
|
||||
|
||||
if (!(await assertWritePermission(session.user.id, workspaceId))) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await performUpdateWorkspaceFileFolder({
|
||||
workspaceId,
|
||||
folderId,
|
||||
userId: session.user.id,
|
||||
...parsed.data.body,
|
||||
})
|
||||
if (!result.success || !result.folder) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: result.error },
|
||||
{ status: workspaceFilesOrchestrationStatus(result.errorCode) }
|
||||
)
|
||||
}
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'folder_renamed',
|
||||
{ workspace_id: workspaceId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
return NextResponse.json({ success: true, folder: result.folder })
|
||||
} catch (error) {
|
||||
logger.error('Failed to update workspace file folder:', error)
|
||||
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(deleteWorkspaceFileFolderContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, folderId } = parsed.data.params
|
||||
|
||||
if (!(await assertWritePermission(session.user.id, workspaceId))) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await performDeleteWorkspaceFileItems({
|
||||
workspaceId,
|
||||
userId: session.user.id,
|
||||
folderIds: [folderId],
|
||||
})
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: result.error },
|
||||
{
|
||||
status:
|
||||
result.errorCode === 'validation'
|
||||
? 400
|
||||
: result.errorCode === 'not_found'
|
||||
? 404
|
||||
: 500,
|
||||
}
|
||||
)
|
||||
}
|
||||
if (!result.deletedItems) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete workspace file folder' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'folder_deleted',
|
||||
{ workspace_id: workspaceId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, deletedItems: result.deletedItems })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete workspace file folder:', error)
|
||||
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
createWorkspaceFileFolderContract,
|
||||
listWorkspaceFileFoldersContract,
|
||||
} from '@/lib/api/contracts/workspace-file-folders'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace'
|
||||
import {
|
||||
performCreateWorkspaceFileFolder,
|
||||
workspaceFilesOrchestrationStatus,
|
||||
} from '@/lib/workspace-files/orchestration'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceFileFoldersAPI')
|
||||
|
||||
async function getWorkspacePermission(userId: string, workspaceId: string) {
|
||||
return getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(listWorkspaceFileFoldersContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { scope } = parsed.data.query
|
||||
|
||||
const permission = await getWorkspacePermission(session.user.id, workspaceId)
|
||||
if (!permission) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const folders = await listWorkspaceFileFolders(workspaceId, { scope })
|
||||
return NextResponse.json({ success: true, folders })
|
||||
}
|
||||
)
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(createWorkspaceFileFolderContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { name, parentId } = parsed.data.body
|
||||
|
||||
const permission = await getWorkspacePermission(session.user.id, workspaceId)
|
||||
if (permission !== 'admin' && permission !== 'write') {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await performCreateWorkspaceFileFolder({
|
||||
workspaceId,
|
||||
userId: session.user.id,
|
||||
name,
|
||||
parentId,
|
||||
})
|
||||
if (!result.success || !result.folder) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: result.error },
|
||||
{ status: workspaceFilesOrchestrationStatus(result.errorCode) }
|
||||
)
|
||||
}
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'folder_created',
|
||||
{ workspace_id: workspaceId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
return NextResponse.json({ success: true, folder: result.folder })
|
||||
} catch (error) {
|
||||
logger.error('Failed to create workspace file folder:', error)
|
||||
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetSession, mockGetPerms, mockResolveImage, mockDownloadFile } = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockGetPerms: vi.fn(),
|
||||
mockResolveImage: vi.fn(),
|
||||
mockDownloadFile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
auth: { api: { getSession: vi.fn() } },
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetPerms }))
|
||||
vi.mock('@/lib/uploads/server/inline-image', () => ({
|
||||
resolveWorkspaceInlineImage: mockResolveImage,
|
||||
}))
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile }))
|
||||
|
||||
import { GET } from '@/app/api/workspaces/[id]/files/inline/route'
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00])
|
||||
const params = { params: Promise.resolve({ id: 'ws-1' }) }
|
||||
const req = (q: string) => new NextRequest(`http://localhost/api/workspaces/ws-1/files/inline?${q}`)
|
||||
|
||||
describe('GET /api/workspaces/[id]/files/inline', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'u1' } })
|
||||
mockGetPerms.mockResolvedValue('read')
|
||||
mockResolveImage.mockResolvedValue({
|
||||
key: 'workspace/ws-1/x-photo.png',
|
||||
contentType: 'image/png',
|
||||
filename: 'photo.png',
|
||||
})
|
||||
mockDownloadFile.mockResolvedValue(PNG)
|
||||
})
|
||||
|
||||
it('serves a workspace-scoped image by fileId', async () => {
|
||||
const res = await GET(req('fileId=wf_abc'), params)
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockResolveImage).toHaveBeenCalledWith('ws-1', { fileId: 'wf_abc' })
|
||||
})
|
||||
|
||||
it('serves a workspace-scoped image by key', async () => {
|
||||
const res = await GET(req(`key=${encodeURIComponent('workspace/ws-1/x-photo.png')}`), params)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('404s when the reference does not resolve in the workspace (cross-workspace)', async () => {
|
||||
mockResolveImage.mockResolvedValue(null)
|
||||
const res = await GET(req('fileId=wf_other'), params)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('404s without workspace membership, before resolving the file', async () => {
|
||||
mockGetPerms.mockResolvedValue(null)
|
||||
const res = await GET(req('fileId=wf_abc'), params)
|
||||
expect(res.status).toBe(404)
|
||||
expect(mockResolveImage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('401s without a session', async () => {
|
||||
mockGetSession.mockResolvedValue(null)
|
||||
const res = await GET(req('fileId=wf_abc'), params)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('400s when neither key nor fileId is provided', async () => {
|
||||
const res = await GET(req(''), params)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getInlineWorkspaceFileContract } from '@/lib/api/contracts/workspace-files'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { serveInlineImage } from '@/app/api/files/serve-inline-image'
|
||||
import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkspaceInlineFileAPI')
|
||||
|
||||
/**
|
||||
* GET /api/workspaces/[id]/files/inline?key=<cloudKey>|fileId=<id>
|
||||
*
|
||||
* Serves an image embedded in a workspace markdown document, **scoped to the workspace in the path**.
|
||||
* The markdown editor rewrites its embedded `/api/files/serve/<key>` and `/api/files/view/<id>` srcs to
|
||||
* this route so a referenced file resolves only within the document's workspace — a cross-workspace
|
||||
* reference returns 404 and does not render, even for a viewer who belongs to the other workspace. Read
|
||||
* access to the workspace is required; disposition/content-type handling mirrors the serve route.
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const parsed = await parseRequest(getInlineWorkspaceFileContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const ref = parsed.data.query
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Authorize before disclosing anything; deny with 404 so a non-member can't probe existence.
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (!permission) {
|
||||
throw new FileNotFoundError('Not found')
|
||||
}
|
||||
|
||||
const image = await resolveWorkspaceInlineImage(workspaceId, ref)
|
||||
if (!image) {
|
||||
throw new FileNotFoundError('Not found')
|
||||
}
|
||||
|
||||
return await serveInlineImage(image, { sniff: false })
|
||||
} catch (error) {
|
||||
if (error instanceof FileNotFoundError) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
logger.error('Error serving workspace inline image:', error)
|
||||
return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file'))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { moveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceFileMoveAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(moveWorkspaceFileItemsContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { fileIds, folderIds, targetFolderId } = parsed.data.body
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (permission !== 'admin' && permission !== 'write') {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await performMoveWorkspaceFileItems({
|
||||
workspaceId,
|
||||
userId: session.user.id,
|
||||
fileIds,
|
||||
folderIds,
|
||||
targetFolderId,
|
||||
})
|
||||
if (!result.success || !result.movedItems) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: result.error },
|
||||
{
|
||||
status:
|
||||
result.errorCode === 'conflict'
|
||||
? 409
|
||||
: result.errorCode === 'not_found'
|
||||
? 404
|
||||
: result.errorCode === 'validation'
|
||||
? 400
|
||||
: 500,
|
||||
}
|
||||
)
|
||||
}
|
||||
if (fileIds.length > 0) {
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'file_moved',
|
||||
{ workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
}
|
||||
if (folderIds.length > 0) {
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'folder_moved',
|
||||
{ workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
movedItems: result.movedItems,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to move workspace file items:', error)
|
||||
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
authMockFns,
|
||||
permissionsMock,
|
||||
permissionsMockFns,
|
||||
storageServiceMock,
|
||||
storageServiceMockFns,
|
||||
} from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockCheckStorageQuota, mockGenerateWorkspaceFileKey, mockUseBlobStorage } = vi.hoisted(
|
||||
() => ({
|
||||
mockCheckStorageQuota: vi.fn(),
|
||||
mockGenerateWorkspaceFileKey: vi.fn(),
|
||||
mockUseBlobStorage: { value: false },
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/billing/storage', () => ({
|
||||
checkStorageQuota: mockCheckStorageQuota,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
generateWorkspaceFileKey: mockGenerateWorkspaceFileKey,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/config', () => ({
|
||||
get USE_BLOB_STORAGE() {
|
||||
return mockUseBlobStorage.value
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
|
||||
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
|
||||
|
||||
import { POST } from '@/app/api/workspaces/[id]/files/presigned/route'
|
||||
|
||||
const params = (id = WS) => ({ params: Promise.resolve({ id }) })
|
||||
|
||||
const makeRequest = (body: unknown) =>
|
||||
new NextRequest(`http://localhost/api/workspaces/${WS}/files/presigned`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const validBody = {
|
||||
fileName: 'video.mp4',
|
||||
contentType: 'video/mp4',
|
||||
fileSize: 10 * 1024 * 1024,
|
||||
}
|
||||
|
||||
describe('POST /api/workspaces/[id]/files/presigned', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
mockCheckStorageQuota.mockResolvedValue({ allowed: true })
|
||||
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
|
||||
mockGenerateWorkspaceFileKey.mockReturnValue(`workspace/${WS}/123-abc-video.mp4`)
|
||||
storageServiceMockFns.mockGeneratePresignedUploadUrl.mockResolvedValue({
|
||||
url: 'https://s3/presigned',
|
||||
key: `workspace/${WS}/123-abc-video.mp4`,
|
||||
uploadHeaders: { 'Content-Type': 'video/mp4' },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValueOnce(null)
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 when user has read-only permission', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read')
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 400 for missing fileName', async () => {
|
||||
const res = await POST(makeRequest({ ...validBody, fileName: '' }), params())
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 for negative fileSize', async () => {
|
||||
const res = await POST(makeRequest({ ...validBody, fileSize: -1 }), params())
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('accepts fileSize === 0 (empty new files)', async () => {
|
||||
const res = await POST(makeRequest({ ...validBody, fileSize: 0 }), params())
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('returns 413 when fileSize exceeds 5 GiB ceiling', async () => {
|
||||
const res = await POST(
|
||||
makeRequest({ ...validBody, fileSize: 6 * 1024 * 1024 * 1024 }),
|
||||
params()
|
||||
)
|
||||
expect(res.status).toBe(413)
|
||||
})
|
||||
|
||||
it('returns 413 when storage quota would be exceeded', async () => {
|
||||
mockCheckStorageQuota.mockResolvedValueOnce({ allowed: false, error: 'Over quota' })
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(413)
|
||||
expect(body.error).toBe('Over quota')
|
||||
})
|
||||
|
||||
it('returns local fallback signal when cloud storage is not configured', async () => {
|
||||
storageServiceMockFns.mockHasCloudStorage.mockReturnValueOnce(false)
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.directUploadSupported).toBe(false)
|
||||
expect(body.presignedUrl).toBe('')
|
||||
expect(body.fileInfo.name).toBe('video.mp4')
|
||||
expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('issues a presigned URL bound to the workspace', async () => {
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.directUploadSupported).toBe(true)
|
||||
expect(body.presignedUrl).toBe('https://s3/presigned')
|
||||
expect(body.fileInfo.key).toBe(`workspace/${WS}/123-abc-video.mp4`)
|
||||
expect(body.fileInfo.path).toContain('?context=workspace')
|
||||
expect(body.fileInfo.path).toContain('s3')
|
||||
expect(body.uploadHeaders).toEqual({ 'Content-Type': 'video/mp4' })
|
||||
|
||||
expect(mockGenerateWorkspaceFileKey).toHaveBeenCalledWith(WS, 'video.mp4')
|
||||
expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: 'workspace',
|
||||
userId: 'user-1',
|
||||
customKey: `workspace/${WS}/123-abc-video.mp4`,
|
||||
metadata: { workspaceId: WS },
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('serves blob path when blob storage is configured', async () => {
|
||||
mockUseBlobStorage.value = true
|
||||
try {
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
const body = await res.json()
|
||||
expect(body.fileInfo.path).toContain('/blob/')
|
||||
} finally {
|
||||
mockUseBlobStorage.value = false
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { workspacePresignedUploadContract } from '@/lib/api/contracts/workspace-files'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkStorageQuota } from '@/lib/billing/storage'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
|
||||
import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace'
|
||||
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service'
|
||||
import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspacePresignedAPI')
|
||||
|
||||
/**
|
||||
* POST /api/workspaces/[id]/files/presigned
|
||||
* Returns a presigned PUT URL for a workspace-scoped object key. The client
|
||||
* uploads the bytes directly to S3/Blob, then calls /files/register to
|
||||
* insert metadata.
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
const parsed = await parseRequest(workspacePresignedUploadContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { params, body } = parsed.data
|
||||
const workspaceId = params.id
|
||||
const { fileName, contentType, fileSize, folderId } = body
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin' && permission !== 'write') {
|
||||
logger.warn(`User ${userId} lacks write permission for ${workspaceId}`)
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (fileSize > MAX_WORKSPACE_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` },
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
|
||||
let targetFolderId: string | null
|
||||
try {
|
||||
targetFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Invalid target folder') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasCloudStorage()) {
|
||||
logger.info(`Local storage detected, signaling API fallback for ${fileName}`)
|
||||
return NextResponse.json({
|
||||
fileName,
|
||||
presignedUrl: '',
|
||||
fileInfo: { path: '', key: '', name: fileName, size: fileSize, type: contentType },
|
||||
directUploadSupported: false,
|
||||
})
|
||||
}
|
||||
|
||||
const quotaCheck = await checkStorageQuota(userId, fileSize)
|
||||
if (!quotaCheck.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: quotaCheck.error || 'Storage limit exceeded' },
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
|
||||
const key = generateWorkspaceFileKey(workspaceId, fileName)
|
||||
const presigned = await generatePresignedUploadUrl({
|
||||
fileName,
|
||||
contentType,
|
||||
fileSize,
|
||||
context: 'workspace',
|
||||
userId,
|
||||
customKey: key,
|
||||
expirationSeconds: 3600,
|
||||
metadata: { workspaceId, ...(targetFolderId ? { folderId: targetFolderId } : {}) },
|
||||
})
|
||||
|
||||
const finalPath = `/api/files/serve/${USE_BLOB_STORAGE ? 'blob' : 's3'}/${encodeURIComponent(key)}?context=workspace`
|
||||
|
||||
logger.info(`Issued workspace presigned URL for ${fileName} -> ${key}`)
|
||||
|
||||
return NextResponse.json({
|
||||
fileName,
|
||||
presignedUrl: presigned.url,
|
||||
fileInfo: {
|
||||
path: finalPath,
|
||||
key: presigned.key,
|
||||
name: fileName,
|
||||
size: fileSize,
|
||||
type: contentType,
|
||||
},
|
||||
uploadHeaders: presigned.uploadHeaders,
|
||||
directUploadSupported: true,
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
auditMock,
|
||||
auditMockFns,
|
||||
authMockFns,
|
||||
permissionsMock,
|
||||
permissionsMockFns,
|
||||
posthogServerMock,
|
||||
posthogServerMockFns,
|
||||
} from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockRegisterUploadedWorkspaceFile, mockParseWorkspaceFileKey, FileConflictErrorImpl } =
|
||||
vi.hoisted(() => {
|
||||
class FileConflictErrorImpl extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'FileConflictError'
|
||||
}
|
||||
}
|
||||
return {
|
||||
mockRegisterUploadedWorkspaceFile: vi.fn(),
|
||||
mockParseWorkspaceFileKey: vi.fn(),
|
||||
FileConflictErrorImpl,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace', () => ({
|
||||
registerUploadedWorkspaceFile: mockRegisterUploadedWorkspaceFile,
|
||||
parseWorkspaceFileKey: mockParseWorkspaceFileKey,
|
||||
FileConflictError: FileConflictErrorImpl,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/posthog/server', () => posthogServerMock)
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
|
||||
const VALID_KEY = `workspace/${WS}/123-abc-video.mp4`
|
||||
|
||||
import { POST } from '@/app/api/workspaces/[id]/files/register/route'
|
||||
|
||||
const params = (id = WS) => ({ params: Promise.resolve({ id }) })
|
||||
|
||||
const makeRequest = (body: unknown) =>
|
||||
new NextRequest(`http://localhost/api/workspaces/${WS}/files/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const validBody = {
|
||||
key: VALID_KEY,
|
||||
name: 'video.mp4',
|
||||
contentType: 'video/mp4',
|
||||
}
|
||||
|
||||
describe('POST /api/workspaces/[id]/files/register', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authMockFns.mockGetSession.mockResolvedValue({
|
||||
user: { id: 'user-1', name: 'User One', email: 'u@example.com' },
|
||||
})
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
mockParseWorkspaceFileKey.mockImplementation((key: string) => {
|
||||
const match = key.match(/^workspace\/([^/]+)\//)
|
||||
return match ? match[1] : null
|
||||
})
|
||||
mockRegisterUploadedWorkspaceFile.mockResolvedValue({
|
||||
file: {
|
||||
id: 'wf_123',
|
||||
name: 'video.mp4',
|
||||
size: 10 * 1024 * 1024,
|
||||
type: 'video/mp4',
|
||||
url: '/api/files/serve/...',
|
||||
key: VALID_KEY,
|
||||
context: 'workspace',
|
||||
},
|
||||
created: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValueOnce(null)
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 when user lacks write permission', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read')
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects keys belonging to a different workspace', async () => {
|
||||
const otherWsKey = `workspace/00000000-0000-0000-0000-000000000000/123-abc-video.mp4`
|
||||
const res = await POST(makeRequest({ ...validBody, key: otherWsKey }), params())
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error).toContain('does not belong')
|
||||
expect(mockRegisterUploadedWorkspaceFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 for empty key/name', async () => {
|
||||
const res = await POST(makeRequest({ ...validBody, key: '' }), params())
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when storage object is missing', async () => {
|
||||
mockRegisterUploadedWorkspaceFile.mockRejectedValueOnce(
|
||||
new Error('Uploaded object not found in storage')
|
||||
)
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 409 on duplicate file conflict', async () => {
|
||||
mockRegisterUploadedWorkspaceFile.mockRejectedValueOnce(new FileConflictErrorImpl('video.mp4'))
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(409)
|
||||
expect(body.isDuplicate).toBe(true)
|
||||
})
|
||||
|
||||
it('skips audit + analytics on idempotent re-register (created=false)', async () => {
|
||||
mockRegisterUploadedWorkspaceFile.mockResolvedValueOnce({
|
||||
file: {
|
||||
id: 'wf_123',
|
||||
name: 'video.mp4',
|
||||
size: 10 * 1024 * 1024,
|
||||
type: 'video/mp4',
|
||||
url: '/api/files/serve/...',
|
||||
key: VALID_KEY,
|
||||
context: 'workspace',
|
||||
},
|
||||
created: false,
|
||||
})
|
||||
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(posthogServerMockFns.mockCaptureServerEvent).not.toHaveBeenCalled()
|
||||
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('finalizes upload, records audit and analytics', async () => {
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.file).toMatchObject({ id: 'wf_123', key: VALID_KEY })
|
||||
|
||||
expect(mockRegisterUploadedWorkspaceFile).toHaveBeenCalledWith({
|
||||
workspaceId: WS,
|
||||
userId: 'user-1',
|
||||
key: VALID_KEY,
|
||||
originalName: 'video.mp4',
|
||||
contentType: 'video/mp4',
|
||||
})
|
||||
|
||||
expect(posthogServerMockFns.mockCaptureServerEvent).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'file_uploaded',
|
||||
expect.objectContaining({ workspace_id: WS, file_type: 'video/mp4' }),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
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 { registerWorkspaceFileContract } from '@/lib/api/contracts/workspace-files'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
FileConflictError,
|
||||
parseWorkspaceFileKey,
|
||||
registerUploadedWorkspaceFile,
|
||||
} from '@/lib/uploads/contexts/workspace'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceRegisterAPI')
|
||||
|
||||
/**
|
||||
* POST /api/workspaces/[id]/files/register
|
||||
* Finalize a direct-to-storage upload by inserting metadata, updating quota,
|
||||
* and recording an audit log. Validates the storage key belongs to the
|
||||
* caller's workspace to prevent cross-tenant key smuggling.
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
const parsed = await parseRequest(registerWorkspaceFileContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { params, body } = parsed.data
|
||||
const workspaceId = params.id
|
||||
const { key, name, contentType, folderId } = body
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin' && permission !== 'write') {
|
||||
logger.warn(`User ${userId} lacks write permission for ${workspaceId}`)
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (parseWorkspaceFileKey(key) !== workspaceId) {
|
||||
logger.warn(`Key ${key} does not belong to workspace ${workspaceId}`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Storage key does not belong to this workspace' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { file: userFile, created } = await registerUploadedWorkspaceFile({
|
||||
workspaceId,
|
||||
userId,
|
||||
key,
|
||||
originalName: name,
|
||||
contentType,
|
||||
folderId,
|
||||
})
|
||||
|
||||
if (created) {
|
||||
logger.info(`Registered direct upload ${name} -> ${key}`)
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'file_uploaded',
|
||||
{ workspace_id: workspaceId, file_type: contentType },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
action: AuditAction.FILE_UPLOADED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: userFile.id,
|
||||
resourceName: name,
|
||||
description: `Uploaded file "${name}"`,
|
||||
metadata: { fileSize: userFile.size, fileType: contentType },
|
||||
request,
|
||||
})
|
||||
} else {
|
||||
logger.info(`Idempotent re-register for existing upload ${name} -> ${key}`)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, file: userFile })
|
||||
} catch (error) {
|
||||
logger.error('Failed to register workspace file:', error)
|
||||
|
||||
const errorMessage = getErrorMessage(error, 'Failed to register file')
|
||||
const isDuplicate =
|
||||
error instanceof FileConflictError || errorMessage.includes('already exists')
|
||||
const isMissing = errorMessage.includes('not found in storage')
|
||||
|
||||
const status = isDuplicate ? 409 : isMissing ? 404 : 500
|
||||
return NextResponse.json({ success: false, error: errorMessage, isDuplicate }, { status })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Tests for the workspace files upload route's bounded multipart read.
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { authMockFns, permissionsMock, permissionsMockFns, posthogServerMock } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockUploadWorkspaceFile, mockGetSharesForResources, mockRecordAudit } = vi.hoisted(() => ({
|
||||
mockUploadWorkspaceFile: vi.fn(),
|
||||
mockGetSharesForResources: vi.fn(),
|
||||
mockRecordAudit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace', () => ({
|
||||
uploadWorkspaceFile: mockUploadWorkspaceFile,
|
||||
FileConflictError: class FileConflictError extends Error {},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/shared/types', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/uploads/shared/types')>()
|
||||
return {
|
||||
...actual,
|
||||
MAX_WORKSPACE_FORMDATA_FILE_SIZE: 1024,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/public-shares/share-manager', () => ({
|
||||
getSharesForResources: mockGetSharesForResources,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/posthog/server', () => posthogServerMock)
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
vi.mock('@/app/api/workflows/utils', () => ({
|
||||
verifyWorkspaceMembership: vi.fn().mockResolvedValue('write'),
|
||||
}))
|
||||
vi.mock('@sim/audit', () => ({
|
||||
recordAudit: mockRecordAudit,
|
||||
AuditAction: { FILE_UPLOADED: 'file_uploaded' },
|
||||
AuditResourceType: { FILE: 'file' },
|
||||
}))
|
||||
|
||||
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
|
||||
|
||||
import { POST } from '@/app/api/workspaces/[id]/files/route'
|
||||
|
||||
const routeContext = { params: Promise.resolve({ id: WS }) }
|
||||
|
||||
function buildFormData(file: File): FormData {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return formData
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a pull-based stream that emits fixed-size chunks on demand, so the
|
||||
* size-capped reader's `reader.cancel()` simply stops future `pull` calls
|
||||
* instead of racing an external (e.g. undici FormData) chunk producer.
|
||||
*/
|
||||
function makeChunkedOverLimitBody(
|
||||
chunkBytes: number,
|
||||
chunkCount: number
|
||||
): ReadableStream<Uint8Array> {
|
||||
let emitted = 0
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (emitted >= chunkCount) {
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
emitted++
|
||||
controller.enqueue(new Uint8Array(chunkBytes))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('workspace files upload route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
mockGetSharesForResources.mockResolvedValue(new Map())
|
||||
mockUploadWorkspaceFile.mockResolvedValue({
|
||||
id: 'file-1',
|
||||
name: 'file.txt',
|
||||
url: 'https://example.com/file.txt',
|
||||
size: 11,
|
||||
type: 'text/plain',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a declared content-length above the limit before reading the body', async () => {
|
||||
const formData = buildFormData(new File(['x'.repeat(10)], 'file.txt', { type: 'text/plain' }))
|
||||
const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-length': String(10 * 1024 * 1024) },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const response = await POST(req, routeContext)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(413)
|
||||
expect(data.error).toContain('exceeds maximum size')
|
||||
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a chunked body without content-length once the streamed size trips the cap', async () => {
|
||||
const body = makeChunkedOverLimitBody(64 * 1024, 32)
|
||||
const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
// @ts-expect-error - duplex is required by undici for streamed bodies but missing from NextRequestInit types
|
||||
duplex: 'half',
|
||||
})
|
||||
expect(req.headers.get('content-length')).toBeNull()
|
||||
|
||||
const response = await POST(req, routeContext)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(413)
|
||||
expect(data.error).toContain('exceeds maximum size')
|
||||
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uploads a normal, well-under-limit file successfully', async () => {
|
||||
const file = new File(['hello world'], 'file.txt', { type: 'text/plain' })
|
||||
const formData = buildFormData(file)
|
||||
const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-length': '512' },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const response = await POST(req, routeContext)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
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 {
|
||||
listWorkspaceFilesQuerySchema,
|
||||
workspaceFilesParamsSchema,
|
||||
} from '@/lib/api/contracts/workspace-files'
|
||||
import { getValidationErrorMessage } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import {
|
||||
isPayloadSizeLimitError,
|
||||
MAX_MULTIPART_OVERHEAD_BYTES,
|
||||
readFormDataWithLimit,
|
||||
} from '@/lib/core/utils/stream-limits'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getSharesForResources } from '@/lib/public-shares/share-manager'
|
||||
import {
|
||||
FileConflictError,
|
||||
listWorkspaceFiles,
|
||||
uploadWorkspaceFile,
|
||||
} from '@/lib/uploads/contexts/workspace'
|
||||
import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkspaceFilesAPI')
|
||||
|
||||
/**
|
||||
* GET /api/workspaces/[id]/files
|
||||
* List all files for a workspace (requires read permission)
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const paramsResult = workspaceFilesParamsSchema.safeParse(await params)
|
||||
if (!paramsResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { id: workspaceId } = paramsResult.data
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check workspace permissions (requires read)
|
||||
const userPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!userPermission) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const queryResult = listWorkspaceFilesQuerySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams.entries())
|
||||
)
|
||||
if (!queryResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(queryResult.error, 'Invalid scope') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { scope } = queryResult.data
|
||||
|
||||
const files = await listWorkspaceFiles(workspaceId, { scope })
|
||||
|
||||
const shares = await getSharesForResources(
|
||||
'file',
|
||||
files.map((file) => file.id)
|
||||
)
|
||||
const filesWithShares = files.map((file) => ({
|
||||
...file,
|
||||
share: shares.get(file.id) ?? null,
|
||||
}))
|
||||
|
||||
logger.info(`[${requestId}] Listed ${files.length} files for workspace ${workspaceId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
files: filesWithShares,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error listing workspace files:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Failed to list files'),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/workspaces/[id]/files
|
||||
* Upload a new file to workspace storage (requires write permission)
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const paramsResult = workspaceFilesParamsSchema.safeParse(await params)
|
||||
if (!paramsResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { id: workspaceId } = paramsResult.data
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check workspace permissions (requires write)
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceId
|
||||
)
|
||||
if (userPermission !== 'admin' && userPermission !== 'write') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
let formData: FormData
|
||||
try {
|
||||
formData = await readFormDataWithLimit(request, {
|
||||
maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES,
|
||||
label: 'workspace file upload body',
|
||||
})
|
||||
} catch (error) {
|
||||
if (isPayloadSizeLimitError(error)) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 })
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Request body must be valid multipart form data' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const rawFile = formData.get('file')
|
||||
const rawFolderId = formData.get('folderId')
|
||||
const folderId =
|
||||
typeof rawFolderId === 'string' && rawFolderId.length > 0 ? rawFolderId : null
|
||||
|
||||
if (!rawFile || !(rawFile instanceof File)) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const fileName = rawFile.name || 'untitled.md'
|
||||
|
||||
if (rawFile.size > MAX_WORKSPACE_FORMDATA_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `File size exceeds maximum of ${MAX_WORKSPACE_FORMDATA_FILE_SIZE} bytes (${(rawFile.size / (1024 * 1024)).toFixed(2)}MB)`,
|
||||
},
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await rawFile.arrayBuffer())
|
||||
|
||||
const userFile = await uploadWorkspaceFile(
|
||||
workspaceId,
|
||||
session.user.id,
|
||||
buffer,
|
||||
fileName,
|
||||
rawFile.type || 'application/octet-stream',
|
||||
{ folderId }
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`)
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'file_uploaded',
|
||||
{ workspace_id: workspaceId, file_type: rawFile.type || 'application/octet-stream' },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
action: AuditAction.FILE_UPLOADED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: userFile.id,
|
||||
resourceName: fileName,
|
||||
description: `Uploaded file "${fileName}"`,
|
||||
metadata: { fileSize: rawFile.size, fileType: rawFile.type || 'application/octet-stream' },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
file: userFile,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error uploading workspace file:`, error)
|
||||
|
||||
const errorMessage = getErrorMessage(error, 'Failed to upload file')
|
||||
const isDuplicate =
|
||||
error instanceof FileConflictError || errorMessage.includes('already exists')
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
isDuplicate,
|
||||
},
|
||||
{ status: isDuplicate ? 409 : 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getForkAvailabilityContract } from '@/lib/api/contracts/workspace-fork'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
|
||||
/**
|
||||
* Whether forking is available for this workspace: the server-evaluated verdict of the
|
||||
* same gate every fork route enforces (env/plan + the `workspace-forking` AppConfig
|
||||
* rollout flag). Member-readable — it only reveals feature on/off, and the client uses
|
||||
* it to show/hide the Forks settings tab and context-menu entries.
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getForkAvailabilityContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
|
||||
const access = await checkWorkspaceAccess(id, session.user.id)
|
||||
if (!access.exists || !access.workspace) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const available = await isForkingAvailableForWorkspace(
|
||||
access.workspace.organizationId,
|
||||
session.user.id
|
||||
)
|
||||
return NextResponse.json({ available })
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,217 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getForkDiffContract } from '@/lib/api/contracts/workspace-fork'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { loadTargetDraftSubBlocks } from '@/ee/workspace-forking/lib/copy/copy-workflows'
|
||||
import { loadSourceDeployedStates } from '@/ee/workspace-forking/lib/copy/deploy-bridge'
|
||||
import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store'
|
||||
import {
|
||||
collectForkDependentReconfigs,
|
||||
collectForkResourceUsages,
|
||||
} from '@/ee/workspace-forking/lib/mapping/dependent-reconfigs'
|
||||
import {
|
||||
forkDependentValueKey,
|
||||
loadForkDependentValues,
|
||||
} from '@/ee/workspace-forking/lib/mapping/dependent-value-store'
|
||||
import { listForkResourceCandidates } from '@/ee/workspace-forking/lib/mapping/resources'
|
||||
import {
|
||||
annotateForkClearedRefSourceLiveness,
|
||||
collectForkClearedRefCandidates,
|
||||
} from '@/ee/workspace-forking/lib/promote/cleared-refs'
|
||||
import { computeForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan'
|
||||
import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
|
||||
import { readTargetDraftDependentValue } from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getForkDiffContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
const { otherWorkspaceId, direction } = parsed.data.query
|
||||
|
||||
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
|
||||
|
||||
const { deployedWorkflows, sourceStates } = await loadSourceDeployedStates(
|
||||
auth.sourceWorkspaceId
|
||||
)
|
||||
const plan = await computeForkPromotePlan({
|
||||
executor: db,
|
||||
edge: auth.edge,
|
||||
sourceWorkspaceId: auth.sourceWorkspaceId,
|
||||
targetWorkspaceId: auth.targetWorkspaceId,
|
||||
direction,
|
||||
deployedSourceWorkflows: deployedWorkflows,
|
||||
sourceStates,
|
||||
})
|
||||
|
||||
// Resolve dependent-reconfig target block ids through the SAME persisted block map the
|
||||
// sync will use, so a re-pick the modal keys by target block id lands on the block the
|
||||
// promote actually writes (on push that's the parent's original id, not a derived one).
|
||||
const sourceIsParent = auth.sourceWorkspaceId === auth.edge.parentWorkspaceId
|
||||
const blockMap = await loadForkBlockMap(db, auth.edge.childWorkspaceId)
|
||||
const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap)
|
||||
|
||||
// Stored dependent values are the source of truth for what each selector is set to. Overlay
|
||||
// them as each field's currentValue so the modal pre-fills what the user actually saved.
|
||||
// Before the FIRST sync populates the store (fork-create seeds mappings but no dependent
|
||||
// values), the fallback is the TARGET's own configured value (loaded from its draft) - never
|
||||
// the source's, which would overwrite the target's selection. The stored read spans EVERY
|
||||
// plan target: a create-mode (never-synced) workflow's deterministic target id is what the
|
||||
// first sync will use, so values pre-configured for it in the mapping editor pre-fill here
|
||||
// too. The draft read stays replace-scoped (creates have no target draft to fall back to).
|
||||
const replaceTargetIds = plan.items
|
||||
.filter((item) => item.mode === 'replace')
|
||||
.map((item) => item.targetWorkflowId)
|
||||
const allTargetIds = plan.items.map((item) => item.targetWorkflowId)
|
||||
const [storedValues, targetDraftByWorkflow, sourceCandidates, sourceWorkflowRows] =
|
||||
await Promise.all([
|
||||
loadForkDependentValues(db, auth.edge.childWorkspaceId, allTargetIds),
|
||||
loadTargetDraftSubBlocks(db, replaceTargetIds),
|
||||
// Source resource labels (per kind) + workflow names, for the cleared-ref list's display.
|
||||
listForkResourceCandidates(db, auth.sourceWorkspaceId),
|
||||
db
|
||||
.select({ id: workflow.id, name: workflow.name })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, auth.sourceWorkspaceId)),
|
||||
])
|
||||
const storedByKey = new Map(
|
||||
storedValues.map((entry) => [
|
||||
forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey),
|
||||
entry.value,
|
||||
])
|
||||
)
|
||||
|
||||
// Source block subBlocks keyed by their resolved target identity, so the first-sync draft
|
||||
// fallback can identity-check a nested tool against the SOURCE dependent tool it came from -
|
||||
// an index alone may point at a different tool in the target draft, whose value isn't the
|
||||
// dependent's. Read structurally (only each subblock's `value`), so the in-memory state's
|
||||
// blocks pass without a cast.
|
||||
const sourceBlocksByTarget = new Map<string, Map<string, Record<string, { value?: unknown }>>>()
|
||||
for (const item of plan.items) {
|
||||
if (item.mode !== 'replace') continue
|
||||
const state = sourceStates.get(item.sourceWorkflowId)
|
||||
if (!state) continue
|
||||
const byBlock = new Map<string, Record<string, { value?: unknown }>>()
|
||||
for (const [sourceBlockId, block] of Object.entries(state.blocks)) {
|
||||
byBlock.set(resolveBlockId(item.targetWorkflowId, sourceBlockId), block.subBlocks ?? {})
|
||||
}
|
||||
sourceBlocksByTarget.set(item.targetWorkflowId, byBlock)
|
||||
}
|
||||
|
||||
// Replace-target fields pre-fill from the store, falling back to the TARGET's own draft
|
||||
// value before the first sync populates the store (never the source's, which would
|
||||
// overwrite the target's selection). Create-target fields (never-synced workflows)
|
||||
// pre-fill from the store, falling back to the SOURCE value the collector emitted -
|
||||
// that's exactly what the first sync copies verbatim, so the pre-fill is honest and
|
||||
// configuring it ahead of the first sync is possible (the deterministic target ids
|
||||
// already exist).
|
||||
const dependentReconfigs = [
|
||||
...collectForkDependentReconfigs(plan.items, sourceStates, resolveBlockId).map((field) => ({
|
||||
...field,
|
||||
currentValue:
|
||||
storedByKey.get(
|
||||
forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
|
||||
) ??
|
||||
readTargetDraftDependentValue(
|
||||
targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId),
|
||||
sourceBlocksByTarget.get(field.targetWorkflowId)?.get(field.targetBlockId),
|
||||
field.subBlockKey
|
||||
),
|
||||
})),
|
||||
...collectForkDependentReconfigs(plan.items, sourceStates, resolveBlockId, 'create').map(
|
||||
(field) => ({
|
||||
...field,
|
||||
currentValue:
|
||||
storedByKey.get(
|
||||
forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
|
||||
) ?? field.currentValue,
|
||||
})
|
||||
),
|
||||
]
|
||||
|
||||
// References this sync will blank in the target (per block/field), for the pre-sync cleared-ref
|
||||
// list. Labels resolve from the source candidate lists + workflow names loaded above.
|
||||
const sourceLabels = new Map<string, string>()
|
||||
for (const [kind, candidates] of Object.entries(sourceCandidates)) {
|
||||
for (const candidate of candidates)
|
||||
sourceLabels.set(`${kind}:${candidate.id}`, candidate.label)
|
||||
}
|
||||
const sourceWorkflowNames = new Map(sourceWorkflowRows.map((row) => [row.id, row.name]))
|
||||
// Annotate each reference-cause entry's source liveness so the client can phrase the blocker
|
||||
// reason (a deleted source can't be copied - it must be mapped to a live target resource).
|
||||
const clearedRefs = await annotateForkClearedRefSourceLiveness(
|
||||
db,
|
||||
auth.sourceWorkspaceId,
|
||||
collectForkClearedRefCandidates({
|
||||
items: plan.items,
|
||||
sourceStates,
|
||||
resolver: plan.resolver,
|
||||
workflowIdMap: plan.workflowIdMap,
|
||||
resolveBlockId,
|
||||
sourceLabels,
|
||||
sourceWorkflowNames,
|
||||
})
|
||||
)
|
||||
|
||||
const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({
|
||||
kind: reference.kind,
|
||||
sourceId: reference.sourceId,
|
||||
required: reference.required,
|
||||
blockName: reference.blockName,
|
||||
})
|
||||
|
||||
// Orient the mapping around the workspace the modal is open in (`id`): show the
|
||||
// caller's workflow name first, the sync partner's second, so renames are legible.
|
||||
const currentIsSource = auth.sourceWorkspaceId === id
|
||||
const workflows = [
|
||||
...plan.items.map((item) => {
|
||||
if (item.mode === 'create') {
|
||||
// The target inherits the source's name, so both sides read the same.
|
||||
return {
|
||||
action: 'create' as const,
|
||||
currentName: item.sourceMeta.name,
|
||||
otherName: item.sourceMeta.name,
|
||||
}
|
||||
}
|
||||
const targetName = item.targetName ?? item.sourceMeta.name
|
||||
return {
|
||||
action: 'update' as const,
|
||||
currentName: currentIsSource ? item.sourceMeta.name : targetName,
|
||||
otherName: currentIsSource ? targetName : item.sourceMeta.name,
|
||||
}
|
||||
}),
|
||||
...plan.archivedTargets.map((target) => ({
|
||||
action: 'archive' as const,
|
||||
currentName: target.name,
|
||||
otherName: target.name,
|
||||
})),
|
||||
]
|
||||
|
||||
return NextResponse.json({
|
||||
sourceWorkspaceId: auth.sourceWorkspaceId,
|
||||
targetWorkspaceId: auth.targetWorkspaceId,
|
||||
willUpdate: plan.willUpdate,
|
||||
willCreate: plan.willCreate,
|
||||
willArchive: plan.willArchive,
|
||||
workflows,
|
||||
unmappedRequired: plan.unmappedRequired.map(toRef),
|
||||
unmappedOptional: plan.unmappedOptional.map(toRef),
|
||||
mcpReauthServerIds: plan.mcpReauthServerIds,
|
||||
inlineSecretSources: plan.inlineSecretSources,
|
||||
dependentReconfigs,
|
||||
resourceUsages: collectForkResourceUsages(plan.items, sourceStates),
|
||||
copyableUnmapped: plan.copyableUnmapped,
|
||||
clearedRefs,
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockGetSession,
|
||||
mockAssertWorkspaceAdminAccess,
|
||||
mockGetForkParent,
|
||||
mockGetForkChildren,
|
||||
mockGetUndoableRunForTarget,
|
||||
mockGetEffectiveWorkspacePermission,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockAssertWorkspaceAdminAccess: vi.fn(),
|
||||
mockGetForkParent: vi.fn(),
|
||||
mockGetForkChildren: vi.fn(),
|
||||
mockGetUndoableRunForTarget: vi.fn(),
|
||||
mockGetEffectiveWorkspacePermission: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
auth: { api: { getSession: vi.fn() } },
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({
|
||||
assertWorkspaceAdminAccess: mockAssertWorkspaceAdminAccess,
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({
|
||||
getForkParent: mockGetForkParent,
|
||||
getForkChildren: mockGetForkChildren,
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/workspace-forking/lib/promote/promote-run-store', () => ({
|
||||
getUndoableRunForTarget: mockGetUndoableRunForTarget,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getEffectiveWorkspacePermission: mockGetEffectiveWorkspacePermission,
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/workspaces/[id]/fork/lineage/route'
|
||||
|
||||
const WORKSPACE_ID = 'workspace-1'
|
||||
const VIEWER_ID = 'user-1'
|
||||
const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) }
|
||||
|
||||
const parentNode = { id: 'parent-1', name: 'Parent', organizationId: 'org-1' }
|
||||
const childCreatedAt = new Date('2026-01-02T03:04:05.000Z')
|
||||
const childNode = (id: string, name: string) => ({
|
||||
id,
|
||||
name,
|
||||
organizationId: 'org-1',
|
||||
createdAt: childCreatedAt,
|
||||
})
|
||||
|
||||
describe('fork lineage route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetSession.mockResolvedValue({ user: { id: VIEWER_ID } })
|
||||
mockAssertWorkspaceAdminAccess.mockResolvedValue({ id: WORKSPACE_ID })
|
||||
mockGetForkParent.mockResolvedValue(null)
|
||||
mockGetForkChildren.mockResolvedValue([])
|
||||
mockGetUndoableRunForTarget.mockResolvedValue(null)
|
||||
mockGetEffectiveWorkspacePermission.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('returns 401 when there is no session', async () => {
|
||||
mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const res = await GET(createMockRequest('GET'), routeContext)
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(mockAssertWorkspaceAdminAccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires admin on the current workspace before loading lineage', async () => {
|
||||
await GET(createMockRequest('GET'), routeContext)
|
||||
|
||||
expect(mockAssertWorkspaceAdminAccess).toHaveBeenCalledWith(WORKSPACE_ID, VIEWER_ID)
|
||||
})
|
||||
|
||||
it('marks accessible and inaccessible nodes via the canonical permission resolver', async () => {
|
||||
mockGetForkParent.mockResolvedValue(parentNode)
|
||||
mockGetForkChildren.mockResolvedValue([
|
||||
childNode('fork-accessible', 'Accessible fork'),
|
||||
childNode('fork-hidden', 'Hidden fork'),
|
||||
])
|
||||
mockGetEffectiveWorkspacePermission.mockImplementation(
|
||||
async (_userId: string, ws: { id: string }) => {
|
||||
if (ws.id === parentNode.id) return 'read'
|
||||
if (ws.id === 'fork-accessible') return 'admin'
|
||||
return null
|
||||
}
|
||||
)
|
||||
|
||||
const res = await GET(createMockRequest('GET'), routeContext)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.parent).toEqual({ ...parentNode, viewerAccessible: true })
|
||||
expect(body.children).toEqual([
|
||||
{
|
||||
id: 'fork-accessible',
|
||||
name: 'Accessible fork',
|
||||
organizationId: 'org-1',
|
||||
createdAt: childCreatedAt.toISOString(),
|
||||
viewerAccessible: true,
|
||||
},
|
||||
{
|
||||
id: 'fork-hidden',
|
||||
name: 'Hidden fork',
|
||||
organizationId: 'org-1',
|
||||
createdAt: childCreatedAt.toISOString(),
|
||||
viewerAccessible: false,
|
||||
},
|
||||
])
|
||||
expect(mockGetEffectiveWorkspacePermission).toHaveBeenCalledWith(
|
||||
VIEWER_ID,
|
||||
expect.objectContaining({ id: parentNode.id, organizationId: 'org-1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('marks the parent inaccessible when the viewer holds no permission on it', async () => {
|
||||
mockGetForkParent.mockResolvedValue(parentNode)
|
||||
mockGetEffectiveWorkspacePermission.mockResolvedValue(null)
|
||||
|
||||
const res = await GET(createMockRequest('GET'), routeContext)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.parent).toEqual({ ...parentNode, viewerAccessible: false })
|
||||
expect(body.children).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a null parent null without resolving permissions', async () => {
|
||||
const res = await GET(createMockRequest('GET'), routeContext)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.parent).toBeNull()
|
||||
expect(body.children).toEqual([])
|
||||
expect(body.undoableRun).toBeNull()
|
||||
expect(mockGetEffectiveWorkspacePermission).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workspace } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getForkLineageContract } from '@/lib/api/contracts/workspace-fork'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getEffectiveWorkspacePermission } from '@/lib/workspaces/permissions/utils'
|
||||
import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage'
|
||||
import { getUndoableRunForTarget } from '@/ee/workspace-forking/lib/promote/promote-run-store'
|
||||
|
||||
/**
|
||||
* Annotates a lineage node with whether the viewer holds any access to it (explicit
|
||||
* grant or org-admin derivation, via the canonical workspace-permission resolver).
|
||||
* Lineage rows are visible to any admin of the CURRENT workspace, who may have no
|
||||
* access to the other side of an edge; the flag drives per-action gating in the
|
||||
* Forks UI. Resolved per node - lineage children lists are small and bounded.
|
||||
*/
|
||||
async function withViewerAccess<T extends { id: string; organizationId: string | null }>(
|
||||
node: T,
|
||||
viewerId: string
|
||||
): Promise<T & { viewerAccessible: boolean }> {
|
||||
const permission = await getEffectiveWorkspacePermission(viewerId, node)
|
||||
return { ...node, viewerAccessible: permission !== null }
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getForkLineageContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
|
||||
await assertWorkspaceAdminAccess(workspaceId, session.user.id)
|
||||
|
||||
const [rawParent, rawChildren, run] = await Promise.all([
|
||||
getForkParent(workspaceId),
|
||||
getForkChildren(workspaceId),
|
||||
getUndoableRunForTarget(db, workspaceId),
|
||||
])
|
||||
|
||||
const [parent, children] = await Promise.all([
|
||||
rawParent ? withViewerAccess(rawParent, session.user.id) : null,
|
||||
Promise.all(rawChildren.map((child) => withViewerAccess(child, session.user.id))),
|
||||
])
|
||||
|
||||
let undoableRun: {
|
||||
otherWorkspaceId: string
|
||||
otherName: string
|
||||
direction: 'push' | 'pull'
|
||||
} | null = null
|
||||
if (run) {
|
||||
const [other] = await db
|
||||
.select({ name: workspace.name })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, run.sourceWorkspaceId))
|
||||
.limit(1)
|
||||
undoableRun = {
|
||||
otherWorkspaceId: run.sourceWorkspaceId,
|
||||
otherName: other?.name ?? 'workspace',
|
||||
direction: run.direction,
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
workspaceId,
|
||||
parent,
|
||||
children: children.map((child) => ({
|
||||
...child,
|
||||
createdAt: child.createdAt.toISOString(),
|
||||
})),
|
||||
undoableRun,
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
import { db } from '@sim/db'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
getForkMappingContract,
|
||||
updateForkMappingContract,
|
||||
} from '@/lib/api/contracts/workspace-fork'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import { acquireForkEdgeLock, setForkLockTimeout } from '@/ee/workspace-forking/lib/lineage/lineage'
|
||||
import { reconcileForkDependentValues } from '@/ee/workspace-forking/lib/mapping/dependent-value-store'
|
||||
import {
|
||||
applyForkMappingEntries,
|
||||
getForkMappingView,
|
||||
validateForkMappingTargets,
|
||||
} from '@/ee/workspace-forking/lib/mapping/mapping-service'
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getForkMappingContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
const { otherWorkspaceId, direction } = parsed.data.query
|
||||
|
||||
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
|
||||
|
||||
const { entries } = await getForkMappingView({
|
||||
edge: auth.edge,
|
||||
sourceWorkspaceId: auth.sourceWorkspaceId,
|
||||
targetWorkspaceId: auth.targetWorkspaceId,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
childWorkspaceId: auth.edge.childWorkspaceId,
|
||||
parentWorkspaceId: auth.edge.parentWorkspaceId,
|
||||
sourceWorkspaceId: auth.sourceWorkspaceId,
|
||||
targetWorkspaceId: auth.targetWorkspaceId,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export const PUT = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateForkMappingContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
const { otherWorkspaceId, direction, entries, dependentValues } = parsed.data.body
|
||||
|
||||
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
|
||||
|
||||
await validateForkMappingTargets(auth.sourceWorkspaceId, auth.targetWorkspaceId, entries)
|
||||
|
||||
// Serialize concurrent mapping saves on this edge so a push (keyed child-side, deleted
|
||||
// then re-upserted parent-side) can't leave duplicate rows for the same source. Same
|
||||
// edge lock promote/rollback use, with a bounded wait.
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await setForkLockTimeout(tx)
|
||||
await acquireForkEdgeLock(tx, auth.edge.childWorkspaceId)
|
||||
const applied = await applyForkMappingEntries(
|
||||
tx,
|
||||
auth.edge,
|
||||
session.user.id,
|
||||
direction,
|
||||
entries
|
||||
)
|
||||
// Store dependent-field values with the mapping (each named workflow's stored set is
|
||||
// replaced by exactly what was sent - promote's reconcile semantics, scoped to the
|
||||
// payload's workflows since a mapping save has no promote plan). Omitted = untouched;
|
||||
// rows for a workflow that never becomes a sync replace target are inert (promote
|
||||
// loads the store scoped to its plan's targets).
|
||||
if (dependentValues !== undefined) {
|
||||
const targetWorkflowIds = Array.from(
|
||||
new Set(dependentValues.map((entry) => entry.workflowId))
|
||||
)
|
||||
await reconcileForkDependentValues(
|
||||
tx,
|
||||
auth.edge.childWorkspaceId,
|
||||
targetWorkflowIds,
|
||||
dependentValues.map((entry) => ({
|
||||
targetWorkflowId: entry.workflowId,
|
||||
targetBlockId: entry.blockId,
|
||||
subBlockKey: entry.subBlockKey,
|
||||
value: entry.value,
|
||||
}))
|
||||
)
|
||||
}
|
||||
return applied
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true as const, updated })
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { promoteForkContract } from '@/lib/api/contracts/workspace-fork'
|
||||
import { 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 { recordBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
|
||||
import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import { promoteFork } from '@/ee/workspace-forking/lib/promote/promote'
|
||||
|
||||
const logger = createLogger('WorkspaceForkPromoteAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(promoteForkContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
const { otherWorkspaceId, direction, dependentValues, copyResources } = parsed.data.body
|
||||
|
||||
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
|
||||
|
||||
const result = await promoteFork({
|
||||
edge: auth.edge,
|
||||
sourceWorkspaceId: auth.sourceWorkspaceId,
|
||||
targetWorkspaceId: auth.targetWorkspaceId,
|
||||
direction,
|
||||
userId: session.user.id,
|
||||
actorName: session.user.name ?? undefined,
|
||||
dependentValues,
|
||||
copyResources,
|
||||
requestId,
|
||||
})
|
||||
|
||||
const body = {
|
||||
promoteRunId: result.promoteRunId,
|
||||
updated: result.updated,
|
||||
created: result.created,
|
||||
archived: result.archived,
|
||||
redeployed: result.redeployed,
|
||||
deployFailed: result.deployFailed,
|
||||
unmappedRequired: result.unmappedRequired,
|
||||
blockers: result.blockers,
|
||||
needsConfiguration: result.needsConfiguration,
|
||||
clearedOptional: result.clearedOptional,
|
||||
}
|
||||
|
||||
if (result.blocked) {
|
||||
logger.info(`[${requestId}] Promote blocked (${result.blocked})`, {
|
||||
sourceWorkspaceId: auth.sourceWorkspaceId,
|
||||
targetWorkspaceId: auth.targetWorkspaceId,
|
||||
})
|
||||
return NextResponse.json(body)
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
workspaceId: auth.targetWorkspaceId,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.WORKSPACE_FORK_PROMOTED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: auth.targetWorkspaceId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: auth.target.name,
|
||||
description: `Promoted workflows from "${auth.source.name}" to "${auth.target.name}"`,
|
||||
metadata: {
|
||||
direction,
|
||||
sourceWorkspaceId: auth.sourceWorkspaceId,
|
||||
updated: result.updated,
|
||||
created: result.created,
|
||||
archived: result.archived,
|
||||
redeployed: result.redeployed,
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
|
||||
const otherName =
|
||||
otherWorkspaceId === auth.sourceWorkspaceId ? auth.source.name : auth.target.name
|
||||
await recordBackgroundWork(db, {
|
||||
workspaceId: id,
|
||||
kind: 'fork_sync',
|
||||
status:
|
||||
result.deployFailed > 0 ||
|
||||
result.needsConfiguration.length > 0 ||
|
||||
result.clearedOptional.length > 0
|
||||
? 'completed_with_warnings'
|
||||
: 'completed',
|
||||
message: direction === 'pull' ? `Pulled from "${otherName}"` : `Pushed to "${otherName}"`,
|
||||
metadata: {
|
||||
actorName: session.user.name ?? undefined,
|
||||
otherWorkspaceId,
|
||||
otherWorkspaceName: otherName,
|
||||
direction,
|
||||
updated: result.updated,
|
||||
created: result.created,
|
||||
archived: result.archived,
|
||||
redeployed: result.redeployed,
|
||||
deployFailed: result.deployFailed,
|
||||
updatedNames: result.updatedNames,
|
||||
createdNames: result.createdNames,
|
||||
archivedNames: result.archivedNames,
|
||||
needsConfiguration: result.needsConfiguration,
|
||||
clearedOptional: result.clearedOptional,
|
||||
},
|
||||
}).catch((error) =>
|
||||
logger.error(`[${requestId}] Failed to record sync activity`, {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json(body)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
import { db } from '@sim/db'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getForkResourcesContract } from '@/lib/api/contracts/workspace-fork'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import { listForkCopyableResources } from '@/ee/workspace-forking/lib/mapping/resources'
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getForkResourcesContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
|
||||
await assertWorkspaceAdminAccess(id, session.user.id)
|
||||
|
||||
const resources = await listForkCopyableResources(db, id)
|
||||
return NextResponse.json(resources)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { workspace } 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 { rollbackForkContract } from '@/lib/api/contracts/workspace-fork'
|
||||
import { 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 { recordBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
|
||||
import { assertCanRollback } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import { rollbackFork } from '@/ee/workspace-forking/lib/promote/rollback'
|
||||
|
||||
const logger = createLogger('WorkspaceForkRollbackAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(rollbackForkContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
const { otherWorkspaceId } = parsed.data.body
|
||||
|
||||
const target = await assertCanRollback(id, session.user.id)
|
||||
|
||||
const result = await rollbackFork({
|
||||
targetWorkspaceId: id,
|
||||
otherWorkspaceId,
|
||||
userId: session.user.id,
|
||||
requestId,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: id,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.WORKSPACE_FORK_ROLLED_BACK,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: id,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: target.name,
|
||||
description: `Rolled back the last promote into "${target.name}"`,
|
||||
metadata: { otherWorkspaceId, ...result },
|
||||
request: req,
|
||||
})
|
||||
|
||||
// Durable audit entry scoped to this workspace so the undo shows in its Manage Forks
|
||||
// → Activity log. Non-critical: a failure must not fail the (committed) rollback.
|
||||
const [other] = await db
|
||||
.select({ name: workspace.name })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, otherWorkspaceId))
|
||||
.limit(1)
|
||||
const otherName = other?.name ?? 'the source workspace'
|
||||
await recordBackgroundWork(db, {
|
||||
workspaceId: id,
|
||||
kind: 'fork_rollback',
|
||||
status: result.skipped > 0 ? 'completed_with_warnings' : 'completed',
|
||||
message: `Undid the last sync from "${otherName}"`,
|
||||
metadata: {
|
||||
actorName: session.user.name ?? undefined,
|
||||
otherWorkspaceId,
|
||||
otherWorkspaceName: otherName,
|
||||
restored: result.restored,
|
||||
removed: result.archived,
|
||||
unarchived: result.unarchived,
|
||||
skipped: result.skipped,
|
||||
},
|
||||
}).catch((error) =>
|
||||
logger.error(`[${requestId}] Failed to record rollback activity`, {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { forkWorkspaceContract } from '@/lib/api/contracts/workspace-fork'
|
||||
import { 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 { createFork } from '@/ee/workspace-forking/lib/create-fork'
|
||||
import { assertCanFork } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
|
||||
const logger = createLogger('WorkspaceForkAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const { id: sourceWorkspaceId } = await context.params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { source, policy } = await assertCanFork(sourceWorkspaceId, session.user.id)
|
||||
|
||||
const parsed = await parseRequest(forkWorkspaceContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const copy = parsed.data.body.copy
|
||||
const result = await createFork({
|
||||
source,
|
||||
policy,
|
||||
userId: session.user.id,
|
||||
actorName: session.user.name ?? undefined,
|
||||
name: parsed.data.body.name,
|
||||
selection: {
|
||||
files: copy?.files ?? [],
|
||||
tables: copy?.tables ?? [],
|
||||
knowledgeBases: copy?.knowledgeBases ?? [],
|
||||
customTools: copy?.customTools ?? [],
|
||||
skills: copy?.skills ?? [],
|
||||
mcpServers: copy?.mcpServers ?? [],
|
||||
workflowMcpServers: copy?.workflowMcpServers ?? [],
|
||||
},
|
||||
requestId,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: result.workspace.id,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.WORKSPACE_FORKED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: result.workspace.id,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: result.workspace.name,
|
||||
description: `Forked workspace from "${source.name}"`,
|
||||
metadata: {
|
||||
parentWorkspaceId: source.id,
|
||||
workflowsCopied: result.workflowsCopied,
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Forked workspace ${sourceWorkspaceId} -> ${result.workspace.id}`)
|
||||
return NextResponse.json(result, { status: 201 })
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { unlinkForkContract } from '@/lib/api/contracts/workspace-fork'
|
||||
import { 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 { assertCanUnlink } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import { unlinkForkEdge } from '@/ee/workspace-forking/lib/lineage/unlink'
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(unlinkForkContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
const { otherWorkspaceId } = parsed.data.body
|
||||
|
||||
const { edge, current } = await assertCanUnlink(id, otherWorkspaceId, session.user.id)
|
||||
const result = await unlinkForkEdge(edge, requestId)
|
||||
|
||||
if (result.unlinked) {
|
||||
recordAudit({
|
||||
workspaceId: id,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.WORKSPACE_FORK_UNLINKED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: id,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: current.name,
|
||||
description: `Disconnected the fork relationship with workspace "${otherWorkspaceId}"`,
|
||||
metadata: {
|
||||
otherWorkspaceId,
|
||||
childWorkspaceId: edge.childWorkspaceId,
|
||||
parentWorkspaceId: edge.parentWorkspaceId,
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
import { db, mothershipInboxTask, workspace } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateInboxConfigContract } from '@/lib/api/contracts/inbox'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { disableInbox, enableInbox, updateInboxAddress } from '@/lib/mothership/inbox/lifecycle'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('InboxConfigAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (_req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id: workspaceId } = await params
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (!permission) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [wsResult, statsResult, entitled] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
inboxEnabled: workspace.inboxEnabled,
|
||||
inboxAddress: workspace.inboxAddress,
|
||||
})
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1),
|
||||
db
|
||||
.select({
|
||||
status: mothershipInboxTask.status,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(mothershipInboxTask)
|
||||
.where(eq(mothershipInboxTask.workspaceId, workspaceId))
|
||||
.groupBy(mothershipInboxTask.status),
|
||||
hasWorkspaceInboxAccess(workspaceId),
|
||||
])
|
||||
|
||||
const [ws] = wsResult
|
||||
if (!ws) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const stats = {
|
||||
total: 0,
|
||||
completed: 0,
|
||||
processing: 0,
|
||||
failed: 0,
|
||||
}
|
||||
for (const row of statsResult) {
|
||||
const count = Number(row.count)
|
||||
stats.total += count
|
||||
if (row.status === 'completed') stats.completed = count
|
||||
else if (row.status === 'processing') stats.processing = count
|
||||
else if (row.status === 'failed') stats.failed = count
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
enabled: ws.inboxEnabled,
|
||||
address: ws.inboxAddress,
|
||||
entitled,
|
||||
taskStats: stats,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export const PATCH = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const { id: workspaceId } = await context.params
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateInboxConfigContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const body = parsed.data.body
|
||||
|
||||
try {
|
||||
if (body.enabled === false) {
|
||||
await disableInbox(workspaceId)
|
||||
return NextResponse.json({ enabled: false, address: null })
|
||||
}
|
||||
|
||||
if (!(await hasWorkspaceInboxAccess(workspaceId))) {
|
||||
return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (body.enabled === true) {
|
||||
const [current] = await db
|
||||
.select({ inboxEnabled: workspace.inboxEnabled })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
if (current?.inboxEnabled) {
|
||||
return NextResponse.json({ error: 'Inbox is already enabled' }, { status: 409 })
|
||||
}
|
||||
const config = await enableInbox(workspaceId, { username: body.username })
|
||||
return NextResponse.json(config)
|
||||
}
|
||||
|
||||
if (body.username) {
|
||||
const config = await updateInboxAddress(workspaceId, body.username)
|
||||
return NextResponse.json(config)
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'No valid update provided' }, { status: 400 })
|
||||
} catch (error) {
|
||||
logger.error('Inbox config update failed', {
|
||||
workspaceId,
|
||||
error: getErrorMessage(error, 'Unknown error'),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to update inbox') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
import { db, mothershipInboxAllowedSender, permissions, user } from '@sim/db'
|
||||
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 { addInboxSenderContract, removeInboxSenderContract } from '@/lib/api/contracts/inbox'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('InboxSendersAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (_req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id: workspaceId } = await params
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [hasAccess, permission] = await Promise.all([
|
||||
hasWorkspaceInboxAccess(workspaceId),
|
||||
getUserEntityPermissions(session.user.id, 'workspace', workspaceId),
|
||||
])
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 })
|
||||
}
|
||||
if (!permission) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [senders, members] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: mothershipInboxAllowedSender.id,
|
||||
email: mothershipInboxAllowedSender.email,
|
||||
label: mothershipInboxAllowedSender.label,
|
||||
createdAt: mothershipInboxAllowedSender.createdAt,
|
||||
})
|
||||
.from(mothershipInboxAllowedSender)
|
||||
.where(eq(mothershipInboxAllowedSender.workspaceId, workspaceId))
|
||||
.orderBy(mothershipInboxAllowedSender.createdAt),
|
||||
db
|
||||
.select({
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(user, eq(permissions.userId, user.id))
|
||||
.where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
senders: senders.map((s) => ({
|
||||
id: s.id,
|
||||
email: s.email,
|
||||
label: s.label,
|
||||
createdAt: s.createdAt,
|
||||
})),
|
||||
workspaceMembers: members.map((m) => ({
|
||||
email: m.email,
|
||||
name: m.name,
|
||||
isAutoAllowed: true,
|
||||
})),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const { id: workspaceId } = await context.params
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [hasAccess, permission] = await Promise.all([
|
||||
hasWorkspaceInboxAccess(workspaceId),
|
||||
getUserEntityPermissions(session.user.id, 'workspace', workspaceId),
|
||||
])
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 })
|
||||
}
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(addInboxSenderContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { email, label } = parsed.data.body
|
||||
const normalizedEmail = email.toLowerCase()
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: mothershipInboxAllowedSender.id })
|
||||
.from(mothershipInboxAllowedSender)
|
||||
.where(
|
||||
and(
|
||||
eq(mothershipInboxAllowedSender.workspaceId, workspaceId),
|
||||
eq(mothershipInboxAllowedSender.email, normalizedEmail)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'Sender already exists' }, { status: 409 })
|
||||
}
|
||||
|
||||
const [sender] = await db
|
||||
.insert(mothershipInboxAllowedSender)
|
||||
.values({
|
||||
id: generateId(),
|
||||
workspaceId,
|
||||
email: normalizedEmail,
|
||||
label: label || null,
|
||||
addedBy: session.user.id,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return NextResponse.json({ sender })
|
||||
} catch (error) {
|
||||
logger.error('Failed to add sender', { workspaceId, error })
|
||||
return NextResponse.json({ error: 'Failed to add sender' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const { id: workspaceId } = await context.params
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [hasAccess, permission] = await Promise.all([
|
||||
hasWorkspaceInboxAccess(workspaceId),
|
||||
getUserEntityPermissions(session.user.id, 'workspace', workspaceId),
|
||||
])
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 })
|
||||
}
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(removeInboxSenderContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { senderId } = parsed.data.body
|
||||
|
||||
await db
|
||||
.delete(mothershipInboxAllowedSender)
|
||||
.where(
|
||||
and(
|
||||
eq(mothershipInboxAllowedSender.id, senderId),
|
||||
eq(mothershipInboxAllowedSender.workspaceId, workspaceId)
|
||||
)
|
||||
)
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete sender', { workspaceId, error })
|
||||
return NextResponse.json({ error: 'Failed to delete sender' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
import { db, mothershipInboxTask } from '@sim/db'
|
||||
import { and, desc, eq, lt } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { inboxTasksQuerySchema, inboxWorkspaceParamsSchema } from '@/lib/api/contracts/inbox'
|
||||
import { getValidationErrorMessage } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const paramsResult = inboxWorkspaceParamsSchema.safeParse(await params)
|
||||
if (!paramsResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { id: workspaceId } = paramsResult.data
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [hasAccess, permission] = await Promise.all([
|
||||
hasWorkspaceInboxAccess(workspaceId),
|
||||
getUserEntityPermissions(session.user.id, 'workspace', workspaceId),
|
||||
])
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 })
|
||||
}
|
||||
if (!permission) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const queryResult = inboxTasksQuerySchema.safeParse(
|
||||
Object.fromEntries(req.nextUrl.searchParams.entries())
|
||||
)
|
||||
if (!queryResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(queryResult.error, 'Invalid query parameters') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { cursor } = queryResult.data
|
||||
const status = queryResult.data.status ?? 'all'
|
||||
const limit = queryResult.data.limit ?? 20
|
||||
|
||||
const conditions = [eq(mothershipInboxTask.workspaceId, workspaceId)]
|
||||
|
||||
if (status !== 'all') {
|
||||
conditions.push(eq(mothershipInboxTask.status, status))
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
const cursorDate = new Date(cursor)
|
||||
if (Number.isNaN(cursorDate.getTime())) {
|
||||
return NextResponse.json({ error: 'Invalid cursor value' }, { status: 400 })
|
||||
}
|
||||
conditions.push(lt(mothershipInboxTask.createdAt, cursorDate))
|
||||
}
|
||||
|
||||
const tasks = await db
|
||||
.select({
|
||||
id: mothershipInboxTask.id,
|
||||
fromEmail: mothershipInboxTask.fromEmail,
|
||||
fromName: mothershipInboxTask.fromName,
|
||||
subject: mothershipInboxTask.subject,
|
||||
bodyPreview: mothershipInboxTask.bodyPreview,
|
||||
status: mothershipInboxTask.status,
|
||||
hasAttachments: mothershipInboxTask.hasAttachments,
|
||||
resultSummary: mothershipInboxTask.resultSummary,
|
||||
errorMessage: mothershipInboxTask.errorMessage,
|
||||
rejectionReason: mothershipInboxTask.rejectionReason,
|
||||
chatId: mothershipInboxTask.chatId,
|
||||
createdAt: mothershipInboxTask.createdAt,
|
||||
completedAt: mothershipInboxTask.completedAt,
|
||||
})
|
||||
.from(mothershipInboxTask)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(mothershipInboxTask.createdAt))
|
||||
.limit(limit + 1) // Fetch one extra to determine hasMore
|
||||
|
||||
const hasMore = tasks.length > limit
|
||||
const resultTasks = hasMore ? tasks.slice(0, limit) : tasks
|
||||
const nextCursor =
|
||||
hasMore && resultTasks.length > 0
|
||||
? resultTasks[resultTasks.length - 1].createdAt.toISOString()
|
||||
: null
|
||||
|
||||
return NextResponse.json({
|
||||
tasks: resultTasks,
|
||||
pagination: {
|
||||
limit,
|
||||
hasMore,
|
||||
nextCursor,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { workspaceParamsSchema } from '@/lib/api/contracts'
|
||||
import { getValidationErrorMessage } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
getUserEntityPermissions,
|
||||
getWorkspaceMemberProfiles,
|
||||
} from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceMembersAPI')
|
||||
|
||||
/**
|
||||
* GET /api/workspaces/[id]/members
|
||||
*
|
||||
* Returns lightweight member profiles (id, name, image) for a workspace.
|
||||
* Intended for UI display (avatars, owner cells) without the overhead of
|
||||
* full permission data.
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const paramsResult = workspaceParamsSchema.safeParse(await params)
|
||||
if (!paramsResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { id: workspaceId } = paramsResult.data
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (permission === null) {
|
||||
return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 })
|
||||
}
|
||||
|
||||
const members = await getWorkspaceMemberProfiles(workspaceId)
|
||||
|
||||
return NextResponse.json({ members })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching workspace members:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch workspace members' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,253 @@
|
||||
import { dbReplica } from '@sim/db'
|
||||
import { pausedExecutions, workflow, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, gte, inArray, isNotNull, isNull, lte, or, type SQL, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { workspaceMetricsExecutionsQuerySchema } from '@/lib/api/contracts/workspaces'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('MetricsExecutionsAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const { id: workspaceId } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const qp = workspaceMetricsExecutionsQuerySchema.parse(
|
||||
Object.fromEntries(searchParams.entries())
|
||||
)
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
let end = qp.endTime ? new Date(qp.endTime) : new Date()
|
||||
let start = qp.startTime
|
||||
? new Date(qp.startTime)
|
||||
: new Date(end.getTime() - 24 * 60 * 60 * 1000)
|
||||
|
||||
const isAllTime = qp.allTime === true
|
||||
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return NextResponse.json({ error: 'Invalid time range' }, { status: 400 })
|
||||
}
|
||||
|
||||
const segments = qp.segments
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, userId)
|
||||
if (!access.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
const wfWhere = [eq(workflow.workspaceId, workspaceId)] as any[]
|
||||
if (qp.folderIds) {
|
||||
const folderList = qp.folderIds.split(',').filter(Boolean)
|
||||
wfWhere.push(inArray(workflow.folderId, folderList))
|
||||
}
|
||||
if (qp.workflowIds) {
|
||||
const wfList = qp.workflowIds.split(',').filter(Boolean)
|
||||
wfWhere.push(inArray(workflow.id, wfList))
|
||||
}
|
||||
|
||||
const workflows = await dbReplica
|
||||
.select({ id: workflow.id, name: workflow.name })
|
||||
.from(workflow)
|
||||
.where(and(...wfWhere))
|
||||
|
||||
if (workflows.length === 0) {
|
||||
return NextResponse.json({
|
||||
workflows: [],
|
||||
startTime: start.toISOString(),
|
||||
endTime: end.toISOString(),
|
||||
segmentMs: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const workflowIdList = workflows.map((w) => w.id)
|
||||
|
||||
const baseLogWhere = [inArray(workflowExecutionLogs.workflowId, workflowIdList)] as SQL[]
|
||||
if (qp.triggers) {
|
||||
const t = qp.triggers.split(',').filter(Boolean)
|
||||
baseLogWhere.push(inArray(workflowExecutionLogs.trigger, t))
|
||||
}
|
||||
|
||||
if (qp.level && qp.level !== 'all') {
|
||||
const levels = qp.level.split(',').filter(Boolean)
|
||||
const levelConditions: SQL[] = []
|
||||
|
||||
for (const level of levels) {
|
||||
if (level === 'error') {
|
||||
levelConditions.push(eq(workflowExecutionLogs.level, 'error'))
|
||||
} else if (level === 'info') {
|
||||
const condition = and(
|
||||
eq(workflowExecutionLogs.level, 'info'),
|
||||
isNotNull(workflowExecutionLogs.endedAt)
|
||||
)
|
||||
if (condition) levelConditions.push(condition)
|
||||
} else if (level === 'running') {
|
||||
const condition = and(
|
||||
eq(workflowExecutionLogs.level, 'info'),
|
||||
isNull(workflowExecutionLogs.endedAt)
|
||||
)
|
||||
if (condition) levelConditions.push(condition)
|
||||
} else if (level === 'pending') {
|
||||
const condition = and(
|
||||
eq(workflowExecutionLogs.level, 'info'),
|
||||
or(
|
||||
sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`,
|
||||
and(
|
||||
isNotNull(pausedExecutions.status),
|
||||
sql`${pausedExecutions.status} != 'fully_resumed'`
|
||||
)
|
||||
)
|
||||
)
|
||||
if (condition) levelConditions.push(condition)
|
||||
}
|
||||
}
|
||||
|
||||
if (levelConditions.length > 0) {
|
||||
const combinedCondition =
|
||||
levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions)
|
||||
if (combinedCondition) baseLogWhere.push(combinedCondition)
|
||||
}
|
||||
}
|
||||
|
||||
if (isAllTime) {
|
||||
const boundsQuery = dbReplica
|
||||
.select({
|
||||
minDate: sql<Date>`MIN(${workflowExecutionLogs.startedAt})`,
|
||||
maxDate: sql<Date>`MAX(${workflowExecutionLogs.startedAt})`,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.leftJoin(
|
||||
pausedExecutions,
|
||||
eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)
|
||||
)
|
||||
.where(and(...baseLogWhere))
|
||||
|
||||
const [bounds] = await boundsQuery
|
||||
|
||||
if (bounds?.minDate && bounds?.maxDate) {
|
||||
start = new Date(bounds.minDate)
|
||||
end = new Date(Math.max(new Date(bounds.maxDate).getTime(), Date.now()))
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
workflows: workflows.map((wf) => ({
|
||||
workflowId: wf.id,
|
||||
workflowName: wf.name,
|
||||
segments: [],
|
||||
})),
|
||||
startTime: new Date().toISOString(),
|
||||
endTime: new Date().toISOString(),
|
||||
segmentMs: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (start >= end) {
|
||||
return NextResponse.json({ error: 'Invalid time range' }, { status: 400 })
|
||||
}
|
||||
|
||||
const totalMs = Math.max(1, end.getTime() - start.getTime())
|
||||
const segmentMs = Math.max(1, Math.floor(totalMs / Math.max(1, segments)))
|
||||
|
||||
const logWhere = [
|
||||
...baseLogWhere,
|
||||
gte(workflowExecutionLogs.startedAt, start),
|
||||
lte(workflowExecutionLogs.startedAt, end),
|
||||
]
|
||||
|
||||
const logs = await dbReplica
|
||||
.select({
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
level: workflowExecutionLogs.level,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
pausedTotalPauseCount: pausedExecutions.totalPauseCount,
|
||||
pausedResumedCount: pausedExecutions.resumedCount,
|
||||
pausedStatus: pausedExecutions.status,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.leftJoin(
|
||||
pausedExecutions,
|
||||
eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)
|
||||
)
|
||||
.where(and(...logWhere))
|
||||
|
||||
type Bucket = {
|
||||
timestamp: string
|
||||
totalExecutions: number
|
||||
successfulExecutions: number
|
||||
durations: number[]
|
||||
}
|
||||
|
||||
const wfIdToBuckets = new Map<string, Bucket[]>()
|
||||
for (const wf of workflows) {
|
||||
const buckets: Bucket[] = Array.from({ length: segments }, (_, i) => ({
|
||||
timestamp: new Date(start.getTime() + i * segmentMs).toISOString(),
|
||||
totalExecutions: 0,
|
||||
successfulExecutions: 0,
|
||||
durations: [],
|
||||
}))
|
||||
wfIdToBuckets.set(wf.id, buckets)
|
||||
}
|
||||
|
||||
for (const log of logs) {
|
||||
if (!log.workflowId) continue // Skip logs for deleted workflows
|
||||
const idx = Math.min(
|
||||
segments - 1,
|
||||
Math.max(0, Math.floor((log.startedAt.getTime() - start.getTime()) / segmentMs))
|
||||
)
|
||||
const buckets = wfIdToBuckets.get(log.workflowId)
|
||||
if (!buckets) continue
|
||||
const b = buckets[idx]
|
||||
b.totalExecutions += 1
|
||||
if ((log.level || '').toLowerCase() !== 'error') b.successfulExecutions += 1
|
||||
if (typeof log.totalDurationMs === 'number') b.durations.push(log.totalDurationMs)
|
||||
}
|
||||
|
||||
function percentile(arr: number[], p: number): number {
|
||||
if (arr.length === 0) return 0
|
||||
const sorted = [...arr].sort((a, b) => a - b)
|
||||
const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * (sorted.length - 1)))
|
||||
return sorted[idx]
|
||||
}
|
||||
|
||||
const result = workflows.map((wf) => {
|
||||
const buckets = wfIdToBuckets.get(wf.id) as Bucket[]
|
||||
const segmentsOut = buckets.map((b) => {
|
||||
const avg =
|
||||
b.durations.length > 0
|
||||
? Math.round(b.durations.reduce((s, d) => s + d, 0) / b.durations.length)
|
||||
: 0
|
||||
const p50 = percentile(b.durations, 50)
|
||||
const p90 = percentile(b.durations, 90)
|
||||
const p99 = percentile(b.durations, 99)
|
||||
return {
|
||||
timestamp: b.timestamp,
|
||||
totalExecutions: b.totalExecutions,
|
||||
successfulExecutions: b.successfulExecutions,
|
||||
avgDurationMs: avg,
|
||||
p50Ms: p50,
|
||||
p90Ms: p90,
|
||||
p99Ms: p99,
|
||||
}
|
||||
})
|
||||
return { workflowId: wf.id, workflowName: wf.name, segments: segmentsOut }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
workflows: result,
|
||||
startTime: start.toISOString(),
|
||||
endTime: end.toISOString(),
|
||||
segmentMs,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('MetricsExecutionsAPI error', error)
|
||||
return NextResponse.json({ error: 'Failed to compute metrics' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetSession, mockGetUserEntityPermissions, mockGetWorkspaceOwnerSubscriptionAccess } =
|
||||
vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockGetUserEntityPermissions: vi.fn(),
|
||||
mockGetWorkspaceOwnerSubscriptionAccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
auth: { api: { getSession: vi.fn() } },
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getUserEntityPermissions: mockGetUserEntityPermissions,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/workspace-access', () => ({
|
||||
getWorkspaceOwnerSubscriptionAccess: mockGetWorkspaceOwnerSubscriptionAccess,
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/workspaces/[id]/owner-billing/route'
|
||||
|
||||
const WORKSPACE_ID = 'ws-1'
|
||||
|
||||
const PAID_ACCESS = {
|
||||
plan: 'team_25000',
|
||||
status: 'active',
|
||||
isPaid: true,
|
||||
isPro: false,
|
||||
isTeam: true,
|
||||
isEnterprise: false,
|
||||
isOrgScoped: true,
|
||||
organizationId: 'org-1',
|
||||
}
|
||||
|
||||
function buildParams() {
|
||||
return { params: Promise.resolve({ id: WORKSPACE_ID }) }
|
||||
}
|
||||
|
||||
async function callGet() {
|
||||
const request = createMockRequest('GET')
|
||||
const response = await GET(request, buildParams())
|
||||
return { status: response.status, body: await response.json() }
|
||||
}
|
||||
|
||||
describe('GET /api/workspaces/[id]/owner-billing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'u-1' } })
|
||||
mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
mockGetWorkspaceOwnerSubscriptionAccess.mockResolvedValue(PAID_ACCESS)
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockGetSession.mockResolvedValue(null)
|
||||
const { status } = await callGet()
|
||||
expect(status).toBe(401)
|
||||
expect(mockGetWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 when the caller has no workspace access', async () => {
|
||||
mockGetUserEntityPermissions.mockResolvedValue(null)
|
||||
const { status } = await callGet()
|
||||
expect(status).toBe(404)
|
||||
expect(mockGetWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the workspace owner subscription access for a member', async () => {
|
||||
const { status, body } = await callGet()
|
||||
expect(status).toBe(200)
|
||||
expect(body).toEqual(PAID_ACCESS)
|
||||
expect(mockGetWorkspaceOwnerSubscriptionAccess).toHaveBeenCalledWith(WORKSPACE_ID)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getWorkspaceOwnerBillingContract } from '@/lib/api/contracts/workspaces'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
/**
|
||||
* Subscription access state of the workspace's billed account — the workspace-
|
||||
* scoped counterpart to the viewer `/api/billing`. Lets the UI gate workspace
|
||||
* features (e.g. the deploy modal) on the owner's plan rather than the viewer's,
|
||||
* so a free member of a paid workspace isn't shown an upgrade wall.
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getWorkspaceOwnerBillingContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (!permission) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const ownerAccess = await getWorkspaceOwnerSubscriptionAccess(workspaceId)
|
||||
return NextResponse.json(ownerAccess)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,238 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ORG_ADMIN_ROLES } from '@sim/platform-authz/workspace'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateWorkspacePermissionsContract } from '@/lib/api/contracts/workspaces'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
getUsersWithPermissions,
|
||||
getWorkspacePermissionsForViewer,
|
||||
hasWorkspaceAdminAccess,
|
||||
} from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspacesPermissionsAPI')
|
||||
|
||||
/**
|
||||
* GET /api/workspaces/[id]/permissions
|
||||
*
|
||||
* Retrieves all users who have permissions for the specified workspace.
|
||||
* Returns user details along with their specific permissions.
|
||||
*
|
||||
* @param workspaceId - The workspace ID from the URL parameters
|
||||
* @returns Array of users with their permissions for the workspace
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const { id: workspaceId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const result = await getWorkspacePermissionsForViewer(workspaceId, session.user.id)
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching workspace permissions:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch workspace permissions' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PATCH /api/workspaces/[id]/permissions
|
||||
*
|
||||
* Updates permissions for existing workspace members.
|
||||
* Only admin users can update permissions.
|
||||
*
|
||||
* @param workspaceId - The workspace ID from the URL parameters
|
||||
* @param updates - Array of permission updates for users
|
||||
* @returns Success message or error
|
||||
*/
|
||||
export const PATCH = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const { id: workspaceId } = await context.params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, workspaceId)
|
||||
|
||||
if (!hasAdminAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Admin access required to update permissions' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateWorkspacePermissionsContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const body = parsed.data.body
|
||||
|
||||
const workspaceRow = await db
|
||||
.select({
|
||||
billedAccountUserId: workspace.billedAccountUserId,
|
||||
organizationId: workspace.organizationId,
|
||||
})
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceRow.length) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const billedAccountUserId = workspaceRow[0].billedAccountUserId
|
||||
const organizationId = workspaceRow[0].organizationId
|
||||
|
||||
if (organizationId) {
|
||||
const targetUserIds = body.updates.map((update) => update.userId)
|
||||
const orgAdminTargets = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, organizationId),
|
||||
inArray(member.userId, targetUserIds),
|
||||
inArray(member.role, [...ORG_ADMIN_ROLES])
|
||||
)
|
||||
)
|
||||
if (orgAdminTargets.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Organization admins are workspace admins and their role cannot be changed' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const selfUpdate = body.updates.find((update) => update.userId === session.user.id)
|
||||
if (selfUpdate && selfUpdate.permissions !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot remove your own admin permissions' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
billedAccountUserId &&
|
||||
body.updates.some(
|
||||
(update) => update.userId === billedAccountUserId && update.permissions !== 'admin'
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Workspace billing account must retain admin permissions' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Capture existing permissions and user info for audit metadata
|
||||
const existingPerms = await db
|
||||
.select({
|
||||
userId: permissions.userId,
|
||||
permissionType: permissions.permissionType,
|
||||
email: user.email,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(user, eq(permissions.userId, user.id))
|
||||
.where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId)))
|
||||
|
||||
const permLookup = new Map(
|
||||
existingPerms.map((p) => [p.userId, { permission: p.permissionType, email: p.email }])
|
||||
)
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const update of body.updates) {
|
||||
await tx
|
||||
.delete(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, update.userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
|
||||
await tx.insert(permissions).values({
|
||||
id: generateId(),
|
||||
userId: update.userId,
|
||||
entityType: 'workspace' as const,
|
||||
entityId: workspaceId,
|
||||
permissionType: update.permissions,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const [wsEnvRow] = await db
|
||||
.select({ variables: workspaceEnvironment.variables })
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1)
|
||||
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
|
||||
if (wsEnvKeys.length > 0) {
|
||||
await syncWorkspaceEnvCredentials({
|
||||
workspaceId,
|
||||
envKeys: wsEnvKeys,
|
||||
actingUserId: session.user.id,
|
||||
})
|
||||
}
|
||||
|
||||
const updatedUsers = await getUsersWithPermissions(workspaceId)
|
||||
|
||||
for (const update of body.updates) {
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'workspace_member_role_changed',
|
||||
{ workspace_id: workspaceId, new_role: update.permissions },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.MEMBER_ROLE_CHANGED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
resourceName: permLookup.get(update.userId)?.email ?? update.userId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
description: `Changed permissions for ${permLookup.get(update.userId)?.email ?? update.userId} from ${permLookup.get(update.userId)?.permission ?? 'none'} to ${update.permissions}`,
|
||||
metadata: {
|
||||
targetUserId: update.userId,
|
||||
targetEmail: permLookup.get(update.userId)?.email ?? undefined,
|
||||
previousRole: permLookup.get(update.userId)?.permission ?? null,
|
||||
newRole: update.permissions,
|
||||
},
|
||||
request,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Permissions updated successfully',
|
||||
users: updatedUsers,
|
||||
total: updatedUsers.length,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error updating workspace permissions:', error)
|
||||
return NextResponse.json({ error: 'Failed to update workspace permissions' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,322 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { deleteWorkspaceBodySchema, updateWorkspaceContract } from '@/lib/api/contracts'
|
||||
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { archiveWorkspace } from '@/lib/workspaces/lifecycle'
|
||||
|
||||
const logger = createLogger('WorkspaceByIdAPI')
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workspace } from '@sim/db/schema'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
getEffectiveWorkspacePermission,
|
||||
getUserEntityPermissions,
|
||||
} from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const workspaceId = id
|
||||
|
||||
// Check if user has any access to this workspace
|
||||
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (!userPermission) {
|
||||
return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get workspace details
|
||||
const workspaceDetails = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!workspaceDetails) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
workspace: {
|
||||
...workspaceDetails,
|
||||
permissions: userPermission,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export const PATCH = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateWorkspaceContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const workspaceId = parsed.data.params.id
|
||||
|
||||
// Check if user has admin permissions to update workspace
|
||||
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (userPermission !== 'admin') {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = parsed.data.body
|
||||
const { name, color, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body
|
||||
|
||||
if (
|
||||
name === undefined &&
|
||||
color === undefined &&
|
||||
logoUrl === undefined &&
|
||||
billedAccountUserId === undefined &&
|
||||
allowPersonalApiKeys === undefined
|
||||
) {
|
||||
return NextResponse.json({ error: 'No updates provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const existingWorkspace = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!existingWorkspace) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {}
|
||||
|
||||
if (name !== undefined) {
|
||||
updateData.name = name
|
||||
}
|
||||
|
||||
if (color !== undefined) {
|
||||
updateData.color = color
|
||||
}
|
||||
|
||||
if (logoUrl !== undefined) {
|
||||
updateData.logoUrl = logoUrl
|
||||
}
|
||||
|
||||
if (allowPersonalApiKeys !== undefined) {
|
||||
updateData.allowPersonalApiKeys = Boolean(allowPersonalApiKeys)
|
||||
}
|
||||
|
||||
if (billedAccountUserId !== undefined) {
|
||||
if (
|
||||
existingWorkspace.organizationId &&
|
||||
existingWorkspace.workspaceMode === 'organization'
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Organization workspaces use organization billing and cannot change billed account.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existingWorkspace.workspaceMode === 'personal') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Personal workspaces are always billed to their owner and cannot change billed account.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const candidateId = billedAccountUserId
|
||||
|
||||
const candidatePermission = await getEffectiveWorkspacePermission(
|
||||
candidateId,
|
||||
existingWorkspace
|
||||
)
|
||||
if (candidatePermission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Billed account must be a workspace admin' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
updateData.billedAccountUserId = candidateId
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return NextResponse.json({ error: 'No valid updates provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
updateData.updatedAt = new Date()
|
||||
|
||||
await db.update(workspace).set(updateData).where(eq(workspace.id, workspaceId))
|
||||
|
||||
const updatedWorkspace = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
action: AuditAction.WORKSPACE_UPDATED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
resourceName: updatedWorkspace?.name ?? existingWorkspace.name,
|
||||
description: `Updated workspace "${updatedWorkspace?.name ?? existingWorkspace.name}"`,
|
||||
metadata: {
|
||||
changes: {
|
||||
...(name !== undefined && { name: { from: existingWorkspace.name, to: name } }),
|
||||
...(color !== undefined && { color: { from: existingWorkspace.color, to: color } }),
|
||||
...(logoUrl !== undefined && {
|
||||
logoUrl: { from: existingWorkspace.logoUrl, to: logoUrl },
|
||||
}),
|
||||
...(allowPersonalApiKeys !== undefined && {
|
||||
allowPersonalApiKeys: {
|
||||
from: existingWorkspace.allowPersonalApiKeys,
|
||||
to: allowPersonalApiKeys,
|
||||
},
|
||||
}),
|
||||
...(billedAccountUserId !== undefined && {
|
||||
billedAccountUserId: {
|
||||
from: existingWorkspace.billedAccountUserId,
|
||||
to: billedAccountUserId,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
workspace: {
|
||||
...updatedWorkspace,
|
||||
permissions: userPermission,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error updating workspace:', error)
|
||||
return NextResponse.json({ error: 'Failed to update workspace' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const workspaceId = id
|
||||
const rawBody = await request.json().catch(() => ({}))
|
||||
const bodyValidation = deleteWorkspaceBodySchema.safeParse(rawBody)
|
||||
if (!bodyValidation.success) return validationErrorResponse(bodyValidation.error)
|
||||
|
||||
// Check if user has admin permissions to delete workspace
|
||||
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (userPermission !== 'admin') {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const [[workspaceRecord], totalWorkspaces] = await Promise.all([
|
||||
db
|
||||
.select({ name: workspace.name })
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
|
||||
.limit(1),
|
||||
db
|
||||
.select({ id: permissions.entityId })
|
||||
.from(permissions)
|
||||
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, session.user.id),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
isNull(workspace.archivedAt)
|
||||
)
|
||||
),
|
||||
])
|
||||
|
||||
/** Counts all workspace memberships (any role), not just admin — prevents the user from reaching a zero-workspace state. */
|
||||
if (totalWorkspaces.length <= 1) {
|
||||
return NextResponse.json({ error: 'Cannot delete the only workspace' }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info(`Deleting workspace ${workspaceId} for user ${session.user.id}`)
|
||||
|
||||
const workspaceWorkflows = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, workspaceId))
|
||||
|
||||
const workflowIds = workspaceWorkflows.map((entry) => entry.id)
|
||||
|
||||
const archiveResult = await archiveWorkspace(workspaceId, {
|
||||
requestId: `workspace-${workspaceId}`,
|
||||
})
|
||||
|
||||
if (!archiveResult.archived && !workspaceRecord) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
action: AuditAction.WORKSPACE_DELETED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
resourceName: workspaceRecord?.name,
|
||||
description: `Archived workspace "${workspaceRecord?.name || workspaceId}"`,
|
||||
metadata: {
|
||||
affected: {
|
||||
workflows: workflowIds.length,
|
||||
},
|
||||
archived: archiveResult.archived,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'workspace_deleted',
|
||||
{ workspace_id: workspaceId, workflow_count: workflowIds.length },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error(`Error deleting workspace ${workspaceId}:`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete workspace' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
// Reuse the PATCH handler implementation for PUT requests
|
||||
return PATCH(request, { params })
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user