Skip to content

Commit 46ecbf4

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
Merge remote-tracking branch 'origin/staging' into feat/markdown-pdf-download
2 parents 2779c64 + 061ecd3 commit 46ecbf4

89 files changed

Lines changed: 4690 additions & 385 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/content/docs/en/integrations/calendly.mdx

Lines changed: 397 additions & 0 deletions
Large diffs are not rendered by default.

apps/docs/openapi-v2-files-audit.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,7 @@
566566
"get": {
567567
"operationId": "downloadFile",
568568
"summary": "Download File",
569-
"description": "Download the current file bytes from a workspace.",
569+
"description": "Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it returns `409` while that artifact is still compiling and `413` if it renders past the size ceiling.",
570570
"tags": ["Files"],
571571
"parameters": [
572572
{
@@ -638,6 +638,12 @@
638638
"404": {
639639
"$ref": "#/components/responses/NotFound"
640640
},
641+
"409": {
642+
"$ref": "#/components/responses/Conflict"
643+
},
644+
"413": {
645+
"$ref": "#/components/responses/PayloadTooLarge"
646+
},
641647
"429": {
642648
"$ref": "#/components/responses/RateLimited"
643649
},

apps/docs/openapi-v2-knowledge.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@
273273
}
274274
}
275275
},
276-
"put": {
276+
"patch": {
277277
"operationId": "updateKnowledgeBase",
278278
"summary": "Update Knowledge Base",
279279
"description": "Update a knowledge base name, description, chunking configuration, or folder placement.",

apps/docs/openapi-v2-tables.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -864,7 +864,7 @@
864864
}
865865
}
866866
},
867-
"put": {
867+
"patch": {
868868
"operationId": "updateTableRows",
869869
"summary": "Update Rows by Filter",
870870
"description": "Apply the same partial data patch to every row matching a non-empty predicate.",

apps/sim/app/api/files/serve/[...path]/route.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,8 @@ import type { NextRequest } from 'next/server'
44
import { NextResponse } from 'next/server'
55
import { fileServeParamsSchema, fileServeQuerySchema } from '@/lib/api/contracts/storage-transfer'
66
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
7-
import {
8-
DocCompileUserError,
9-
resolveServableDocBytes,
10-
} from '@/lib/copilot/tools/server/files/doc-compile'
7+
import { resolveServableDocBytes } from '@/lib/copilot/tools/server/files/doc-compile'
8+
import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error'
119
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1210
import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads'
1311
import type { StorageContext } from '@/lib/uploads/config'

apps/sim/app/api/table/[tableId]/groups/route.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ interface CapturedDefinition {
88
auth: unknown
99
operation: { id: string }
1010
useCase: unknown
11+
mapInput(input: {
12+
params: { tableId: string }
13+
body: {
14+
workspaceId: string
15+
group: Record<string, unknown>
16+
outputColumns: Record<string, unknown>[]
17+
autoRun?: boolean
18+
}
19+
}): Record<string, unknown>
1120
}
1221

1322
const mocks = vi.hoisted(() => ({
@@ -70,4 +79,27 @@ describe('/api/table/[tableId]/groups', () => {
7079
expect(route.operation.id).toBe(useCase.operation.id)
7180
}
7281
})
82+
83+
it('preserves the legacy create default while honoring an explicit opt-out', () => {
84+
const route = definition('POST')
85+
const input = {
86+
params: { tableId: 'table-1' },
87+
body: {
88+
workspaceId: 'workspace-1',
89+
group: { id: 'group-1' },
90+
outputColumns: [{ name: 'Result' }],
91+
},
92+
}
93+
94+
expect(route.mapInput(input)).toEqual({
95+
tableId: 'table-1',
96+
...input.body,
97+
autoRun: true,
98+
})
99+
expect(route.mapInput({ ...input, body: { ...input.body, autoRun: false } })).toEqual({
100+
tableId: 'table-1',
101+
...input.body,
102+
autoRun: false,
103+
})
104+
})
73105
})

apps/sim/app/api/table/[tableId]/groups/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@ export const POST = defineInternalJsonRoute({
4848
auth: internalTableSessionOrExecutorAuth,
4949
rateLimit,
5050
errorPolicy,
51-
mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }),
51+
mapInput: ({ params, body }) => ({
52+
tableId: params.tableId,
53+
...body,
54+
autoRun: body.autoRun ?? true,
55+
}),
5256
present: ({ table }) => presentTable(table),
5357
})
5458

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest, hybridAuthMockFns } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({
8+
checkServerSideUsageLimits: vi.fn(),
9+
getHighestPrioritySubscription: vi.fn(),
10+
getRateLimitStatusWithSubscription: vi.fn(),
11+
getUserStorageLimit: vi.fn(),
12+
getUserStorageUsage: vi.fn(),
13+
}))
14+
15+
vi.mock('@/lib/billing', () => ({
16+
checkServerSideUsageLimits: mocks.checkServerSideUsageLimits,
17+
}))
18+
19+
vi.mock('@/lib/billing/core/subscription', () => ({
20+
getHighestPrioritySubscription: mocks.getHighestPrioritySubscription,
21+
}))
22+
23+
vi.mock('@/lib/billing/storage', () => ({
24+
getUserStorageLimit: mocks.getUserStorageLimit,
25+
getUserStorageUsage: mocks.getUserStorageUsage,
26+
}))
27+
28+
vi.mock('@/lib/core/rate-limiter', () => ({
29+
RateLimiter: class {
30+
getRateLimitStatusWithSubscription = mocks.getRateLimitStatusWithSubscription
31+
},
32+
}))
33+
34+
import { GET } from '@/app/api/users/me/usage-limits/route'
35+
36+
const SYNC_RESET_AT = new Date('2026-08-11T12:00:00.000Z')
37+
const ASYNC_RESET_AT = new Date('2026-08-11T12:01:00.000Z')
38+
const SUBSCRIPTION = { plan: 'pro' }
39+
40+
describe('GET /api/users/me/usage-limits', () => {
41+
beforeEach(() => {
42+
vi.clearAllMocks()
43+
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({
44+
success: true,
45+
userId: 'user-1',
46+
authType: 'session',
47+
})
48+
mocks.getHighestPrioritySubscription.mockResolvedValue(SUBSCRIPTION)
49+
mocks.getRateLimitStatusWithSubscription
50+
.mockResolvedValueOnce({
51+
requestsPerMinute: 100,
52+
maxBurst: 200,
53+
remaining: 99,
54+
resetAt: SYNC_RESET_AT,
55+
})
56+
.mockResolvedValueOnce({
57+
requestsPerMinute: 50,
58+
maxBurst: 100,
59+
remaining: 0,
60+
resetAt: ASYNC_RESET_AT,
61+
})
62+
mocks.checkServerSideUsageLimits.mockResolvedValue({ currentUsage: 12.5, limit: 100 })
63+
mocks.getUserStorageUsage.mockResolvedValue(250)
64+
mocks.getUserStorageLimit.mockResolvedValue(1_000)
65+
})
66+
67+
it('preserves the complete legacy response for session callers', async () => {
68+
const response = await GET(createMockRequest('GET'))
69+
70+
expect(response.status).toBe(200)
71+
await expect(response.json()).resolves.toEqual({
72+
success: true,
73+
rateLimit: {
74+
sync: {
75+
isLimited: false,
76+
requestsPerMinute: 100,
77+
maxBurst: 200,
78+
remaining: 99,
79+
resetAt: SYNC_RESET_AT.toISOString(),
80+
},
81+
async: {
82+
isLimited: true,
83+
requestsPerMinute: 50,
84+
maxBurst: 100,
85+
remaining: 0,
86+
resetAt: ASYNC_RESET_AT.toISOString(),
87+
},
88+
authType: 'manual',
89+
},
90+
usage: {
91+
currentPeriodCost: 12.5,
92+
limit: 100,
93+
plan: 'pro',
94+
},
95+
storage: {
96+
usedBytes: 250,
97+
limitBytes: 1_000,
98+
percentUsed: 25,
99+
},
100+
})
101+
expect(mocks.getRateLimitStatusWithSubscription).toHaveBeenNthCalledWith(
102+
1,
103+
'user-1',
104+
SUBSCRIPTION,
105+
'manual',
106+
false
107+
)
108+
expect(mocks.getRateLimitStatusWithSubscription).toHaveBeenNthCalledWith(
109+
2,
110+
'user-1',
111+
SUBSCRIPTION,
112+
'manual',
113+
true
114+
)
115+
})
116+
117+
it('reports API key callers as API traffic', async () => {
118+
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({
119+
success: true,
120+
userId: 'user-1',
121+
authType: 'api_key',
122+
})
123+
124+
const response = await GET(createMockRequest('GET'))
125+
const body = await response.json()
126+
127+
expect(body.rateLimit.authType).toBe('api')
128+
expect(mocks.getRateLimitStatusWithSubscription).toHaveBeenNthCalledWith(
129+
1,
130+
'user-1',
131+
SUBSCRIPTION,
132+
'api',
133+
false
134+
)
135+
})
136+
137+
it('returns 401 before reading usage data when authentication fails', async () => {
138+
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({ success: false })
139+
140+
const response = await GET(createMockRequest('GET'))
141+
142+
expect(response.status).toBe(401)
143+
expect(mocks.getHighestPrioritySubscription).not.toHaveBeenCalled()
144+
expect(mocks.getRateLimitStatusWithSubscription).not.toHaveBeenCalled()
145+
expect(mocks.checkServerSideUsageLimits).not.toHaveBeenCalled()
146+
})
147+
})

