From 470d350cbdc605084bb79a6b3289eabf834c7a97 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 14:19:09 -0700 Subject: [PATCH 1/2] improvement(api): harden application route boundaries --- .../app/api/audit-logs/export/route.test.ts | 2 +- apps/sim/app/api/audit-logs/export/route.ts | 12 +- apps/sim/app/api/v1/admin/audit-logs/route.ts | 2 +- .../app/api/v1/audit-logs/[id]/route.test.ts | 2 +- apps/sim/app/api/v1/audit-logs/[id]/route.ts | 2 +- apps/sim/app/api/v1/audit-logs/auth.ts | 130 +----- apps/sim/app/api/v1/audit-logs/route.test.ts | 2 +- apps/sim/app/api/v1/audit-logs/route.ts | 8 +- apps/sim/app/api/v2/audit-logs/route.test.ts | 49 +-- .../sim/app/api/v2/billing/logs/route.test.ts | 47 +- .../app/api/v2/billing/status/route.test.ts | 45 +- apps/sim/app/api/v2/credentials/route.test.ts | 49 +-- .../[id]/documents/[documentId]/route.ts | 22 +- .../api/v2/knowledge/[id]/documents/route.ts | 12 +- apps/sim/app/api/v2/knowledge/[id]/route.ts | 23 +- .../sim/app/api/v2/logs/[runId]/route.test.ts | 45 +- apps/sim/app/api/v2/logs/route.test.ts | 47 +- apps/sim/app/api/v2/workspaces/route.test.ts | 45 +- apps/sim/lib/api/contracts/v2/shared.ts | 5 +- apps/sim/lib/api/list-query.ts | 3 +- apps/sim/lib/api/server/routes/index.ts | 1 + .../api/server/routes/v2-json-route.test.ts | 401 ++++++++++++++++++ .../routes/v2-resource-concealment.test.ts | 98 +++++ .../server/routes/v2-resource-concealment.ts | 35 ++ .../application/audit-log-use-cases.test.ts | 4 +- .../authorized-audit-log-use-case.ts | 2 +- .../audit-logs/application/get-audit-log.ts | 2 +- .../audit-logs/application/list-audit-logs.ts | 6 +- apps/sim/lib/audit-logs/authorization.ts | 98 +++++ .../api/v1 => lib}/audit-logs/query.test.ts | 4 +- .../{app/api/v1 => lib}/audit-logs/query.ts | 35 +- apps/sim/lib/credentials/queries.ts | 6 +- .../folders/application-folder-caps.test.ts | 154 +++++++ apps/sim/lib/folders/cascade.test.ts | 10 +- apps/sim/lib/folders/cascade.ts | 3 +- apps/sim/lib/folders/constants.ts | 2 + apps/sim/lib/folders/errors.ts | 11 + apps/sim/lib/folders/orchestration.test.ts | 28 ++ apps/sim/lib/folders/orchestration.ts | 9 +- apps/sim/lib/folders/queries.test.ts | 21 +- apps/sim/lib/folders/queries.ts | 12 +- apps/sim/lib/knowledge/api/route-policies.ts | 11 + .../knowledge/application/contexts.test.ts | 60 +++ .../sim/lib/knowledge/application/contexts.ts | 16 +- apps/sim/lib/knowledge/constants.ts | 4 +- apps/sim/lib/knowledge/service.ts | 4 +- apps/sim/lib/secrets/application/use-cases.ts | 6 +- apps/sim/lib/table/api/route-policies.ts | 38 +- .../sim/lib/table/application/context.test.ts | 44 +- apps/sim/lib/table/application/context.ts | 16 +- .../sim/lib/table/application/folder-paths.ts | 5 +- apps/sim/lib/table/application/folders.ts | 21 +- apps/sim/lib/table/application/imports.ts | 5 +- apps/sim/lib/table/application/tables.ts | 19 +- apps/sim/lib/table/service.ts | 6 +- .../workspace/workspace-file-manager.ts | 4 +- apps/sim/lib/workflows/api/route-policies.ts | 48 +-- .../lib/workflows/application/context.test.ts | 89 ++++ apps/sim/lib/workflows/application/context.ts | 58 +-- .../application/import-export.test.ts | 5 +- .../workflows/application/import-export.ts | 16 +- .../workflows/application/list-workflows.ts | 8 +- .../workflows/application/read-workflow.ts | 8 +- .../workflows/application/update-workflow.ts | 6 +- .../application/workflow-folders.test.ts | 20 + .../workflows/application/workflow-folders.ts | 21 +- .../workflows/operations/export-workflow.ts | 43 +- apps/sim/lib/workflows/queries.ts | 3 +- .../lib/workspace-files/api/route-policies.ts | 13 +- packages/testing/src/mocks/index.ts | 9 + packages/testing/src/mocks/v2-route.mock.ts | 42 ++ 71 files changed, 1478 insertions(+), 664 deletions(-) create mode 100644 apps/sim/lib/api/server/routes/v2-json-route.test.ts create mode 100644 apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts create mode 100644 apps/sim/lib/api/server/routes/v2-resource-concealment.ts create mode 100644 apps/sim/lib/audit-logs/authorization.ts rename apps/sim/{app/api/v1 => lib}/audit-logs/query.test.ts (96%) rename apps/sim/{app/api/v1 => lib}/audit-logs/query.ts (85%) create mode 100644 apps/sim/lib/folders/application-folder-caps.test.ts create mode 100644 apps/sim/lib/folders/constants.ts create mode 100644 apps/sim/lib/folders/errors.ts create mode 100644 apps/sim/lib/knowledge/api/route-policies.ts create mode 100644 apps/sim/lib/knowledge/application/contexts.test.ts create mode 100644 apps/sim/lib/workflows/application/context.test.ts create mode 100644 packages/testing/src/mocks/v2-route.mock.ts diff --git a/apps/sim/app/api/audit-logs/export/route.test.ts b/apps/sim/app/api/audit-logs/export/route.test.ts index 367c04cb86f..6f177ab7b9f 100644 --- a/apps/sim/app/api/audit-logs/export/route.test.ts +++ b/apps/sim/app/api/audit-logs/export/route.test.ts @@ -22,7 +22,7 @@ vi.mock('@/app/api/v1/audit-logs/auth', () => ({ validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess, })) -vi.mock('@/app/api/v1/audit-logs/query', () => ({ +vi.mock('@/lib/audit-logs/query', () => ({ buildFilterConditions: mockBuildFilterConditions, buildOrgScopeCondition: mockBuildOrgScopeCondition, getOrgWorkspaceIds: mockGetOrgWorkspaceIds, diff --git a/apps/sim/app/api/audit-logs/export/route.ts b/apps/sim/app/api/audit-logs/export/route.ts index 089b1274811..e6d4a562809 100644 --- a/apps/sim/app/api/audit-logs/export/route.ts +++ b/apps/sim/app/api/audit-logs/export/route.ts @@ -3,17 +3,17 @@ import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { exportAuditLogsContract } from '@/lib/api/contracts/audit-logs' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { formatCsvValue, toCsvRow } from '@/lib/table/export-format' -import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' -import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { buildFilterConditions, buildOrgScopeCondition, getOrgWorkspaceIds, queryAuditLogs, -} from '@/app/api/v1/audit-logs/query' +} from '@/lib/audit-logs/query' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { formatCsvValue, toCsvRow } from '@/lib/table/export-format' +import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' const logger = createLogger('AuditLogsExportAPI') diff --git a/apps/sim/app/api/v1/admin/audit-logs/route.ts b/apps/sim/app/api/v1/admin/audit-logs/route.ts index f3dbc231e69..8403bed1530 100644 --- a/apps/sim/app/api/v1/admin/audit-logs/route.ts +++ b/apps/sim/app/api/v1/admin/audit-logs/route.ts @@ -24,6 +24,7 @@ import { createLogger } from '@sim/logger' import { and, count, desc } from 'drizzle-orm' import { v1AdminListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs' import { parseRequest } from '@/lib/api/server' +import { buildFilterConditions } from '@/lib/audit-logs/query' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { @@ -32,7 +33,6 @@ import { listResponse, } from '@/app/api/v1/admin/responses' import { type AdminAuditLog, createPaginationMeta, toAdminAuditLog } from '@/app/api/v1/admin/types' -import { buildFilterConditions } from '@/app/api/v1/audit-logs/query' const logger = createLogger('AdminAuditLogsAPI') diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts index 0424663f9c1..6849e05ffe6 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts @@ -28,7 +28,7 @@ vi.mock('@/app/api/v1/audit-logs/auth', () => ({ validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess, })) -vi.mock('@/app/api/v1/audit-logs/query', () => ({ +vi.mock('@/lib/audit-logs/query', () => ({ buildOrgScopeCondition: mockBuildOrgScopeCondition, getOrgWorkspaceIds: mockGetOrgWorkspaceIds, })) diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.ts index bac0b83f160..965d619cee8 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.ts @@ -20,10 +20,10 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1GetAuditLogContract } from '@/lib/api/contracts/v1/audit-logs' import { parseRequest } from '@/lib/api/server' +import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/lib/audit-logs/query' 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 { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts index 7076d5ec7d0..739e1c39918 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -1,135 +1,13 @@ -/** - * Enterprise audit log authorization. - * - * Validates that the authenticated user is an admin/owner of an enterprise organization - * and returns the organization context needed for scoped queries. - */ - -import { db } from '@sim/db' -import { member, subscription } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' import { NextResponse } from 'next/server' -import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' -import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' -import { isAuditLogsEnabled, isBillingEnabled } from '@/lib/core/config/env-flags' - -const logger = createLogger('V1AuditLogsAuth') - -interface EnterpriseAuditContext { - organizationId: string - orgMemberIds: string[] -} +import { + type EnterpriseAuditContext, + resolveEnterpriseAuditAccess, +} from '@/lib/audit-logs/authorization' type AuthResult = | { success: true; context: EnterpriseAuditContext } | { success: false; response: NextResponse } -/** - * Structured enterprise audit-access result shared by the v1 and v2 surfaces so - * each version can render the failure in its own response envelope. - */ -export type EnterpriseAuditAccessResult = - | { success: true; context: EnterpriseAuditContext } - | { success: false; status: number; message: string } - -/** - * Core enterprise audit-access check (no response rendering). - * - * Checks: - * 1. User belongs to an organization (the target one when - * `targetOrganizationId` is given) - * 2. User has admin or owner role - * 3. The organization is entitled to audit logs — an active enterprise - * subscription when billing runs, otherwise the deployment's audit-logs - * entitlement - * - * The subscription query is skipped entirely with billing off. Requiring it - * there made audit logs unreachable on every self-hosted deployment, since no - * subscription row is ever written without billing. - * - * Returns the organization ID and all member user IDs on success. - */ -export async function resolveEnterpriseAuditAccess( - userId: string, - targetOrganizationId?: string -): Promise { - const [membership] = await db - .select({ organizationId: member.organizationId, role: member.role }) - .from(member) - .where( - targetOrganizationId - ? and(eq(member.userId, userId), eq(member.organizationId, targetOrganizationId)) - : eq(member.userId, userId) - ) - .limit(1) - - if (!membership) { - return { - success: false, - status: 403, - message: targetOrganizationId - ? 'Not a member of the requested organization' - : 'Not a member of any organization', - } - } - - if (membership.role !== 'admin' && membership.role !== 'owner') { - return { success: false, status: 403, message: 'Organization admin or owner role required' } - } - - if (isBillingEnabled) { - const billingBlocked = await isOrganizationBillingBlocked(membership.organizationId) - if (billingBlocked) { - return { success: false, status: 403, message: 'Active enterprise subscription required' } - } - } else if (!isAuditLogsEnabled) { - return { - success: false, - status: 403, - message: - 'Audit logs are disabled. Set ENTERPRISE_ENABLED or AUDIT_LOGS_ENABLED to enable them.', - } - } - - const [orgSub, orgMembers] = await Promise.all([ - isBillingEnabled - ? db - .select({ id: subscription.id }) - .from(subscription) - .where( - and( - eq(subscription.referenceId, membership.organizationId), - eq(subscription.plan, 'enterprise'), - inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) - ) - ) - .limit(1) - : Promise.resolve([]), - db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, membership.organizationId)), - ]) - - if (isBillingEnabled && orgSub.length === 0) { - return { success: false, status: 403, message: 'Active enterprise subscription required' } - } - - const orgMemberIds = orgMembers.map((m) => m.userId) - - logger.info('Enterprise audit access validated', { - userId, - organizationId: membership.organizationId, - memberCount: orgMemberIds.length, - }) - - return { - success: true, - context: { organizationId: membership.organizationId, orgMemberIds }, - } -} - /** * v1 wrapper: renders {@link resolveEnterpriseAuditAccess} as the v1 `{ error }` * response body. diff --git a/apps/sim/app/api/v1/audit-logs/route.test.ts b/apps/sim/app/api/v1/audit-logs/route.test.ts index 9fa39f447eb..2644d07132f 100644 --- a/apps/sim/app/api/v1/audit-logs/route.test.ts +++ b/apps/sim/app/api/v1/audit-logs/route.test.ts @@ -34,7 +34,7 @@ vi.mock('@/app/api/v1/audit-logs/auth', () => ({ validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess, })) -vi.mock('@/app/api/v1/audit-logs/query', () => ({ +vi.mock('@/lib/audit-logs/query', () => ({ buildFilterConditions: mockBuildFilterConditions, buildOrgScopeCondition: mockBuildOrgScopeCondition, getOrgWorkspaceIds: mockGetOrgWorkspaceIds, diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts index c6eca39ffe2..36c12d1019d 100644 --- a/apps/sim/app/api/v1/audit-logs/route.ts +++ b/apps/sim/app/api/v1/audit-logs/route.ts @@ -25,15 +25,15 @@ import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { v1ListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs' import { parseRequest } from '@/lib/api/server' -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' +} from '@/lib/audit-logs/query' +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 { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, 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 a8c984556ce..ec46e6fe9d6 100644 --- a/apps/sim/app/api/v2/audit-logs/route.test.ts +++ b/apps/sim/app/api/v2/audit-logs/route.test.ts @@ -1,32 +1,25 @@ /** * @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - checkPreauth: vi.fn(), - checkOperationRate: vi.fn(), - gate: vi.fn(), list: vi.fn(), get: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -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: mocks.gate })) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/lib/audit-logs/application/list-audit-logs', () => ({ listAuditLogs: { operation: { id: 'audit_logs.list' }, execute: mocks.list }, @@ -67,18 +60,10 @@ const log = { describe('v2 audit-log routes', () => { beforeEach(() => { vi.clearAllMocks() - 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'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.list.mockResolvedValue({ data: [log], nextCursor: 'next-1' }) mocks.get.mockResolvedValue({ log }) }) @@ -87,8 +72,8 @@ describe('v2 audit-log routes', () => { const response = await listLogs(new NextRequest('http://localhost:3000/api/v2/audit-logs')) expect(response.status).toBe(400) - expect(mocks.authenticate).toHaveBeenCalled() - expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2) + expect(v2RouteMocks.authenticate).toHaveBeenCalled() + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) expect(mocks.list).not.toHaveBeenCalled() }) 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 95d0f017a58..0c5e5f79387 100644 --- a/apps/sim/app/api/v2/billing/logs/route.test.ts +++ b/apps/sim/app/api/v2/billing/logs/route.test.ts @@ -1,31 +1,24 @@ /** * @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - checkPreauth: vi.fn(), - checkOperationRate: vi.fn(), - gate: vi.fn(), execute: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -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: mocks.gate })) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/lib/billing/application/list-billing-logs', () => ({ listBillingLogs: { operation: { id: 'billing.logs.list' }, execute: mocks.execute }, @@ -46,18 +39,10 @@ describe('GET /api/v2/billing/logs', () => { vi.clearAllMocks() 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'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.execute.mockResolvedValue({ usage: { logs: [ @@ -116,7 +101,7 @@ describe('GET /api/v2/billing/logs', () => { ) expect(response.status).toBe(400) - expect(mocks.authenticate).toHaveBeenCalled() + expect(v2RouteMocks.authenticate).toHaveBeenCalled() expect(mocks.execute).not.toHaveBeenCalled() }) }) 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 6c87206289f..30e6f21b74e 100644 --- a/apps/sim/app/api/v2/billing/status/route.test.ts +++ b/apps/sim/app/api/v2/billing/status/route.test.ts @@ -1,31 +1,24 @@ /** * @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - checkPreauth: vi.fn(), - checkOperationRate: vi.fn(), - gate: vi.fn(), execute: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -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: mocks.gate })) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/lib/billing/application/get-billing-status', () => ({ getBillingStatus: { operation: { id: 'billing.status.read' }, execute: mocks.execute }, @@ -52,18 +45,10 @@ const result = { describe('GET /api/v2/billing/status', () => { beforeEach(() => { vi.clearAllMocks() - 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'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.execute.mockResolvedValue(result) }) diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index 4a4badb6b0b..a1987f1b5af 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -1,31 +1,24 @@ /** * @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - checkPreauth: vi.fn(), - checkOperationRate: vi.fn(), - gate: vi.fn(), execute: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -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: mocks.gate })) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/lib/credentials/application/list-workspace-credentials', () => ({ listWorkspaceCredentials: { @@ -68,18 +61,10 @@ const credential = { describe('GET /api/v2/credentials', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.gate.mockResolvedValue(null) - mocks.checkPreauth.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-01-01T01:00:00Z'), - }) - mocks.checkOperationRate.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.execute.mockResolvedValue({ credentials: [credential] }) }) @@ -87,8 +72,8 @@ describe('GET /api/v2/credentials', () => { const response = await GET(new NextRequest('http://localhost:3000/api/v2/credentials')) expect(response.status).toBe(400) - expect(mocks.authenticate).toHaveBeenCalled() - expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2) + expect(v2RouteMocks.authenticate).toHaveBeenCalled() + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) expect(mocks.execute).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts index f12d79ddd31..2a0faf317f5 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -2,13 +2,8 @@ import { v2DeleteKnowledgeDocumentContract, v2GetKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' -import { - defineV2JsonRoute, - type V2ErrorPolicy, - v2ApiKeyAuth, - v2OrchestrationErrorPolicy, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { deleteKnowledgeDocument, readKnowledgeDocument, @@ -16,7 +11,6 @@ import { import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { captureServerEvent } from '@/lib/posthog/server' import { serializeDate } from '@/app/api/v1/knowledge/utils' -import { v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -33,21 +27,13 @@ function toProcessingStatus(status: string): 'pending' | 'processing' | 'complet } } -const concealKnowledgeDocumentReadAuthorization = { - render(error) { - const response = v2OrchestrationErrorPolicy.render(error) - if (response?.status === 403) return v2Error('NOT_FOUND', 'Knowledge base not found') - return response - }, -} satisfies V2ErrorPolicy - /** GET /api/v2/knowledge/[id]/documents/[documentId] — Get document details. */ export const GET = defineV2JsonRoute({ contract: v2GetKnowledgeDocumentContract, auth: v2ApiKeyAuth, operation: knowledgeOperations.readDocument, rateLimit: v2RateLimits.publicApi, - errorPolicy: concealKnowledgeDocumentReadAuthorization, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => ({ knowledgeBaseId: params.id, documentId: params.documentId, @@ -85,7 +71,7 @@ export const DELETE = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.deleteDocument, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: v2KnowledgeErrorPolicies.default, mapInput: ({ params, query }) => ({ knowledgeBaseId: params.id, documentId: params.documentId, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts index 5acd58ac0c5..539ffd677a1 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -8,7 +8,6 @@ import { import { parseRequest } from '@/lib/api/server' import { defineV2JsonRoute, - type V2ErrorPolicy, v2ApiKeyAuth, v2OrchestrationErrorPolicy, v2RateLimits, @@ -23,6 +22,7 @@ import { readFormDataWithLimit, } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' import { admitKnowledgeDocumentUpload, @@ -43,14 +43,6 @@ export const revalidate = 0 const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 -const concealKnowledgeDocumentListAuthorization = { - render(error) { - const response = v2OrchestrationErrorPolicy.render(error) - if (response?.status === 403) return v2Error('NOT_FOUND', 'Knowledge base not found') - return response - }, -} satisfies V2ErrorPolicy - function toV2DocumentSummary(document: { id: string knowledgeBaseId: string @@ -85,7 +77,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.listDocuments, rateLimit: v2RateLimits.publicApi, - errorPolicy: concealKnowledgeDocumentListAuthorization, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => { const decodedCursor = query.cursor ? decodeCursor<{ offset: number }>(query.cursor) : null if ( diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts index a92d1b5fa85..aca1150ddc2 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -3,14 +3,9 @@ import { v2GetKnowledgeBaseContract, v2UpdateKnowledgeBaseContract, } from '@/lib/api/contracts/v2/knowledge' -import { - defineV2JsonRoute, - type V2ErrorPolicy, - v2ApiKeyAuth, - v2OrchestrationErrorPolicy, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { PlatformEvents } from '@/lib/core/telemetry' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { deleteKnowledgeBaseOperation, readKnowledgeBase, @@ -53,21 +48,13 @@ function toV2KnowledgeBase(knowledgeBase: KnowledgeBaseWithCounts, folderPath: s } } -const concealKnowledgeBaseReadAuthorization = { - render(error) { - const response = v2OrchestrationErrorPolicy.render(error) - if (response?.status === 403) return v2Error('NOT_FOUND', 'Knowledge base not found') - return response - }, -} satisfies V2ErrorPolicy - /** GET /api/v2/knowledge/[id] — Get knowledge base details. */ export const GET = defineV2JsonRoute({ contract: v2GetKnowledgeBaseContract, auth: v2ApiKeyAuth, operation: knowledgeOperations.read, rateLimit: v2RateLimits.publicApi, - errorPolicy: concealKnowledgeBaseReadAuthorization, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => ({ knowledgeBaseId: params.id, assertedWorkspaceId: query.workspaceId, @@ -84,7 +71,7 @@ export const PUT = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.update, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: v2KnowledgeErrorPolicies.default, parseOptions: { invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), }, @@ -109,7 +96,7 @@ export const DELETE = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.delete, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: v2KnowledgeErrorPolicies.default, mapInput: ({ params, query }) => ({ knowledgeBaseId: params.id, assertedWorkspaceId: query.workspaceId, 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 b538e07d63c..907a1b00e1a 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.test.ts @@ -1,31 +1,24 @@ /** * @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - checkPreauth: vi.fn(), - checkOperationRate: vi.fn(), - gate: vi.fn(), execute: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -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: mocks.gate })) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/lib/logs/application/get-public-log', () => ({ getPublicLog: { operation: { id: 'logs.read_detail' }, execute: mocks.execute }, @@ -71,18 +64,10 @@ const log = { describe('GET /api/v2/logs/[runId]', () => { 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'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.execute.mockResolvedValue({ log, workflowFolderPath: '/agents', diff --git a/apps/sim/app/api/v2/logs/route.test.ts b/apps/sim/app/api/v2/logs/route.test.ts index fb7c5013984..069bb3ed605 100644 --- a/apps/sim/app/api/v2/logs/route.test.ts +++ b/apps/sim/app/api/v2/logs/route.test.ts @@ -1,31 +1,24 @@ /** * @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - checkPreauth: vi.fn(), - checkOperationRate: vi.fn(), - gate: vi.fn(), execute: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -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: mocks.gate })) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/lib/logs/application/list-public-logs', () => ({ listPublicLogs: { operation: { id: 'logs.list' }, execute: mocks.execute }, @@ -67,18 +60,10 @@ const log = { describe('GET /api/v2/logs', () => { 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'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.execute.mockResolvedValue({ items: [{ log, executionData: { finalOutput: false, traceSpans: [] } }], nextCursor: null, @@ -121,7 +106,7 @@ describe('GET /api/v2/logs', () => { ) expect(response.status).toBe(400) - expect(mocks.authenticate).toHaveBeenCalled() + expect(v2RouteMocks.authenticate).toHaveBeenCalled() expect(mocks.execute).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v2/workspaces/route.test.ts b/apps/sim/app/api/v2/workspaces/route.test.ts index bde0ffa3827..d8f6fae0513 100644 --- a/apps/sim/app/api/v2/workspaces/route.test.ts +++ b/apps/sim/app/api/v2/workspaces/route.test.ts @@ -1,32 +1,25 @@ /** * @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - checkPreauth: vi.fn(), - checkOperationRate: vi.fn(), - gate: vi.fn(), getWorkspace: vi.fn(), listMembers: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -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: mocks.gate })) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/lib/workspaces/application/get-public-workspace', () => ({ getPublicWorkspace: { @@ -63,18 +56,10 @@ const context = () => ({ params: Promise.resolve({ workspaceId: WORKSPACE_ID }) 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'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.getWorkspace.mockResolvedValue({ workspace: { id: WORKSPACE_ID, diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 653ef97c0bc..6048c8bab13 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { LIST_SORT_ORDERS, type ListSortOrder } from '@/lib/api/list-query' import { FolderPathError, parseFolderPath, requireNonRootFolderPath } from '@/lib/folders/paths' /** @@ -92,9 +93,9 @@ export const v2SearchSchema = z .max(200, 'search is too long') .optional() -export const v2SortOrderSchema = z.enum(['asc', 'desc']) +export const v2SortOrderSchema = z.enum(LIST_SORT_ORDERS) -export type V2SortOrder = z.output +export type V2SortOrder = ListSortOrder function canonicalFolderPathSchema(parser: (path: string) => string[]) { return z.string().superRefine((path, ctx) => { diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts index eb0d450705d..6607ecde36a 100644 --- a/apps/sim/lib/api/list-query.ts +++ b/apps/sim/lib/api/list-query.ts @@ -13,7 +13,8 @@ import { sql, } from 'drizzle-orm' -export type ListSortOrder = 'asc' | 'desc' +export const LIST_SORT_ORDERS = ['asc', 'desc'] as const +export type ListSortOrder = (typeof LIST_SORT_ORDERS)[number] /** * Runtime half of the v2 list convention declared in diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index 408ecff04d0..22c753ca9a2 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -23,3 +23,4 @@ export { v2OrchestrationErrorPolicy, v2RateLimits, } from '@/lib/api/server/routes/v2-json-route' +export { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes/v2-resource-concealment' diff --git a/apps/sim/lib/api/server/routes/v2-json-route.test.ts b/apps/sim/lib/api/server/routes/v2-json-route.test.ts new file mode 100644 index 00000000000..ea4f0ba6710 --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-json-route.test.ts @@ -0,0 +1,401 @@ +/** + * @vitest-environment node + */ +import type { PersonalApiKeyPrincipal } from '@sim/auth/principal' +import { + MockV2ApiKeyUnauthenticatedError, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import type { ParsedRequest } from '@/lib/api/server/validation' +import type { OperationUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import type { V2ApiKeyAuthContext } from '@/lib/api/server/routes/v2-api-key-auth' +import { + defineV2JsonRoute, + type V2ErrorPolicy, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes/v2-json-route' + +const operation = { id: 'widgets.update' } as const +const principal: PersonalApiKeyPrincipal = { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-1', +} +const auth = { + principal, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], + rateLimitSubscription: null, + keyType: 'personal', +} satisfies V2ApiKeyAuthContext +const resetAt = new Date('2026-08-08T20:00:00.000Z') +const allowedRate = { allowed: true, remaining: 99, resetAt } + +const contract = defineRouteContract({ + method: 'POST', + path: '/api/v2/widgets', + body: z.object({ value: z.string() }).strict(), + response: { + mode: 'json', + status: 201, + schema: z.object({ data: z.object({ value: z.string() }) }), + }, +}) + +interface Input { + value: string +} + +interface Result { + value: string +} + +type Execute = OperationUseCase['execute'] + +interface HandlerOverrides { + beforeParse?: (args: { + request: NextRequest + principal: PersonalApiKeyPrincipal + params: Record + }) => void | Promise + errorPolicy?: V2ErrorPolicy + execute?: Execute + mapInput?: (input: ParsedRequest) => Input + onSuccess?: (args: { + principal: PersonalApiKeyPrincipal + input: Input + result: Result + }) => void | Promise + present?: (result: Result) => { data: { value: string } } | Promise<{ data: { value: string } }> + statusForResult?: (result: Result) => number +} + +function createHandler(overrides: HandlerOverrides = {}) { + const useCase: OperationUseCase = { + operation, + execute: + overrides.execute ?? + (async ({ input }) => ({ + value: input.value, + })), + } + return defineV2JsonRoute({ + contract, + auth: v2ApiKeyAuth, + operation, + rateLimit: v2RateLimits.publicApi, + errorPolicy: overrides.errorPolicy ?? v2OrchestrationErrorPolicy, + beforeParse: overrides.beforeParse, + mapInput: overrides.mapInput ?? (({ body }) => body), + useCase, + present: overrides.present ?? ((result) => ({ data: result })), + onSuccess: overrides.onSuccess, + statusForResult: overrides.statusForResult, + }) +} + +function request(body: unknown = { value: 'ok' }): NextRequest { + return new NextRequest('http://localhost/api/v2/widgets', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), + }) +} + +describe('defineV2JsonRoute', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue({ allowed: true, remaining: 599, resetAt }) + v2RouteMocks.operationRate.mockResolvedValue(allowedRate) + }) + + it('runs admission, parsing, use case, presentation, and success effects in order', async () => { + const events: string[] = [] + v2RouteMocks.preauthRate.mockImplementation(async () => { + events.push('ip-limit') + return { allowed: true, remaining: 599, resetAt } + }) + v2RouteMocks.authenticate.mockImplementation(async () => { + events.push('authenticate') + return { ...auth, rateLimitSubjectIds: ['api-key:key-1'] as const } + }) + v2RouteMocks.gate.mockImplementation(async () => { + events.push('rollout') + return null + }) + v2RouteMocks.operationRate.mockImplementation(async () => { + events.push('operation-limit') + return allowedRate + }) + + const handler = createHandler({ + beforeParse: () => { + events.push('before-parse') + }, + mapInput: ({ body }) => { + events.push('parse-and-map') + return body + }, + execute: async ({ input }) => { + events.push('use-case') + return input + }, + present: (result) => { + events.push('presentation') + return { data: result } + }, + onSuccess: () => { + events.push('on-success') + }, + }) + + const response = await handler(request()) + + expect(response.status).toBe(201) + expect(events).toEqual([ + 'ip-limit', + 'authenticate', + 'rollout', + 'operation-limit', + 'before-parse', + 'parse-and-map', + 'use-case', + 'presentation', + 'on-success', + ]) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + }) + + it('fails closed before authentication when the IP bucket cannot admit the request', async () => { + v2RouteMocks.preauthRate.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt, + retryAfterMs: 60_000, + }) + + const response = await createHandler()(request()) + + expect(response.status).toBe(429) + expect(v2RouteMocks.preauthRate).toHaveBeenCalledWith( + expect.stringMatching(/^v2:preauth:ip:/), + expect.objectContaining({ maxTokens: 600 }), + { failClosed: true } + ) + expect(v2RouteMocks.authenticate).not.toHaveBeenCalled() + expect(v2RouteMocks.gate).not.toHaveBeenCalled() + expect(v2RouteMocks.operationRate).not.toHaveBeenCalled() + }) + + it('renders invalid credentials as 401 without continuing admission', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce( + new MockV2ApiKeyUnauthenticatedError('API key required') + ) + + const response = await createHandler()(request()) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ + error: { code: 'UNAUTHORIZED', message: 'API key required' }, + }) + expect(v2RouteMocks.gate).not.toHaveBeenCalled() + expect(v2RouteMocks.operationRate).not.toHaveBeenCalled() + }) + + it('short-circuits parsing and operation rate limiting when rollout denies admission', async () => { + const mapInput = vi.fn<(input: ParsedRequest) => Input>() + const execute = vi.fn() + v2RouteMocks.gate.mockResolvedValueOnce( + NextResponse.json({ error: { code: 'NOT_FOUND', message: 'Not found' } }, { status: 404 }) + ) + + const response = await createHandler({ mapInput, execute })(request()) + + expect(response.status).toBe(404) + expect(v2RouteMocks.operationRate).not.toHaveBeenCalled() + expect(mapInput).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + }) + + it.each([ + { + stage: 'authentication', + fail: () => + v2RouteMocks.authenticate.mockRejectedValueOnce(new Error('auth store unavailable')), + }, + { + stage: 'rollout gate', + fail: () => v2RouteMocks.gate.mockRejectedValueOnce(new Error('gate store unavailable')), + }, + { + stage: 'operation rate limit', + fail: () => v2RouteMocks.operationRate.mockRejectedValue(new Error('rate store unavailable')), + }, + ])('maps $stage infrastructure failure to 503', async ({ fail }) => { + fail() + + const response = await createHandler()(request()) + + expect(response.status).toBe(503) + await expect(response.json()).resolves.toEqual({ + error: { + code: 'SERVICE_UNAVAILABLE', + message: 'Service temporarily unavailable', + }, + }) + }) + + it('enforces every rate subject and publishes the most restrictive allowed bucket', async () => { + const restrictiveReset = new Date('2026-08-08T21:00:00.000Z') + v2RouteMocks.operationRate.mockImplementation(async (key: string) => + key.endsWith('user:user-1') + ? { allowed: true, remaining: 12, resetAt: restrictiveReset } + : { allowed: true, remaining: 80, resetAt } + ) + + const response = await createHandler()(request()) + + expect(response.status).toBe(201) + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) + expect(v2RouteMocks.operationRate).toHaveBeenCalledWith( + 'v2:widgets.update:api-key:key-1', + expect.objectContaining({ maxTokens: 100 }) + ) + expect(v2RouteMocks.operationRate).toHaveBeenCalledWith( + 'v2:widgets.update:user:user-1', + expect.objectContaining({ maxTokens: 100 }) + ) + expect(response.headers.get('X-RateLimit-Limit')).toBe('100') + expect(response.headers.get('X-RateLimit-Remaining')).toBe('12') + expect(response.headers.get('X-RateLimit-Reset')).toBe(restrictiveReset.toISOString()) + }) + + it('rejects when any rate subject is denied, regardless of other bucket capacity', async () => { + const mapInput = vi.fn<(input: ParsedRequest) => Input>() + const execute = vi.fn() + v2RouteMocks.operationRate.mockImplementation(async (key: string) => + key.endsWith('user:user-1') + ? { allowed: false, remaining: 0, resetAt, retryAfterMs: 30_000 } + : { allowed: true, remaining: 99, resetAt } + ) + + const response = await createHandler({ mapInput, execute })(request()) + + expect(response.status).toBe(429) + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) + expect(response.headers.get('X-RateLimit-Remaining')).toBe('0') + expect(mapInput).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + }) + + it('short-circuits parsing when beforeParse rejects the admitted principal', async () => { + const mapInput = vi.fn<(input: ParsedRequest) => Input>() + const execute = vi.fn() + const response = await createHandler({ + beforeParse: () => { + throw new OrchestrationError('forbidden', 'Header policy denied') + }, + mapInput, + execute, + })(request()) + + expect(response.status).toBe(403) + expect(mapInput).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + expect(response.headers.get('X-RateLimit-Remaining')).toBe('99') + }) + + it('authenticates, gates, and rate-limits before parse rejection, then stops', async () => { + const execute = vi.fn() + const present = vi.fn<(result: Result) => { data: { value: string } }>() + const onSuccess = vi.fn() + const mapInput = vi.fn<(input: ParsedRequest) => Input>() + const response = await createHandler({ execute, present, onSuccess, mapInput })(request({})) + + expect(response.status).toBe(400) + expect(v2RouteMocks.authenticate).toHaveBeenCalledOnce() + expect(v2RouteMocks.gate).toHaveBeenCalledOnce() + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) + expect(mapInput).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + expect(present).not.toHaveBeenCalled() + expect(onSuccess).not.toHaveBeenCalled() + expect(response.headers.get('X-RateLimit-Remaining')).toBe('99') + }) + + it('short-circuits presentation and onSuccess after a typed use-case failure', async () => { + const present = vi.fn<(result: Result) => { data: { value: string } }>() + const onSuccess = vi.fn() + const response = await createHandler({ + execute: async () => { + throw new OrchestrationError('conflict', 'Already exists') + }, + present, + onSuccess, + })(request()) + + expect(response.status).toBe(409) + expect(present).not.toHaveBeenCalled() + expect(onSuccess).not.toHaveBeenCalled() + expect(response.headers.get('X-RateLimit-Remaining')).toBe('99') + }) + + it('validates the presented response before onSuccess', async () => { + const onSuccess = vi.fn() + const response = await createHandler({ + present: () => + ({ data: { value: 42 } }) as unknown as { + data: { value: string } + }, + onSuccess, + })(request()) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + expect(onSuccess).not.toHaveBeenCalled() + }) + + it('turns an onSuccess failure into an error response after successful presentation', async () => { + const present = vi.fn((result: Result) => ({ data: result })) + const response = await createHandler({ + present, + onSuccess: () => { + throw new OrchestrationError('conflict', 'Success projection failed') + }, + })(request()) + + expect(present).toHaveBeenCalledOnce() + expect(response.status).toBe(409) + await expect(response.json()).resolves.toEqual({ + error: { code: 'CONFLICT', message: 'Success projection failed' }, + }) + }) + + it('fails fast on an invalid dynamic success status', async () => { + const response = await createHandler({ statusForResult: () => 400 })(request()) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + }) +}) diff --git a/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts b/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts new file mode 100644 index 00000000000..8224d495182 --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts @@ -0,0 +1,98 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { V2ErrorPolicy } from '@/lib/api/server/routes' +import { + DelegatedWorkspaceAuthorizationError, + InsufficientWorkspacePermissionsError, + PersonalApiKeysDisabledError, + PrincipalKindAuthorizationError, + WorkspaceApiKeyAuthorizationError, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { v2TableErrorPolicies } from '@/lib/table/api/route-policies' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api/route-policies' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api/route-policies' + +const policies: Array<{ + domain: string + policy: V2ErrorPolicy + notFoundMessage: string +}> = [ + { + domain: 'file', + policy: v2FileErrorPolicies.concealResourceAuthorization, + notFoundMessage: 'File not found', + }, + { + domain: 'workflow', + policy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + notFoundMessage: 'Workflow not found', + }, + { + domain: 'workflow run', + policy: v2WorkflowErrorPolicies.concealRunAuthorization, + notFoundMessage: 'Run not found', + }, + { + domain: 'table', + policy: v2TableErrorPolicies.concealTableAuthorization, + notFoundMessage: 'Table not found', + }, + { + domain: 'table import', + policy: v2TableErrorPolicies.concealImportAuthorization, + notFoundMessage: 'Table import not found', + }, + { + domain: 'table export', + policy: v2TableErrorPolicies.concealExportAuthorization, + notFoundMessage: 'Table export not found', + }, + { + domain: 'knowledge base', + policy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + notFoundMessage: 'Knowledge base not found', + }, +] + +const resourceAuthorizationErrors = [ + new InsufficientWorkspacePermissionsError(), + new WorkspaceApiKeyAuthorizationError(), + new DelegatedWorkspaceAuthorizationError(), + new PrincipalKindAuthorizationError('workspace_api_key', 'resources.read'), +] + +describe.each(policies)('$domain resource concealment', ({ policy, notFoundMessage }) => { + it.each(resourceAuthorizationErrors)( + 'conceals typed resource authorization: %s', + async (error) => { + const response = policy.render(error) + expect(response?.status).toBe(404) + await expect(response?.json()).resolves.toEqual({ + error: { code: 'NOT_FOUND', message: notFoundMessage }, + }) + } + ) + + it('preserves workspace personal-key policy denial as forbidden', async () => { + const response = policy.render(new PersonalApiKeysDisabledError()) + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Personal API keys are not allowed for this workspace', + }, + }) + }) + + it('preserves unrelated forbidden business failures', async () => { + const response = policy.render(new OrchestrationError('forbidden', 'Business rule denied')) + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toEqual({ + error: { code: 'FORBIDDEN', message: 'Business rule denied' }, + }) + }) +}) diff --git a/apps/sim/lib/api/server/routes/v2-resource-concealment.ts b/apps/sim/lib/api/server/routes/v2-resource-concealment.ts new file mode 100644 index 00000000000..6df54519f19 --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-resource-concealment.ts @@ -0,0 +1,35 @@ +import type { V2ErrorPolicy } from '@/lib/api/server/routes/v2-json-route' +import { + DelegatedWorkspaceAuthorizationError, + InsufficientWorkspacePermissionsError, + PrincipalKindAuthorizationError, + WorkspaceApiKeyAuthorizationError, +} from '@/lib/core/application' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +type V2ErrorRenderer = V2ErrorPolicy['render'] + +function isResourceAuthorizationError(error: unknown): boolean { + return ( + error instanceof DelegatedWorkspaceAuthorizationError || + error instanceof InsufficientWorkspacePermissionsError || + error instanceof PrincipalKindAuthorizationError || + error instanceof WorkspaceApiKeyAuthorizationError + ) +} + +/** Conceals only typed resource-authorization failures without hiding workspace policy denials. */ +export function createV2ResourceConcealmentPolicy(options: { + notFoundMessage: string + render?: V2ErrorRenderer +}): V2ErrorPolicy { + const render = options.render ?? v2CaughtOrchestrationError + return { + render(error) { + if (isResourceAuthorizationError(error)) { + return v2Error('NOT_FOUND', options.notFoundMessage) + } + return render(error) + }, + } +} 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 index 7ebbc8676df..afce912a58d 100644 --- 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 @@ -14,11 +14,11 @@ const mocks = vi.hoisted(() => ({ recordAudit: vi.fn(), })) -vi.mock('@/app/api/v1/audit-logs/auth', () => ({ +vi.mock('@/lib/audit-logs/authorization', () => ({ resolveEnterpriseAuditAccess: mocks.resolveAccess, })) -vi.mock('@/app/api/v1/audit-logs/query', () => ({ +vi.mock('@/lib/audit-logs/query', () => ({ getOrgWorkspaceIds: mocks.getOrgWorkspaceIds, buildOrgScopeCondition: mocks.buildOrgScopeCondition, buildFilterConditions: mocks.buildFilterConditions, 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 index bc9e15f352f..363816b7c83 100644 --- 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 @@ -1,8 +1,8 @@ import type { Principal } from '@sim/auth/principal' import type { AuditLogOperation, AuditLogPrincipal } from '@/lib/audit-logs/application/operations' +import { resolveEnterpriseAuditAccess } from '@/lib/audit-logs/authorization' 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 diff --git a/apps/sim/lib/audit-logs/application/get-audit-log.ts b/apps/sim/lib/audit-logs/application/get-audit-log.ts index 5cc262a44b3..3e5bfbdb61f 100644 --- a/apps/sim/lib/audit-logs/application/get-audit-log.ts +++ b/apps/sim/lib/audit-logs/application/get-audit-log.ts @@ -3,8 +3,8 @@ 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 { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/lib/audit-logs/query' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' export interface GetAuditLogInput { organizationId: string diff --git a/apps/sim/lib/audit-logs/application/list-audit-logs.ts b/apps/sim/lib/audit-logs/application/list-audit-logs.ts index 54da626fef4..a387267fb5a 100644 --- a/apps/sim/lib/audit-logs/application/list-audit-logs.ts +++ b/apps/sim/lib/audit-logs/application/list-audit-logs.ts @@ -1,13 +1,13 @@ 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 { + type AuditLogFilterParams, buildFilterConditions, buildOrgScopeCondition, getOrgWorkspaceIds, queryAuditLogs, -} from '@/app/api/v1/audit-logs/query' +} from '@/lib/audit-logs/query' +import { OrchestrationError } from '@/lib/core/orchestration/types' export interface ListAuditLogsInput { organizationId: string diff --git a/apps/sim/lib/audit-logs/authorization.ts b/apps/sim/lib/audit-logs/authorization.ts new file mode 100644 index 00000000000..518e9f0d3ff --- /dev/null +++ b/apps/sim/lib/audit-logs/authorization.ts @@ -0,0 +1,98 @@ +import { db } from '@sim/db' +import { member, subscription } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, inArray } from 'drizzle-orm' +import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' +import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import { isAuditLogsEnabled, isBillingEnabled } from '@/lib/core/config/env-flags' + +const logger = createLogger('AuditLogAuthorization') + +export interface EnterpriseAuditContext { + organizationId: string + orgMemberIds: string[] +} + +export type EnterpriseAuditAccessResult = + | { success: true; context: EnterpriseAuditContext } + | { success: false; status: 403; message: string } + +/** Resolves transport-neutral enterprise audit-log access for an organization administrator. */ +export async function resolveEnterpriseAuditAccess( + userId: string, + targetOrganizationId?: string +): Promise { + const [membership] = await db + .select({ organizationId: member.organizationId, role: member.role }) + .from(member) + .where( + targetOrganizationId + ? and(eq(member.userId, userId), eq(member.organizationId, targetOrganizationId)) + : eq(member.userId, userId) + ) + .limit(1) + + if (!membership) { + return { + success: false, + status: 403, + message: targetOrganizationId + ? 'Not a member of the requested organization' + : 'Not a member of any organization', + } + } + + if (membership.role !== 'admin' && membership.role !== 'owner') { + return { success: false, status: 403, message: 'Organization admin or owner role required' } + } + + if (isBillingEnabled) { + const billingBlocked = await isOrganizationBillingBlocked(membership.organizationId) + if (billingBlocked) { + return { success: false, status: 403, message: 'Active enterprise subscription required' } + } + } else if (!isAuditLogsEnabled) { + return { + success: false, + status: 403, + message: + 'Audit logs are disabled. Set ENTERPRISE_ENABLED or AUDIT_LOGS_ENABLED to enable them.', + } + } + + const [orgSub, orgMembers] = await Promise.all([ + isBillingEnabled + ? db + .select({ id: subscription.id }) + .from(subscription) + .where( + and( + eq(subscription.referenceId, membership.organizationId), + eq(subscription.plan, 'enterprise'), + inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) + ) + ) + .limit(1) + : Promise.resolve([]), + db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, membership.organizationId)), + ]) + + if (isBillingEnabled && orgSub.length === 0) { + return { success: false, status: 403, message: 'Active enterprise subscription required' } + } + + const orgMemberIds = orgMembers.map((organizationMember) => organizationMember.userId) + logger.info('Enterprise audit access validated', { + userId, + organizationId: membership.organizationId, + memberCount: orgMemberIds.length, + }) + + return { + success: true, + context: { organizationId: membership.organizationId, orgMemberIds }, + } +} diff --git a/apps/sim/app/api/v1/audit-logs/query.test.ts b/apps/sim/lib/audit-logs/query.test.ts similarity index 96% rename from apps/sim/app/api/v1/audit-logs/query.test.ts rename to apps/sim/lib/audit-logs/query.test.ts index 72740ca537f..78b82b90767 100644 --- a/apps/sim/app/api/v1/audit-logs/query.test.ts +++ b/apps/sim/lib/audit-logs/query.test.ts @@ -1,13 +1,13 @@ /** * @vitest-environment node * - * Tests for the enterprise audit-log tenant boundary. The global drizzle-orm + * Verifies the enterprise audit-log tenant boundary. The global drizzle-orm * mock returns structured operator objects, so these tests assert directly on * the predicate tree. */ import { dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' +import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/lib/audit-logs/query' const ORG_ID = 'org-1' const MEMBER_IDS = ['user-1', 'user-2'] diff --git a/apps/sim/app/api/v1/audit-logs/query.ts b/apps/sim/lib/audit-logs/query.ts similarity index 85% rename from apps/sim/app/api/v1/audit-logs/query.ts rename to apps/sim/lib/audit-logs/query.ts index 795c54ecfb4..70fbea8fde1 100644 --- a/apps/sim/app/api/v1/audit-logs/query.ts +++ b/apps/sim/lib/audit-logs/query.ts @@ -69,9 +69,7 @@ export function buildFilterConditions(params: AuditLogFilterParams): SQL { const rows = await db .select({ id: workspace.id }) @@ -87,14 +85,7 @@ export interface OrgScopeParams { includeDeparted: boolean } -/** - * Builds the tenant-boundary predicate for organization audit log access: - * rows in org-attached workspaces, plus org-level rows (`workspace_id IS - * NULL`) tied to the org via `metadata.organizationId` or the organization - * resource itself. Actor membership is never a standalone boundary — when - * `includeDeparted` is false it only narrows the org scope to current members - * and system events (null actor). - */ +/** Builds the tenant-boundary predicate for organization audit log access. */ export function buildOrgScopeCondition(params: OrgScopeParams): SQL { const { organizationId, orgWorkspaceIds, orgMemberIds, includeDeparted } = params @@ -114,9 +105,7 @@ export function buildOrgScopeCondition(params: OrgScopeParams): SQL { ? or(inArray(auditLog.workspaceId, orgWorkspaceIds), orgLevelCondition)! : orgLevelCondition - if (includeDeparted) { - return orgScope - } + if (includeDeparted) return orgScope const currentActorCondition = orgMemberIds.length > 0 @@ -150,7 +139,6 @@ export async function queryAuditLogs( cursor?: string ): Promise { const allConditions = [...conditions] - if (cursor) { const cursorCondition = buildCursorCondition(cursor) if (cursorCondition) allConditions.push(cursorCondition) @@ -165,15 +153,12 @@ export async function queryAuditLogs( const hasMore = rows.length > limit const data = rows.slice(0, limit) - - let nextCursor: string | undefined - if (hasMore && data.length > 0) { - const last = data[data.length - 1] - nextCursor = encodeCursor({ - createdAt: last.createdAt.toISOString(), - id: last.id, - }) + const last = data.at(-1) + return { + data, + nextCursor: + hasMore && last + ? encodeCursor({ createdAt: last.createdAt.toISOString(), id: last.id }) + : undefined, } - - return { data, nextCursor } } diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index d37df87ed2a..9724fadd410 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { credential, credentialMember } from '@sim/db/schema' 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 type { ListSortOrder } from '@/lib/api/list-query' import { listOrderBy, searchFilter } from '@/lib/api/list-query' import { isSharedCredentialType, SHARED_CREDENTIAL_TYPES } from '@/lib/credentials/access' import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -58,7 +58,7 @@ export async function listVisibleWorkspaceCredentials(params: { /** Case-insensitive substring match on the credential display name. */ search?: string sortBy?: V2CredentialSortBy - sortOrder?: V2SortOrder + sortOrder?: ListSortOrder }): Promise { const { workspaceId, @@ -140,7 +140,7 @@ export async function listWorkspacePrincipalCredentials(params: { providerId?: string search?: string sortBy?: V2CredentialSortBy - sortOrder?: V2SortOrder + sortOrder?: ListSortOrder }): Promise { const { workspaceId, diff --git a/apps/sim/lib/folders/application-folder-caps.test.ts b/apps/sim/lib/folders/application-folder-caps.test.ts new file mode 100644 index 00000000000..7a6d2524f25 --- /dev/null +++ b/apps/sim/lib/folders/application-folder-caps.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + listFolderRows: vi.fn(), + listTables: vi.fn(), + listWorkflows: vi.fn(), + loadFolderIndex: vi.fn(), + resolvePermission: vi.fn(), + resolveTableWorkspace: vi.fn(), + resolveWorkflowWorkspace: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => actual === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/folders/queries', () => ({ + listActiveFolderRows: mocks.listFolderRows, + loadActiveFolderPathIndex: mocks.loadFolderIndex, + resolveFolderPathFromIndex: (index: { idByPath: Map }, path: string) => + path === '/' ? null : index.idByPath.get(path), +})) +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.resolveWorkflowWorkspace, +})) +vi.mock('@/lib/workflows/queries', () => ({ + InvalidWorkflowListCursorError: class InvalidWorkflowListCursorError extends Error {}, + listWorkspaceWorkflows: mocks.listWorkflows, +})) +vi.mock('@/lib/table/application/context', () => ({ + resolveTableWorkspaceContext: mocks.resolveTableWorkspace, +})) +vi.mock('@/lib/table', () => ({ + createTable: vi.fn(), + deleteTable: vi.fn(), + getTableById: vi.fn(), + getWorkspaceTableLimits: vi.fn(), + moveTableToFolder: vi.fn(), + queryTables: mocks.listTables, + renameTable: vi.fn(), + updateTableDescription: vi.fn(), +})) +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: vi.fn() })) + +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { listTableFoldersUseCase } from '@/lib/table/application/folders' +import { listTablesUseCase } from '@/lib/table/application/tables' +import { listWorkflows } from '@/lib/workflows/application/list-workflows' +import { listWorkflowFolders } from '@/lib/workflows/application/workflow-folders' + +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const folderIndex = { + idByPath: new Map(), + pathById: new Map(), + rowById: new Map(), +} + +describe('workflow and table application folder caps', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveWorkflowWorkspace.mockResolvedValue(context) + mocks.resolveTableWorkspace.mockResolvedValue(context) + mocks.loadFolderIndex.mockResolvedValue(folderIndex) + mocks.listFolderRows.mockResolvedValue([]) + mocks.listWorkflows.mockResolvedValue({ data: [], nextCursorKeys: null }) + mocks.listTables.mockResolvedValue({ tables: [], nextKeys: null }) + }) + + it.each([ + [ + 'workflow', + () => + listWorkflowFolders.execute({ + principal, + input: { + workspaceId: context.workspaceId, + sortBy: 'name', + sortOrder: 'asc', + }, + }), + ], + [ + 'table', + () => + listTableFoldersUseCase.execute({ + principal, + input: { workspaceId: context.workspaceId }, + }), + ], + ] as const)('bounds the %s folder-list index and result rows', async (resourceType, execute) => { + await execute() + + expect(mocks.loadFolderIndex).toHaveBeenCalledWith( + context.workspaceId, + resourceType, + undefined, + { maxRows: MAX_FOLDERS_PER_WORKSPACE } + ) + expect(mocks.listFolderRows).toHaveBeenCalledWith( + context.workspaceId, + resourceType, + expect.objectContaining({ maxRows: MAX_FOLDERS_PER_WORKSPACE }) + ) + }) + + it.each([ + [ + 'workflow', + () => + listWorkflows.execute({ + principal, + input: { + workspaceId: context.workspaceId, + deployedOnly: false, + sortBy: 'name', + sortOrder: 'asc', + limit: 25, + }, + }), + ], + [ + 'table', + () => + listTablesUseCase.execute({ + principal, + input: { + workspaceId: context.workspaceId, + sortBy: 'name', + sortOrder: 'asc', + limit: 25, + }, + }), + ], + ] as const)('bounds the %s paged-resource folder index', async (resourceType, execute) => { + await execute() + + expect(mocks.loadFolderIndex).toHaveBeenCalledWith( + context.workspaceId, + resourceType, + undefined, + { maxRows: MAX_FOLDERS_PER_WORKSPACE } + ) + }) +}) diff --git a/apps/sim/lib/folders/cascade.test.ts b/apps/sim/lib/folders/cascade.test.ts index e75fa3ee45d..e177dd3c308 100644 --- a/apps/sim/lib/folders/cascade.test.ts +++ b/apps/sim/lib/folders/cascade.test.ts @@ -13,6 +13,7 @@ import { toCascadeCounts, } from '@/lib/folders/cascade' import { FOLDER_RESOURCES, type FolderResourceConfig } from '@/lib/folders/config' +import { FolderCollectionLimitExceededError } from '@/lib/folders/errors' import { folderMutationStatus } from '@/lib/folders/status' interface SelectCall { @@ -155,9 +156,14 @@ describe('collectCascadeSubtreeIds', () => { ], }) - await expect( + const rejection = expect( collectCascadeSubtreeIds(tx, 'ws-1', 'knowledge_base', 'root', TIMESTAMP, 2) - ).rejects.toThrow('Folder cascade exceeds the 2 row limit') + ).rejects + await rejection.toBeInstanceOf(FolderCollectionLimitExceededError) + await rejection.toMatchObject({ + code: 'payload_too_large', + message: 'Folder cascade exceeds the 2 row limit', + }) }) }) diff --git a/apps/sim/lib/folders/cascade.ts b/apps/sim/lib/folders/cascade.ts index c4da00dedd7..08db6cab1b3 100644 --- a/apps/sim/lib/folders/cascade.ts +++ b/apps/sim/lib/folders/cascade.ts @@ -3,6 +3,7 @@ import { folder as folderTable } from '@sim/db/schema' import { and, eq, inArray, isNull, or, type SQL } from 'drizzle-orm' import type { FolderCascadeCountsApi, FolderResourceType } from '@/lib/api/contracts/folders' import type { FolderResourceConfig } from '@/lib/folders/config' +import { FolderCollectionLimitExceededError } from '@/lib/folders/errors' import { collectDescendantFolderIds } from '@/lib/folders/subtree' /** Narrow enough for both `db` and an open transaction handle. */ @@ -47,7 +48,7 @@ export async function collectCascadeSubtreeIds( ) const cascadeFolders = maxRows === undefined ? await query : await query.limit(maxRows + 1) if (maxRows !== undefined && cascadeFolders.length > maxRows) { - throw new Error(`Folder cascade exceeds the ${maxRows} row limit`) + throw new FolderCollectionLimitExceededError('cascade', maxRows) } return [folderId, ...collectDescendantFolderIds(cascadeFolders, folderId)] diff --git a/apps/sim/lib/folders/constants.ts b/apps/sim/lib/folders/constants.ts new file mode 100644 index 00000000000..64574b1c817 --- /dev/null +++ b/apps/sim/lib/folders/constants.ts @@ -0,0 +1,2 @@ +/** Hard bound for any active folder tree materialized by an application operation. */ +export const MAX_FOLDERS_PER_WORKSPACE = 10_000 diff --git a/apps/sim/lib/folders/errors.ts b/apps/sim/lib/folders/errors.ts new file mode 100644 index 00000000000..525a9279ef9 --- /dev/null +++ b/apps/sim/lib/folders/errors.ts @@ -0,0 +1,11 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' + +type FolderCollection = 'cascade' | 'list' | 'path index' + +/** Typed failure used when a complete folder collection cannot be materialized safely. */ +export class FolderCollectionLimitExceededError extends OrchestrationError { + constructor(collection: FolderCollection, maxRows: number) { + super('payload_too_large', `Folder ${collection} exceeds the ${maxRows} row limit`) + this.name = 'FolderCollectionLimitExceededError' + } +} diff --git a/apps/sim/lib/folders/orchestration.test.ts b/apps/sim/lib/folders/orchestration.test.ts index 013bd4c166c..93c7b713ae7 100644 --- a/apps/sim/lib/folders/orchestration.test.ts +++ b/apps/sim/lib/folders/orchestration.test.ts @@ -11,6 +11,7 @@ import { schemaMock, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { FolderCollectionLimitExceededError } from '@/lib/folders/errors' const { mockArchiveFolderCascade, @@ -335,6 +336,33 @@ describe('createFolder', () => { }) describe('path-owned folder mutations', () => { + it('returns a typed limit failure before expanding an oversized mutation index', async () => { + mockLoadActiveFolderPathIndex.mockRejectedValueOnce( + new FolderCollectionLimitExceededError('path index', 10_000) + ) + + const result = await createFolderAtPathTransition({ + resourceType: 'workflow', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + maxFolderRows: 10_000, + }) + + expect(result).toEqual({ + success: false, + error: 'Folder path index exceeds the 10000 row limit', + errorCode: 'payload_too_large', + }) + expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( + 'ws-1', + 'workflow', + expect.anything(), + { maxRows: 10_000 } + ) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + it('does not project legacy audit from the application transition', async () => { queueTableRows(schemaMock.folder, [{ minSortOrder: 0 }]) dbChainMockFns.returning.mockResolvedValueOnce([folderRow()]) diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index f2540f15bff..2215267cd5b 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -6,7 +6,7 @@ import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull, min } from 'drizzle-orm' import type { FolderCascadeCountsApi, FolderResourceType } from '@/lib/api/contracts/folders' -import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { asOrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { withTransactionRetry } from '@/lib/db/transaction' import type { DbOrTx } from '@/lib/db/types' import { @@ -142,6 +142,8 @@ function isEffectivelyLocked(index: FolderPathIndex & { path: string } + params: Omit & { + path: string + maxFolderRows?: number + } ): Promise { return executeCreateFolderAtPath(params, false) } diff --git a/apps/sim/lib/folders/queries.test.ts b/apps/sim/lib/folders/queries.test.ts index 3d7a8e1ef02..dac83c35df7 100644 --- a/apps/sim/lib/folders/queries.test.ts +++ b/apps/sim/lib/folders/queries.test.ts @@ -9,6 +9,7 @@ import { schemaMock, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FolderCollectionLimitExceededError } from '@/lib/folders/errors' import { findActiveFolder, listActiveFolderRows, @@ -190,18 +191,28 @@ describe('folder queries', () => { it('fails before building an oversized path index', async () => { queueTableRows(schemaMock.folder, [ROW, { ...ROW, id: 'f-2' }, { ...ROW, id: 'f-3' }]) - await expect( + const rejection = expect( loadActiveFolderPathIndex('ws-1', 'knowledge_base', undefined, { maxRows: 2 }) - ).rejects.toThrow('Folder path index exceeds the 2 row limit') + ).rejects + await rejection.toBeInstanceOf(FolderCollectionLimitExceededError) + await rejection.toMatchObject({ + code: 'payload_too_large', + message: 'Folder path index exceeds the 2 row limit', + }) expect(dbChainMockFns.limit).toHaveBeenCalledWith(3) }) it('fails before returning an oversized folder list', async () => { queueTableRows(schemaMock.folder, [ROW, { ...ROW, id: 'f-2' }, { ...ROW, id: 'f-3' }]) - await expect(listActiveFolderRows('ws-1', 'knowledge_base', { maxRows: 2 })).rejects.toThrow( - 'Folder list exceeds the 2 row limit' - ) + const rejection = expect( + listActiveFolderRows('ws-1', 'knowledge_base', { maxRows: 2 }) + ).rejects + await rejection.toBeInstanceOf(FolderCollectionLimitExceededError) + await rejection.toMatchObject({ + code: 'payload_too_large', + message: 'Folder list exceeds the 2 row limit', + }) expect(dbChainMockFns.limit).toHaveBeenCalledWith(3) }) }) diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index ff2bade9564..27128143d75 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -2,9 +2,9 @@ import { db } from '@sim/db' import { folder } from '@sim/db/schema' import { and, type Column, eq, isNotNull, isNull } from 'drizzle-orm' import type { FolderApi, FolderResourceType } from '@/lib/api/contracts/folders' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' -import { listOrderBy, searchFilter } from '@/lib/api/list-query' +import { type ListSortOrder, listOrderBy, searchFilter } from '@/lib/api/list-query' import type { DbOrTx } from '@/lib/db/types' +import { FolderCollectionLimitExceededError } from '@/lib/folders/errors' import { buildFolderPathIndex, type FolderPathIndex, ROOT_FOLDER_PATH } from '@/lib/folders/paths' import type { FolderQueryScope } from '@/hooks/queries/utils/folder-keys' @@ -158,14 +158,14 @@ interface ListFoldersOptions { /** Case-insensitive substring match on the folder name. */ search?: string sortBy?: FolderSortBy - sortOrder?: V2SortOrder + sortOrder?: ListSortOrder } interface ListActiveFolderRowsOptions { parentId?: string | null search?: string sortBy?: Exclude - sortOrder?: V2SortOrder + sortOrder?: ListSortOrder maxRows?: number } @@ -187,7 +187,7 @@ export async function loadActiveFolderPathIndex( ) const rows = options?.maxRows === undefined ? await query : await query.limit(options.maxRows + 1) if (options?.maxRows !== undefined && rows.length > options.maxRows) { - throw new Error(`Folder path index exceeds the ${options.maxRows} row limit`) + throw new FolderCollectionLimitExceededError('path index', options.maxRows) } return buildFolderPathIndex(rows) @@ -229,7 +229,7 @@ export async function listActiveFolderRows( .orderBy(...listOrderBy(FOLDER_SORTS[options.sortBy ?? 'name'], options.sortOrder ?? 'asc')) const rows = options.maxRows === undefined ? await query : await query.limit(options.maxRows + 1) if (options.maxRows !== undefined && rows.length > options.maxRows) { - throw new Error(`Folder list exceeds the ${options.maxRows} row limit`) + throw new FolderCollectionLimitExceededError('list', options.maxRows) } return rows } diff --git a/apps/sim/lib/knowledge/api/route-policies.ts b/apps/sim/lib/knowledge/api/route-policies.ts new file mode 100644 index 00000000000..a832cbf32e5 --- /dev/null +++ b/apps/sim/lib/knowledge/api/route-policies.ts @@ -0,0 +1,11 @@ +import { + createV2ResourceConcealmentPolicy, + v2OrchestrationErrorPolicy, +} from '@/lib/api/server/routes' + +export const v2KnowledgeErrorPolicies = { + default: v2OrchestrationErrorPolicy, + concealKnowledgeBaseAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Knowledge base not found', + }), +} as const diff --git a/apps/sim/lib/knowledge/application/contexts.test.ts b/apps/sim/lib/knowledge/application/contexts.test.ts new file mode 100644 index 00000000000..950ae781ecd --- /dev/null +++ b/apps/sim/lib/knowledge/application/contexts.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getKnowledgeBase: vi.fn(), + loadWorkspace: vi.fn(), +})) + +vi.mock('@/lib/knowledge/service', () => ({ getKnowledgeBaseById: mocks.getKnowledgeBase })) +vi.mock('@/lib/knowledge/documents/service', () => ({ getKnowledgeDocument: vi.fn() })) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +import { + resolveActiveKnowledgeBaseContext, + resolveKnowledgeWorkspaceContext, +} from '@/lib/knowledge/application/contexts' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-user-1', +} +const knowledgeBase = { id: 'knowledge-1', workspaceId: 'workspace-1' } + +describe('knowledge application contexts', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getKnowledgeBase.mockResolvedValue(knowledgeBase) + mocks.loadWorkspace.mockResolvedValue(workspace) + }) + + it('uses the canonical active-workspace loader', async () => { + await expect(resolveKnowledgeWorkspaceContext({ workspaceId: 'workspace-1' })).resolves.toBe( + workspace + ) + expect(mocks.loadWorkspace).toHaveBeenCalledWith('workspace-1') + }) + + it('conceals an inactive canonical workspace as knowledge-base absence', async () => { + mocks.loadWorkspace.mockResolvedValueOnce(null) + + await expect( + resolveActiveKnowledgeBaseContext({ knowledgeBaseId: 'knowledge-1' }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Knowledge base not found' }) + }) + + it('propagates canonical workspace database failures', async () => { + const failure = new Error('workspace database unavailable') + mocks.loadWorkspace.mockRejectedValueOnce(failure) + + await expect( + resolveActiveKnowledgeBaseContext({ knowledgeBaseId: 'knowledge-1' }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/knowledge/application/contexts.ts b/apps/sim/lib/knowledge/application/contexts.ts index 1c2648aabd1..57a6cd81c64 100644 --- a/apps/sim/lib/knowledge/application/contexts.ts +++ b/apps/sim/lib/knowledge/application/contexts.ts @@ -1,12 +1,10 @@ -import { db } from '@sim/db' -import { workspace } from '@sim/db/schema' -import { and, eq, isNull } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { KnowledgeAuthorizationContext } from '@/lib/knowledge/application/authorization' import type { ActiveKnowledgeDocument } from '@/lib/knowledge/documents/service' import { getKnowledgeDocument } from '@/lib/knowledge/documents/service' import { getKnowledgeBaseById } from '@/lib/knowledge/service' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' export interface KnowledgeWorkspaceContext extends KnowledgeAuthorizationContext { billedAccountUserId: string @@ -25,17 +23,7 @@ export interface ActiveKnowledgeDocumentContext extends ActiveKnowledgeBaseConte export async function loadKnowledgeWorkspaceContext( workspaceId: string ): Promise { - const [row] = await db - .select({ - workspaceId: workspace.id, - workspaceOrganizationId: workspace.organizationId, - allowPersonalApiKeys: workspace.allowPersonalApiKeys, - billedAccountUserId: workspace.billedAccountUserId, - }) - .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .limit(1) - return row ?? null + return loadActiveWorkspaceApplicationContext(workspaceId) } export async function resolveKnowledgeWorkspaceContext(input: { diff --git a/apps/sim/lib/knowledge/constants.ts b/apps/sim/lib/knowledge/constants.ts index e0f53db00ae..54cdf56ec97 100644 --- a/apps/sim/lib/knowledge/constants.ts +++ b/apps/sim/lib/knowledge/constants.ts @@ -1,9 +1,11 @@ +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' + /** Max character length for a knowledge base description, enforced at every layer (UI, internal API, v1 API). */ export const KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH = 10_000 /** Hard bound for full-workspace knowledge-base list projections. */ export const MAX_KNOWLEDGE_BASES_PER_WORKSPACE = 10_000 /** Hard bound for path-indexed knowledge folder trees and recursive cascades. */ -export const MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE = 10_000 +export const MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE = MAX_FOLDERS_PER_WORKSPACE /** Hard bound for connector-type rows projected onto one knowledge-base list. */ export const MAX_KNOWLEDGE_CONNECTOR_TYPE_ROWS_PER_LIST = 100_000 diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index 8de2d573357..6ce9201e60a 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -24,7 +24,7 @@ import { sql, } from 'drizzle-orm' import type { V2KnowledgeBaseSortBy } from '@/lib/api/contracts/v2/knowledge' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { ListSortOrder } from '@/lib/api/list-query' import { listOrderBy, searchFilter } from '@/lib/api/list-query' import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' @@ -143,7 +143,7 @@ export interface GetKnowledgeBasesOptions { /** Case-insensitive substring match on the knowledge base name. */ search?: string sortBy?: V2KnowledgeBaseSortBy - sortOrder?: V2SortOrder + sortOrder?: ListSortOrder } async function attachConnectorTypes( diff --git a/apps/sim/lib/secrets/application/use-cases.ts b/apps/sim/lib/secrets/application/use-cases.ts index f30efa998b3..abd1b5c27ce 100644 --- a/apps/sim/lib/secrets/application/use-cases.ts +++ b/apps/sim/lib/secrets/application/use-cases.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import type { Principal } from '@sim/auth/principal' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { ListSortOrder } from '@/lib/api/list-query' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getWorkspaceEnvKeyAdminAccess } from '@/lib/credentials/environment' @@ -52,7 +52,7 @@ async function listSecretMetadata(params: { scope?: SecretScope search?: string sortBy: SecretSortBy - sortOrder: V2SortOrder + sortOrder: ListSortOrder }): Promise { const workspaceAccess = await checkWorkspaceAccess(params.workspaceId, params.userId) const rows = await listVisibleWorkspaceCredentials({ @@ -125,7 +125,7 @@ export interface ListSecretsInput { scope?: SecretScope search?: string sortBy: SecretSortBy - sortOrder: V2SortOrder + sortOrder: ListSortOrder } export const listSecretsUseCase = defineAuthorizedWorkspaceUseCase({ diff --git a/apps/sim/lib/table/api/route-policies.ts b/apps/sim/lib/table/api/route-policies.ts index a6b270166d1..4f0b039202c 100644 --- a/apps/sim/lib/table/api/route-policies.ts +++ b/apps/sim/lib/table/api/route-policies.ts @@ -1,4 +1,4 @@ -import type { V2ErrorPolicy } from '@/lib/api/server/routes' +import { createV2ResourceConcealmentPolicy, type V2ErrorPolicy } from '@/lib/api/server/routes' import { TableOperationError } from '@/lib/table/application/errors' import { TableLockedError } from '@/lib/table/mutation-locks' import { @@ -27,28 +27,16 @@ export const v2TableErrorPolicies = { default: { render: renderTableError, } satisfies V2ErrorPolicy, - concealTableAuthorization: { - render(error) { - const response = renderTableError(error) - if (!response) return null - if (response.status === 403) return v2Error('NOT_FOUND', 'Table not found') - return response - }, - } satisfies V2ErrorPolicy, - concealImportAuthorization: { - render(error) { - const response = renderTableError(error) - if (!response) return null - if (response.status === 403) return v2Error('NOT_FOUND', 'Table import not found') - return response - }, - } satisfies V2ErrorPolicy, - concealExportAuthorization: { - render(error) { - const response = renderTableError(error) - if (!response) return null - if (response.status === 403) return v2Error('NOT_FOUND', 'Table export not found') - return response - }, - } satisfies V2ErrorPolicy, + concealTableAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Table not found', + render: renderTableError, + }), + concealImportAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Table import not found', + render: renderTableError, + }), + concealExportAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Table export not found', + render: renderTableError, + }), } as const diff --git a/apps/sim/lib/table/application/context.test.ts b/apps/sim/lib/table/application/context.test.ts index bc6f2b41186..d1b8ff08a66 100644 --- a/apps/sim/lib/table/application/context.test.ts +++ b/apps/sim/lib/table/application/context.test.ts @@ -4,24 +4,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { getTableById, select } = vi.hoisted(() => ({ +const { getTableById, loadWorkspace } = vi.hoisted(() => ({ getTableById: vi.fn(), - select: vi.fn(), + loadWorkspace: vi.fn(), })) -vi.mock('@sim/db', () => ({ db: { select } })) vi.mock('@/lib/table', () => ({ getTableById })) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: loadWorkspace, +})) import { resolveActiveTableContext } from '@/lib/table/application/context' -function mockWorkspaceQuery(rows: unknown[]) { - const limit = vi.fn().mockResolvedValue(rows) - const where = vi.fn(() => ({ limit })) - const from = vi.fn(() => ({ where })) - select.mockReturnValue({ from }) - return { from, where, limit } -} - describe('table application context', () => { beforeEach(() => { vi.clearAllMocks() @@ -30,18 +24,15 @@ describe('table application context', () => { workspaceId: 'workspace-1', name: 'Contacts', }) + loadWorkspace.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-user-1', + }) }) it('derives workspace scope from the canonical active table', async () => { - mockWorkspaceQuery([ - { - workspaceId: 'workspace-1', - workspaceOrganizationId: 'organization-1', - allowPersonalApiKeys: true, - billedAccountUserId: 'billing-user-1', - }, - ]) - await expect( resolveActiveTableContext({ tableId: 'table-1', assertedWorkspaceId: 'workspace-1' }) ).resolves.toMatchObject({ @@ -50,22 +41,29 @@ describe('table application context', () => { billedAccountUserId: 'billing-user-1', }) expect(getTableById).toHaveBeenCalledWith('table-1') - expect(select).toHaveBeenCalledTimes(1) + expect(loadWorkspace).toHaveBeenCalledWith('workspace-1') }) it('conceals an asserted cross-workspace table before workspace resolution', async () => { await expect( resolveActiveTableContext({ tableId: 'table-1', assertedWorkspaceId: 'workspace-2' }) ).rejects.toMatchObject({ code: 'not_found', message: 'Table not found' }) - expect(select).not.toHaveBeenCalled() + expect(loadWorkspace).not.toHaveBeenCalled() }) it('fails when the canonical workspace is unavailable', async () => { - mockWorkspaceQuery([]) + loadWorkspace.mockResolvedValueOnce(null) await expect(resolveActiveTableContext({ tableId: 'table-1' })).rejects.toMatchObject({ code: 'not_found', message: 'Workspace not found', }) }) + + it('propagates canonical workspace database failures', async () => { + const failure = new Error('workspace database unavailable') + loadWorkspace.mockRejectedValueOnce(failure) + + await expect(resolveActiveTableContext({ tableId: 'table-1' })).rejects.toBe(failure) + }) }) diff --git a/apps/sim/lib/table/application/context.ts b/apps/sim/lib/table/application/context.ts index 2d9603aec8b..d87150c0f50 100644 --- a/apps/sim/lib/table/application/context.ts +++ b/apps/sim/lib/table/application/context.ts @@ -1,9 +1,7 @@ -import { db } from '@sim/db' -import { workspace } from '@sim/db/schema' -import { and, eq, isNull } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getTableById, type TableDefinition } from '@/lib/table' import type { TableAuthorizationContext } from '@/lib/table/application/authorization' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' export type TableWorkspaceContext = TableAuthorizationContext @@ -15,17 +13,7 @@ export interface ActiveTableContext extends TableWorkspaceContext { export async function resolveTableWorkspaceContext( workspaceId: string ): Promise { - const [canonical] = await db - .select({ - workspaceId: workspace.id, - workspaceOrganizationId: workspace.organizationId, - allowPersonalApiKeys: workspace.allowPersonalApiKeys, - billedAccountUserId: workspace.billedAccountUserId, - }) - .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .limit(1) - + const canonical = await loadActiveWorkspaceApplicationContext(workspaceId) if (!canonical) throw new OrchestrationError('not_found', 'Workspace not found') return canonical } diff --git a/apps/sim/lib/table/application/folder-paths.ts b/apps/sim/lib/table/application/folder-paths.ts index 62683ff3504..e6864954eb1 100644 --- a/apps/sim/lib/table/application/folder-paths.ts +++ b/apps/sim/lib/table/application/folder-paths.ts @@ -1,4 +1,5 @@ import type { folder } from '@sim/db/schema' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { withFolderTreeLock } from '@/lib/folders/locks' import type { FolderPathIndex } from '@/lib/folders/paths' import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' @@ -16,7 +17,9 @@ export async function resolveTableFolderPath( path: string ): Promise { return withFolderTreeLock(workspaceId, 'table', async (tx) => { - const index = await loadActiveFolderPathIndex(workspaceId, 'table', tx) + const index = await loadActiveFolderPathIndex(workspaceId, 'table', tx, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) const folderId = resolveFolderPathFromIndex(index, path) return folderId === undefined ? null : { folderId, index } }) diff --git a/apps/sim/lib/table/application/folders.ts b/apps/sim/lib/table/application/folders.ts index 4126481064e..dbf9c825d32 100644 --- a/apps/sim/lib/table/application/folders.ts +++ b/apps/sim/lib/table/application/folders.ts @@ -1,7 +1,8 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalAttribution } from '@sim/auth/principal' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { ListSortOrder } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { createFolderAtPathTransition, deleteFolderByPathTransition, @@ -23,7 +24,7 @@ export interface ListTableFoldersInput { parentPath?: string search?: string sortBy?: Exclude - sortOrder?: V2SortOrder + sortOrder?: ListSortOrder } export const listTableFoldersUseCase = defineAuthorizedTableUseCase({ @@ -31,7 +32,9 @@ export const listTableFoldersUseCase = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: ListTableFoldersInput }) => resolveTableWorkspaceContext(input.workspaceId), async execute({ input, context }) { - const index = await loadActiveFolderPathIndex(context.workspaceId, 'table') + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) const parentId = input.parentPath === undefined ? undefined @@ -44,6 +47,7 @@ export const listTableFoldersUseCase = defineAuthorizedTableUseCase({ search: input.search, sortBy: input.sortBy, sortOrder: input.sortOrder, + maxRows: MAX_FOLDERS_PER_WORKSPACE, }) return { folders, index } }, @@ -67,11 +71,14 @@ export const createTableFolderUseCase = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, userId: attribution.attributedUserId, path: input.path, + maxFolderRows: MAX_FOLDERS_PER_WORKSPACE, }) if (!result.success || !result.folder) { throwTableOperationFailure(result, 'Failed to create folder') } - const index = await loadActiveFolderPathIndex(context.workspaceId, 'table') + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) return { folder: result.folder, index, path: input.path } }, projectAudit({ result }) { @@ -104,11 +111,14 @@ export const updateTableFolderUseCase = defineAuthorizedTableUseCase({ userId: attribution.attributedUserId, path: input.path, destinationPath: input.destinationPath, + maxFolderRows: MAX_FOLDERS_PER_WORKSPACE, }) if (!result.success || !result.folder) { throwTableOperationFailure(result, 'Failed to move folder') } - const index = await loadActiveFolderPathIndex(context.workspaceId, 'table') + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) return { folder: result.folder, index, path: input.destinationPath, sourcePath: input.path } }, projectAudit({ result }) { @@ -145,6 +155,7 @@ export const deleteTableFolderUseCase = defineAuthorizedTableUseCase({ userId: attribution.attributedUserId, path: input.path, recursive: input.recursive, + maxFolderRows: MAX_FOLDERS_PER_WORKSPACE, }) if (!result.success || !result.deletedItems || !result.folderId || !result.folderName) { throwTableOperationFailure(result, 'Failed to delete folder') diff --git a/apps/sim/lib/table/application/imports.ts b/apps/sim/lib/table/application/imports.ts index e55563ad2dc..370770fdd3e 100644 --- a/apps/sim/lib/table/application/imports.ts +++ b/apps/sim/lib/table/application/imports.ts @@ -7,6 +7,7 @@ import type { } from '@/lib/api/contracts/v2/tables' import { authorizeWorkspaceOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { withFolderTreeLock } from '@/lib/folders/locks' import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' @@ -140,7 +141,9 @@ async function resolveImportFolderId( if (body.target.type !== 'new') return undefined const path = body.target.folderPath ?? ROOT_FOLDER_PATH return withFolderTreeLock(workspaceId, 'table', async (tx) => { - const index = await loadActiveFolderPathIndex(workspaceId, 'table', tx) + const index = await loadActiveFolderPathIndex(workspaceId, 'table', tx, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) const folderId = resolveFolderPathFromIndex(index, path) if (folderId === undefined) { throw new OrchestrationError('not_found', 'Folder not found') diff --git a/apps/sim/lib/table/application/tables.ts b/apps/sim/lib/table/application/tables.ts index 2374fd1b379..8f904d3b22c 100644 --- a/apps/sim/lib/table/application/tables.ts +++ b/apps/sim/lib/table/application/tables.ts @@ -1,10 +1,10 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalAttribution } from '@sim/auth/principal' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' import type { V2TableSortBy } from '@/lib/api/contracts/v2/tables' -import type { CursorKey } from '@/lib/api/list-query' +import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { createTable, @@ -32,7 +32,7 @@ export interface ListTablesInput { folderPath?: string search?: string sortBy: V2TableSortBy - sortOrder: V2SortOrder + sortOrder: ListSortOrder limit: number after?: CursorKey[] } @@ -42,7 +42,9 @@ export const listTablesUseCase = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: ListTablesInput }) => resolveTableWorkspaceContext(input.workspaceId), async execute({ input, context }) { - const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'table') + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'table', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) const folderId = input.folderPath === undefined ? undefined @@ -139,7 +141,9 @@ export const readTableUseCase = defineAuthorizedTableUseCase({ assertedWorkspaceId: input.workspaceId, }), async execute({ context }) { - const index = await loadActiveFolderPathIndex(context.workspaceId, 'table') + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) return { table: context.table, folderPath: tableFolderPathForId(index, context.table.folderId), @@ -223,7 +227,10 @@ export const updateTableUseCase = defineAuthorizedTableUseCase({ throw new OrchestrationError('not_found', 'Table not found') } const index = - resolution?.index ?? (await loadActiveFolderPathIndex(context.workspaceId, 'table')) + resolution?.index ?? + (await loadActiveFolderPathIndex(context.workspaceId, 'table', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + })) return { table, folderPath: tableFolderPathForId(index, table.folderId), diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index a596ab1244b..e30a4ecb83e 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -14,8 +14,8 @@ import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, type Column, count, eq, isNotNull, isNull, type SQL, sql } from 'drizzle-orm' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' import type { V2TableSortBy } from '@/lib/api/contracts/v2/tables' +import type { ListSortOrder } from '@/lib/api/list-query' import { type CursorKey, encodeKeyset, @@ -291,7 +291,7 @@ interface ListTablesOptions { /** Case-insensitive substring match on the table name. */ search?: string sortBy?: V2TableSortBy - sortOrder?: V2SortOrder + sortOrder?: ListSortOrder } /** @@ -377,7 +377,7 @@ export interface QueryTablesOptions { /** Case-insensitive substring match on the table name. */ search?: string sortBy: V2TableSortBy - sortOrder: V2SortOrder + sortOrder: ListSortOrder limit: number /** Keyset values from a cursor, in the sort's key order. */ after?: CursorKey[] diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index bd5075549cd..257c529122f 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -17,7 +17,7 @@ import { generateShortId } from '@sim/utils/id' import { and, eq, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm' import type { ShareRecord } from '@/lib/api/contracts/public-shares' import type { V2FileSortBy } from '@/lib/api/contracts/v2/files' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { ListSortOrder } from '@/lib/api/list-query' import { type CursorKey, encodeKeyset, @@ -1213,7 +1213,7 @@ export interface QueryWorkspaceFilesOptions { /** Case-insensitive substring match on the file name. */ search?: string sortBy: V2FileSortBy - sortOrder: V2SortOrder + sortOrder: ListSortOrder limit: number /** Keyset values from a cursor, in the sort's key order. */ after?: CursorKey[] diff --git a/apps/sim/lib/workflows/api/route-policies.ts b/apps/sim/lib/workflows/api/route-policies.ts index a90b6888819..9f17e4708d5 100644 --- a/apps/sim/lib/workflows/api/route-policies.ts +++ b/apps/sim/lib/workflows/api/route-policies.ts @@ -1,40 +1,10 @@ -import { type V2ErrorPolicy, v2OrchestrationErrorPolicy } from '@/lib/api/server/routes' import { - DelegatedWorkspaceAuthorizationError, - InsufficientWorkspacePermissionsError, - PersonalApiKeysDisabledError, - PrincipalKindAuthorizationError, - WorkspaceApiKeyAuthorizationError, -} from '@/lib/core/application' + createV2ResourceConcealmentPolicy, + type V2ErrorPolicy, + v2OrchestrationErrorPolicy, +} from '@/lib/api/server/routes' import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' -import { - v2CaughtOrchestrationError, - v2Error, - v2ErrorForOrchestration, -} from '@/app/api/v2/lib/response' - -function isConcealedResourceAuthorizationError(error: unknown): boolean { - return ( - error instanceof DelegatedWorkspaceAuthorizationError || - error instanceof InsufficientWorkspacePermissionsError || - error instanceof PrincipalKindAuthorizationError || - error instanceof WorkspaceApiKeyAuthorizationError - ) -} - -function concealResourceAuthorization(resourceName: 'Workflow' | 'Run'): V2ErrorPolicy { - return { - render(error) { - if (error instanceof PersonalApiKeysDisabledError) { - return v2CaughtOrchestrationError(error) - } - if (isConcealedResourceAuthorizationError(error)) { - return v2Error('NOT_FOUND', `${resourceName} not found`) - } - return v2CaughtOrchestrationError(error) - }, - } -} +import { v2CaughtOrchestrationError, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' export const v2WorkflowErrorPolicies = { default: v2OrchestrationErrorPolicy, @@ -46,6 +16,10 @@ export const v2WorkflowErrorPolicies = { return v2CaughtOrchestrationError(error) }, } satisfies V2ErrorPolicy, - concealWorkflowAuthorization: concealResourceAuthorization('Workflow'), - concealRunAuthorization: concealResourceAuthorization('Run'), + concealWorkflowAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workflow not found', + }), + concealRunAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Run not found', + }), } as const diff --git a/apps/sim/lib/workflows/application/context.test.ts b/apps/sim/lib/workflows/application/context.test.ts new file mode 100644 index 00000000000..5c8f59b0e0c --- /dev/null +++ b/apps/sim/lib/workflows/application/context.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ loadWorkspace: vi.fn() })) + +vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: vi.fn() })) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +import { + resolveActiveWorkflowApplicationContext, + resolveActiveWorkspaceApplicationContext, +} from '@/lib/workflows/application/context' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-user-1', +} +const workflow = { id: 'workflow-1', workspaceId: 'workspace-1', archivedAt: null } + +describe('workflow application contexts', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.loadWorkspace.mockResolvedValue(workspace) + }) + + it('uses the canonical loader for workspace-scoped operations', async () => { + await expect(resolveActiveWorkspaceApplicationContext('workspace-1')).resolves.toBe(workspace) + expect(mocks.loadWorkspace).toHaveBeenCalledWith('workspace-1') + }) + + it('derives workflow authorization from its canonical active workspace', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { workflowId: 'workflow-1', workflow, workspaceId: 'workspace-1' }, + ]) + + await expect( + resolveActiveWorkflowApplicationContext({ + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + }) + ).resolves.toEqual({ ...workspace, workflowId: 'workflow-1', workflow }) + expect(mocks.loadWorkspace).toHaveBeenCalledWith('workspace-1') + }) + + it('conceals an asserted workspace mismatch before loading workspace policy', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { workflowId: 'workflow-1', workflow, workspaceId: 'workspace-1' }, + ]) + + await expect( + resolveActiveWorkflowApplicationContext({ + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-2', + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Workflow not found' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) + + it('conceals an inactive canonical workspace as workflow absence', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { workflowId: 'workflow-1', workflow, workspaceId: 'workspace-1' }, + ]) + mocks.loadWorkspace.mockResolvedValueOnce(null) + + await expect( + resolveActiveWorkflowApplicationContext({ workflowId: 'workflow-1' }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Workflow not found' }) + }) + + it('propagates canonical workspace database failures', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { workflowId: 'workflow-1', workflow, workspaceId: 'workspace-1' }, + ]) + const failure = new Error('workspace database unavailable') + mocks.loadWorkspace.mockRejectedValueOnce(failure) + + await expect( + resolveActiveWorkflowApplicationContext({ workflowId: 'workflow-1' }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/workflows/application/context.ts b/apps/sim/lib/workflows/application/context.ts index 27bbdab0bed..49b7c657468 100644 --- a/apps/sim/lib/workflows/application/context.ts +++ b/apps/sim/lib/workflows/application/context.ts @@ -1,15 +1,13 @@ import { db } from '@sim/db' -import { - pausedExecutions, - resumeQueue, - workflow, - workflowExecutionLogs, - workspace, -} from '@sim/db/schema' +import { pausedExecutions, resumeQueue, workflow, workflowExecutionLogs } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' import { getJobQueue } from '@/lib/core/async-jobs' import { OrchestrationError } from '@/lib/core/orchestration/types' import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/execution-job-ids' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' export interface ActiveWorkflowApplicationContext { workflowId: string @@ -20,13 +18,6 @@ export interface ActiveWorkflowApplicationContext { billedAccountUserId: string } -export interface ActiveWorkspaceApplicationContext { - workspaceId: string - workspaceOrganizationId: string | null - allowPersonalApiKeys: boolean - billedAccountUserId: string -} - export interface ActiveWorkflowRunApplicationContext extends ActiveWorkflowApplicationContext { runId: string } @@ -34,17 +25,7 @@ export interface ActiveWorkflowRunApplicationContext extends ActiveWorkflowAppli export async function resolveActiveWorkspaceApplicationContext( workspaceId: string ): Promise { - const [context] = await db - .select({ - workspaceId: workspace.id, - workspaceOrganizationId: workspace.organizationId, - allowPersonalApiKeys: workspace.allowPersonalApiKeys, - billedAccountUserId: workspace.billedAccountUserId, - }) - .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .limit(1) - + const context = await loadActiveWorkspaceApplicationContext(workspaceId) if (!context) throw new OrchestrationError('not_found', 'Workspace not found') return context } @@ -53,33 +34,28 @@ export async function resolveActiveWorkflowApplicationContext(input: { workflowId: string assertedWorkspaceId?: string }): Promise { - const [context] = await db + const [canonicalWorkflow] = await db .select({ workflowId: workflow.id, workflow, - workspaceId: workspace.id, - workspaceOrganizationId: workspace.organizationId, - allowPersonalApiKeys: workspace.allowPersonalApiKeys, - billedAccountUserId: workspace.billedAccountUserId, + workspaceId: workflow.workspaceId, }) .from(workflow) - .innerJoin(workspace, eq(workflow.workspaceId, workspace.id)) - .where( - and( - eq(workflow.id, input.workflowId), - isNull(workflow.archivedAt), - isNull(workspace.archivedAt) - ) - ) + .where(and(eq(workflow.id, input.workflowId), isNull(workflow.archivedAt))) .limit(1) if ( - !context || - (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== context.workspaceId) + !canonicalWorkflow?.workspaceId || + (input.assertedWorkspaceId !== undefined && + input.assertedWorkspaceId !== canonicalWorkflow.workspaceId) ) { throw new OrchestrationError('not_found', 'Workflow not found') } - return context + const workspaceContext = await loadActiveWorkspaceApplicationContext( + canonicalWorkflow.workspaceId + ) + if (!workspaceContext) throw new OrchestrationError('not_found', 'Workflow not found') + return { ...workspaceContext, ...canonicalWorkflow, workspaceId: workspaceContext.workspaceId } } async function resolveCanonicalRunWorkflowId(runId: string): Promise { diff --git a/apps/sim/lib/workflows/application/import-export.test.ts b/apps/sim/lib/workflows/application/import-export.test.ts index 91c03bf3e83..49ba3869074 100644 --- a/apps/sim/lib/workflows/application/import-export.test.ts +++ b/apps/sim/lib/workflows/application/import-export.test.ts @@ -46,6 +46,7 @@ vi.mock('@/lib/workflows/operations/export-workflow', () => ({ buildWorkflowExportPayload: mocks.buildExport, })) +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { exportWorkflow, importWorkflow } from '@/lib/workflows/application/import-export' import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' @@ -183,7 +184,9 @@ describe('workflow import and export application operations', () => { expect(mocks.resolveWorkflow).toHaveBeenCalledWith({ workflowId: 'workflow-1' }) expect(mocks.buildExport).toHaveBeenCalledWith(workflowRecord) - expect(mocks.loadIndex).toHaveBeenCalledWith('ws-1', 'workflow') + expect(mocks.loadIndex).toHaveBeenCalledWith('ws-1', 'workflow', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) expect(mocks.folderLock).not.toHaveBeenCalled() expect(result).toEqual({ payload: exportPayload, folderPath: '/Reports' }) expect(mocks.recordAudit).toHaveBeenCalledWith( diff --git a/apps/sim/lib/workflows/application/import-export.ts b/apps/sim/lib/workflows/application/import-export.ts index 88692f71514..4516f02ce70 100644 --- a/apps/sim/lib/workflows/application/import-export.ts +++ b/apps/sim/lib/workflows/application/import-export.ts @@ -1,9 +1,9 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalAttribution } from '@sim/auth/principal' -import type { V1WorkflowExportPayload } from '@/lib/api/contracts/v1/workflows' import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { @@ -16,7 +16,10 @@ import { workflowFolderPathForId, } from '@/lib/workflows/application/workflow-folders' import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' -import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' +import { + buildWorkflowExportPayload, + type WorkflowExportPayload, +} from '@/lib/workflows/operations/export-workflow' import { type ImportedWorkflow, importWorkflowIntoWorkspaceTransition, @@ -40,7 +43,7 @@ export interface ExportWorkflowInput { } export interface ExportWorkflowResult { - payload: V1WorkflowExportPayload + payload: WorkflowExportPayload folderPath: string } @@ -104,7 +107,12 @@ export const exportWorkflow = defineAuthorizedWorkflowUseCase({ async execute({ context }): Promise { const payload = await buildWorkflowExportPayload(context.workflow) if (!payload) throw new OrchestrationError('not_found', 'Workflow state not found') - const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const folderIndex = await loadActiveFolderPathIndex( + context.workspaceId, + 'workflow', + undefined, + { maxRows: MAX_FOLDERS_PER_WORKSPACE } + ) return { payload, folderPath: workflowFolderPathForId(folderIndex, context.workflow.folderId), diff --git a/apps/sim/lib/workflows/application/list-workflows.ts b/apps/sim/lib/workflows/application/list-workflows.ts index f278c9317d5..f00ade5e38b 100644 --- a/apps/sim/lib/workflows/application/list-workflows.ts +++ b/apps/sim/lib/workflows/application/list-workflows.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import type { CursorKey } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' @@ -31,7 +32,12 @@ export const listWorkflows = defineAuthorizedWorkflowUseCase({ resolveContext: ({ input }: { input: ListWorkflowsInput }) => resolveActiveWorkspaceApplicationContext(input.workspaceId), async execute({ principal, input, context }) { - const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const folderIndex = await loadActiveFolderPathIndex( + context.workspaceId, + 'workflow', + undefined, + { maxRows: MAX_FOLDERS_PER_WORKSPACE } + ) const folderId = input.folderPath === undefined ? undefined diff --git a/apps/sim/lib/workflows/application/read-workflow.ts b/apps/sim/lib/workflows/application/read-workflow.ts index 366ba20b2e0..984fd49a782 100644 --- a/apps/sim/lib/workflows/application/read-workflow.ts +++ b/apps/sim/lib/workflows/application/read-workflow.ts @@ -1,6 +1,7 @@ import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' @@ -30,7 +31,12 @@ export const readWorkflow = defineAuthorizedWorkflowUseCase({ if (!workflow || workflow.archivedAt || workflow.workspaceId !== context.workspaceId) { throw new OrchestrationError('not_found', 'Workflow not found') } - const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const folderIndex = await loadActiveFolderPathIndex( + context.workspaceId, + 'workflow', + undefined, + { maxRows: MAX_FOLDERS_PER_WORKSPACE } + ) const inputs = extractInputFieldsFromBlocks(snapshot.normalizedData?.blocks ?? {}) logger.info('Read workflow', { workspaceId: context.workspaceId, diff --git a/apps/sim/lib/workflows/application/update-workflow.ts b/apps/sim/lib/workflows/application/update-workflow.ts index bea4570979e..2760ff20c62 100644 --- a/apps/sim/lib/workflows/application/update-workflow.ts +++ b/apps/sim/lib/workflows/application/update-workflow.ts @@ -7,6 +7,7 @@ import { WorkflowLockedError, } from '@sim/platform-authz/workflow' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' @@ -68,7 +69,10 @@ export const updateWorkflow = defineAuthorizedWorkflowUseCase({ if (!transition.workflow) throw new Error('Successful workflow update returned no workflow') const folderIndex = - resolution?.index ?? (await loadActiveFolderPathIndex(context.workspaceId, 'workflow')) + resolution?.index ?? + (await loadActiveFolderPathIndex(context.workspaceId, 'workflow', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + })) logger.info('Updated workflow', { workspaceId: context.workspaceId, workflowId: context.workflowId, diff --git a/apps/sim/lib/workflows/application/workflow-folders.test.ts b/apps/sim/lib/workflows/application/workflow-folders.test.ts index c779babfd01..b99d0a0715b 100644 --- a/apps/sim/lib/workflows/application/workflow-folders.test.ts +++ b/apps/sim/lib/workflows/application/workflow-folders.test.ts @@ -3,6 +3,7 @@ */ import type { Principal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' const mocks = vi.hoisted(() => ({ resolveContext: vi.fn(), @@ -119,11 +120,30 @@ describe('workflow folder application operations', () => { workspaceId: 'ws-1', userId: principal.kind === 'workspace_api_key' ? 'owner-1' : 'user-1', path: '/Reports', + maxFolderRows: MAX_FOLDERS_PER_WORKSPACE, }) expect(mocks.recordAudit).toHaveBeenCalledOnce() } ) + it('bounds both the path index and listed folder rows', async () => { + mocks.listRows.mockResolvedValueOnce([folder]) + + await listWorkflowFolders.execute({ + principal: principals[0], + input: { workspaceId: 'ws-1', sortBy: 'name', sortOrder: 'asc' }, + }) + + expect(mocks.loadIndex).toHaveBeenCalledWith('ws-1', 'workflow', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + expect(mocks.listRows).toHaveBeenCalledWith( + 'ws-1', + 'workflow', + expect.objectContaining({ maxRows: MAX_FOLDERS_PER_WORKSPACE }) + ) + }) + it('rejects a workspace key outside the canonical workspace before mutation', async () => { await expect( createWorkflowFolder.execute({ diff --git a/apps/sim/lib/workflows/application/workflow-folders.ts b/apps/sim/lib/workflows/application/workflow-folders.ts index c8e71faadf7..ae1bd746e89 100644 --- a/apps/sim/lib/workflows/application/workflow-folders.ts +++ b/apps/sim/lib/workflows/application/workflow-folders.ts @@ -3,6 +3,7 @@ import { resolvePrincipalAttribution } from '@sim/auth/principal' import type { folder } from '@sim/db/schema' import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { withFolderTreeLock } from '@/lib/folders/locks' import { createFolderAtPathTransition, @@ -84,7 +85,9 @@ export async function resolveWorkflowFolderPath( path: string ): Promise<{ folderId: string | null; index: WorkflowFolderIndex }> { const resolution = await withFolderTreeLock(workspaceId, 'workflow', async (tx) => { - const index = await loadActiveFolderPathIndex(workspaceId, 'workflow', tx) + const index = await loadActiveFolderPathIndex(workspaceId, 'workflow', tx, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) const folderId = resolveFolderPathFromIndex(index, path) return folderId === undefined ? { found: false as const } @@ -109,7 +112,9 @@ export const listWorkflowFolders = defineAuthorizedWorkflowUseCase({ resolveContext: ({ input }: { input: ListWorkflowFoldersInput }) => resolveActiveWorkspaceApplicationContext(input.workspaceId), async execute({ input, context }): Promise { - const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) const parentId = input.parentPath === undefined ? undefined @@ -122,6 +127,7 @@ export const listWorkflowFolders = defineAuthorizedWorkflowUseCase({ search: input.search, sortBy: input.sortBy, sortOrder: input.sortOrder, + maxRows: MAX_FOLDERS_PER_WORKSPACE, }) return { folders, index } }, @@ -140,9 +146,12 @@ export const createWorkflowFolder = defineAuthorizedWorkflowUseCase({ workspaceId: context.workspaceId, userId: attribution.attributedUserId, path: input.path, + maxFolderRows: MAX_FOLDERS_PER_WORKSPACE, }) if (!result.success || !result.folder || !result.path) throwFolderMutationFailure(result) - const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) return { folder: result.folder, index } }, projectAudit({ input, result }) { @@ -171,9 +180,12 @@ export const relocateWorkflowFolder = defineAuthorizedWorkflowUseCase({ userId: attribution.attributedUserId, path: input.path, destinationPath: input.destinationPath, + maxFolderRows: MAX_FOLDERS_PER_WORKSPACE, }) if (!result.success || !result.folder || !result.path) throwFolderMutationFailure(result) - const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) return { folder: result.folder, index } }, projectAudit({ input, result }) { @@ -206,6 +218,7 @@ export const deleteWorkflowFolder = defineAuthorizedWorkflowUseCase({ userId: attribution.attributedUserId, path: input.path, recursive: input.recursive, + maxFolderRows: MAX_FOLDERS_PER_WORKSPACE, }) if ( !result.success || diff --git a/apps/sim/lib/workflows/operations/export-workflow.ts b/apps/sim/lib/workflows/operations/export-workflow.ts index d6e26c6bff4..b347d9b3289 100644 --- a/apps/sim/lib/workflows/operations/export-workflow.ts +++ b/apps/sim/lib/workflows/operations/export-workflow.ts @@ -1,7 +1,9 @@ import type { Edge } from 'reactflow' -import type { V1WorkflowExportPayload } from '@/lib/api/contracts/v1/workflows' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' -import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' +import { + type ExportWorkflowState, + sanitizeForExport, +} from '@/lib/workflows/sanitization/json-sanitizer' import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' /** @@ -39,7 +41,38 @@ export interface ExportableWorkflowRecord { variables: unknown } -type ExportedEdge = V1WorkflowExportPayload['state']['edges'][number] +export interface WorkflowExportEdge { + id: string + source: string + target: string + sourceHandle: string | undefined + targetHandle: string | undefined + type?: string + animated?: boolean + style?: Record + data?: Record + label?: string + labelStyle?: Record + labelShowBg?: boolean + labelBgStyle?: Record + labelBgPadding?: [number, number] + labelBgBorderRadius?: number + markerStart?: string + markerEnd?: string +} + +export interface WorkflowExportPayload { + version: '1.0' + exportedAt: string + workflow: { + id: string + name: string + description: string | null + workspaceId: string | null + folderId: string | null + } + state: Omit & { edges: WorkflowExportEdge[] } +} /** * Projects a persisted ReactFlow edge onto the wire shape declared by the @@ -50,7 +83,7 @@ type ExportedEdge = V1WorkflowExportPayload['state']['edges'][number] * object. Non-serializable values in those slots are dropped rather than * emitted as `{}`. */ -function toExportedEdge(edge: Edge): ExportedEdge { +function toExportedEdge(edge: Edge): WorkflowExportEdge { return { id: edge.id, source: edge.source, @@ -79,7 +112,7 @@ function toExportedEdge(edge: Edge): ExportedEdge { */ export async function buildWorkflowExportPayload( workflowData: ExportableWorkflowRecord -): Promise { +): Promise { const normalizedData = await loadWorkflowFromNormalizedTables(workflowData.id) if (!normalizedData) return null diff --git a/apps/sim/lib/workflows/queries.ts b/apps/sim/lib/workflows/queries.ts index 2d543003747..e53aae0f052 100644 --- a/apps/sim/lib/workflows/queries.ts +++ b/apps/sim/lib/workflows/queries.ts @@ -8,6 +8,7 @@ import { type KeysetKey, keysetAfter, keysetColumns, + type ListSortOrder, listOrderBy, numberKey, searchFilter, @@ -20,7 +21,7 @@ import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' type WorkflowListScope = 'active' | 'archived' | 'all' export type WorkflowSortBy = 'position' | 'name' | 'createdAt' | 'updatedAt' | 'runCount' -export type WorkflowSortOrder = 'asc' | 'desc' +export type WorkflowSortOrder = ListSortOrder export interface WorkspaceWorkflowListRow { id: string diff --git a/apps/sim/lib/workspace-files/api/route-policies.ts b/apps/sim/lib/workspace-files/api/route-policies.ts index 6ec9c725b04..d8d2d4cdb91 100644 --- a/apps/sim/lib/workspace-files/api/route-policies.ts +++ b/apps/sim/lib/workspace-files/api/route-policies.ts @@ -1,10 +1,10 @@ import { createInternalSessionOrExecutorAuth, + createV2ResourceConcealmentPolicy, type V2ErrorPolicy, v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' -import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' export const internalSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({ audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, @@ -16,12 +16,7 @@ export const internalSessionOrExecutorAuth = createInternalSessionOrExecutorAuth export const v2FileErrorPolicies = { default: v2OrchestrationErrorPolicy, - concealResourceAuthorization: { - render(error) { - const response = v2CaughtOrchestrationError(error) - if (!response) return null - if (response.status === 403) return v2Error('NOT_FOUND', 'File not found') - return response - }, - } satisfies V2ErrorPolicy, + concealResourceAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'File not found', + }) satisfies V2ErrorPolicy, } as const diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index f5d7f1c0d7d..ce93312ff1a 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -172,6 +172,15 @@ export { } from './terminal-console.mock' // URL mocks export { LOCALHOST_HOSTNAMES_MOCK, resetUrlsMock, urlsMock, urlsMockFns } from './urls.mock' +export { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from './v2-route.mock' // Workflow authz package mocks (for @sim/platform-authz/workflow) export { workflowAuthzMock, workflowAuthzMockFns } from './workflow-authz.mock' // Workflows API utils mocks (for @/app/api/workflows/utils) diff --git a/packages/testing/src/mocks/v2-route.mock.ts b/packages/testing/src/mocks/v2-route.mock.ts new file mode 100644 index 00000000000..babe7d3a479 --- /dev/null +++ b/packages/testing/src/mocks/v2-route.mock.ts @@ -0,0 +1,42 @@ +import { vi } from 'vitest' + +export class MockV2ApiKeyUnauthenticatedError extends Error { + constructor(message = 'Invalid API key') { + super(message) + this.name = 'V2ApiKeyUnauthenticatedError' + } +} + +export const v2RouteMocks = { + authenticate: vi.fn(), + gate: vi.fn(), + operationRate: vi.fn(), + preauthRate: vi.fn(), +} + +export const v2ApiKeyAuthModuleMock = { + authenticateV2ApiKey: v2RouteMocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, +} + +export const v2RateLimiterModuleMock = { + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = v2RouteMocks.preauthRate + checkRateLimitDirectOrThrow = v2RouteMocks.operationRate + }, +} + +export const v2GateModuleMock = { v2ApiGateError: v2RouteMocks.gate } + +export const V2_PREAUTH_RATE_LIMIT_ALLOWED = { + allowed: true, + remaining: 599, + resetAt: new Date('2026-01-01T01:00:00.000Z'), +} as const + +export const V2_OPERATION_RATE_LIMIT_ALLOWED = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00.000Z'), +} as const From e787cbb1680bece45698a917299da0b3598193eb Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 17:40:49 -0700 Subject: [PATCH 2/2] fix(folders): reject creates at workspace cap --- apps/sim/lib/folders/orchestration.test.ts | 27 ++++++++++++++++++++++ apps/sim/lib/folders/orchestration.ts | 4 ++++ 2 files changed, 31 insertions(+) diff --git a/apps/sim/lib/folders/orchestration.test.ts b/apps/sim/lib/folders/orchestration.test.ts index 93c7b713ae7..d87b037d6e3 100644 --- a/apps/sim/lib/folders/orchestration.test.ts +++ b/apps/sim/lib/folders/orchestration.test.ts @@ -363,6 +363,33 @@ describe('path-owned folder mutations', () => { expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) + it.each(['workflow', 'table'] as const)( + 'rejects a %s folder create at the cap before inserting', + async (resourceType) => { + const existing = folderRow({ id: 'existing-1', resourceType, name: 'Existing' }) + mockLoadActiveFolderPathIndex.mockResolvedValueOnce({ + rowById: new Map([[existing.id, existing]]), + pathById: new Map([[existing.id, '/Existing']]), + idByPath: new Map([['/Existing', existing.id]]), + }) + + const result = await createFolderAtPathTransition({ + resourceType, + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + maxFolderRows: 1, + }) + + expect(result).toEqual({ + success: false, + error: 'Folder path index exceeds the 1 row limit', + errorCode: 'payload_too_large', + }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + } + ) + it('does not project legacy audit from the application transition', async () => { queueTableRows(schemaMock.folder, [{ minSortOrder: 0 }]) dbChainMockFns.returning.mockResolvedValueOnce([folderRow()]) diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index 2215267cd5b..b08a8115916 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -18,6 +18,7 @@ import { toCascadeCounts, } from '@/lib/folders/cascade' import { folderResourceConfig } from '@/lib/folders/config' +import { FolderCollectionLimitExceededError } from '@/lib/folders/errors' import { acquireFolderMutationLock, withFolderTreeLock } from '@/lib/folders/locks' import { deduplicateFolderName } from '@/lib/folders/naming' import { @@ -199,6 +200,9 @@ async function executeCreateFolderAtPath( ) { throw new Error('Folder is locked') } + if (params.maxFolderRows !== undefined && index.rowById.size >= params.maxFolderRows) { + throw new FolderCollectionLimitExceededError('path index', params.maxFolderRows) + } const sortOrder = await nextFolderSortOrder( params.resourceType,