diff --git a/apps/sim/app/api/table/[tableId]/groups/route.test.ts b/apps/sim/app/api/table/[tableId]/groups/route.test.ts index 24b6c533a57..5f717198604 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.test.ts @@ -8,6 +8,15 @@ interface CapturedDefinition { auth: unknown operation: { id: string } useCase: unknown + mapInput(input: { + params: { tableId: string } + body: { + workspaceId: string + group: Record + outputColumns: Record[] + autoRun?: boolean + } + }): Record } const mocks = vi.hoisted(() => ({ @@ -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, + }) + }) }) diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts index 70ab6758ade..4e56d8b509c 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -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), }) diff --git a/apps/sim/app/api/users/me/usage-limits/route.test.ts b/apps/sim/app/api/users/me/usage-limits/route.test.ts new file mode 100644 index 00000000000..53e655043ae --- /dev/null +++ b/apps/sim/app/api/users/me/usage-limits/route.test.ts @@ -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() + }) +}) diff --git a/apps/sim/app/api/users/me/usage-limits/route.ts b/apps/sim/app/api/users/me/usage-limits/route.ts index b5ef4610fb8..b5dab923a9a 100644 --- a/apps/sim/app/api/users/me/usage-limits/route.ts +++ b/apps/sim/app/api/users/me/usage-limits/route.ts @@ -1,27 +1,43 @@ 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), @@ -29,12 +45,27 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 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, @@ -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) diff --git a/apps/sim/lib/api/contracts/usage-limits.test.ts b/apps/sim/lib/api/contracts/usage-limits.test.ts new file mode 100644 index 00000000000..5a6dc9885fa --- /dev/null +++ b/apps/sim/lib/api/contracts/usage-limits.test.ts @@ -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() + }) +}) diff --git a/apps/sim/lib/api/contracts/usage-limits.ts b/apps/sim/lib/api/contracts/usage-limits.ts index 6d0fc852e07..44467151c60 100644 --- a/apps/sim/lib/api/contracts/usage-limits.ts +++ b/apps/sim/lib/api/contracts/usage-limits.ts @@ -1,30 +1,54 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' -export const storageUsageSchema = z.object({ - usedBytes: z.number(), - limitBytes: z.number(), - percentUsed: z.number(), -}) +export const storageUsageSchema = z + .object({ + usedBytes: z.number(), + limitBytes: z.number(), + percentUsed: z.number(), + }) + .strict() + +export const usageLimitRateStatusSchema = z + .object({ + isLimited: z.boolean(), + requestsPerMinute: z.number(), + maxBurst: z.number(), + remaining: z.number(), + resetAt: z.string().datetime(), + }) + .strict() + +export const usageLimitsResponseSchema = z + .object({ + success: z.literal(true), + rateLimit: z + .object({ + sync: usageLimitRateStatusSchema, + async: usageLimitRateStatusSchema, + authType: z.enum(['api', 'manual']), + }) + .strict(), + usage: z + .object({ + currentPeriodCost: z.number(), + limit: z.number(), + plan: z.string(), + }) + .strict(), + storage: storageUsageSchema, + }) + .strict() export const usageLimitsRequestSchema = z.object({}).strict() +export type UsageLimitsResponse = z.output + export const getUsageLimitsContract = defineRouteContract({ method: 'GET', path: '/api/users/me/usage-limits', response: { mode: 'json', - schema: z - .object({ - success: z.boolean(), - usage: z - .object({ - plan: z.string().optional(), - }) - .passthrough() - .optional(), - storage: storageUsageSchema, - }) - .passthrough(), + schema: usageLimitsResponseSchema, }, }) diff --git a/apps/sim/lib/logs/public-queries.test.ts b/apps/sim/lib/logs/public-queries.test.ts index 8e3e4092a65..499bac77ac0 100644 --- a/apps/sim/lib/logs/public-queries.test.ts +++ b/apps/sim/lib/logs/public-queries.test.ts @@ -19,11 +19,12 @@ describe('public log cursor', () => { expect(decodePublicLogCursor(encodePublicLogCursor(cursor), 'asc')).toBeNull() }) - it('rejects legacy cursors without an order binding', () => { + it('accepts legacy cursors under the order requested by the caller', () => { const legacyCursor = Buffer.from( JSON.stringify({ startedAt: cursor.startedAt, id: cursor.id }) ).toString('base64') - expect(decodePublicLogCursor(legacyCursor, 'desc')).toBeNull() + expect(decodePublicLogCursor(legacyCursor, 'desc')).toEqual(cursor) + expect(decodePublicLogCursor(legacyCursor, 'asc')).toEqual({ ...cursor, order: 'asc' }) }) }) diff --git a/apps/sim/lib/logs/public-queries.ts b/apps/sim/lib/logs/public-queries.ts index e627f76ecd7..25bc964f92d 100644 --- a/apps/sim/lib/logs/public-queries.ts +++ b/apps/sim/lib/logs/public-queries.ts @@ -27,17 +27,18 @@ export function decodePublicLogCursor( ): PublicLogCursor | null { try { const parsed = JSON.parse(Buffer.from(cursor, 'base64').toString()) as Record + const order = parsed.order === undefined ? expectedOrder : parsed.order if ( typeof parsed.startedAt !== 'string' || typeof parsed.id !== 'string' || - (parsed.order !== 'asc' && parsed.order !== 'desc') || - parsed.order !== expectedOrder + (order !== 'asc' && order !== 'desc') || + order !== expectedOrder ) { return null } const startedAt = new Date(parsed.startedAt) if (Number.isNaN(startedAt.getTime())) return null - return { startedAt: parsed.startedAt, id: parsed.id, order: parsed.order } + return { startedAt: parsed.startedAt, id: parsed.id, order } } catch { return null }