apps/sim/app/api/users/me/usage-limits/route.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,71 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import { type NextRequest, NextResponse } from 'next/server'
4-
import { usageLimitsRequestSchema } from '@/lib/api/contracts/usage-limits'
5-
import { checkHybridAuth } from '@/lib/auth/hybrid'
4+
import { getUsageLimitsContract, usageLimitsRequestSchema } from '@/lib/api/contracts/usage-limits'
5+
import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
66
import { checkServerSideUsageLimits } from '@/lib/billing'
77
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
88
import { getUserStorageLimit, getUserStorageUsage } from '@/lib/billing/storage'
9+
import { RateLimiter } from '@/lib/core/rate-limiter'
910
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1011
import { createErrorResponse } from '@/app/api/workflows/utils'
1112

1213
const logger = createLogger('UsageLimitsAPI')
1314

1415
export const GET = withRouteHandler(async (request: NextRequest) => {
15-
usageLimitsRequestSchema.parse({})
16-
1716
try {
1817
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
1918
if (!auth.success || !auth.userId) {
2019
return createErrorResponse('Authentication required', 401)
2120
}
21+
usageLimitsRequestSchema.parse({})
2222
const authenticatedUserId = auth.userId
2323

2424
const userSubscription = await getHighestPrioritySubscription(authenticatedUserId)
25+
const rateLimiter = new RateLimiter()
26+
const triggerType = auth.authType === AuthType.API_KEY ? 'api' : 'manual'
27+
const [syncStatus, asyncStatus] = await Promise.all([
28+
rateLimiter.getRateLimitStatusWithSubscription(
29+
authenticatedUserId,
30+
userSubscription,
31+
triggerType,
32+
false
33+
),
34+
rateLimiter.getRateLimitStatusWithSubscription(
35+
authenticatedUserId,
36+
userSubscription,
37+
triggerType,
38+
true
39+
),
40+
])
2541

2642
const [usageCheck, storageUsage, storageLimit] = await Promise.all([
2743
checkServerSideUsageLimits(authenticatedUserId),
2844
getUserStorageUsage(authenticatedUserId),
2945
getUserStorageLimit(authenticatedUserId),
3046
])
3147

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

36-
return NextResponse.json({
50+
const response = getUsageLimitsContract.response.schema.parse({
3751
success: true,
52+
rateLimit: {
53+
sync: {
54+
isLimited: syncStatus.remaining === 0,
55+
requestsPerMinute: syncStatus.requestsPerMinute,
56+
maxBurst: syncStatus.maxBurst,
57+
remaining: syncStatus.remaining,
58+
resetAt: syncStatus.resetAt.toISOString(),
59+
},
60+
async: {
61+
isLimited: asyncStatus.remaining === 0,
62+
requestsPerMinute: asyncStatus.requestsPerMinute,
63+
maxBurst: asyncStatus.maxBurst,
64+
remaining: asyncStatus.remaining,
65+
resetAt: asyncStatus.resetAt.toISOString(),
66+
},
67+
authType: triggerType,
68+
},
3869
usage: {
3970
currentPeriodCost,
4071
limit: usageCheck.limit,
@@ -46,6 +77,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
4677
percentUsed: storageLimit > 0 ? (storageUsage / storageLimit) * 100 : 0,
4778
},
4879
})
80+
return NextResponse.json(response)
4981
} catch (error) {
5082
logger.error('Error checking usage limits:', error)
5183
return createErrorResponse(getErrorMessage(error, 'Failed to check usage limits'), 500)

apps/sim/app/api/v1/files/[fileId]/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ vi.mock('@sim/audit', () => ({
3737
}))
3838
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() }))
3939

40-
import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile'
40+
import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error'
4141
import { GET } from '@/app/api/v1/files/[fileId]/route'
4242

4343
const WORKSPACE_ID = 'ws-1'

0 commit comments

Comments
 (0)