fix(api): restore legacy endpoint compatibility (#6552)

This commit is contained in:
Theodore Li
2026-08-11 15:12:38 -04:00
committed by GitHub
parent 440a68bff9
commit 061ecd34e3
8 changed files with 319 additions and 30 deletions
@@ -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(() => ({
@@ -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,
})
})
})
@@ -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),
})
@@ -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()
})
})
@@ -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)
@@ -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()
})
})
+41 -17
View File
@@ -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<typeof usageLimitsResponseSchema>
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,
},
})
+3 -2
View File
@@ -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' })
})
})
+4 -3
View File
@@ -27,17 +27,18 @@ export function decodePublicLogCursor(
): PublicLogCursor | null {
try {
const parsed = JSON.parse(Buffer.from(cursor, 'base64').toString()) as Record<string, unknown>
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
}