Skip to content

Commit ca50798

Browse files
committed
fix(zoho-desk): make the attachment cap reachable, unbreak selector paging
Final audit round. The 50 MB attachment ceiling could never be hit. This route returns the file as base64 inside its JSON body, and the executor reads internal tool responses through readToolResponseBody, capped at 10 MB. Base64 inflates 4/3, so ~7.5 MB of raw bytes is the real ceiling - and the old limit meant a larger attachment was downloaded, encoded and serialized in full (peaking near 250 MB of live allocation, with nothing bounding concurrent downloads) purely to be rejected afterwards. The cap is now the reachable size, so the limit enforces itself while the bytes are still streaming, and an overflow returns 413 with the actual ceiling instead of a generic 500. Raising it properly means uploading in the route and returning a file reference, as the WhatsApp media route does - not a bigger constant. Selector paging assumed a 0-based `from`. Zoho's docs contradict themselves: the pagination section says "range 0-4999, default 0" while the listing examples read as 1-based ("from=5 and limit=50 retrieves records 5 to 54"). Under the 1-based reading, stepping by exactly the page size re-fetches the boundary record and the dropdown shows a duplicate per page. Rather than pick a base that cannot be confirmed without a live tenant, the department and agent drains dedupe by id, which is correct under either reading. The organization list was unpaginated, and Zoho's listing APIs default to ten per page. An account with more accessible portals silently got a truncated dropdown, and since every other selector and every tool call is gated on orgId, a missing portal was unreachable except through the advanced manual field. Both the selector route and list_organizations now request the documented maximum. Docs: regenerated so the trigger table includes manualOrgId, and two service-account claims are hedged to match what the code already says it cannot verify - that zsoid equals the Desk orgId header value, and that every tool works under the requested scopes (Zoho publishes no scope for the attachment content sub-path). Also: status and priority move out of advanced mode - they are the fields most often changed on a ticket update; the custom-fields wand prompt now ends with the required "Return ONLY" clause; and the shared-orgId rationale comment cites the mechanism that actually applies (buildCanonicalIndex dedupe plus the first-non- empty rule in getCanonicalValues) rather than a blocks.test.ts branch that never evaluates this pair.
1 parent b362b58 commit ca50798

9 files changed

Lines changed: 96 additions & 19 deletions

File tree

apps/docs/content/docs/en/integrations/zoho-desk-service-account.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,9 @@ Add a Zoho Desk block to your workflow. In the credential dropdown, your Self Cl
133133

134134
{/* TODO(screenshot): Zoho Desk block in a workflow with the Self Client selected as the credential */}
135135

136-
The block calls the Zoho Desk REST API with a freshly minted access token — the same requests as the OAuth flow, so every Zoho Desk tool works, subject to the scopes above.
136+
The block calls the Zoho Desk REST API with a freshly minted access token — the same requests as the OAuth flow, so the Zoho Desk tools work the same way, subject to the scopes above.
137+
138+
One exception is worth knowing: Zoho publishes no OAuth scope for the attachment download sub-path, so **Get Attachment** is the one operation whose scope requirement we could not confirm from Zoho's documentation. If it returns a scope error, the requested scope list needs widening.
137139

138140
### Triggers still need OAuth
139141

@@ -150,7 +152,7 @@ Access tokens minted from a Self Client live for one hour and there is **no refr
150152

