Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions apps/sim/app/api/table/[tableId]/groups/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,15 @@ interface CapturedDefinition {
auth: unknown
operation: { id: string }
useCase: unknown
mapInput(input: {
params: { tableId: string }
body: {
workspaceId: string
group: Record<string, unknown>
outputColumns: Record<string, unknown>[]
autoRun?: boolean
}
}): Record<string, unknown>
}

const mocks = vi.hoisted(() => ({
Expand DownExpand Up@@ -70,4 +79,27 @@ describe('/api/table/[tableId]/groups', () => {
expect(route.operation.id).toBe(useCase.operation.id)
}
})

it('preserves the legacy create default while honoring an explicit opt-out', () => {
const route = definition('POST')
const input = {
params: { tableId: 'table-1' },
body: {
workspaceId: 'workspace-1',
group: { id: 'group-1' },
outputColumns: [{ name: 'Result' }],
},
}

expect(route.mapInput(input)).toEqual({
tableId: 'table-1',
...input.body,
autoRun: true,
})
expect(route.mapInput({ ...input, body: { ...input.body, autoRun: false } })).toEqual({
tableId: 'table-1',
...input.body,
autoRun: false,
})
})
})
6 changes: 5 additions & 1 deletion apps/sim/app/api/table/[tableId]/groups/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,7 +48,11 @@ export const POST = defineInternalJsonRoute({
auth: internalTableSessionOrExecutorAuth,
rateLimit,
errorPolicy,
mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }),
mapInput: ({ params, body }) => ({
tableId: params.tableId,
...body,
autoRun: body.autoRun ?? true,
}),
present: ({ table }) => presentTable(table),
})

Expand Down
147 changes: 147 additions & 0 deletions apps/sim/app/api/users/me/usage-limits/route.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
/**
* @vitest-environment node
*/
import { createMockRequest, hybridAuthMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
checkServerSideUsageLimits: vi.fn(),
getHighestPrioritySubscription: vi.fn(),
getRateLimitStatusWithSubscription: vi.fn(),
getUserStorageLimit: vi.fn(),
getUserStorageUsage: vi.fn(),
}))

vi.mock('@/lib/billing', () => ({
checkServerSideUsageLimits: mocks.checkServerSideUsageLimits,
}))

vi.mock('@/lib/billing/core/subscription', () => ({
getHighestPrioritySubscription: mocks.getHighestPrioritySubscription,
}))

vi.mock('@/lib/billing/storage', () => ({
getUserStorageLimit: mocks.getUserStorageLimit,
getUserStorageUsage: mocks.getUserStorageUsage,
}))

vi.mock('@/lib/core/rate-limiter', () => ({
RateLimiter: class {
getRateLimitStatusWithSubscription = mocks.getRateLimitStatusWithSubscription
},
}))

import { GET } from '@/app/api/users/me/usage-limits/route'

const SYNC_RESET_AT = new Date('2026-08-11T12:00:00.000Z')
const ASYNC_RESET_AT = new Date('2026-08-11T12:01:00.000Z')
const SUBSCRIPTION = { plan: 'pro' }

describe('GET /api/users/me/usage-limits', () => {
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'session',
})
mocks.getHighestPrioritySubscription.mockResolvedValue(SUBSCRIPTION)
mocks.getRateLimitStatusWithSubscription
.mockResolvedValueOnce({
requestsPerMinute: 100,
maxBurst: 200,
remaining: 99,
resetAt: SYNC_RESET_AT,
})
.mockResolvedValueOnce({
requestsPerMinute: 50,
maxBurst: 100,
remaining: 0,
resetAt: ASYNC_RESET_AT,
})
mocks.checkServerSideUsageLimits.mockResolvedValue({ currentUsage: 12.5, limit: 100 })
mocks.getUserStorageUsage.mockResolvedValue(250)
mocks.getUserStorageLimit.mockResolvedValue(1_000)
})

it('preserves the complete legacy response for session callers', async () => {
const response = await GET(createMockRequest('GET'))

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
rateLimit: {
sync: {
isLimited: false,
requestsPerMinute: 100,
maxBurst: 200,
remaining: 99,
resetAt: SYNC_RESET_AT.toISOString(),
},
async: {
isLimited: true,
requestsPerMinute: 50,
maxBurst: 100,
remaining: 0,
resetAt: ASYNC_RESET_AT.toISOString(),
},
authType: 'manual',
},
usage: {
currentPeriodCost: 12.5,
limit: 100,
plan: 'pro',
},
storage: {
usedBytes: 250,
limitBytes: 1_000,
percentUsed: 25,
},
})
expect(mocks.getRateLimitStatusWithSubscription).toHaveBeenNthCalledWith(
1,
'user-1',
SUBSCRIPTION,
'manual',
false
)
expect(mocks.getRateLimitStatusWithSubscription).toHaveBeenNthCalledWith(
2,
'user-1',
SUBSCRIPTION,
'manual',
true
)
})

