mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(api): restore legacy endpoint compatibility (#6552)
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user