151153
<FAQ items={[
152154
{ question: "Why a Self Client instead of OAuth?", answer: "A Self Client authenticates as your Zoho organization, not as a person — nothing expires when someone leaves or their login lapses. Sim mints short-lived tokens from the stored client ID and secret whenever a workflow runs." },
153-
{ question: "Where do I find the Organization ID?", answer: "In Zoho Desk, go to Setup (gear icon) → Developer Space → API. The numeric Organization ID shown there is the value to paste. It is the same ID that Zoho Desk API calls send in the orgId header." },
155+
{ question: "Where do I find the Organization ID?", answer: "In Zoho Desk, go to Setup (gear icon) → Developer Space → API. The numeric Organization ID shown there is the value to paste. This is expected to be the same ID that Zoho Desk API calls send in the orgId header; if Zoho rejects it with missing_org_info, paste the full ZohoDesk.<your-org-id> value instead." },
154156
{ question: "Zoho rejects my credentials with invalid_client — why?", answer: "Either the client ID or secret was mistyped, or the client you created is not a Self Client. Only Self Clients support the client-credentials grant — in the Zoho API Console, Add Client → Self Client. Copy both values from the client's Client Secret tab." },
155157
{ question: "Zoho returns missing_org_info or rejects the organization — why?", answer: "Zoho could not resolve a Desk organization from the ID you pasted. Re-copy the numeric Organization ID from Setup → Developer Space → API in the Desk portal you want to use. If your Zoho account has multiple Desk portals, make sure it is the ID of the right one." },
156158
{ question: "Can I use a Self Client with a non-US Zoho account?", answer: "Yes, for the US, EU, IN, and AU data centers. Set the Data center field to us, eu, in, or au when you add the credential, and Sim mints tokens against that region's accounts server and calls the Desk host in the same region. Leaving it blank means US. The JP, CA, SA, CN, and UK data centers are not supported yet, and the interactive OAuth connection remains US-only." },

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,7 @@ Trigger a workflow when a Zoho Desk event occurs (ticket, comment, thread, conta
491491
| --------- | ---- | -------- | ----------- |
492492
| `triggerCredentials` | string | Yes | This trigger creates and manages a webhook subscription in your Zoho Desk account. |
493493
| `orgId` | project-selector | Yes | The Zoho Desk organization \(portal\) to subscribe in. |
494+
| `manualOrgId` | string | Yes | Type an organization ID instead of picking one from the list. |
494495
| `eventType` | string | Yes | Event |
495496
| `triggerDepartmentIds` | string | No | Restrict events to these departments. Leave empty for all departments. |
496497
| `fields` | string | No | For Ticket Updated: only fire when one of these fields changes \(max 5\). Previous values are included in the payload. |

apps/sim/app/api/tools/zoho_desk/agents/route.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const logger = createLogger('ZohoDeskAgentsAPI')
1616

1717
/**
1818
* `GET /api/v1/agents` is index-paginated exactly like `/departments`: `from` is
19-
* a 0-based offset and `limit` caps at 200 (default 10). A short page means the
19+
* an index offset and `limit` caps at 200 (default 10). A short page means the
2020
* list is exhausted. The page cap bounds the drain so a provider that keeps
2121
* returning full pages cannot loop forever.
2222
*/
@@ -70,6 +70,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
7070

7171
const headers = buildZohoDeskHeaders({ accessToken, orgId })
7272
const agents: Array<{ id: string; name: string }> = []
73+
// Zoho's docs disagree on whether `from` is 0- or 1-based (pagination section
74+
// says "range 0-4999, default 0"; listing examples read as 1-based). Deduping
75+
// by id is correct under both, so the drain never yields a repeated agent.
76+
const seenIds = new Set<string>()
7377

7478
try {
7579
for (let page = 0; page < MAX_AGENT_PAGES; page++) {
@@ -120,7 +124,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
120124
const pageItems = Array.isArray(body.data) ? (body.data as ZohoAgent[]) : []
121125
for (const agent of pageItems) {
122126
if (agent.id === undefined || agent.id === null) continue
123-
agents.push({ id: String(agent.id), name: getAgentLabel(agent) })
127+
const id = String(agent.id)
128+
if (seenIds.has(id)) continue
129+
seenIds.add(id)
130+
agents.push({ id, name: getAgentLabel(agent) })
124131
}
125132

126133
if (pageItems.length < AGENT_PAGE_SIZE) break

apps/sim/app/api/tools/zoho_desk/attachment/route.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { zohoDeskGetAttachmentContract } from '@/lib/api/contracts/tools/zoho-de
55
import { parseRequest } from '@/lib/api/server'
66
import { checkInternalAuth } from '@/lib/auth/hybrid'
77
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
8+
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
89
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
910
import { isZohoHost } from '@/tools/zoho_desk/host-allowlist'
1011
import {
@@ -18,7 +19,25 @@ export const dynamic = 'force-dynamic'
1819

1920
const logger = createLogger('ZohoDeskAttachmentAPI')
2021

21-
const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024
22+
/**
23+
* Ceiling on a downloaded attachment.
24+
*
25+
* This route returns the file base64-encoded inside its JSON body, and the
26+
* executor reads an internal tool response through `readToolResponseBody`, which
27+
* caps at `MAX_TOOL_RESPONSE_BODY_BYTES` (10 MB). Base64 inflates by 4/3, so the
28+
* largest attachment that can actually survive the round trip is ~7.5 MB of raw
29+
* bytes. A larger ceiling here is not merely useless - it is actively harmful:
30+
* the route would download, encode, and serialize the whole file (peaking around
31+
* 250 MB of live allocation for a 50 MB attachment, with nothing limiting
32+
* concurrent downloads) only for the executor to reject the oversized body
33+
* afterwards. Capping at the reachable size makes the transport limit enforce
34+
* itself early, while the bytes are still being streamed.
35+
*
36+
* Raising this requires uploading in the route and returning a file reference
37+
* instead of inline base64, the way the WhatsApp media and Typeform file routes
38+
* do - not a bigger number here.
39+
*/
40+
const MAX_ATTACHMENT_BYTES = 7 * 1024 * 1024
2241

2342
export const POST = withRouteHandler(async (request: NextRequest) => {
2443
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
@@ -91,6 +110,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
91110
},
92111
})
93112
} catch (error) {
113+
// An oversized attachment is a client-visible limit, not a server fault -
114+
// surface it as 413 with the actual ceiling, mirroring the WhatsApp media
115+
// route, instead of collapsing it into a generic 500.
116+
if (isPayloadSizeLimitError(error)) {
117+
logger.warn('Zoho Desk attachment exceeds the download limit', {
118+
maxBytes: MAX_ATTACHMENT_BYTES,
119+
})
120+
return NextResponse.json(
121+
{
122+
success: false,
123+
error: `Attachment exceeds the ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))} MB download limit`,
124+
},
125+
{ status: 413 }
126+
)
127+
}
94128
logger.error('Error downloading Zoho Desk attachment', { error: getErrorMessage(error) })
95129
return NextResponse.json(
96130
{ success: false, error: getErrorMessage(error, 'Failed to download attachment') },

apps/sim/app/api/tools/zoho_desk/departments/route.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,21 @@ export const dynamic = 'force-dynamic'
1515
const logger = createLogger('ZohoDeskDepartmentsAPI')
1616

1717
/**
18-
* `GET /api/v1/departments` is index-paginated: `from` is a 0-based offset and
19-
* `limit` caps at 200 (default 10). A short page means the list is exhausted.
18+
* `GET /api/v1/departments` is index-paginated with `limit` capped at 200
19+
* (default 10). A short page means the list is exhausted.
20+
*
21+
* Zoho's docs contradict themselves on whether `from` is 0- or 1-based: the
22+
* pagination section documents "range 0-4999, default 0", while the listing
23+
* examples read "from=5 and limit=50 retrieves records 5 to 54" (1-based). Under
24+
* the 1-based reading, stepping by exactly PAGE_SIZE re-fetches the boundary
25+
* record. Rather than guess a base we cannot confirm without a live tenant, the
26+
* accumulator dedupes by id, which is correct under BOTH readings — the worst
27+
* case is one redundant record per page boundary, never a duplicate entry or a
28+
* skipped one.
29+
*
2030
* The page cap bounds the drain so a provider that keeps returning full pages
21-
* cannot loop forever — 20 x 200 covers any realistic Desk portal.
31+
* cannot loop forever — 20 x 200 covers any realistic Desk portal and keeps the
32+
* maximum `from` inside Zoho's documented 4999 ceiling.
2233
*/
2334
const DEPARTMENT_PAGE_SIZE = 200
2435
const MAX_DEPARTMENT_PAGES = 20
@@ -47,6 +58,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4758

4859
const headers = buildZohoDeskHeaders({ accessToken, orgId })
4960
const departments: Array<{ id: string; name: string }> = []
61+
const seenIds = new Set<string>()
5062

5163
try {
5264
for (let page = 0; page < MAX_DEPARTMENT_PAGES; page++) {
@@ -96,8 +108,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
96108
const pageItems = Array.isArray(body.data) ? (body.data as ZohoDepartment[]) : []
97109
for (const department of pageItems) {
98110
if (department.id === undefined || department.id === null) continue
111+
const id = String(department.id)
112+
// Dedupe: see the pagination note above — a 1-based `from` would repeat
113+
// the boundary record on every page after the first.
114+
if (seenIds.has(id)) continue
115+
seenIds.add(id)
99116
departments.push({
100-
id: String(department.id),
117+
id,
101118
name: department.name || department.nameInCustomerPortal || String(department.id),
102119
})
103120
}

apps/sim/app/api/tools/zoho_desk/organizations/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4141
let organizationsUrl: URL
4242
try {
4343
organizationsUrl = assertZohoUrl(`${apiBase}/organizations`)
44+
// Zoho's listing APIs default to a page size of 10. Without an explicit
45+
// limit, an account with more than ten accessible portals would silently get
46+
// a truncated dropdown - and because every other selector and every tool
47+
// call is gated on orgId, a missing portal is unreachable except through the
48+
// advanced manual field. 200 is the documented per-page ceiling on the
49+
// sibling listing endpoints and is far above any real portal count, so this
50+
// needs no drain loop.
51+
organizationsUrl.searchParams.set('limit', '200')
4452
} catch {
4553
return NextResponse.json({ error: 'Credential resolved to a non-Zoho host' }, { status: 400 })
4654
}

apps/sim/blocks/blocks/zoho-desk.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,15 +180,13 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
180180
type: 'short-input',
181181
placeholder: 'e.g. Open, Closed',
182182
condition: { field: 'operation', value: ['update_ticket', 'list_tickets'] },
183-
mode: 'advanced',
184183
},
185184
{
186185
id: 'priority',
187186
title: 'Priority',
188187
type: 'short-input',
189188
placeholder: 'e.g. High',
190189
condition: { field: 'operation', value: ['update_ticket', 'list_tickets'] },
191-
mode: 'advanced',
192190
},
193191
{
194192
id: 'assigneeId',
@@ -297,7 +295,8 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
297295
mode: 'advanced',
298296
wandConfig: {
299297
enabled: true,
300-
prompt: 'Generate a JSON object of Zoho Desk custom field API names to values.',
298+
prompt:
299+
'Generate a JSON object mapping Zoho Desk custom field API names (they start with cf_) to values. Return ONLY the JSON object - no explanations, no extra text.',
301300
generationType: 'json-object',
302301
},
303302
},

apps/sim/tools/zoho_desk/list_organizations.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ export const zohoDeskListOrganizationsTool: ToolConfig<
3131
request: {
3232
// The organizations endpoint is the only Desk call that does not require the
3333
// orgId header, so it can be listed before an organization is selected.
34-
url: (params) => `${getZohoDeskApiBase(params)}/organizations`,
34+
// limit=200: Zoho's listing APIs default to 10 per page, which would
35+
// silently truncate an account with more accessible portals than that.
36+
url: (params) => `${getZohoDeskApiBase(params)}/organizations?limit=200`,
3537
method: 'GET',
3638
headers: (params) => {
3739
if (!params.accessToken) throw new Error('Zoho Desk access token is required')

apps/sim/triggers/zoho_desk/webhook.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,18 @@ export const zohoDeskWebhookTrigger: TriggerConfig = {
6767
// `triggerDepartmentIds` below). An organization is the portal the whole
6868
// block talks to and means exactly the same thing in either mode, so one
6969
// shared value is the correct behavior — switching the block between tool
70-
// and trigger mode keeps the portal the user already picked. The platform
71-
// supports this explicitly: `buildCanonicalIndex` refuses to let a
72-
// `trigger`-mode subblock overwrite a basicId claimed by a non-trigger
73-
// one, and `blocks.test.ts` blesses basic/trigger id sharing as "valid
74-
// pattern 2".
70+
// and trigger mode keeps the portal the user already picked.
71+
//
72+
// The platform supports it: `buildCanonicalIndex` dedupes the repeated id
73+
// and refuses to let a `trigger`-mode subblock overwrite a basicId claimed
74+
// by a non-trigger one, and `isSubBlockVisibleForTriggerMode` renders only
75+
// the active mode's twin, so the field never appears twice.
76+
//
77+
// A distinct id here would be actively worse, not merely inconsistent: a
78+
// separate `triggerManualOrgId` would put two entries in this group's
79+
// `advancedIds`, and `getCanonicalValues` returns the first non-empty one
80+
// in array order — so a stale tool-mode `manualOrgId` could silently
81+
// supply the trigger's organization.
7582
id: 'orgId',
7683
title: 'Organization',
7784
type: 'project-selector',
@@ -112,7 +119,7 @@ export const zohoDeskWebhookTrigger: TriggerConfig = {
112119
mode: 'trigger',
113120
},
114121
{
115-
// Deliberately distinct from the block's tool-mode `departmentIds` filter.
122+
// NOTE: distinct from the block's tool-mode `departmentIds` filter.
116123
// `block.subBlocks` is keyed by id, so a shared id would let a value typed
117124
// as a list_tickets filter silently become the webhook's department filter
118125
// (and vice versa) when the block is switched between tool and trigger mode.

0 commit comments

Comments
 (0)