it('reports API key callers as API traffic', async () => {
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'api_key',
})

const response = await GET(createMockRequest('GET'))
const body = await response.json()

expect(body.rateLimit.authType).toBe('api')
expect(mocks.getRateLimitStatusWithSubscription).toHaveBeenNthCalledWith(
1,
'user-1',
SUBSCRIPTION,
'api',
false
)
})

it('returns 401 before reading usage data when authentication fails', async () => {
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({ success: false })

const response = await GET(createMockRequest('GET'))

expect(response.status).toBe(401)
expect(mocks.getHighestPrioritySubscription).not.toHaveBeenCalled()
expect(mocks.getRateLimitStatusWithSubscription).not.toHaveBeenCalled()
expect(mocks.checkServerSideUsageLimits).not.toHaveBeenCalled()
})
})
46 changes: 39 additions & 7 deletions apps/sim/app/api/users/me/usage-limits/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,71 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { usageLimitsRequestSchema } from '@/lib/api/contracts/usage-limits'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getUsageLimitsContract, usageLimitsRequestSchema } from '@/lib/api/contracts/usage-limits'
import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getUserStorageLimit, getUserStorageUsage } from '@/lib/billing/storage'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createErrorResponse } from '@/app/api/workflows/utils'

const logger = createLogger('UsageLimitsAPI')

export const GET = withRouteHandler(async (request: NextRequest) => {
usageLimitsRequestSchema.parse({})

try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return createErrorResponse('Authentication required', 401)
}
usageLimitsRequestSchema.parse({})
const authenticatedUserId = auth.userId

const userSubscription = await getHighestPrioritySubscription(authenticatedUserId)
const rateLimiter = new RateLimiter()
const triggerType = auth.authType === AuthType.API_KEY ? 'api' : 'manual'
const [syncStatus, asyncStatus] = await Promise.all([
rateLimiter.getRateLimitStatusWithSubscription(
authenticatedUserId,
userSubscription,
triggerType,
false
),
rateLimiter.getRateLimitStatusWithSubscription(
authenticatedUserId,
userSubscription,
triggerType,
true
),
])

const [usageCheck, storageUsage, storageLimit] = await Promise.all([
checkServerSideUsageLimits(authenticatedUserId),
getUserStorageUsage(authenticatedUserId),
getUserStorageLimit(authenticatedUserId),
])

// Same computation as `limit` (one source, one tier) — the pair can never
// disagree under replication lag or mixed baseline/ledger tiers.
const currentPeriodCost = usageCheck.currentUsage

return NextResponse.json({
const response = getUsageLimitsContract.response.schema.parse({
success: true,
rateLimit: {
sync: {
isLimited: syncStatus.remaining === 0,
requestsPerMinute: syncStatus.requestsPerMinute,
maxBurst: syncStatus.maxBurst,
remaining: syncStatus.remaining,
resetAt: syncStatus.resetAt.toISOString(),
},
async: {
isLimited: asyncStatus.remaining === 0,
requestsPerMinute: asyncStatus.requestsPerMinute,
maxBurst: asyncStatus.maxBurst,
remaining: asyncStatus.remaining,
resetAt: asyncStatus.resetAt.toISOString(),
},
authType: triggerType,
},
usage: {
currentPeriodCost,
limit: usageCheck.limit,
Expand All@@ -46,6 +77,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
percentUsed: storageLimit > 0 ? (storageUsage / storageLimit) * 100 : 0,
},
})
return NextResponse.json(response)
} catch (error) {
logger.error('Error checking usage limits:', error)
return createErrorResponse(getErrorMessage(error, 'Failed to check usage limits'), 500)
Expand Down
48 changes: 48 additions & 0 deletions apps/sim/lib/api/contracts/usage-limits.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { usageLimitsResponseSchema } from '@/lib/api/contracts/usage-limits'

const VALID_RESPONSE = {
success: true,
rateLimit: {
sync: {
isLimited: false,
requestsPerMinute: 100,
maxBurst: 200,
remaining: 99,
resetAt: '2026-08-11T12:00:00.000Z',
},
async: {
isLimited: true,
requestsPerMinute: 50,
maxBurst: 100,
remaining: 0,
resetAt: '2026-08-11T12:01:00.000Z',
},
authType: 'manual',
},
usage: {
currentPeriodCost: 12.5,
limit: 100,
plan: 'pro',
},
storage: {
usedBytes: 250,
limitBytes: 1_000,
percentUsed: 25,
},
} as const

describe('usageLimitsResponseSchema', () => {
it('accepts the complete legacy response', () => {
expect(usageLimitsResponseSchema.parse(VALID_RESPONSE)).toEqual(VALID_RESPONSE)
})

it('rejects a response that drops legacy rate-limit data', () => {
const { rateLimit: _, ...responseWithoutRateLimit } = VALID_RESPONSE

expect(() => usageLimitsResponseSchema.parse(responseWithoutRateLimit)).toThrow()
})
})
Loading
Loading