diff --git a/apps/sim/app/api/audit-logs/route.test.ts b/apps/sim/app/api/audit-logs/route.test.ts new file mode 100644 index 00000000000..2d8c4af1aa1 --- /dev/null +++ b/apps/sim/app/api/audit-logs/route.test.ts @@ -0,0 +1,83 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + execute: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) + +vi.mock('@/lib/audit-logs/application/list-audit-logs', () => ({ + listAuditLogs: { operation: { id: 'audit_logs.list' }, execute: mocks.execute }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET } from '@/app/api/audit-logs/route' + +const log = { + id: 'audit-1', + workspaceId: 'workspace-1', + actorId: 'admin-1', + actorName: 'Ada', + actorEmail: 'ada@example.com', + action: 'workspace.updated', + resourceType: 'workspace', + resourceId: 'workspace-1', + resourceName: 'Engineering', + description: null, + metadata: {}, + createdAt: new Date('2026-08-01T00:00:00Z'), +} + +describe('GET /api/audit-logs', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'admin-1' }, + session: { id: 'session-1' }, + }) + mocks.execute.mockResolvedValue({ data: [log], nextCursor: 'next-1' }) + }) + + it('authenticates before parsing the organization query', async () => { + const response = await GET(new NextRequest('http://localhost:3000/api/audit-logs')) + + expect(response.status).toBe(400) + expect(mocks.getSession).toHaveBeenCalled() + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('keeps the internal envelope while sharing the application operation', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/audit-logs?organizationId=organization-1' + ) + const response = await GET(request) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + success: true, + data: [{ id: 'audit-1', actorId: 'admin-1' }], + nextCursor: 'next-1', + }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' }, + input: expect.objectContaining({ organizationId: 'organization-1' }), + request, + }) + }) + + it('preserves internal typed error presentation', async () => { + mocks.execute.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Admin required')) + + const response = await GET( + new NextRequest('http://localhost:3000/api/audit-logs?organizationId=organization-1') + ) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Admin required' }) + }) +}) diff --git a/apps/sim/app/api/audit-logs/route.ts b/apps/sim/app/api/audit-logs/route.ts index 0b71d5ef7ef..b944bfb9d52 100644 --- a/apps/sim/app/api/audit-logs/route.ts +++ b/apps/sim/app/api/audit-logs/route.ts @@ -1,97 +1,42 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' import { listAuditLogsContract } from '@/lib/api/contracts/audit-logs' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' -import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { - buildFilterConditions, - buildOrgScopeCondition, - getOrgWorkspaceIds, - queryAuditLogs, -} from '@/app/api/v1/audit-logs/query' - -const logger = createLogger('AuditLogsAPI') + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { listAuditLogs } from '@/lib/audit-logs/application/list-audit-logs' +import { auditLogOperations } from '@/lib/audit-logs/application/operations' +import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' export const dynamic = 'force-dynamic' -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest( - listAuditLogsContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json( - { error: getValidationErrorMessage(error, 'Invalid query parameters') }, - { status: 400 } - ), - } - ) - if (!parsed.success) return parsed.response - - const authResult = await validateEnterpriseAuditAccess( - session.user.id, - parsed.data.query.organizationId - ) - if (!authResult.success) { - return authResult.response - } - - const { organizationId, orgMemberIds } = authResult.context - - const { - organizationId: _targetOrganizationId, - search, - action, - resourceType, - actorId, - startDate, - endDate, - includeDeparted, - limit, - cursor, - } = parsed.data.query - - const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) - const scopeCondition = buildOrgScopeCondition({ - organizationId, - orgWorkspaceIds, - orgMemberIds, - includeDeparted, - }) - const filterConditions = buildFilterConditions({ - action, - resourceType, - actorId, - search, - startDate, - endDate, - }) - - const { data, nextCursor } = await queryAuditLogs( - [scopeCondition, ...filterConditions], - limit, - cursor - ) - - return NextResponse.json({ - success: true, - data: data.map(formatAuditLogEntry), - nextCursor, - }) - } catch (error: unknown) { - const message = getErrorMessage(error, 'Unknown error') - logger.error('Audit logs fetch error', { error: message }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const GET = defineInternalJsonRoute({ + contract: listAuditLogsContract, + auth: internalSessionAuth, + operation: auditLogOperations.list, + rateLimit: internalRateLimits.none({ + reason: 'Existing authenticated audit-log settings read has no request-rate policy', + }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ query }) => ({ + organizationId: query.organizationId, + includeDeparted: query.includeDeparted, + filters: { + search: query.search, + action: query.action, + resourceType: query.resourceType, + actorId: query.actorId, + startDate: query.startDate, + endDate: query.endDate, + }, + limit: query.limit, + cursor: query.cursor, + }), + useCase: listAuditLogs, + present: ({ data, nextCursor }) => ({ + success: true, + data: data.map(formatAuditLogEntry), + nextCursor, + }), }) diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts index 242f07ada89..54d5292bb29 100644 --- a/apps/sim/app/api/v2/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts @@ -1,12 +1,13 @@ -import { db } from '@sim/db' -import { auditLog } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' import { v2GetAuditLogContract } from '@/lib/api/contracts/v2/audit-logs' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' -import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { getAuditLog } from '@/lib/audit-logs/application/get-audit-log' +import { auditLogOperations } from '@/lib/audit-logs/application/operations' import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' -import { v2Data, v2Error } from '@/app/api/v2/lib/response' export const revalidate = 0 @@ -17,36 +18,13 @@ export const revalidate = 0 * organization. Audit logs are personal-key-only because a workspace-scoped * key must never expand into organization-wide visibility. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetAuditLogContract, - rateLimitEndpoint: 'audit-logs', - handler: async ({ input, auth: { userId, rateLimit } }) => { - if (rateLimit.keyType !== 'personal') { - return v2Error('FORBIDDEN', 'Audit logs require a personal API key') - } - - const authResult = await resolveEnterpriseAuditAccess(userId, input.query.organizationId) - if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) - - const { id } = input.params - const { organizationId, orgMemberIds } = authResult.context - - const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) - const scopeCondition = buildOrgScopeCondition({ - organizationId, - orgWorkspaceIds, - orgMemberIds, - includeDeparted: true, - }) - - const [log] = await db - .select() - .from(auditLog) - .where(and(eq(auditLog.id, id), scopeCondition)) - .limit(1) - - if (!log) return v2Error('NOT_FOUND', 'Audit log not found') - - return v2Data(formatV2AuditLogEntry(log), { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: auditLogOperations.readDetail, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ id: params.id, organizationId: query.organizationId }), + useCase: getAuditLog, + present: ({ log }) => ({ data: formatV2AuditLogEntry(log) }), }) diff --git a/apps/sim/app/api/v2/audit-logs/route.test.ts b/apps/sim/app/api/v2/audit-logs/route.test.ts index c80f6e7c406..a8c984556ce 100644 --- a/apps/sim/app/api/v2/audit-logs/route.test.ts +++ b/apps/sim/app/api/v2/audit-logs/route.test.ts @@ -4,103 +4,138 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveEnterpriseAuditAccess, - mockBuildFilterConditions, - mockBuildOrgScopeCondition, - mockGetOrgWorkspaceIds, - mockQueryAuditLogs, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveEnterpriseAuditAccess: vi.fn(), - mockBuildFilterConditions: vi.fn(), - mockBuildOrgScopeCondition: vi.fn(), - mockGetOrgWorkspaceIds: vi.fn(), - mockQueryAuditLogs: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + list: vi.fn(), + get: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/app/api/v1/audit-logs/auth', () => ({ - resolveEnterpriseAuditAccess: mockResolveEnterpriseAuditAccess, +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, })) -vi.mock('@/app/api/v1/audit-logs/query', () => ({ - buildFilterConditions: mockBuildFilterConditions, - buildOrgScopeCondition: mockBuildOrgScopeCondition, - getOrgWorkspaceIds: mockGetOrgWorkspaceIds, - queryAuditLogs: mockQueryAuditLogs, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +vi.mock('@/lib/audit-logs/application/list-audit-logs', () => ({ + listAuditLogs: { operation: { id: 'audit_logs.list' }, execute: mocks.list }, })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/audit-logs/application/get-audit-log', () => ({ + getAuditLog: { operation: { id: 'audit_logs.read_detail' }, execute: mocks.get }, })) -import { GET } from '@/app/api/v2/audit-logs/route' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET as getDetail } from '@/app/api/v2/audit-logs/[id]/route' +import { GET as listLogs } from '@/app/api/v2/audit-logs/route' -const RATE_LIMIT = { - allowed: true, - userId: 'admin-1', - keyType: 'personal', - limit: 100, - remaining: 99, - resetAt: new Date('2026-08-01T00:00:00Z'), +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'admin-1', keyId: 'key-1' }, + rolloutUserId: 'admin-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:admin-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, } - -function callGet(query = '') { - return GET(new NextRequest(`http://localhost:3000/api/v2/audit-logs${query}`)) +const log = { + id: 'audit-1', + workspaceId: 'workspace-1', + actorId: 'admin-1', + actorName: 'Ada', + actorEmail: 'ada@example.com', + action: 'workspace.updated', + resourceType: 'workspace', + resourceId: 'workspace-1', + resourceName: 'Engineering', + description: null, + metadata: {}, + createdAt: new Date('2026-08-01T00:00:00Z'), + ipAddress: null, + userAgent: null, } -describe('GET /api/v2/audit-logs', () => { +describe('v2 audit-log routes', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) - mockResolveEnterpriseAuditAccess.mockResolvedValue({ - success: true, - context: { organizationId: 'org-1', orgMemberIds: ['admin-1'] }, + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-01T01:00:00Z'), + }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-01T01:00:00Z'), }) - mockGetOrgWorkspaceIds.mockResolvedValue([]) - mockBuildOrgScopeCondition.mockReturnValue({ type: 'scope' }) - mockBuildFilterConditions.mockReturnValue([]) - mockQueryAuditLogs.mockResolvedValue({ data: [], nextCursor: undefined }) + mocks.list.mockResolvedValue({ data: [log], nextCursor: 'next-1' }) + mocks.get.mockResolvedValue({ log }) }) - it('requires an explicit organization before authorization', async () => { - const response = await callGet() + it('authenticates and rate-limits before validating organization input', async () => { + const response = await listLogs(new NextRequest('http://localhost:3000/api/v2/audit-logs')) expect(response.status).toBe(400) - expect(mockResolveEnterpriseAuditAccess).not.toHaveBeenCalled() + expect(mocks.authenticate).toHaveBeenCalled() + expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2) + expect(mocks.list).not.toHaveBeenCalled() }) - it('rejects workspace keys before organization-wide access is resolved', async () => { - mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) - - const response = await callGet('?organizationId=org-1') + it('maps list filters into the authorized application operation', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/v2/audit-logs?organizationId=org-1&actorEmail=ada%40example.com' + ) + const response = await listLogs(request) - expect(response.status).toBe(403) - expect(mockResolveEnterpriseAuditAccess).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ data: [{ id: 'audit-1' }], nextCursor: 'next-1' }) + expect(mocks.list).toHaveBeenCalledWith({ + principal: auth.principal, + input: expect.objectContaining({ + organizationId: 'org-1', + filters: expect.objectContaining({ actorEmail: 'ada@example.com' }), + }), + request, + }) + expect(response.headers.get('x-ratelimit-limit')).toBe('100') }) - it('authorizes exactly the requested organization for personal keys', async () => { - const response = await callGet('?organizationId=org-1') + it('projects typed admin-policy failures without leaking internals', async () => { + mocks.list.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Admin required')) - expect(response.status).toBe(200) - expect(mockResolveEnterpriseAuditAccess).toHaveBeenCalledWith('admin-1', 'org-1') - expect(mockQueryAuditLogs).toHaveBeenCalled() + const response = await listLogs( + new NextRequest('http://localhost:3000/api/v2/audit-logs?organizationId=org-1') + ) + + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ error: { code: 'FORBIDDEN' } }) }) - it('filters by the public actor email without requiring a user ID', async () => { - const response = await callGet('?organizationId=org-1&actorEmail=ada%40example.com') + it('keeps the detail envelope independent', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/v2/audit-logs/audit-1?organizationId=org-1' + ) + const response = await getDetail(request, { + params: Promise.resolve({ id: 'audit-1' }), + }) expect(response.status).toBe(200) - expect(mockBuildFilterConditions).toHaveBeenCalledWith( - expect.objectContaining({ actorEmail: 'ada@example.com' }) - ) - expect(mockBuildFilterConditions).toHaveBeenCalledWith( - expect.not.objectContaining({ actorId: expect.anything() }) - ) + expect(await response.json()).toMatchObject({ data: { id: 'audit-1' } }) + expect(mocks.get).toHaveBeenCalledWith({ + principal: auth.principal, + input: { id: 'audit-1', organizationId: 'org-1' }, + request, + }) }) }) diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts index dca2168878e..b9bc8743819 100644 --- a/apps/sim/app/api/v2/audit-logs/route.ts +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -1,14 +1,13 @@ import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { - buildFilterConditions, - buildOrgScopeCondition, - getOrgWorkspaceIds, - queryAuditLogs, -} from '@/app/api/v1/audit-logs/query' + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { listAuditLogs } from '@/lib/audit-logs/application/list-audit-logs' +import { auditLogOperations } from '@/lib/audit-logs/application/operations' import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' -import { v2CursorList, v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -20,49 +19,30 @@ export const revalidate = 0 * are personal-key-only because a workspace-scoped key must never expand into * organization-wide visibility. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListAuditLogsContract, - rateLimitEndpoint: 'audit-logs', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const params = input.query - - if (rateLimit.keyType !== 'personal') { - return v2Error('FORBIDDEN', 'Audit logs require a personal API key') - } - - const authResult = await resolveEnterpriseAuditAccess(userId, params.organizationId) - if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) - - const { organizationId, orgMemberIds } = authResult.context - - const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) - - if (params.workspaceId && !orgWorkspaceIds.includes(params.workspaceId)) { - return v2Error('BAD_REQUEST', 'workspaceId does not belong to your organization') - } - - const scopeCondition = buildOrgScopeCondition({ - organizationId, - orgWorkspaceIds, - orgMemberIds, - includeDeparted: params.includeDeparted, - }) - const filterConditions = buildFilterConditions({ - action: params.action, - resourceType: params.resourceType, - resourceId: params.resourceId, - workspaceId: params.workspaceId, - actorEmail: params.actorEmail, - startDate: params.startDate, - endDate: params.endDate, - }) - - const { data, nextCursor } = await queryAuditLogs( - [scopeCondition, ...filterConditions], - params.limit, - params.cursor - ) - - return v2CursorList(data.map(formatV2AuditLogEntry), nextCursor ?? null, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: auditLogOperations.list, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => ({ + organizationId: query.organizationId, + includeDeparted: query.includeDeparted, + filters: { + action: query.action, + resourceType: query.resourceType, + resourceId: query.resourceId, + workspaceId: query.workspaceId, + actorEmail: query.actorEmail, + startDate: query.startDate, + endDate: query.endDate, + }, + limit: query.limit, + cursor: query.cursor, + }), + useCase: listAuditLogs, + present: ({ data, nextCursor }) => ({ + data: data.map(formatV2AuditLogEntry), + nextCursor: nextCursor ?? null, + }), }) diff --git a/apps/sim/app/api/v2/billing/logs/route.test.ts b/apps/sim/app/api/v2/billing/logs/route.test.ts index e3a5c354dbc..95d0f017a58 100644 --- a/apps/sim/app/api/v2/billing/logs/route.test.ts +++ b/apps/sim/app/api/v2/billing/logs/route.test.ts @@ -3,139 +3,120 @@ */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { apportionCredits } from '@/lib/billing/credits/conversion' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetUserUsageLogs, - mockGetUsageCreditsByLogId, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetUserUsageLogs: vi.fn(), - mockGetUsageCreditsByLogId: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + execute: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/billing/core/usage-log', () => ({ - getUserUsageLogs: mockGetUserUsageLogs, - getUsageCreditsByLogId: mockGetUsageCreditsByLogId, +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +vi.mock('@/lib/billing/application/list-billing-logs', () => ({ + listBillingLogs: { operation: { id: 'billing.logs.list' }, execute: mocks.execute }, })) import { GET } from '@/app/api/v2/billing/logs/route' -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'personal', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -function callLogs(query = '') { - return GET(new NextRequest(`http://localhost:3000/api/v2/billing/logs${query}`)) +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, } describe('GET /api/v2/billing/logs', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetUserUsageLogs.mockResolvedValue({ - logs: [ - { - id: 'log-1', - createdAt: '2026-07-01T00:00:00.000Z', - category: 'model', - source: 'workflow', - description: 'claude-sonnet', - cost: 0.06, - workspaceId: 'ws-1', - workflowId: 'workflow-1', - workflowName: 'Support Agent', - executionId: 'execution-1', - }, - ], - summary: { totalCost: 0, bySource: {} }, - pagination: { hasMore: false }, + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-01T00:00:00Z')) + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-01T01:00:00Z'), + }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-01T01:00:00Z'), + }) + mocks.execute.mockResolvedValue({ + usage: { + logs: [ + { + id: 'log-1', + createdAt: '2026-07-01T00:00:00.000Z', + source: 'workflow', + cost: 0.06, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + workflowName: 'Support Agent', + executionId: 'run-1', + }, + ], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: false }, + }, + creditsByLogId: { 'log-1': 12 }, }) - mockGetUsageCreditsByLogId.mockResolvedValue( - apportionCredits([{ key: 'log-1', dollars: 0.06 }]) - ) }) - it('returns ledger rows without embedding billing status', async () => { - const response = await callLogs() - const body = await response.json() + it('maps billing filters and preserves the public ledger envelope', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/v2/billing/logs?workspaceId=workspace-1&source=sim-chat' + ) + const response = await GET(request) expect(response.status).toBe(200) - expect(body).toEqual({ + expect(await response.json()).toEqual({ data: [ { id: 'log-1', createdAt: '2026-07-01T00:00:00.000Z', source: 'workflow', - workspaceId: 'ws-1', + workspaceId: 'workspace-1', workflow: { id: 'workflow-1', name: 'Support Agent' }, - runId: 'execution-1', + runId: 'run-1', creditCost: 12, }, ], nextCursor: null, }) - expect(body).not.toHaveProperty('status') - }) - - it('normalizes both internal chat sources to sim-chat', async () => { - const response = await callLogs('?source=sim-chat') - - expect(response.status).toBe(200) - expect(mockGetUserUsageLogs).toHaveBeenCalledWith( - 'user-1', - expect.objectContaining({ source: ['copilot', 'workspace-chat'] }) - ) - }) - - it('forwards the cursor when more rows remain', async () => { - mockGetUserUsageLogs.mockResolvedValue({ - logs: [], - summary: { totalCost: 0, bySource: {} }, - pagination: { hasMore: true, nextCursor: 'log-42' }, + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: expect.objectContaining({ + workspaceId: 'workspace-1', + source: ['copilot', 'workspace-chat'], + }), + request, }) - mockGetUsageCreditsByLogId.mockResolvedValue({}) - - const body = await (await callLogs()).json() - - expect(body.nextCursor).toBe('log-42') }) - it('rejects custom periods without a start date', async () => { - const response = await callLogs('?period=custom') + it('authenticates before rejecting invalid custom ranges', async () => { + const response = await GET( + new NextRequest('http://localhost:3000/api/v2/billing/logs?period=custom') + ) expect(response.status).toBe(400) - expect(mockGetUserUsageLogs).not.toHaveBeenCalled() - }) - - it('authorizes a personal key before reading a workspace ledger', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - - const response = await callLogs('?workspaceId=ws-2') - - expect(response.status).toBe(403) - expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + expect(mocks.authenticate).toHaveBeenCalled() + expect(mocks.execute).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/billing/logs/route.ts b/apps/sim/app/api/v2/billing/logs/route.ts index b2893ec95b7..03d02a8d715 100644 --- a/apps/sim/app/api/v2/billing/logs/route.ts +++ b/apps/sim/app/api/v2/billing/logs/route.ts @@ -1,38 +1,39 @@ import { v2ListBillingLogsContract } from '@/lib/api/contracts/v2/billing' -import { getUsageCreditsByLogId, getUserUsageLogs } from '@/lib/billing/core/usage-log' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { listBillingLogs } from '@/lib/billing/application/list-billing-logs' +import { billingOperations } from '@/lib/billing/application/operations' import { toBillingUsageLogSource, toInternalUsageLogSources } from '@/lib/billing/usage-sources' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' -import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' -import { v2CursorList } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** Cursor-paged, credit-denominated billing ledger. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListBillingLogsContract, - rateLimitEndpoint: 'billing-usage', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { source, workspaceId, period, startDate, endDate, limit, cursor } = input.query - - const workspaceFilter = await v2BillingWorkspaceFilter(rateLimit, workspaceId) - if (!workspaceFilter.ok) return workspaceFilter.response - - const dateRange = resolveDateRange(period, startDate, endDate) - const filter = { - source: source ? toInternalUsageLogSources(source) : undefined, - workspaceId: workspaceFilter.workspaceId, + auth: v2ApiKeyAuth, + operation: billingOperations.listLogs, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => { + const dateRange = resolveDateRange(query.period, query.startDate, query.endDate) + return { + source: query.source ? toInternalUsageLogSources(query.source) : undefined, + workspaceId: query.workspaceId, startDate: dateRange.startDate, endDate: dateRange.endDate, + limit: query.limit, + cursor: query.cursor, } - - const [result, creditsByLogId] = await Promise.all([ - getUserUsageLogs(userId, { ...filter, limit, cursor, includeSummary: false }), - getUsageCreditsByLogId(userId, filter), - ]) - - const items = result.logs.map((log) => ({ + }, + useCase: listBillingLogs, + present: ({ usage, creditsByLogId }) => ({ + data: usage.logs.map((log) => ({ id: log.id, createdAt: log.createdAt, source: toBillingUsageLogSource(log.source), @@ -40,12 +41,7 @@ export const GET = withPublicApiRouteHandler({ workflow: log.workflowId ? { id: log.workflowId, name: log.workflowName ?? null } : null, runId: log.executionId ?? null, creditCost: creditsByLogId[log.id] ?? 0, - })) - - return v2CursorList( - items, - result.pagination.hasMore ? (result.pagination.nextCursor ?? null) : null, - { rateLimit } - ) - }, + })), + nextCursor: usage.pagination.hasMore ? (usage.pagination.nextCursor ?? null) : null, + }), }) diff --git a/apps/sim/app/api/v2/billing/status/route.test.ts b/apps/sim/app/api/v2/billing/status/route.test.ts index 84734542d33..6c87206289f 100644 --- a/apps/sim/app/api/v2/billing/status/route.test.ts +++ b/apps/sim/app/api/v2/billing/status/route.test.ts @@ -4,168 +4,106 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockCheckBillingBlocked, - mockCheckUsageStatus, - mockGetHighestPrioritySubscription, - mockDeriveBillingContext, - mockResolveBillingAttribution, - mockCheckAttributedBillingBlocks, - mockToUsageLimitSubscription, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockCheckBillingBlocked: vi.fn(), - mockCheckUsageStatus: vi.fn(), - mockGetHighestPrioritySubscription: vi.fn(), - mockDeriveBillingContext: vi.fn(), - mockResolveBillingAttribution: vi.fn(), - mockCheckAttributedBillingBlocks: vi.fn(), - mockToUsageLimitSubscription: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + execute: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ - checkBillingBlocked: mockCheckBillingBlocked, - checkBillingEntityBlocked: vi.fn(), - checkUsageStatus: mockCheckUsageStatus, +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, })) -vi.mock('@/lib/billing/core/subscription', () => ({ - getHighestPrioritySubscription: mockGetHighestPrioritySubscription, -})) - -vi.mock('@/lib/billing/core/usage-log', () => ({ - deriveBillingContext: mockDeriveBillingContext, -})) - -vi.mock('@/lib/billing/core/billing-attribution', () => ({ - resolveBillingAttribution: mockResolveBillingAttribution, - checkAttributedBillingBlocks: mockCheckAttributedBillingBlocks, - toUsageLimitSubscription: mockToUsageLimitSubscription, -})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/billing/application/get-billing-status', () => ({ + getBillingStatus: { operation: { id: 'billing.status.read' }, execute: mocks.execute }, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET } from '@/app/api/v2/billing/status/route' -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'personal', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: 'workspace-1', keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -function callStatus(query = '') { - return GET(new NextRequest(`http://localhost:3000/api/v2/billing/status${query}`)) +const result = { + workspaceId: 'workspace-1', + period: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, + plan: 'team', + status: 'active' as const, + credits: { used: 500, limit: 20_000, remaining: 19_500 }, } describe('GET /api/v2/billing/status', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro' }) - mockDeriveBillingContext.mockReturnValue({ - billingEntity: { type: 'user', id: 'user-1' }, - billingPeriod: { - start: new Date('2026-07-01T00:00:00Z'), - end: new Date('2026-08-01T00:00:00Z'), - }, - }) - mockCheckUsageStatus.mockResolvedValue({ - isExceeded: false, - currentUsage: 2.5, - limit: 100, + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-01T01:00:00Z'), }) - mockCheckBillingBlocked.mockResolvedValue({ blocked: false }) - mockCheckAttributedBillingBlocks.mockResolvedValue({ blocked: false }) - mockToUsageLimitSubscription.mockReturnValue({ - referenceId: 'org-1', - plan: 'team', - status: 'active', - seats: 5, - periodStart: new Date('2026-07-01T00:00:00Z'), - periodEnd: new Date('2026-08-01T00:00:00Z'), + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-01T01:00:00Z'), }) + mocks.execute.mockResolvedValue(result) }) - it('returns status and allowance without ledger rows or source summaries', async () => { - const response = await callStatus() - const body = await response.json() + it('passes only the authenticated principal and requested scope to the use case', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/v2/billing/status?workspaceId=workspace-1' + ) + const response = await GET(request) expect(response.status).toBe(200) - expect(body.data).toEqual({ - workspaceId: null, - period: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, - plan: 'pro', - status: 'active', - credits: { used: 500, limit: 20000, remaining: 19500 }, + expect(await response.json()).toEqual({ data: result }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: 'workspace-1' }, + request, }) - expect(body.data).not.toHaveProperty('bySourceCredits') + expect(response.headers.get('x-ratelimit-limit')).toBe('100') }) - it('reports billing blocks before usage-limit state', async () => { - mockCheckUsageStatus.mockResolvedValue({ isExceeded: true, currentUsage: 100, limit: 100 }) - mockCheckBillingBlocked.mockResolvedValue({ blocked: true }) + it('projects typed workspace-policy errors', async () => { + mocks.execute.mockRejectedValueOnce( + new OrchestrationError('forbidden', 'API key is not authorized for this workspace') + ) - const body = await (await callStatus()).json() + const response = await GET( + new NextRequest('http://localhost:3000/api/v2/billing/status?workspaceId=workspace-2') + ) - expect(body.data.status).toBe('billing_blocked') + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ error: { code: 'FORBIDDEN' } }) }) - it('resolves a workspace billing status against the workspace payer', async () => { - mockResolveBillingAttribution.mockResolvedValue({ - actorUserId: 'user-1', - workspaceId: 'ws-1', - organizationId: 'org-1', - billedAccountUserId: 'owner-1', - billingEntity: { type: 'organization', id: 'org-1' }, - billingPeriod: { - start: '2026-07-01T00:00:00.000Z', - end: '2026-08-01T00:00:00.000Z', - }, - payerSubscription: { - id: 'sub-1', - referenceId: 'org-1', - plan: 'team', - status: 'active', - seats: 5, - periodStart: '2026-07-01T00:00:00.000Z', - periodEnd: '2026-08-01T00:00:00.000Z', - }, - }) + it('hides unknown billing infrastructure errors', async () => { + mocks.execute.mockRejectedValueOnce(new Error('stripe account details')) - const body = await (await callStatus('?workspaceId=ws-1')).json() + const response = await GET(new NextRequest('http://localhost:3000/api/v2/billing/status')) - expect(body.data.workspaceId).toBe('ws-1') - expect(body.data.plan).toBe('team') - expect(mockCheckUsageStatus).toHaveBeenCalledWith( - 'owner-1', - expect.objectContaining({ referenceId: 'org-1', plan: 'team' }) - ) - }) - - it('403s a workspace API key asking for a different workspace', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - keyType: 'workspace', - workspaceId: 'ws-1', + expect(response.status).toBe(500) + expect(await response.json()).toMatchObject({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, }) - - const response = await callStatus('?workspaceId=ws-2') - - expect(response.status).toBe(403) - expect(mockCheckUsageStatus).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/billing/status/route.ts b/apps/sim/app/api/v2/billing/status/route.ts index 868e7dd0c06..b5a7fd95b5f 100644 --- a/apps/sim/app/api/v2/billing/status/route.ts +++ b/apps/sim/app/api/v2/billing/status/route.ts @@ -1,87 +1,24 @@ +import { v2GetBillingStatusContract } from '@/lib/api/contracts/v2/billing' import { - type V2BillingStatusData, - v2GetBillingStatusContract, -} from '@/lib/api/contracts/v2/billing' -import { - checkBillingBlocked, - checkBillingEntityBlocked, - checkUsageStatus, -} from '@/lib/billing/calculations/usage-monitor' -import { - checkAttributedBillingBlocks, - resolveBillingAttribution, - toUsageLimitSubscription, -} from '@/lib/billing/core/billing-attribution' -import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { deriveBillingContext } from '@/lib/billing/core/usage-log' -import { dollarsToCredits } from '@/lib/billing/credits/conversion' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' -import { v2Data } from '@/app/api/v2/lib/response' + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { getBillingStatus } from '@/lib/billing/application/get-billing-status' +import { billingOperations } from '@/lib/billing/application/operations' export const dynamic = 'force-dynamic' export const revalidate = 0 /** Current billing standing; ledger events are exposed separately by `/billing/logs`. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetBillingStatusContract, - rateLimitEndpoint: 'billing-usage', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const workspaceFilter = await v2BillingWorkspaceFilter(rateLimit, input.query.workspaceId) - if (!workspaceFilter.ok) return workspaceFilter.response - - let data: V2BillingStatusData - if (workspaceFilter.workspaceId) { - const attribution = await resolveBillingAttribution({ - actorUserId: userId, - workspaceId: workspaceFilter.workspaceId, - }) - const [usage, block] = await Promise.all([ - checkUsageStatus(attribution.billedAccountUserId, toUsageLimitSubscription(attribution)), - checkAttributedBillingBlocks(attribution), - ]) - data = { - workspaceId: workspaceFilter.workspaceId, - period: attribution.billingPeriod, - plan: attribution.payerSubscription?.plan ?? 'free', - status: block.blocked ? 'billing_blocked' : usage.isExceeded ? 'limit_exceeded' : 'active', - credits: { - used: dollarsToCredits(usage.currentUsage), - limit: dollarsToCredits(usage.limit), - remaining: dollarsToCredits(usage.limit - usage.currentUsage), - }, - } - } else { - const subscription = await getHighestPrioritySubscription(userId) - const { billingEntity, billingPeriod } = deriveBillingContext(userId, subscription) - const [usage, actorBlock, payerBlock] = await Promise.all([ - checkUsageStatus(userId, subscription), - checkBillingBlocked(userId), - billingEntity.type === 'user' && billingEntity.id === userId - ? Promise.resolve({ blocked: false }) - : checkBillingEntityBlocked(billingEntity), - ]) - data = { - workspaceId: null, - period: { - start: billingPeriod.start.toISOString(), - end: billingPeriod.end.toISOString(), - }, - plan: subscription?.plan ?? 'free', - status: - actorBlock.blocked || payerBlock.blocked - ? 'billing_blocked' - : usage.isExceeded - ? 'limit_exceeded' - : 'active', - credits: { - used: dollarsToCredits(usage.currentUsage), - limit: dollarsToCredits(usage.limit), - remaining: dollarsToCredits(usage.limit - usage.currentUsage), - }, - } - } - - return v2Data(data, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: billingOperations.readStatus, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => ({ workspaceId: query.workspaceId }), + useCase: getBillingStatus, + present: (data) => ({ data }), }) diff --git a/apps/sim/app/api/v2/billing/utils.ts b/apps/sim/app/api/v2/billing/utils.ts deleted file mode 100644 index 9cf95afcbef..00000000000 --- a/apps/sim/app/api/v2/billing/utils.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { NextResponse } from 'next/server' -import { type RateLimitResult, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Error } from '@/app/api/v2/lib/response' - -type BillingWorkspaceFilter = - | { ok: true; workspaceId: string | undefined } - | { ok: false; response: NextResponse } - -/** - * Resolves the effective `workspaceId` ledger filter for the caller's key. - * Personal keys may read their account-wide ledger without a filter. When any - * key targets a workspace, the caller must have read access and the workspace's - * API-key policy must allow the key type. Workspace-scoped keys remain pinned to - * their own workspace. - */ -export async function v2BillingWorkspaceFilter( - rateLimit: RateLimitResult, - requestedWorkspaceId: string | undefined -): Promise { - if ( - rateLimit.keyType === 'workspace' && - requestedWorkspaceId && - requestedWorkspaceId !== rateLimit.workspaceId - ) { - return { - ok: false, - response: v2Error('FORBIDDEN', 'API key is not authorized for this workspace'), - } - } - - const workspaceId = - rateLimit.keyType === 'workspace' ? rateLimit.workspaceId : requestedWorkspaceId - - if (!workspaceId) { - if (rateLimit.keyType === 'workspace') { - return { - ok: false, - response: v2Error('FORBIDDEN', 'Workspace-scoped API key is missing its workspace'), - } - } - return { ok: true, workspaceId: undefined } - } - - const userId = rateLimit.userId - if (!userId) { - return { - ok: false, - response: v2Error('UNAUTHORIZED', 'Authentication required'), - } - } - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) { - return { - ok: false, - response: v2Error('FORBIDDEN', access.message), - } - } - - return { ok: true, workspaceId } -} diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index adb94296696..4a4badb6b0b 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -4,175 +4,152 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockCheckWorkspaceAccess, - mockListVisibleWorkspaceCredentials, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockCheckWorkspaceAccess: vi.fn(), - mockListVisibleWorkspaceCredentials: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + execute: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - checkWorkspaceAccess: mockCheckWorkspaceAccess, +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, })) -vi.mock('@/lib/credentials/queries', () => ({ - listVisibleWorkspaceCredentials: mockListVisibleWorkspaceCredentials, -})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/credentials/application/list-workspace-credentials', () => ({ + listWorkspaceCredentials: { + operation: { id: 'credentials.connections.list' }, + execute: mocks.execute, + }, })) import { GET } from '@/app/api/v2/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} - -function buildVisible(overrides: Record = {}) { - return { - id: 'cred_abc123', +const auth = { + principal: { + kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, - type: 'service_account' as const, - displayName: 'Zoom account acct_123', - description: null, - providerId: 'zoom-service-account', - accountId: null, - envKey: null, - envOwnerUserId: null, - createdBy: 'user-1', - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - hasServiceAccountKey: true, - role: 'admin' as const, - ...overrides, - } + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const credential = { + id: 'credential-1', + workspaceId: WORKSPACE_ID, + type: 'service_account' as const, + displayName: 'Zoom account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: 'MUST_NOT_LEAK', + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + hasServiceAccountKey: true, + role: 'member' as const, } - -const callList = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/credentials?${query}`)) describe('GET /api/v2/credentials', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: true }) - mockListVisibleWorkspaceCredentials.mockResolvedValue([buildVisible()]) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callList(`workspaceId=${WORKSPACE_ID}`) - - expect(res.status).toBe(404) - expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callList('') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-01-01T01:00:00Z'), }) - - const res = await callList(`workspaceId=${WORKSPACE_ID}`) - - expect(res.status).toBe(403) - expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), + }) + mocks.execute.mockResolvedValue({ credentials: [credential] }) }) - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - - const res = await callList(`workspaceId=${WORKSPACE_ID}`) + it('authenticates and charges before validating workspace input', async () => { + const response = await GET(new NextRequest('http://localhost:3000/api/v2/credentials')) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') + expect(response.status).toBe(400) + expect(mocks.authenticate).toHaveBeenCalled() + expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2) + expect(mocks.execute).not.toHaveBeenCalled() }) - it('returns connection metadata without environment-secret fields', async () => { - const res = await callList(`workspaceId=${WORKSPACE_ID}`) - const body = await res.json() + it('calls the application operation with the workspace principal', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&type=service_account` + ) + const response = await GET(request) - expect(res.status).toBe(200) - expect(body.nextCursor).toBeNull() - expect(body.data).toEqual([ - { - id: 'cred_abc123', + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: WORKSPACE_ID, type: 'service_account', - displayName: 'Zoom account acct_123', - description: null, - providerId: 'zoom-service-account', - accountId: null, - hasServiceAccountKey: true, - role: 'admin', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', + providerId: undefined, + search: undefined, + sortBy: 'createdAt', + sortOrder: 'desc', }, - ]) - expect(JSON.stringify(body)).not.toContain('envKey') - expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: WORKSPACE_ID, - userId: 'user-1', - types: ['oauth', 'service_account'], - }) - ) + request, + }) }) - it('accepts only OAuth and service-account type filters', async () => { - await callList(`workspaceId=${WORKSPACE_ID}&type=oauth&providerId=slack`) - expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( - expect.objectContaining({ types: ['oauth'], providerId: 'slack' }) + it('projects credential metadata field by field without secret material', async () => { + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}`) ) - - const invalid = await callList(`workspaceId=${WORKSPACE_ID}&type=env_workspace`) - expect(invalid.status).toBe(400) + const body = await response.json() + + expect(body).toEqual({ + data: [ + { + id: 'credential-1', + type: 'service_account', + displayName: 'Zoom account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + hasServiceAccountKey: true, + role: 'member', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, + ], + nextCursor: null, + }) + expect(JSON.stringify(body)).not.toContain('envKey') + expect(JSON.stringify(body)).not.toContain('createdBy') }) - it('rejects invalid list controls', async () => { - const invalidSort = await callList(`workspaceId=${WORKSPACE_ID}&sortBy=name);--`) - const invalidDirection = await callList(`workspaceId=${WORKSPACE_ID}&sortOrder=sideways`) - const emptySearch = await callList(`workspaceId=${WORKSPACE_ID}&search=`) + it('hides repository errors that may contain secret details', async () => { + mocks.execute.mockRejectedValueOnce(new Error('encryptedServiceAccountKey failed')) + + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}`) + ) - expect(invalidSort.status).toBe(400) - expect(invalidDirection.status).toBe(400) - expect(emptySearch.status).toBe(400) + expect(response.status).toBe(500) + expect(await response.json()).toMatchObject({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) }) }) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index c61307198f1..0312ea3a957 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -1,42 +1,28 @@ import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' -import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { listWorkspaceCredentials } from '@/lib/credentials/application/list-workspace-credentials' +import { credentialOperations } from '@/lib/credentials/application/operations' import { toV2Credential } from '@/app/api/v2/credentials/utils' -import { v2CursorList, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/credentials — List the credentials the caller can see in a workspace. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListCredentialsContract, - rateLimitEndpoint: 'credentials', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, type, providerId, search, sortBy, sortOrder } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - /** - * Credential visibility is per credential, not per workspace: membership - * rows and shared-type admin access decide what this caller sees, so the - * workspace permission is re-read here for the `canAdmin` bit. - */ - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - const credentials = await listVisibleWorkspaceCredentials({ - workspaceId, - userId, - workspaceAccess, - types: type ? [type] : ['oauth', 'service_account'], - providerId, - search, - sortBy, - sortOrder, - }) - - // The per-workspace credential set is small and bounded → a single full page. - return v2CursorList(credentials.map(toV2Credential), null, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: credentialOperations.listConnections, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => query, + useCase: listWorkspaceCredentials, + present: ({ credentials }) => ({ + data: credentials.map(toV2Credential), + nextCursor: null, + }), }) diff --git a/apps/sim/app/api/v2/logs/[runId]/route.test.ts b/apps/sim/app/api/v2/logs/[runId]/route.test.ts index df621d35169..b538e07d63c 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.test.ts @@ -1,130 +1,139 @@ /** * @vitest-environment node */ - -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockLoadActiveFolderPathIndex, - mockMaterializeExecutionData, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), - mockMaterializeExecutionData: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + execute: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, })) -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, -})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) -vi.mock('@/lib/logs/execution/trace-store', () => ({ - materializeExecutionData: mockMaterializeExecutionData, +vi.mock('@/lib/logs/application/get-public-log', () => ({ + getPublicLog: { operation: { id: 'logs.read_detail' }, execute: mocks.execute }, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET } from '@/app/api/v2/logs/[runId]/route' -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -const LOG_ROW = { +const log = { + executionId: 'run-1', workflowId: 'workflow-1', - workspaceId: 'workspace-1', - executionId: 'execution-1', deploymentVersionId: 'deployment-1', status: 'completed', level: 'info', trigger: 'api', - startedAt: new Date('2024-01-01T00:00:00Z'), - endedAt: new Date('2024-01-01T00:00:01Z'), + startedAt: new Date('2026-08-06T00:00:00Z'), + endedAt: new Date('2026-08-06T00:00:01Z'), totalDurationMs: 1000, - executionData: { stored: true }, - costTotal: '0.01', files: null, - createdAt: new Date('2024-01-01T00:00:00Z'), - workflowState: { blocks: {}, edges: [] }, workflowName: 'Support Agent', - workflowDescription: 'Handles support requests', - workflowFolderId: null, - workflowUserId: 'user-1', - workflowOwnerEmail: 'ada@example.com', + workflowDescription: null, + workflowOwnerEmail: 'owner@example.com', workflowWorkspaceId: 'workspace-1', - workflowCreatedAt: new Date('2023-12-01T00:00:00Z'), - workflowUpdatedAt: new Date('2023-12-02T00:00:00Z'), + workflowCreatedAt: new Date('2026-01-01T00:00:00Z'), + workflowUpdatedAt: new Date('2026-01-02T00:00:00Z'), workflowArchivedAt: null, -} - -const routeContext = () => ({ params: Promise.resolve({ runId: 'execution-1' }) }) - -function callGet() { - return GET(new NextRequest('http://localhost:3000/api/v2/logs/execution-1'), routeContext()) + workflowState: { blocks: {}, edges: [] }, + costTotal: '0.01', + createdAt: new Date('2026-08-06T00:00:00Z'), } describe('GET /api/v2/logs/[runId]', () => { beforeEach(() => { vi.clearAllMocks() - resetDbChainMock() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockLoadActiveFolderPathIndex.mockResolvedValue({ pathById: new Map() }) - dbChainMockFns.limit.mockResolvedValue([LOG_ROW]) - }) - - it('uses runId as the sole public identity and includes diagnostic data', async () => { - const traceSpans = [ - { - id: 'span-1', - name: 'Agent', - type: 'agent', - durationMs: 1000, - status: 'success', - output: { answer: 'done' }, - }, - ] - mockMaterializeExecutionData.mockResolvedValue({ - traceSpans, - finalOutput: { answer: 'done' }, + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-06T01:00:00Z'), + }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-06T01:00:00Z'), }) + mocks.execute.mockResolvedValue({ + log, + workflowFolderPath: '/agents', + executionData: { traceSpans: [], finalOutput: { ok: true } }, + }) + }) - const response = await callGet() + it('uses runId as the sole asserted identity', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/logs/run-1') + const response = await GET(request, { params: Promise.resolve({ runId: 'run-1' }) }) const body = await response.json() expect(response.status).toBe(200) - expect(body.data.runId).toBe('execution-1') - expect(body.data).not.toHaveProperty('id') + expect(body.data).toMatchObject({ + runId: 'run-1', + workflow: { folderPath: '/agents', ownerEmail: 'owner@example.com' }, + finalOutput: { ok: true }, + }) expect(body.data).not.toHaveProperty('executionData') - expect(body.data.traceSpans).toEqual(traceSpans) - expect(body.data.finalOutput).toEqual({ answer: 'done' }) - expect(body.data.workflowState).toEqual({ blocks: {}, edges: [] }) - expect(body.data.workflow.ownerEmail).toBe('ada@example.com') - expect(body.data.workflow).not.toHaveProperty('userId') + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { runId: 'run-1' }, + request, + }) + }) + + it('conceals canonical workspace authorization as log not-found', async () => { + mocks.execute.mockRejectedValueOnce( + new OrchestrationError('forbidden', 'Workspace API key cannot perform this operation') + ) + + const response = await GET(new NextRequest('http://localhost:3000/api/v2/logs/run-1'), { + params: Promise.resolve({ runId: 'run-1' }), + }) + + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ + error: { code: 'NOT_FOUND', message: 'Log not found' }, + }) }) - it('returns empty diagnostic collections when the execution produced none', async () => { - mockMaterializeExecutionData.mockResolvedValue({}) + it('hides unexpected materialization errors', async () => { + mocks.execute.mockRejectedValueOnce(new Error('storage key details')) - const body = await (await callGet()).json() + const response = await GET(new NextRequest('http://localhost:3000/api/v2/logs/run-1'), { + params: Promise.resolve({ runId: 'run-1' }), + }) - expect(body.data.traceSpans).toEqual([]) - expect(body.data.finalOutput).toBeNull() + expect(response.status).toBe(500) + expect(await response.json()).toMatchObject({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) }) }) diff --git a/apps/sim/app/api/v2/logs/[runId]/route.ts b/apps/sim/app/api/v2/logs/[runId]/route.ts index 811637abd8f..1902a4b44b0 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.ts @@ -1,41 +1,25 @@ import { traceSpansSchema } from '@/lib/api/contracts/logs' import { type V2LogDetail, v2GetLogContract, v2LogStatusSchema } from '@/lib/api/contracts/v2/logs' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { materializeExecutionData } from '@/lib/logs/execution/trace-store' -import { getPublicWorkflowLog } from '@/lib/logs/public-queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Data, v2Error } from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2LogErrorPolicies } from '@/lib/logs/api/route-policies' +import { getPublicLog } from '@/lib/logs/application/get-public-log' +import { logOperations } from '@/lib/logs/application/operations' export const revalidate = 0 /** * Returns the diagnostic representation of a run. The run ID is the sole - * public identity; the workflow-execution-log row key remains an internal - * storage and pagination detail. + * public identity; canonical workflow and workspace scope come from the run. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetLogContract, - rateLimitEndpoint: 'logs-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { runId } = input.params - - const log = await getPublicWorkflowLog({ column: 'executionId', value: runId }) - - if (!log) return v2Error('NOT_FOUND', 'Log not found') - - const access = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId) - if (access) return v2Error('NOT_FOUND', 'Log not found') - - const folderIndex = await loadActiveFolderPathIndex(log.workspaceId, 'workflow') - const executionData = await materializeExecutionData( - log.executionData as Record | null, - { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } - ) - if (log.workflowUserId && !log.workflowOwnerEmail) { - throw new Error(`Unable to resolve workflow owner email for ${log.workflowUserId}`) - } - + auth: v2ApiKeyAuth, + operation: logOperations.readDetail, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2LogErrorPolicies.concealDetailAuthorization, + mapInput: ({ params }) => ({ runId: params.runId }), + useCase: getPublicLog, + present: ({ log, workflowFolderPath, executionData }) => { const detail: V2LogDetail = { runId: log.executionId, workflowId: log.workflowId, @@ -51,9 +35,7 @@ export const GET = withPublicApiRouteHandler({ id: log.workflowId, name: log.workflowName || 'Deleted Workflow', description: log.workflowDescription, - folderPath: log.workflowFolderId - ? (folderIndex.pathById.get(log.workflowFolderId) ?? null) - : null, + folderPath: workflowFolderPath, ownerEmail: log.workflowOwnerEmail, workspaceId: log.workflowWorkspaceId, createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null, @@ -66,7 +48,6 @@ export const GET = withPublicApiRouteHandler({ cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, createdAt: log.createdAt.toISOString(), } - - return v2Data(detail, { rateLimit }) + return { data: detail } }, }) diff --git a/apps/sim/app/api/v2/logs/route.test.ts b/apps/sim/app/api/v2/logs/route.test.ts index 14bd9c03d4c..fb7c5013984 100644 --- a/apps/sim/app/api/v2/logs/route.test.ts +++ b/apps/sim/app/api/v2/logs/route.test.ts @@ -4,107 +4,135 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockListPublicWorkflowLogs, - mockMaterializeExecutionData, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockListPublicWorkflowLogs: vi.fn(), - mockMaterializeExecutionData: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + execute: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, })) -vi.mock('@/lib/logs/public-queries', () => ({ - decodePublicLogCursor: vi.fn(), - listPublicWorkflowLogs: mockListPublicWorkflowLogs, -})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) -vi.mock('@/lib/logs/execution/trace-store', () => ({ - materializeExecutionData: mockMaterializeExecutionData, +vi.mock('@/lib/logs/application/list-public-logs', () => ({ + listPublicLogs: { operation: { id: 'logs.list' }, execute: mocks.execute }, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET } from '@/app/api/v2/logs/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2026-08-06T01:00:00.000Z'), +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } -const LOG_ROW = { - executionId: 'execution-1', +const log = { + executionId: 'run-1', workflowId: 'workflow-1', workspaceId: WORKSPACE_ID, deploymentVersionId: null, status: 'completed', level: 'info', trigger: 'api', - startedAt: new Date('2026-08-06T00:00:00.000Z'), - endedAt: new Date('2026-08-06T00:00:01.000Z'), + startedAt: new Date('2026-08-06T00:00:00Z'), + endedAt: new Date('2026-08-06T00:00:01Z'), totalDurationMs: 1000, costTotal: null, files: null, - executionData: { stored: true }, workflowName: 'Support Agent', workflowDescription: null, workflowArchivedAt: null, } -function callLogs(query: string) { - return GET( - new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&${query}`) - ) -} - -describe('GET /api/v2/logs materialized fields', () => { +describe('GET /api/v2/logs', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockListPublicWorkflowLogs.mockResolvedValue({ data: [LOG_ROW], nextCursor: null }) + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-06T01:00:00Z'), + }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-06T01:00:00Z'), + }) + mocks.execute.mockResolvedValue({ + items: [{ log, executionData: { finalOutput: false, traceSpans: [] } }], + nextCursor: null, + includeFullDetails: true, + includeFinalOutput: true, + includeTraceSpans: true, + }) }) - it.each([false, 0, ''])('preserves a requested falsy final output: %j', async (finalOutput) => { - mockMaterializeExecutionData.mockResolvedValue({ finalOutput }) - - const response = await callLogs('includeFinalOutput=true') + it('maps filters into the application operation and preserves diagnostic fields', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&includeFinalOutput=true&includeTraceSpans=true` + ) + const response = await GET(request) const body = await response.json() expect(response.status).toBe(200) - expect(body.data[0].runId).toBe('execution-1') - expect(body.data[0].finalOutput).toBe(finalOutput) - expect(body.data[0].workflow).toMatchObject({ name: 'Support Agent' }) - expect(mockListPublicWorkflowLogs).toHaveBeenCalledWith( - expect.objectContaining({ includeExecutionData: true }) - ) + expect(body.data[0]).toMatchObject({ + runId: 'run-1', + workflow: { name: 'Support Agent' }, + finalOutput: false, + traceSpans: [], + }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: expect.objectContaining({ + workspaceId: WORKSPACE_ID, + includeFinalOutput: true, + includeTraceSpans: true, + }), + request, + }) }) - it('makes includeTraceSpans imply full detail', async () => { - const traceSpans = [{ id: 'span-1', name: 'Agent', type: 'agent' }] - mockMaterializeExecutionData.mockResolvedValue({ traceSpans }) + it('rejects malformed cursors after admission and before protected reads', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&cursor=not-a-cursor` + ) + ) - const response = await callLogs('includeTraceSpans=true') - const body = await response.json() + expect(response.status).toBe(400) + expect(mocks.authenticate).toHaveBeenCalled() + expect(mocks.execute).not.toHaveBeenCalled() + }) - expect(response.status).toBe(200) - expect(body.data[0].traceSpans).toEqual(traceSpans) - expect(body.data[0].workflow).toMatchObject({ name: 'Support Agent' }) - expect(mockListPublicWorkflowLogs).toHaveBeenCalledWith( - expect.objectContaining({ includeExecutionData: true }) + it('projects typed folder errors', async () => { + mocks.execute.mockRejectedValueOnce(new OrchestrationError('not_found', 'Folder not found')) + + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}`) ) + + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ error: { code: 'NOT_FOUND' } }) }) }) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index 5e65d643894..ea37bd87e30 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -4,75 +4,57 @@ import { v2ListLogsContract, v2LogStatusSchema, } from '@/lib/api/contracts/v2/logs' -import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { materializeExecutionData } from '@/lib/logs/execution/trace-store' -import { decodePublicLogCursor, listPublicWorkflowLogs } from '@/lib/logs/public-queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { resolveFolderPathId } from '@/app/api/v2/lib/folders' -import { v2CursorList, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { v2LogErrorPolicies } from '@/lib/logs/api/route-policies' +import { listPublicLogs } from '@/lib/logs/application/list-public-logs' +import { logOperations } from '@/lib/logs/application/operations' +import { decodePublicLogCursor } from '@/lib/logs/public-queries' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListLogsContract, - rateLimitEndpoint: 'logs', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const params = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const folderPaths = params.folderPaths?.split(',').filter(Boolean) - const folderIndex = folderPaths - ? await loadActiveFolderPathIndex(params.workspaceId, 'workflow') + auth: v2ApiKeyAuth, + operation: logOperations.list, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2LogErrorPolicies.default, + mapInput: ({ query }) => { + const decodedCursor = query.cursor + ? decodePublicLogCursor(query.cursor, query.order ?? 'desc') : null - const resolvedFolderIds = folderPaths?.map((path) => resolveFolderPathId(folderIndex!, path)) - if (resolvedFolderIds?.some((folderId) => folderId === undefined)) { - return v2Error('NOT_FOUND', 'Folder not found') + if (query.cursor && !decodedCursor) { + throw new OrchestrationError('validation', 'Invalid cursor') } - const nonRootFolderIds = resolvedFolderIds?.filter( - (folderId): folderId is string => typeof folderId === 'string' - ) - const includesRoot = resolvedFolderIds?.includes(null) ?? false - - const decodedCursor = params.cursor - ? decodePublicLogCursor(params.cursor, params.order ?? 'desc') - : null - if (params.cursor && !decodedCursor) return v2Error('BAD_REQUEST', 'Invalid cursor') - const cursor = decodedCursor ?? undefined - const includeFullDetails = - params.details === 'full' || params.includeFinalOutput || params.includeTraceSpans - - const filters = { - workspaceId: params.workspaceId, - workflowIds: params.workflowIds?.split(',').filter(Boolean), - folderIds: nonRootFolderIds, - triggers: params.triggers?.split(',').filter(Boolean), - level: params.level, - startDate: params.startDate ? new Date(params.startDate) : undefined, - endDate: params.endDate ? new Date(params.endDate) : undefined, - executionId: params.runId, - minDurationMs: params.minDurationMs, - maxDurationMs: params.maxDurationMs, - minCost: params.minCost, - maxCost: params.maxCost, - model: params.model, - cursor, - order: params.order, + return { + workspaceId: query.workspaceId, + filters: { + workflowIds: query.workflowIds?.split(',').filter(Boolean), + triggers: query.triggers?.split(',').filter(Boolean), + level: query.level, + startDate: query.startDate ? new Date(query.startDate) : undefined, + endDate: query.endDate ? new Date(query.endDate) : undefined, + executionId: query.runId, + minDurationMs: query.minDurationMs, + maxDurationMs: query.maxDurationMs, + minCost: query.minCost, + maxCost: query.maxCost, + model: query.model, + cursor: decodedCursor ?? undefined, + order: query.order, + }, + folderPaths: query.folderPaths?.split(',').filter(Boolean), + limit: query.limit, + includeFullDetails: + query.details === 'full' || query.includeFinalOutput || query.includeTraceSpans, + includeFinalOutput: query.includeFinalOutput, + includeTraceSpans: query.includeTraceSpans, } - - const { data, nextCursor } = await listPublicWorkflowLogs({ - filters, - limit: params.limit, - includeExecutionData: includeFullDetails, - folderScope: folderPaths ? { includesRoot, folderIds: nonRootFolderIds ?? [] } : undefined, - }) - - type LogRow = (typeof data)[number] - const buildItem = (log: LogRow): V2LogListItem => { + }, + useCase: listPublicLogs, + present: ({ items, nextCursor, includeFullDetails, includeFinalOutput, includeTraceSpans }) => ({ + data: items.map(({ log, executionData }): V2LogListItem => { const item: V2LogListItem = { runId: log.executionId, workflowId: log.workflowId, @@ -94,34 +76,16 @@ export const GET = withPublicApiRouteHandler({ deleted: !log.workflowName || log.workflowArchivedAt !== null, } } + if (executionData) { + if (includeFinalOutput && executionData.finalOutput !== undefined) { + item.finalOutput = executionData.finalOutput + } + if (includeTraceSpans) { + item.traceSpans = traceSpansSchema.parse(executionData.traceSpans ?? []) + } + } return item - } - - const needsMaterialize = params.includeFinalOutput || params.includeTraceSpans - - const formattedLogs = needsMaterialize - ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => { - const item = buildItem(log) - if (log.executionData) { - const execData = (await materializeExecutionData( - log.executionData as Record | null, - { - workspaceId: log.workspaceId, - workflowId: log.workflowId, - executionId: log.executionId, - } - )) as Record - if (params.includeFinalOutput && execData.finalOutput !== undefined) { - item.finalOutput = execData.finalOutput - } - if (params.includeTraceSpans) { - item.traceSpans = traceSpansSchema.parse(execData.traceSpans ?? []) - } - } - return item - }) - : data.map(buildItem) - - return v2CursorList(formattedLogs, nextCursor, { rateLimit }) - }, + }), + nextCursor, + }), }) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts index ba109669a1a..b07898ffdb1 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts @@ -2,49 +2,47 @@ import { v2ListWorkspaceMembersContract, v2WorkspaceMemberCursorSchema, } from '@/lib/api/contracts/v2/workspaces' -import { queryPublicWorkspaceMembers } from '@/lib/workspaces/public-queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { - decodeCursor, - encodeCursor, - v2CursorList, - v2Error, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { listPublicWorkspaceMembers } from '@/lib/workspaces/application/list-public-workspace-members' +import { workspaceOperations } from '@/lib/workspaces/application/operations' +import { decodeCursor, encodeCursor } from '@/app/api/v2/lib/response' /** GET /api/v2/workspaces/[workspaceId]/members — Effective member roster. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListWorkspaceMembersContract, - rateLimitEndpoint: 'workspace-members', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId } = input.params - const { cursor, limit } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const decoded = cursor - ? v2WorkspaceMemberCursorSchema.safeParse(decodeCursor(cursor)) + auth: v2ApiKeyAuth, + operation: workspaceOperations.listPublicMembers, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => { + const decoded = query.cursor + ? v2WorkspaceMemberCursorSchema.safeParse(decodeCursor(query.cursor)) : undefined - if (decoded && !decoded.success) return v2Error('BAD_REQUEST', 'Invalid cursor') - - const page = await queryPublicWorkspaceMembers(workspaceId, { - limit, + if (decoded && !decoded.success) { + throw new OrchestrationError('validation', 'Invalid cursor') + } + return { + workspaceId: params.workspaceId, + limit: query.limit, afterEmail: decoded?.data.email, - }) - if (!page) return v2Error('NOT_FOUND', 'Workspace not found') - - return v2CursorList( - page.members.map((member) => ({ - email: member.email, - name: member.name, - image: member.image, - role: member.role, - isExternal: member.isExternal, - joinedAt: member.joinedAt.toISOString(), - })), - page.nextEmail ? encodeCursor({ email: page.nextEmail }) : null, - { rateLimit } - ) + } }, + useCase: listPublicWorkspaceMembers, + present: ({ page }) => ({ + data: page.members.map((member) => ({ + email: member.email, + name: member.name, + image: member.image, + role: member.role, + isExternal: member.isExternal, + joinedAt: member.joinedAt.toISOString(), + })), + nextCursor: page.nextEmail ? encodeCursor({ email: page.nextEmail }) : null, + }), }) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts index 54272638421..ecfdd075bde 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts @@ -1,28 +1,27 @@ import { v2GetWorkspaceContract } from '@/lib/api/contracts/v2/workspaces' -import { getPublicWorkspaceDetail } from '@/lib/workspaces/public-queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { getPublicWorkspace } from '@/lib/workspaces/application/get-public-workspace' +import { workspaceOperations } from '@/lib/workspaces/application/operations' /** GET /api/v2/workspaces/[workspaceId] — Public workspace metadata. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetWorkspaceContract, - rateLimitEndpoint: 'workspaces', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId } = input.params - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const workspace = await getPublicWorkspaceDetail(workspaceId) - if (!workspace) return v2Error('NOT_FOUND', 'Workspace not found') - - return v2Data( - { - ...workspace, - createdAt: workspace.createdAt.toISOString(), - updatedAt: workspace.updatedAt.toISOString(), - }, - { rateLimit } - ) - }, + auth: v2ApiKeyAuth, + operation: workspaceOperations.readPublicDetail, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.workspaceId }), + useCase: getPublicWorkspace, + present: ({ workspace }) => ({ + data: { + ...workspace, + createdAt: workspace.createdAt.toISOString(), + updatedAt: workspace.updatedAt.toISOString(), + }, + }), }) diff --git a/apps/sim/app/api/v2/workspaces/route.test.ts b/apps/sim/app/api/v2/workspaces/route.test.ts index 84ffe4c78a4..bde0ffa3827 100644 --- a/apps/sim/app/api/v2/workspaces/route.test.ts +++ b/apps/sim/app/api/v2/workspaces/route.test.ts @@ -4,151 +4,165 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetPublicWorkspaceDetail, - mockQueryPublicWorkspaceMembers, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetPublicWorkspaceDetail: vi.fn(), - mockQueryPublicWorkspaceMembers: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + getWorkspace: vi.fn(), + listMembers: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, })) -vi.mock('@/lib/workspaces/public-queries', () => ({ - getPublicWorkspaceDetail: mockGetPublicWorkspaceDetail, - queryPublicWorkspaceMembers: mockQueryPublicWorkspaceMembers, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +vi.mock('@/lib/workspaces/application/get-public-workspace', () => ({ + getPublicWorkspace: { + operation: { id: 'workspaces.read_public_detail' }, + execute: mocks.getWorkspace, + }, +})) + +vi.mock('@/lib/workspaces/application/list-public-workspace-members', () => ({ + listPublicWorkspaceMembers: { + operation: { id: 'workspaces.members.list_public' }, + execute: mocks.listMembers, + }, })) -import { GET as listWorkspaceMembers } from '@/app/api/v2/workspaces/[workspaceId]/members/route' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET as listMembers } from '@/app/api/v2/workspaces/[workspaceId]/members/route' import { GET as getWorkspace } from '@/app/api/v2/workspaces/[workspaceId]/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - workspaceId: WORKSPACE_ID, - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2026-08-06T01:00:00.000Z'), +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } const context = () => ({ params: Promise.resolve({ workspaceId: WORKSPACE_ID }) }) -function callWorkspace() { - return getWorkspace( - new NextRequest(`http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}`), - context() - ) -} - -function callMembers(query = '') { - return listWorkspaceMembers( - new NextRequest(`http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}/members${query}`), - context() - ) -} - -beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetPublicWorkspaceDetail.mockResolvedValue({ - id: WORKSPACE_ID, - name: 'Engineering', - color: '#33C482', - logoUrl: null, - mode: 'organization', - memberCount: 2, - createdAt: new Date('2026-01-01T00:00:00.000Z'), - updatedAt: new Date('2026-01-02T00:00:00.000Z'), - }) - mockQueryPublicWorkspaceMembers.mockResolvedValue({ - members: [ - { - userId: 'user-1', - email: 'ada@example.com', - name: 'Ada', - image: null, - role: 'admin', - isExternal: false, - joinedAt: new Date('2026-01-01T00:00:00.000Z'), +describe('v2 workspace routes', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-06T01:00:00Z'), + }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-06T01:00:00Z'), + }) + mocks.getWorkspace.mockResolvedValue({ + workspace: { + id: WORKSPACE_ID, + name: 'Engineering', + color: '#33C482', + logoUrl: null, + mode: 'organization', + memberCount: 1, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + }, + }) + mocks.listMembers.mockResolvedValue({ + page: { + members: [ + { + userId: 'user-1', + email: 'ada@example.com', + name: 'Ada', + image: null, + role: 'admin', + isExternal: false, + joinedAt: new Date('2026-01-01T00:00:00Z'), + }, + ], + nextEmail: 'ada@example.com', }, - ], - nextEmail: 'ada@example.com', + }) }) -}) -describe('GET /api/v2/workspaces/[workspaceId]', () => { - it('returns public metadata without governance or billing identities', async () => { - const response = await callWorkspace() + it('projects public workspace metadata without governance identities', async () => { + const request = new NextRequest(`http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}`) + const response = await getWorkspace(request, context()) const body = await response.json() expect(response.status).toBe(200) - expect(body.data).toEqual({ - id: WORKSPACE_ID, - name: 'Engineering', - color: '#33C482', - logoUrl: null, - mode: 'organization', - memberCount: 2, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-02T00:00:00.000Z', - }) + expect(body.data).toMatchObject({ id: WORKSPACE_ID, name: 'Engineering' }) expect(body.data).not.toHaveProperty('ownerId') expect(body.data).not.toHaveProperty('billedAccountUserId') - }) - - it('enforces workspace read access before loading metadata', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + expect(mocks.getWorkspace).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID }, + request, }) - - const response = await callWorkspace() - - expect(response.status).toBe(403) - expect(mockGetPublicWorkspaceDetail).not.toHaveBeenCalled() }) -}) -describe('GET /api/v2/workspaces/[workspaceId]/members', () => { - it('returns email-attributed members and keeps user IDs out of data and cursors', async () => { - const response = await callMembers('?limit=1') + it('keeps member user IDs out of data and cursors', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}/members?limit=1` + ) + const response = await listMembers(request, context()) const body = await response.json() expect(response.status).toBe(200) - expect(body.data).toEqual([ - { - email: 'ada@example.com', - name: 'Ada', - image: null, - role: 'admin', - isExternal: false, - joinedAt: '2026-01-01T00:00:00.000Z', - }, - ]) - expect(body.data[0]).not.toHaveProperty('userId') + expect(body.data[0]).toEqual({ + email: 'ada@example.com', + name: 'Ada', + image: null, + role: 'admin', + isExternal: false, + joinedAt: '2026-01-01T00:00:00.000Z', + }) expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toEqual({ email: 'ada@example.com', }) }) - it('rejects malformed cursors without querying members', async () => { - const response = await callMembers('?cursor=not-a-cursor') + it('rejects malformed cursors before the application read', async () => { + const response = await listMembers( + new NextRequest( + `http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}/members?cursor=not-a-cursor` + ), + context() + ) expect(response.status).toBe(400) - expect(mockQueryPublicWorkspaceMembers).not.toHaveBeenCalled() + expect(mocks.listMembers).not.toHaveBeenCalled() + }) + + it('projects typed workspace policy errors', async () => { + mocks.getWorkspace.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Access denied')) + + const response = await getWorkspace( + new NextRequest(`http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}`), + context() + ) + + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ error: { code: 'FORBIDDEN' } }) }) }) diff --git a/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts b/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts new file mode 100644 index 00000000000..7ebbc8676df --- /dev/null +++ b/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveAccess: vi.fn(), + getOrgWorkspaceIds: vi.fn(), + buildOrgScopeCondition: vi.fn(), + buildFilterConditions: vi.fn(), + queryAuditLogs: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/app/api/v1/audit-logs/auth', () => ({ + resolveEnterpriseAuditAccess: mocks.resolveAccess, +})) + +vi.mock('@/app/api/v1/audit-logs/query', () => ({ + getOrgWorkspaceIds: mocks.getOrgWorkspaceIds, + buildOrgScopeCondition: mocks.buildOrgScopeCondition, + buildFilterConditions: mocks.buildFilterConditions, + queryAuditLogs: mocks.queryAuditLogs, +})) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) + +import { getAuditLog } from '@/lib/audit-logs/application/get-audit-log' +import { listAuditLogs } from '@/lib/audit-logs/application/list-audit-logs' + +const sessionPrincipal: SessionPrincipal = { + kind: 'session', + userId: 'admin-1', + sessionId: 'session-1', +} +const workspacePrincipal: WorkspaceApiKeyPrincipal = { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', +} +const listInput = { + organizationId: 'organization-1', + includeDeparted: false, + filters: {}, + limit: 50, +} + +describe('audit-log application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.resolveAccess.mockResolvedValue({ + success: true, + context: { organizationId: 'organization-1', orgMemberIds: ['admin-1'] }, + }) + mocks.getOrgWorkspaceIds.mockResolvedValue(['workspace-1']) + mocks.buildOrgScopeCondition.mockReturnValue({ type: 'scope' }) + mocks.buildFilterConditions.mockReturnValue([]) + mocks.queryAuditLogs.mockResolvedValue({ data: [], nextCursor: undefined }) + }) + + it('rejects workspace keys before organization membership is loaded', async () => { + await expect( + listAuditLogs.execute({ principal: workspacePrincipal, input: listInput }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveAccess).not.toHaveBeenCalled() + expect(mocks.queryAuditLogs).not.toHaveBeenCalled() + }) + + it('authorizes the requested organization and scopes the query canonically', async () => { + await expect( + listAuditLogs.execute({ principal: sessionPrincipal, input: listInput }) + ).resolves.toEqual({ data: [], nextCursor: undefined }) + + expect(mocks.resolveAccess).toHaveBeenCalledWith('admin-1', 'organization-1') + expect(mocks.buildOrgScopeCondition).toHaveBeenCalledWith({ + organizationId: 'organization-1', + orgWorkspaceIds: ['workspace-1'], + orgMemberIds: ['admin-1'], + includeDeparted: false, + }) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('rejects a workspace filter outside the authorized organization', async () => { + await expect( + listAuditLogs.execute({ + principal: sessionPrincipal, + input: { ...listInput, filters: { workspaceId: 'workspace-2' } }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.queryAuditLogs).not.toHaveBeenCalled() + }) + + it('returns a typed not-found only after applying organization scope', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + await expect( + getAuditLog.execute({ + principal: sessionPrincipal, + input: { organizationId: 'organization-1', id: 'audit-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.buildOrgScopeCondition).toHaveBeenCalled() + }) + + it('propagates organization-store failures', async () => { + const failure = new Error('database unavailable') + mocks.resolveAccess.mockRejectedValueOnce(failure) + + await expect( + listAuditLogs.execute({ principal: sessionPrincipal, input: listInput }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts b/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts new file mode 100644 index 00000000000..bc9e15f352f --- /dev/null +++ b/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts @@ -0,0 +1,59 @@ +import type { Principal } from '@sim/auth/principal' +import type { AuditLogOperation, AuditLogPrincipal } from '@/lib/audit-logs/application/operations' +import type { OperationUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' + +export interface AuthorizedAuditLogContext { + organizationId: string + orgMemberIds: string[] + actorUserId: string +} + +interface AuthorizedAuditLogDefinition { + operation: O + organizationId(input: I): string + execute(args: { + principal: AuditLogPrincipal + input: I + context: AuthorizedAuditLogContext + }): Promise +} + +function requireAuditLogPrincipal( + principal: Principal, + operation: AuditLogOperation +): asserts principal is AuditLogPrincipal { + if (!operation.principalKinds.some((kind) => kind === principal.kind)) { + throw new OrchestrationError( + 'forbidden', + `Principal kind ${principal.kind} cannot perform operation ${operation.id}` + ) + } +} + +function auditActorUserId(principal: AuditLogPrincipal): string { + return principal.userId +} + +export function defineAuthorizedAuditLogUseCase( + definition: AuthorizedAuditLogDefinition +): OperationUseCase { + return { + operation: definition.operation, + async execute({ principal, input }) { + requireAuditLogPrincipal(principal, definition.operation) + const actorUserId = auditActorUserId(principal) + const access = await resolveEnterpriseAuditAccess( + actorUserId, + definition.organizationId(input) + ) + if (!access.success) throw new OrchestrationError('forbidden', access.message) + return definition.execute({ + principal, + input, + context: { ...access.context, actorUserId }, + }) + }, + } +} diff --git a/apps/sim/lib/audit-logs/application/get-audit-log.ts b/apps/sim/lib/audit-logs/application/get-audit-log.ts new file mode 100644 index 00000000000..5cc262a44b3 --- /dev/null +++ b/apps/sim/lib/audit-logs/application/get-audit-log.ts @@ -0,0 +1,37 @@ +import { db } from '@sim/db' +import { auditLog } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { defineAuthorizedAuditLogUseCase } from '@/lib/audit-logs/application/authorized-audit-log-use-case' +import { auditLogOperations } from '@/lib/audit-logs/application/operations' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' + +export interface GetAuditLogInput { + organizationId: string + id: string +} + +export interface GetAuditLogResult { + log: typeof auditLog.$inferSelect +} + +export const getAuditLog = defineAuthorizedAuditLogUseCase({ + operation: auditLogOperations.readDetail, + organizationId: (input: GetAuditLogInput) => input.organizationId, + execute: async ({ input, context }): Promise => { + const orgWorkspaceIds = await getOrgWorkspaceIds(context.organizationId) + const scopeCondition = buildOrgScopeCondition({ + organizationId: context.organizationId, + orgWorkspaceIds, + orgMemberIds: context.orgMemberIds, + includeDeparted: true, + }) + const [log] = await db + .select() + .from(auditLog) + .where(and(eq(auditLog.id, input.id), scopeCondition)) + .limit(1) + if (!log) throw new OrchestrationError('not_found', 'Audit log not found') + return { log } + }, +}) diff --git a/apps/sim/lib/audit-logs/application/list-audit-logs.ts b/apps/sim/lib/audit-logs/application/list-audit-logs.ts new file mode 100644 index 00000000000..54da626fef4 --- /dev/null +++ b/apps/sim/lib/audit-logs/application/list-audit-logs.ts @@ -0,0 +1,39 @@ +import { defineAuthorizedAuditLogUseCase } from '@/lib/audit-logs/application/authorized-audit-log-use-case' +import { auditLogOperations } from '@/lib/audit-logs/application/operations' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { AuditLogFilterParams } from '@/app/api/v1/audit-logs/query' +import { + buildFilterConditions, + buildOrgScopeCondition, + getOrgWorkspaceIds, + queryAuditLogs, +} from '@/app/api/v1/audit-logs/query' + +export interface ListAuditLogsInput { + organizationId: string + includeDeparted: boolean + filters: AuditLogFilterParams + limit: number + cursor?: string +} + +export type ListAuditLogsResult = Awaited> + +export const listAuditLogs = defineAuthorizedAuditLogUseCase({ + operation: auditLogOperations.list, + organizationId: (input: ListAuditLogsInput) => input.organizationId, + execute: async ({ input, context }): Promise => { + const orgWorkspaceIds = await getOrgWorkspaceIds(context.organizationId) + if (input.filters.workspaceId && !orgWorkspaceIds.includes(input.filters.workspaceId)) { + throw new OrchestrationError('validation', 'workspaceId does not belong to your organization') + } + const scopeCondition = buildOrgScopeCondition({ + organizationId: context.organizationId, + orgWorkspaceIds, + orgMemberIds: context.orgMemberIds, + includeDeparted: input.includeDeparted, + }) + const filterConditions = buildFilterConditions(input.filters) + return queryAuditLogs([scopeCondition, ...filterConditions], input.limit, input.cursor) + }, +}) diff --git a/apps/sim/lib/audit-logs/application/operations.ts b/apps/sim/lib/audit-logs/application/operations.ts new file mode 100644 index 00000000000..3621f6732d1 --- /dev/null +++ b/apps/sim/lib/audit-logs/application/operations.ts @@ -0,0 +1,39 @@ +import type { Principal } from '@sim/auth/principal' +import type { ApplicationOperation } from '@/lib/core/application' + +export type AuditLogPrincipal = Extract + +export interface AuditLogOperation extends ApplicationOperation { + readonly authority: 'organization_admin' + readonly organizationRoles: readonly ['admin', 'owner'] + readonly workspaceApiKey: 'deny' + readonly principalKinds: readonly ['session', 'personal_api_key'] +} + +function defineAuditLogOperation( + operation: AuditLogOperation +): AuditLogOperation { + if ((operation.principalKinds as readonly string[]).includes('workspace_api_key')) { + throw new Error(`Organization-admin operation ${operation.id} cannot allow workspace API keys`) + } + Object.freeze(operation.organizationRoles) + Object.freeze(operation.principalKinds) + return Object.freeze(operation) +} + +export const auditLogOperations = { + list: defineAuditLogOperation({ + id: 'audit_logs.list', + authority: 'organization_admin', + organizationRoles: ['admin', 'owner'], + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key'], + }), + readDetail: defineAuditLogOperation({ + id: 'audit_logs.read_detail', + authority: 'organization_admin', + organizationRoles: ['admin', 'owner'], + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key'], + }), +} as const diff --git a/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts b/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts new file mode 100644 index 00000000000..2377044a181 --- /dev/null +++ b/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts @@ -0,0 +1,98 @@ +import type { Principal } from '@sim/auth/principal' +import { + permissionSatisfies, + resolveEffectiveWorkspacePermission, +} from '@sim/platform-authz/workspace' +import type { + BillingReadOperation, + BillingReadPrincipal, +} from '@/lib/billing/application/operations' +import type { OperationUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' + +export type BillingReadScope = + | { kind: 'account'; userId: string } + | { kind: 'workspace'; workspace: ActiveWorkspaceApplicationContext } + +interface AuthorizedBillingReadContext { + principal: BillingReadPrincipal + operation: O + input: I + scope: BillingReadScope +} + +interface AuthorizedBillingReadDefinition { + operation: O + requestedWorkspaceId(input: I): string | undefined + execute(args: AuthorizedBillingReadContext): Promise +} + +function requireBillingReadPrincipal( + principal: Principal, + operation: BillingReadOperation +): asserts principal is BillingReadPrincipal { + if (!operation.principalKinds.some((kind) => kind === principal.kind)) { + throw new OrchestrationError( + 'forbidden', + `Principal kind ${principal.kind} cannot perform operation ${operation.id}` + ) + } +} + +async function resolveBillingReadScope( + principal: BillingReadPrincipal, + operation: BillingReadOperation, + requestedWorkspaceId: string | undefined +): Promise { + if (principal.kind === 'workspace_api_key') { + if (requestedWorkspaceId && requestedWorkspaceId !== principal.workspaceId) { + throw new OrchestrationError('forbidden', 'API key is not authorized for this workspace') + } + } else if (!requestedWorkspaceId) { + return { kind: 'account', userId: principal.userId } + } + + const workspaceId = + principal.kind === 'workspace_api_key' ? principal.workspaceId : requestedWorkspaceId + if (!workspaceId) throw new Error(`Billing operation ${operation.id} lost its workspace scope`) + + const workspace = await loadActiveWorkspaceApplicationContext(workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + + if (principal.kind === 'personal_api_key') { + if (!workspace.allowPersonalApiKeys) { + throw new OrchestrationError('forbidden', 'Personal API keys are disabled for this workspace') + } + const permission = await resolveEffectiveWorkspacePermission( + principal.userId, + workspace.workspaceId, + workspace.workspaceOrganizationId + ) + if (!permissionSatisfies(permission, operation.workspaceMinimumRole)) { + throw new OrchestrationError('forbidden', 'Access denied') + } + } + + return { kind: 'workspace', workspace } +} + +export function defineAuthorizedBillingReadUseCase( + definition: AuthorizedBillingReadDefinition +): OperationUseCase { + return { + operation: definition.operation, + async execute({ principal, input }) { + requireBillingReadPrincipal(principal, definition.operation) + const scope = await resolveBillingReadScope( + principal, + definition.operation, + definition.requestedWorkspaceId(input) + ) + return definition.execute({ principal, operation: definition.operation, input, scope }) + }, + } +} diff --git a/apps/sim/lib/billing/application/billing-use-cases.test.ts b/apps/sim/lib/billing/application/billing-use-cases.test.ts new file mode 100644 index 00000000000..415f736f269 --- /dev/null +++ b/apps/sim/lib/billing/application/billing-use-cases.test.ts @@ -0,0 +1,188 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + resolveSystemAttribution: vi.fn(), + resolveAttribution: vi.fn(), + checkUsageStatus: vi.fn(), + checkAttributedBlocks: vi.fn(), + toUsageLimitSubscription: vi.fn(), + getSubscription: vi.fn(), + deriveBillingContext: vi.fn(), + checkBillingBlocked: vi.fn(), + checkBillingEntityBlocked: vi.fn(), + getUsageLogs: vi.fn(), + getCredits: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveSystemBillingAttribution: mocks.resolveSystemAttribution, + resolveBillingAttribution: mocks.resolveAttribution, + checkAttributedBillingBlocks: mocks.checkAttributedBlocks, + toUsageLimitSubscription: mocks.toUsageLimitSubscription, +})) + +vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ + checkUsageStatus: mocks.checkUsageStatus, + checkBillingBlocked: mocks.checkBillingBlocked, + checkBillingEntityBlocked: mocks.checkBillingEntityBlocked, +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + getHighestPrioritySubscription: mocks.getSubscription, +})) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + deriveBillingContext: mocks.deriveBillingContext, + getUserUsageLogs: mocks.getUsageLogs, + getUsageCreditsByLogId: mocks.getCredits, +})) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) + +import { getBillingStatus } from '@/lib/billing/application/get-billing-status' +import { listBillingLogs } from '@/lib/billing/application/list-billing-logs' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'personal-key-1', +} +const workspacePrincipal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', +} + +describe('billing application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.checkUsageStatus.mockResolvedValue({ currentUsage: 1, limit: 10, isExceeded: false }) + mocks.checkAttributedBlocks.mockResolvedValue({ blocked: false }) + mocks.toUsageLimitSubscription.mockReturnValue(null) + mocks.resolveSystemAttribution.mockResolvedValue({ + billedAccountUserId: 'billing-owner-1', + billingPeriod: { start: '2026-01-01', end: '2026-02-01' }, + payerSubscription: null, + }) + mocks.resolveAttribution.mockResolvedValue({ + billedAccountUserId: 'billing-owner-1', + billingPeriod: { start: '2026-01-01', end: '2026-02-01' }, + payerSubscription: null, + }) + mocks.getUsageLogs.mockResolvedValue({ + logs: [], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: false }, + }) + mocks.getCredits.mockResolvedValue({}) + }) + + it('rejects unsupported principals before protected loading', async () => { + const session: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } + + await expect(getBillingStatus.execute({ principal: session, input: {} })).rejects.toMatchObject( + { + code: 'forbidden', + } + ) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.getSubscription).not.toHaveBeenCalled() + }) + + it('pins workspace keys before loading a different workspace', async () => { + await expect( + getBillingStatus.execute({ + principal: workspacePrincipal, + input: { workspaceId: 'workspace-2' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.resolveSystemAttribution).not.toHaveBeenCalled() + }) + + it('uses system billing attribution for workspace-key status without human authorization', async () => { + const result = await getBillingStatus.execute({ + principal: workspacePrincipal, + input: {}, + }) + + expect(result.workspaceId).toBe('workspace-1') + expect(mocks.resolveSystemAttribution).toHaveBeenCalledWith('workspace-1') + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.resolveAttribution).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('uses the personal principal as account authority', async () => { + mocks.getSubscription.mockResolvedValue({ plan: 'pro' }) + mocks.deriveBillingContext.mockReturnValue({ + billingEntity: { type: 'user', id: 'user-1' }, + billingPeriod: { + start: new Date('2026-01-01T00:00:00Z'), + end: new Date('2026-02-01T00:00:00Z'), + }, + }) + mocks.checkBillingBlocked.mockResolvedValue({ blocked: false }) + + await getBillingStatus.execute({ principal: personalPrincipal, input: {} }) + + expect(mocks.getSubscription).toHaveBeenCalledWith('user-1') + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) + + it('uses the billing owner only as the workspace ledger attribution', async () => { + await listBillingLogs.execute({ + principal: workspacePrincipal, + input: { + startDate: new Date('2026-01-01T00:00:00Z'), + endDate: new Date('2026-02-01T00:00:00Z'), + limit: 50, + }, + }) + + expect(mocks.getUsageLogs).toHaveBeenCalledWith( + 'billing-owner-1', + expect.objectContaining({ workspaceId: 'workspace-1' }) + ) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('propagates workspace-store failures', async () => { + const failure = new Error('database unavailable') + mocks.loadWorkspace.mockRejectedValueOnce(failure) + + await expect( + getBillingStatus.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1' }, + }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/billing/application/get-billing-status.ts b/apps/sim/lib/billing/application/get-billing-status.ts new file mode 100644 index 00000000000..8a453d2e42f --- /dev/null +++ b/apps/sim/lib/billing/application/get-billing-status.ts @@ -0,0 +1,88 @@ +import { defineAuthorizedBillingReadUseCase } from '@/lib/billing/application/authorized-billing-read-use-case' +import { billingOperations } from '@/lib/billing/application/operations' +import { + checkBillingBlocked, + checkBillingEntityBlocked, + checkUsageStatus, +} from '@/lib/billing/calculations/usage-monitor' +import { + checkAttributedBillingBlocks, + resolveBillingAttribution, + resolveSystemBillingAttribution, + toUsageLimitSubscription, +} from '@/lib/billing/core/billing-attribution' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { deriveBillingContext } from '@/lib/billing/core/usage-log' +import { dollarsToCredits } from '@/lib/billing/credits/conversion' + +export interface GetBillingStatusInput { + workspaceId?: string +} + +export interface BillingStatusResult { + workspaceId: string | null + period: { start: string; end: string } + plan: string + status: 'active' | 'limit_exceeded' | 'billing_blocked' + credits: { used: number; limit: number; remaining: number } +} + +export const getBillingStatus = defineAuthorizedBillingReadUseCase({ + operation: billingOperations.readStatus, + requestedWorkspaceId: (input: GetBillingStatusInput) => input.workspaceId, + execute: async ({ principal, scope }): Promise => { + if (scope.kind === 'workspace') { + const attribution = + principal.kind === 'personal_api_key' + ? await resolveBillingAttribution({ + actorUserId: principal.userId, + workspaceId: scope.workspace.workspaceId, + }) + : await resolveSystemBillingAttribution(scope.workspace.workspaceId) + const [usage, block] = await Promise.all([ + checkUsageStatus(attribution.billedAccountUserId, toUsageLimitSubscription(attribution)), + checkAttributedBillingBlocks(attribution), + ]) + return { + workspaceId: scope.workspace.workspaceId, + period: attribution.billingPeriod, + plan: attribution.payerSubscription?.plan ?? 'free', + status: block.blocked ? 'billing_blocked' : usage.isExceeded ? 'limit_exceeded' : 'active', + credits: { + used: dollarsToCredits(usage.currentUsage), + limit: dollarsToCredits(usage.limit), + remaining: dollarsToCredits(usage.limit - usage.currentUsage), + }, + } + } + + const subscription = await getHighestPrioritySubscription(scope.userId) + const { billingEntity, billingPeriod } = deriveBillingContext(scope.userId, subscription) + const [usage, actorBlock, payerBlock] = await Promise.all([ + checkUsageStatus(scope.userId, subscription), + checkBillingBlocked(scope.userId), + billingEntity.type === 'user' && billingEntity.id === scope.userId + ? Promise.resolve({ blocked: false }) + : checkBillingEntityBlocked(billingEntity), + ]) + return { + workspaceId: null, + period: { + start: billingPeriod.start.toISOString(), + end: billingPeriod.end.toISOString(), + }, + plan: subscription?.plan ?? 'free', + status: + actorBlock.blocked || payerBlock.blocked + ? 'billing_blocked' + : usage.isExceeded + ? 'limit_exceeded' + : 'active', + credits: { + used: dollarsToCredits(usage.currentUsage), + limit: dollarsToCredits(usage.limit), + remaining: dollarsToCredits(usage.limit - usage.currentUsage), + }, + } + }, +}) diff --git a/apps/sim/lib/billing/application/list-billing-logs.ts b/apps/sim/lib/billing/application/list-billing-logs.ts new file mode 100644 index 00000000000..7986d2ea290 --- /dev/null +++ b/apps/sim/lib/billing/application/list-billing-logs.ts @@ -0,0 +1,56 @@ +import { defineAuthorizedBillingReadUseCase } from '@/lib/billing/application/authorized-billing-read-use-case' +import { billingOperations } from '@/lib/billing/application/operations' +import { resolveSystemBillingAttribution } from '@/lib/billing/core/billing-attribution' +import { + getUsageCreditsByLogId, + getUserUsageLogs, + type UsageLogSource, +} from '@/lib/billing/core/usage-log' + +export interface ListBillingLogsInput { + workspaceId?: string + source?: UsageLogSource[] + startDate?: Date + endDate: Date + limit: number + cursor?: string +} + +export interface ListBillingLogsResult { + usage: Awaited> + creditsByLogId: Record +} + +export const listBillingLogs = defineAuthorizedBillingReadUseCase({ + operation: billingOperations.listLogs, + requestedWorkspaceId: (input: ListBillingLogsInput) => input.workspaceId, + execute: async ({ principal, input, scope }): Promise => { + const workspaceId = scope.kind === 'workspace' ? scope.workspace.workspaceId : undefined + let ledgerUserId: string + if (principal.kind === 'personal_api_key') { + ledgerUserId = principal.userId + } else { + if (scope.kind !== 'workspace') { + throw new Error('Workspace API key billing logs require a workspace scope') + } + ledgerUserId = (await resolveSystemBillingAttribution(scope.workspace.workspaceId)) + .billedAccountUserId + } + const filter = { + source: input.source, + workspaceId, + startDate: input.startDate, + endDate: input.endDate, + } + const [usage, creditsByLogId] = await Promise.all([ + getUserUsageLogs(ledgerUserId, { + ...filter, + limit: input.limit, + cursor: input.cursor, + includeSummary: false, + }), + getUsageCreditsByLogId(ledgerUserId, filter), + ]) + return { usage, creditsByLogId } + }, +}) diff --git a/apps/sim/lib/billing/application/operations.ts b/apps/sim/lib/billing/application/operations.ts new file mode 100644 index 00000000000..7fe8bc4fca6 --- /dev/null +++ b/apps/sim/lib/billing/application/operations.ts @@ -0,0 +1,41 @@ +import type { Principal } from '@sim/auth/principal' +import type { ApplicationOperation } from '@/lib/core/application' + +export type BillingReadPrincipal = Extract< + Principal, + { kind: 'personal_api_key' | 'workspace_api_key' } +> + +export interface BillingReadOperation extends ApplicationOperation { + readonly accountScope: 'personal_self' + readonly workspaceMinimumRole: 'read' + readonly workspaceApiKey: 'workspace_only' + readonly principalKinds: readonly ['personal_api_key', 'workspace_api_key'] +} + +function defineBillingReadOperation( + operation: BillingReadOperation +): BillingReadOperation { + if (operation.workspaceMinimumRole !== 'read') { + throw new Error(`Billing read operation ${operation.id} exceeds its workspace-key ceiling`) + } + Object.freeze(operation.principalKinds) + return Object.freeze(operation) +} + +export const billingOperations = { + readStatus: defineBillingReadOperation({ + id: 'billing.status.read', + accountScope: 'personal_self', + workspaceMinimumRole: 'read', + workspaceApiKey: 'workspace_only', + principalKinds: ['personal_api_key', 'workspace_api_key'], + }), + listLogs: defineBillingReadOperation({ + id: 'billing.logs.list', + accountScope: 'personal_self', + workspaceMinimumRole: 'read', + workspaceApiKey: 'workspace_only', + principalKinds: ['personal_api_key', 'workspace_api_key'], + }), +} as const diff --git a/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts b/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts new file mode 100644 index 00000000000..f0dc64e48e3 --- /dev/null +++ b/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts @@ -0,0 +1,146 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + checkWorkspaceAccess: vi.fn(), + listVisible: vi.fn(), + listForWorkspacePrincipal: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mocks.checkWorkspaceAccess, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + listVisibleWorkspaceCredentials: mocks.listVisible, + listWorkspacePrincipalCredentials: mocks.listForWorkspacePrincipal, +})) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) + +import { listWorkspaceCredentials } from '@/lib/credentials/application/list-workspace-credentials' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const input = { + workspaceId: 'workspace-1', + sortBy: 'createdAt' as const, + sortOrder: 'desc' as const, +} + +describe('listWorkspaceCredentials', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.checkWorkspaceAccess.mockResolvedValue({ hasAccess: true, canAdmin: false }) + mocks.listVisible.mockResolvedValue([]) + mocks.listForWorkspacePrincipal.mockResolvedValue([]) + }) + + it('rejects unsupported principals before canonical workspace loading', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect(listWorkspaceCredentials.execute({ principal, input })).rejects.toMatchObject({ + code: 'forbidden', + }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) + + it('lists shared connections for a workspace key without creator identity', async () => { + const principal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + } + + await listWorkspaceCredentials.execute({ principal, input }) + + expect(mocks.listForWorkspacePrincipal).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + types: ['oauth', 'service_account'], + providerId: undefined, + search: undefined, + sortBy: 'createdAt', + sortOrder: 'desc', + }) + expect(mocks.checkWorkspaceAccess).not.toHaveBeenCalled() + expect(mocks.listVisible).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('preserves human per-credential visibility for personal keys', async () => { + const principal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', + } + + await listWorkspaceCredentials.execute({ principal, input: { ...input, type: 'oauth' } }) + + expect(mocks.resolvePermission).toHaveBeenCalledWith('user-1', 'workspace-1', null, undefined, { + forUpdate: undefined, + }) + expect(mocks.listVisible).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', types: ['oauth'] }) + ) + }) + + it('rejects personal keys disabled by canonical workspace policy', async () => { + mocks.loadWorkspace.mockResolvedValue({ ...workspaceContext, allowPersonalApiKeys: false }) + + await expect( + listWorkspaceCredentials.execute({ + principal: { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-1', + }, + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.listVisible).not.toHaveBeenCalled() + }) + + it('propagates repository failures without projecting secret details', async () => { + const failure = new Error('encrypted column read failed') + mocks.listForWorkspacePrincipal.mockRejectedValueOnce(failure) + + await expect( + listWorkspaceCredentials.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + input, + }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/credentials/application/list-workspace-credentials.ts b/apps/sim/lib/credentials/application/list-workspace-credentials.ts new file mode 100644 index 00000000000..2f92c8f1a86 --- /dev/null +++ b/apps/sim/lib/credentials/application/list-workspace-credentials.ts @@ -0,0 +1,67 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + listVisibleWorkspaceCredentials, + listWorkspacePrincipalCredentials, + type VisibleWorkspaceCredential, +} from '@/lib/credentials/queries' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +export interface ListWorkspaceCredentialsInput { + workspaceId: string + type?: 'oauth' | 'service_account' + providerId?: string + search?: string + sortBy: 'displayName' | 'createdAt' | 'updatedAt' + sortOrder: 'asc' | 'desc' +} + +export interface ListWorkspaceCredentialsResult { + credentials: VisibleWorkspaceCredential[] +} + +export const listWorkspaceCredentials = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.listConnections, + resolveContext: async ({ input }: { input: ListWorkspaceCredentialsInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + execute: async ({ principal, input, context }): Promise => { + const types: Array<'oauth' | 'service_account'> = input.type + ? [input.type] + : ['oauth', 'service_account'] + if (principal.kind === 'workspace_api_key') { + return { + credentials: await listWorkspacePrincipalCredentials({ + workspaceId: context.workspaceId, + types, + providerId: input.providerId, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + }), + } + } + + const workspaceAccess = await checkWorkspaceAccess(context.workspaceId, principal.userId) + if (!workspaceAccess.hasAccess) { + throw new OrchestrationError('forbidden', 'Access denied') + } + return { + credentials: await listVisibleWorkspaceCredentials({ + workspaceId: context.workspaceId, + userId: principal.userId, + workspaceAccess, + types, + providerId: input.providerId, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + }), + } + }, +}) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts new file mode 100644 index 00000000000..4a3dcde7c11 --- /dev/null +++ b/apps/sim/lib/credentials/application/operations.ts @@ -0,0 +1,10 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +export const credentialOperations = { + listConnections: defineWorkspaceOperation({ + id: 'credentials.connections.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['personal_api_key', 'workspace_api_key'], + }), +} as const diff --git a/apps/sim/lib/credentials/queries.test.ts b/apps/sim/lib/credentials/queries.test.ts new file mode 100644 index 00000000000..d4391d06206 --- /dev/null +++ b/apps/sim/lib/credentials/queries.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { listWorkspacePrincipalCredentials } from '@/lib/credentials/queries' + +describe('listWorkspacePrincipalCredentials', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('selects and returns only connection metadata', async () => { + dbChainMockFns.orderBy.mockResolvedValueOnce([ + { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'service_account', + displayName: 'Zoom account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + hasServiceAccountKey: true, + }, + ]) + + const result = await listWorkspacePrincipalCredentials({ + workspaceId: 'workspace-1', + types: ['oauth', 'service_account'], + }) + + expect(result).toEqual([ + { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'service_account', + displayName: 'Zoom account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + hasServiceAccountKey: true, + role: 'member', + }, + ]) + expect(dbChainMockFns.select.mock.calls[0]?.[0]).not.toHaveProperty( + 'encryptedServiceAccountKey' + ) + }) + + it('fails fast on an empty connection-type policy', async () => { + await expect( + listWorkspacePrincipalCredentials({ workspaceId: 'workspace-1', types: [] }) + ).rejects.toThrow('Workspace credential types cannot be empty') + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('propagates database failures', async () => { + const failure = new Error('database unavailable') + dbChainMockFns.orderBy.mockRejectedValueOnce(failure) + + await expect( + listWorkspacePrincipalCredentials({ + workspaceId: 'workspace-1', + types: ['oauth', 'service_account'], + }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index db2572e82de..d37df87ed2a 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { credential, credentialMember } from '@sim/db/schema' -import { and, type Column, eq, inArray, isNotNull, or } from 'drizzle-orm' +import { and, type Column, eq, inArray, isNotNull, or, sql } from 'drizzle-orm' import type { V2CredentialSortBy } from '@/lib/api/contracts/v2/credentials' import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' import { listOrderBy, searchFilter } from '@/lib/api/list-query' @@ -126,6 +126,61 @@ export async function listVisibleWorkspaceCredentials(params: { })) } +/** + * Lists workspace-shared connection metadata for a workspace principal. + * + * Workspace API keys have no human identity and therefore never borrow their + * creator's credential memberships. The public operation is limited to OAuth + * and service-account connections, and this query does not select encrypted + * credential material. + */ +export async function listWorkspacePrincipalCredentials(params: { + workspaceId: string + types: Array<'oauth' | 'service_account'> + providerId?: string + search?: string + sortBy?: V2CredentialSortBy + sortOrder?: V2SortOrder +}): Promise { + const { + workspaceId, + types, + providerId, + search, + sortBy = 'createdAt', + sortOrder = 'desc', + } = params + if (types.length === 0) throw new Error('Workspace credential types cannot be empty') + + const whereClauses = [eq(credential.workspaceId, workspaceId), inArray(credential.type, types)] + if (providerId) whereClauses.push(eq(credential.providerId, providerId)) + + const rows = await db + .select({ + id: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + displayName: credential.displayName, + description: credential.description, + providerId: credential.providerId, + accountId: credential.accountId, + createdBy: credential.createdBy, + createdAt: credential.createdAt, + updatedAt: credential.updatedAt, + hasServiceAccountKey: sql`${credential.encryptedServiceAccountKey} IS NOT NULL`, + }) + .from(credential) + .where(and(...whereClauses, searchFilter(credential.displayName, search))) + .orderBy(...listOrderBy(CREDENTIAL_SORTS[sortBy], sortOrder)) + + return rows.map((row) => ({ + ...row, + envKey: null, + envOwnerUserId: null, + role: 'member', + })) +} + /** * A single credential scoped to a workspace, or null when it does not exist * there. Scoping by workspace is what keeps a credential id from another tenant diff --git a/apps/sim/lib/logs/api/route-policies.ts b/apps/sim/lib/logs/api/route-policies.ts new file mode 100644 index 00000000000..0c1ca12ed39 --- /dev/null +++ b/apps/sim/lib/logs/api/route-policies.ts @@ -0,0 +1,14 @@ +import { type V2ErrorPolicy, v2OrchestrationErrorPolicy } from '@/lib/api/server/routes' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +export const v2LogErrorPolicies = { + default: v2OrchestrationErrorPolicy, + concealDetailAuthorization: { + render(error) { + const response = v2CaughtOrchestrationError(error) + if (!response) return null + if (response.status === 403) return v2Error('NOT_FOUND', 'Log not found') + return response + }, + } satisfies V2ErrorPolicy, +} as const diff --git a/apps/sim/lib/logs/application/get-public-log.ts b/apps/sim/lib/logs/application/get-public-log.ts new file mode 100644 index 00000000000..4f9cdc0b3d7 --- /dev/null +++ b/apps/sim/lib/logs/application/get-public-log.ts @@ -0,0 +1,67 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { logOperations } from '@/lib/logs/application/operations' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { getPublicWorkflowLog, getPublicWorkflowLogScope } from '@/lib/logs/public-queries' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' + +type PublicWorkflowLog = NonNullable>> + +interface PublicLogContext extends ActiveWorkspaceApplicationContext { + executionId: string + workflowId: string | null +} + +export interface GetPublicLogInput { + runId: string +} + +export interface GetPublicLogResult { + log: PublicWorkflowLog + workflowFolderPath: string | null + executionData: Record +} + +export const getPublicLog = defineAuthorizedWorkspaceUseCase({ + operation: logOperations.readDetail, + resolveContext: async ({ input }: { input: GetPublicLogInput }): Promise => { + const scope = await getPublicWorkflowLogScope(input.runId) + if (!scope) throw new OrchestrationError('not_found', 'Log not found') + const workspace = await loadActiveWorkspaceApplicationContext(scope.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Log not found') + return { ...workspace, executionId: scope.executionId, workflowId: scope.workflowId } + }, + authorizationOptions: {}, + execute: async ({ context }): Promise => { + const log = await getPublicWorkflowLog( + { column: 'executionId', value: context.executionId }, + context.workspaceId + ) + if (!log || log.workflowId !== context.workflowId) { + throw new OrchestrationError('not_found', 'Log not found') + } + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const executionData = await materializeExecutionData( + log.executionData as Record | null, + { + workspaceId: context.workspaceId, + workflowId: log.workflowId, + executionId: log.executionId, + } + ) + if (log.workflowUserId && !log.workflowOwnerEmail) { + throw new Error(`Unable to resolve workflow owner email for ${log.workflowUserId}`) + } + return { + log, + workflowFolderPath: log.workflowFolderId + ? (folderIndex.pathById.get(log.workflowFolderId) ?? null) + : null, + executionData, + } + }, +}) diff --git a/apps/sim/lib/logs/application/list-public-logs.ts b/apps/sim/lib/logs/application/list-public-logs.ts new file mode 100644 index 00000000000..e75fca02cee --- /dev/null +++ b/apps/sim/lib/logs/application/list-public-logs.ts @@ -0,0 +1,93 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { logOperations } from '@/lib/logs/application/operations' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import type { LogFilters } from '@/lib/logs/public-filters' +import { listPublicWorkflowLogs } from '@/lib/logs/public-queries' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +type PublicLogRow = Awaited>['data'][number] + +export interface ListPublicLogsInput { + workspaceId: string + filters: Omit + folderPaths?: string[] + limit: number + includeFullDetails: boolean + includeFinalOutput: boolean + includeTraceSpans: boolean +} + +export interface PublicLogApplicationItem { + log: PublicLogRow + executionData?: Record +} + +export interface ListPublicLogsResult { + items: PublicLogApplicationItem[] + nextCursor: string | null + includeFullDetails: boolean + includeFinalOutput: boolean + includeTraceSpans: boolean +} + +export const listPublicLogs = defineAuthorizedWorkspaceUseCase({ + operation: logOperations.list, + resolveContext: async ({ input }: { input: ListPublicLogsInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + execute: async ({ input, context }): Promise => { + const folderIndex = input.folderPaths + ? await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + : null + const resolvedFolderIds = input.folderPaths?.map((path) => + path === ROOT_FOLDER_PATH ? null : folderIndex?.idByPath.get(path) + ) + if (resolvedFolderIds?.some((folderId) => folderId === undefined)) { + throw new OrchestrationError('not_found', 'Folder not found') + } + + const folderIds = resolvedFolderIds?.filter( + (folderId): folderId is string => typeof folderId === 'string' + ) + const includesRoot = resolvedFolderIds?.includes(null) ?? false + const needsMaterialization = input.includeFinalOutput || input.includeTraceSpans + const { data, nextCursor } = await listPublicWorkflowLogs({ + filters: { ...input.filters, workspaceId: context.workspaceId, folderIds }, + limit: input.limit, + includeExecutionData: needsMaterialization, + folderScope: input.folderPaths ? { includesRoot, folderIds: folderIds ?? [] } : undefined, + }) + + const items = needsMaterialization + ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => { + if (!log.executionData) return { log } + return { + log, + executionData: await materializeExecutionData( + log.executionData as Record, + { + workspaceId: log.workspaceId, + workflowId: log.workflowId, + executionId: log.executionId, + } + ), + } + }) + : data.map((log) => ({ log })) + + return { + items, + nextCursor, + includeFullDetails: input.includeFullDetails, + includeFinalOutput: input.includeFinalOutput, + includeTraceSpans: input.includeTraceSpans, + } + }, +}) diff --git a/apps/sim/lib/logs/application/operations.ts b/apps/sim/lib/logs/application/operations.ts new file mode 100644 index 00000000000..9026c369451 --- /dev/null +++ b/apps/sim/lib/logs/application/operations.ts @@ -0,0 +1,18 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const PUBLIC_API_PRINCIPAL_KINDS = ['personal_api_key', 'workspace_api_key'] as const + +export const logOperations = { + list: defineWorkspaceOperation({ + id: 'logs.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: PUBLIC_API_PRINCIPAL_KINDS, + }), + readDetail: defineWorkspaceOperation({ + id: 'logs.read_detail', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: PUBLIC_API_PRINCIPAL_KINDS, + }), +} as const diff --git a/apps/sim/lib/logs/application/public-log-use-cases.test.ts b/apps/sim/lib/logs/application/public-log-use-cases.test.ts new file mode 100644 index 00000000000..ef568c35394 --- /dev/null +++ b/apps/sim/lib/logs/application/public-log-use-cases.test.ts @@ -0,0 +1,182 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getLogScope: vi.fn(), + getLog: vi.fn(), + listLogs: vi.fn(), + loadFolders: vi.fn(), + materialize: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/logs/public-queries', () => ({ + getPublicWorkflowLogScope: mocks.getLogScope, + getPublicWorkflowLog: mocks.getLog, + listPublicWorkflowLogs: mocks.listLogs, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mocks.loadFolders, +})) + +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionData: mocks.materialize, +})) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) + +import { getPublicLog } from '@/lib/logs/application/get-public-log' +import { listPublicLogs } from '@/lib/logs/application/list-public-logs' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const log = { + executionId: 'run-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + workflowFolderId: 'folder-1', + workflowUserId: 'owner-1', + workflowOwnerEmail: 'owner@example.com', + executionData: { pointer: true }, +} +const workspacePrincipal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', +} + +describe('public log application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getLogScope.mockResolvedValue({ + executionId: 'run-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }) + mocks.getLog.mockResolvedValue(log) + mocks.listLogs.mockResolvedValue({ data: [log], nextCursor: null }) + mocks.loadFolders.mockResolvedValue({ + idByPath: new Map([['/agents', 'folder-1']]), + pathById: new Map([['folder-1', '/agents']]), + }) + mocks.materialize.mockResolvedValue({ finalOutput: { ok: true } }) + }) + + it('rejects unsupported principals before resolving the run', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + getPublicLog.execute({ principal, input: { runId: 'run-1' } }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.getLogScope).not.toHaveBeenCalled() + expect(mocks.getLog).not.toHaveBeenCalled() + }) + + it('derives workspace and materialization scope from the canonical run', async () => { + const result = await getPublicLog.execute({ + principal: workspacePrincipal, + input: { runId: 'run-1' }, + }) + + expect(mocks.loadWorkspace).toHaveBeenCalledWith('workspace-1') + expect(mocks.getLog).toHaveBeenCalledWith( + { column: 'executionId', value: 'run-1' }, + 'workspace-1' + ) + expect(mocks.materialize).toHaveBeenCalledWith( + { pointer: true }, + { workspaceId: 'workspace-1', workflowId: 'workflow-1', executionId: 'run-1' } + ) + expect(result.workflowFolderPath).toBe('/agents') + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('rejects a workspace key outside the run workspace before materialization', async () => { + await expect( + getPublicLog.execute({ + principal: { ...workspacePrincipal, workspaceId: 'workspace-2' }, + input: { runId: 'run-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.getLog).not.toHaveBeenCalled() + expect(mocks.materialize).not.toHaveBeenCalled() + }) + + it('resolves folder paths only after workspace authorization', async () => { + const result = await listPublicLogs.execute({ + principal: workspacePrincipal, + input: { + workspaceId: 'workspace-1', + filters: {}, + folderPaths: ['/agents'], + limit: 50, + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + }, + }) + + expect(mocks.listLogs).toHaveBeenCalledWith( + expect.objectContaining({ + filters: expect.objectContaining({ workspaceId: 'workspace-1', folderIds: ['folder-1'] }), + folderScope: { includesRoot: false, folderIds: ['folder-1'] }, + }) + ) + expect(result.items).toHaveLength(1) + }) + + it('returns a typed not-found for a missing folder', async () => { + await expect( + listPublicLogs.execute({ + principal: workspacePrincipal, + input: { + workspaceId: 'workspace-1', + filters: {}, + folderPaths: ['/missing'], + limit: 50, + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.listLogs).not.toHaveBeenCalled() + }) + + it('propagates run-store failures', async () => { + const failure = new Error('database unavailable') + mocks.getLogScope.mockRejectedValueOnce(failure) + + await expect( + getPublicLog.execute({ principal: workspacePrincipal, input: { runId: 'run-1' } }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/logs/public-queries.ts b/apps/sim/lib/logs/public-queries.ts index 6c6d7866315..e627f76ecd7 100644 --- a/apps/sim/lib/logs/public-queries.ts +++ b/apps/sim/lib/logs/public-queries.ts @@ -120,6 +120,24 @@ export type PublicWorkflowLogLookup = | { column: 'id'; value: string } | { column: 'executionId'; value: string } +/** + * Resolves only the canonical resource scope needed to authorize a public run + * lookup. Protected log content is loaded separately after authorization. + */ +export async function getPublicWorkflowLogScope(executionId: string) { + const [scope] = await db + .select({ + executionId: workflowExecutionLogs.executionId, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + }) + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.executionId, executionId)) + .limit(1) + + return scope ?? null +} + /** * Loads one workflow log and its optional workflow snapshot. The snapshot join * is deliberately left-sided: a missing snapshot does not make an otherwise diff --git a/apps/sim/lib/workspaces/application/get-public-workspace.ts b/apps/sim/lib/workspaces/application/get-public-workspace.ts new file mode 100644 index 00000000000..df3cb5d3e84 --- /dev/null +++ b/apps/sim/lib/workspaces/application/get-public-workspace.ts @@ -0,0 +1,31 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { workspaceOperations } from '@/lib/workspaces/application/operations' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { + getPublicWorkspaceDetail, + type PublicWorkspaceDetail, +} from '@/lib/workspaces/public-queries' + +export interface GetPublicWorkspaceInput { + workspaceId: string +} + +export interface GetPublicWorkspaceResult { + workspace: PublicWorkspaceDetail +} + +export const getPublicWorkspace = defineAuthorizedWorkspaceUseCase({ + operation: workspaceOperations.readPublicDetail, + resolveContext: async ({ input }: { input: GetPublicWorkspaceInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + execute: async ({ context }): Promise => { + const workspace = await getPublicWorkspaceDetail(context.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return { workspace } + }, +}) diff --git a/apps/sim/lib/workspaces/application/list-public-workspace-members.ts b/apps/sim/lib/workspaces/application/list-public-workspace-members.ts new file mode 100644 index 00000000000..4e5970349a9 --- /dev/null +++ b/apps/sim/lib/workspaces/application/list-public-workspace-members.ts @@ -0,0 +1,36 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { workspaceOperations } from '@/lib/workspaces/application/operations' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { + queryPublicWorkspaceMembers, + type WorkspaceMemberPage, +} from '@/lib/workspaces/public-queries' + +export interface ListPublicWorkspaceMembersInput { + workspaceId: string + limit: number + afterEmail?: string +} + +export interface ListPublicWorkspaceMembersResult { + page: WorkspaceMemberPage +} + +export const listPublicWorkspaceMembers = defineAuthorizedWorkspaceUseCase({ + operation: workspaceOperations.listPublicMembers, + resolveContext: async ({ input }: { input: ListPublicWorkspaceMembersInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + execute: async ({ input, context }): Promise => { + const page = await queryPublicWorkspaceMembers(context.workspaceId, { + limit: input.limit, + afterEmail: input.afterEmail, + }) + if (!page) throw new OrchestrationError('not_found', 'Workspace not found') + return { page } + }, +}) diff --git a/apps/sim/lib/workspaces/application/operations.ts b/apps/sim/lib/workspaces/application/operations.ts new file mode 100644 index 00000000000..7109d87f02a --- /dev/null +++ b/apps/sim/lib/workspaces/application/operations.ts @@ -0,0 +1,18 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const PUBLIC_API_PRINCIPAL_KINDS = ['personal_api_key', 'workspace_api_key'] as const + +export const workspaceOperations = { + readPublicDetail: defineWorkspaceOperation({ + id: 'workspaces.read_public_detail', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: PUBLIC_API_PRINCIPAL_KINDS, + }), + listPublicMembers: defineWorkspaceOperation({ + id: 'workspaces.members.list_public', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: PUBLIC_API_PRINCIPAL_KINDS, + }), +} as const diff --git a/apps/sim/lib/workspaces/application/public-workspace-reads.test.ts b/apps/sim/lib/workspaces/application/public-workspace-reads.test.ts new file mode 100644 index 00000000000..c11a76f0414 --- /dev/null +++ b/apps/sim/lib/workspaces/application/public-workspace-reads.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getDetail: vi.fn(), + listMembers: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workspaces/public-queries', () => ({ + getPublicWorkspaceDetail: mocks.getDetail, + queryPublicWorkspaceMembers: mocks.listMembers, +})) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) + +import { getPublicWorkspace } from '@/lib/workspaces/application/get-public-workspace' +import { listPublicWorkspaceMembers } from '@/lib/workspaces/application/list-public-workspace-members' + +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const workspacePrincipal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', +} + +describe('public workspace application reads', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getDetail.mockResolvedValue({ id: 'workspace-1' }) + mocks.listMembers.mockResolvedValue({ members: [], nextEmail: null }) + }) + + it('authorizes workspace keys as the workspace without billing-owner membership', async () => { + await expect( + getPublicWorkspace.execute({ + principal: workspacePrincipal, + input: { workspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ workspace: { id: 'workspace-1' } }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('requires current personal-key workspace permission before member loading', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + await expect( + listPublicWorkspaceMembers.execute({ + principal: { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-1', + }, + input: { workspaceId: 'workspace-1', limit: 50 }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.listMembers).not.toHaveBeenCalled() + }) + + it('returns not-found for an inactive canonical workspace', async () => { + mocks.loadWorkspace.mockResolvedValue(null) + + await expect( + getPublicWorkspace.execute({ + principal: workspacePrincipal, + input: { workspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.getDetail).not.toHaveBeenCalled() + }) + + it('propagates canonical workspace load failures', async () => { + const failure = new Error('database unavailable') + mocks.loadWorkspace.mockRejectedValueOnce(failure) + + await expect( + getPublicWorkspace.execute({ + principal: workspacePrincipal, + input: { workspaceId: 'workspace-1' }, + }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/workspaces/application/workspace-context.test.ts b/apps/sim/lib/workspaces/application/workspace-context.test.ts new file mode 100644 index 00000000000..c448ef5f91b --- /dev/null +++ b/apps/sim/lib/workspaces/application/workspace-context.test.ts @@ -0,0 +1,44 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +describe('loadActiveWorkspaceApplicationContext', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('returns canonical authorization and billing-attribution fields', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'workspace-1', + organizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }, + ]) + + await expect(loadActiveWorkspaceApplicationContext('workspace-1')).resolves.toEqual({ + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + expect(dbChainMockFns.from).toHaveBeenCalledWith(schemaMock.workspace) + }) + + it('returns null for an inactive or absent workspace', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + await expect(loadActiveWorkspaceApplicationContext('workspace-1')).resolves.toBeNull() + }) + + it('propagates database failures', async () => { + const failure = new Error('database unavailable') + dbChainMockFns.limit.mockRejectedValueOnce(failure) + + await expect(loadActiveWorkspaceApplicationContext('workspace-1')).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/workspaces/application/workspace-context.ts b/apps/sim/lib/workspaces/application/workspace-context.ts new file mode 100644 index 00000000000..fb01e591ac0 --- /dev/null +++ b/apps/sim/lib/workspaces/application/workspace-context.ts @@ -0,0 +1,32 @@ +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import type { WorkspaceAuthorizationContext } from '@/lib/core/application' + +export interface ActiveWorkspaceApplicationContext extends WorkspaceAuthorizationContext { + billedAccountUserId: string +} + +/** Loads the active canonical workspace state required by application authorization. */ +export async function loadActiveWorkspaceApplicationContext( + workspaceId: string +): Promise { + const [row] = await db + .select({ + id: workspace.id, + organizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + if (!row) return null + return { + workspaceId: row.id, + workspaceOrganizationId: row.organizationId, + allowPersonalApiKeys: row.allowPersonalApiKeys, + billedAccountUserId: row.billedAccountUserId, + } +}