Skip to content

Commit 8a431e6

Browse files
committed
feat(zoho-desk): canonical selectors and BlockMeta skills
The block picked its organization with an ad-hoc `combobox` + `fetchOptions`. Only five blocks in the repo did that, and the other four are core blocks (agent/credential/function/logs) - no other OAuth integration used it. Every other resource a user has to identify was a bare short-input taking an opaque numeric id. Zoho Desk now uses the same machinery as the other 25 selector providers: hooks/selectors/providers/zoho-desk/selectors.ts registered in the selector registry, consumed from the block as basic selector + advanced manual input sharing one canonicalParamId, for organization, update-ticket department, and the list-tickets department filter. The trigger's org field moves to the same selector. zoho-desk-org-options.ts is deleted rather than left beside the new path, so blocks/ has zero fetchOptions usages outside the core blocks. Wire params are unchanged (orgId, departmentId, departmentIds, assigneeId, ticketId, contactId) - this is a UI change, not an API change. The organizations route now resolves the credential server-side. It previously had the browser fetch an access token and POST it back, which an earlier audit flagged as the one place a Zoho token left the server; the new selector-credential resolver keeps it server-side for both the OAuth and service-account credential types and re-anchors every outbound host to the Zoho apex allowlist. No agents selector: the endpoint is documented but its OAuth scope is not, and the nearest evidence points at Desk.agents.READ, which we do not request. Adding it would force every existing Zoho Desk user to reconnect for a convenience field, so assigneeId stays a manual input until the scope can be confirmed against a live org. Adds the skills array BlockMeta was missing - 227 of 300 blocks declare one and this did not. Seven skills, each grounded in a use case Zoho or the ecosystem actually advertises (auto-triage, SLA escalation, digest, AI draft reply, customer context, engineering handoff, knowledge-gap report) and each exercising only tools in tools.access. CSAT surveys, ticket creation, dedup and keyword search were deliberately left out: the integration has no tool for them, and a skill implying an unsupported action is worse than a shorter list.
1 parent c6d56df commit 8a431e6

17 files changed

Lines changed: 619 additions & 110 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@ Trigger a workflow when a Zoho Desk event occurs (ticket, comment, thread, conta
490490
| Parameter | Type | Required | Description |
491491
| --------- | ---- | -------- | ----------- |
492492
| `triggerCredentials` | string | Yes | This trigger creates and manages a webhook subscription in your Zoho Desk account. |
493-
| `orgId` | combobox | Yes | The Zoho Desk organization \(portal\) to subscribe in. |
493+
| `orgId` | project-selector | Yes | The Zoho Desk organization \(portal\) to subscribe in. |
494494
| `eventType` | string | Yes | Event |
495495
| `triggerDepartmentIds` | string | No | Restrict events to these departments. Leave empty for all departments. |
496496
| `fields` | string | No | For Ticket Updated: only fire when one of these fields changes \(max 5\). Previous values are included in the payload. |
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { zohoDeskDepartmentsSelectorContract } from '@/lib/api/contracts/selectors'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { resolveZohoDeskSelectorCredential } from '@/app/api/tools/zoho_desk/selector-credential'
10+
import { assertZohoUrl } from '@/tools/zoho_desk/host-allowlist'
11+
import { buildZohoDeskHeaders, getZohoDeskErrorMessage } from '@/tools/zoho_desk/utils'
12+
13+
export const dynamic = 'force-dynamic'
14+
15+
const logger = createLogger('ZohoDeskDepartmentsAPI')
16+
17+
/**
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.
20+
* 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.
22+
*/
23+
const DEPARTMENT_PAGE_SIZE = 200
24+
const MAX_DEPARTMENT_PAGES = 20
25+
26+
interface ZohoDepartment {
27+
id?: string | number
28+
name?: string
29+
nameInCustomerPortal?: string
30+
}
31+
32+
/** Backs the `zoho_desk.departments` selector. */
33+
export const POST = withRouteHandler(async (request: NextRequest) => {
34+
const requestId = generateRequestId()
35+
36+
const parsed = await parseRequest(zohoDeskDepartmentsSelectorContract, request, {})
37+
if (!parsed.success) return parsed.response
38+
const { credential, workflowId, orgId } = parsed.data.body
39+
40+
const resolved = await resolveZohoDeskSelectorCredential(request, {
41+
credentialId: credential,
42+
workflowId,
43+
requestId,
44+
})
45+
if (!resolved.ok) return resolved.response
46+
const { accessToken, apiBase } = resolved.credential
47+
48+
const headers = buildZohoDeskHeaders({ accessToken, orgId })
49+
const departments: Array<{ id: string; name: string }> = []
50+
51+
try {
52+
for (let page = 0; page < MAX_DEPARTMENT_PAGES; page++) {
53+
let departmentsUrl: URL
54+
try {
55+
departmentsUrl = assertZohoUrl(`${apiBase}/departments`)
56+
} catch {
57+
return NextResponse.json(
58+
{ error: 'Credential resolved to a non-Zoho host' },
59+
{ status: 400 }
60+
)
61+
}
62+
departmentsUrl.searchParams.set('from', String(page * DEPARTMENT_PAGE_SIZE))
63+
departmentsUrl.searchParams.set('limit', String(DEPARTMENT_PAGE_SIZE))
64+
65+
// Same rationale as the organizations/attachment routes: pin the resolved
66+
// IP, block private/reserved hops, and drop the token if a Zoho-side
67+
// redirect leaves the original origin.
68+
const response = await secureFetchWithValidation(departmentsUrl.toString(), {
69+
method: 'GET',
70+
headers,
71+
timeout: 15_000,
72+
stripAuthOnRedirect: true,
73+
})
74+
75+
const body: { data?: unknown } = await response
76+
.json()
77+
.then((json) => (json && typeof json === 'object' ? (json as { data?: unknown }) : {}))
78+
.catch(() => ({}))
79+
80+
// Zoho answers 204 with no body once the offset runs past the last
81+
// department, which is a successful end-of-list, not an error.
82+
if (response.status === 204) break
83+
84+
if (!response.ok) {
85+
const message = getZohoDeskErrorMessage(
86+
body,
87+
`Failed to list departments (HTTP ${response.status})`
88+
)
89+
logger.warn('Failed to list Zoho Desk departments', { status: response.status, message })
90+
return NextResponse.json(
91+
{ error: message },
92+
{ status: response.status >= 400 && response.status < 500 ? response.status : 502 }
93+
)
94+
}
95+
96+
const pageItems = Array.isArray(body.data) ? (body.data as ZohoDepartment[]) : []
97+
for (const department of pageItems) {
98+
if (department.id === undefined || department.id === null) continue
99+
departments.push({
100+
id: String(department.id),
101+
name: department.name || department.nameInCustomerPortal || String(department.id),
102+
})
103+
}
104+
105+
if (pageItems.length < DEPARTMENT_PAGE_SIZE) break
106+
if (page === MAX_DEPARTMENT_PAGES - 1) {
107+
logger.warn('Zoho Desk departments listing hit the page cap; list may be incomplete', {
108+
pages: MAX_DEPARTMENT_PAGES,
109+
})
110+
}
111+
}
112+
113+
return NextResponse.json({ departments })
114+
} catch (error) {
115+
const message = getErrorMessage(error, 'Failed to list departments')
116+
logger.error('Error listing Zoho Desk departments', { error: message })
117+
return NextResponse.json({ error: message }, { status: 502 })
118+
}
119+
})

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

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import { type NextRequest, NextResponse } from 'next/server'
4-
import { zohoDeskListOrganizationsContract } from '@/lib/api/contracts/tools/zoho-desk'
4+
import { zohoDeskOrganizationsSelectorContract } from '@/lib/api/contracts/selectors'
55
import { parseRequest } from '@/lib/api/server'
6-
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
76
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
7+
import { generateRequestId } from '@/lib/core/utils/request'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { resolveZohoDeskSelectorCredential } from '@/app/api/tools/zoho_desk/selector-credential'
910
import { assertZohoUrl } from '@/tools/zoho_desk/host-allowlist'
10-
import { getZohoDeskApiBase, getZohoDeskErrorMessage } from '@/tools/zoho_desk/utils'
11+
import { getZohoDeskErrorMessage } from '@/tools/zoho_desk/utils'
1112

1213
export const dynamic = 'force-dynamic'
1314

@@ -19,26 +20,29 @@ interface ZohoOrganization {
1920
portalName?: string
2021
}
2122

23+
/** Backs the `zoho_desk.organizations` selector. */
2224
export const POST = withRouteHandler(async (request: NextRequest) => {
23-
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
24-
if (!authResult.success) {
25-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
26-
}
25+
const requestId = generateRequestId()
2726

28-
const parsed = await parseRequest(zohoDeskListOrganizationsContract, request, {})
27+
const parsed = await parseRequest(zohoDeskOrganizationsSelectorContract, request, {})
2928
if (!parsed.success) return parsed.response
30-
const { accessToken, apiDomain } = parsed.data.body
29+
const { credential, workflowId } = parsed.data.body
30+
31+
const resolved = await resolveZohoDeskSelectorCredential(request, {
32+
credentialId: credential,
33+
workflowId,
34+
requestId,
35+
})
36+
if (!resolved.ok) return resolved.response
37+
const { accessToken, apiBase } = resolved.credential
3138

32-
// apiDomain is client-supplied, so anchor the outbound host to a Zoho apex
33-
// before attaching the OAuth token - otherwise a caller could point the server
34-
// at an arbitrary origin and leak the token.
39+
// apiBase is already anchored by getZohoDeskApiBase; assert again so the URL
40+
// that finally receives the OAuth token is validated at the point of use.
3541
let organizationsUrl: URL
3642
try {
37-
organizationsUrl = assertZohoUrl(
38-
`${getZohoDeskApiBase({ apiDomain: apiDomain ?? undefined })}/organizations`
39-
)
43+
organizationsUrl = assertZohoUrl(`${apiBase}/organizations`)
4044
} catch {
41-
return NextResponse.json({ error: 'apiDomain must be an https Zoho host' }, { status: 400 })
45+
return NextResponse.json({ error: 'Credential resolved to a non-Zoho host' }, { status: 400 })
4246
}
4347

4448
try {
@@ -84,8 +88,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8488
.filter((org) => org.id !== undefined && org.id !== null)
8589
.map((org) => ({
8690
id: String(org.id),
87-
companyName: org.companyName ?? null,
88-
portalName: org.portalName ?? null,
91+
name: org.companyName || org.portalName || String(org.id),
8992
}))
9093

9194
return NextResponse.json({ organizations })
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { db } from '@sim/db'
2+
import { account } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { eq } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
7+
import { resolveCredentialAccessToken, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
8+
import { extractZohoDeskBaseFromScope } from '@/tools/zoho_desk/host-allowlist'
9+
import { getZohoDeskApiBase } from '@/tools/zoho_desk/utils'
10+
11+
const logger = createLogger('ZohoDeskSelectorCredential')
12+
13+
interface ResolvedZohoDeskCredential {
14+
accessToken: string
15+
/** Desk REST base including the `/api/v1` suffix, anchored to the Zoho apex allowlist. */
16+
apiBase: string
17+
}
18+
19+
type ResolveResult =
20+
| { ok: true; credential: ResolvedZohoDeskCredential }
21+
| { ok: false; response: NextResponse }
22+
23+
/**
24+
* Resolve a Zoho Desk credential id into an access token plus the data-center
25+
* Desk REST base, for the selector routes.
26+
*
27+
* Both credential kinds are covered by one path:
28+
* - `zoho-desk-service-account` (client credentials): the minter returns the
29+
* data center's Desk base as `apiDomain` on every mint, so it comes straight
30+
* off the token result.
31+
* - OAuth connection: the token exchange persists the derived Desk base in the
32+
* credential's scope string, so it is read back from the `account` row.
33+
*
34+
* The token never leaves the server — unlike the previous combobox, which
35+
* fetched it into the browser before posting it back.
36+
*/
37+
export async function resolveZohoDeskSelectorCredential(
38+
request: NextRequest,
39+
params: { credentialId: string; workflowId?: string; requestId: string }
40+
): Promise<ResolveResult> {
41+
const { credentialId, workflowId, requestId } = params
42+
43+
const authz = await authorizeCredentialUse(request, { credentialId, workflowId })
44+
if (!authz.ok || !authz.credentialOwnerUserId) {
45+
return {
46+
ok: false,
47+
response: NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }),
48+
}
49+
}
50+
51+
const tokenResult = await resolveCredentialAccessToken(
52+
credentialId,
53+
authz.credentialOwnerUserId,
54+
requestId
55+
)
56+
if (!tokenResult?.accessToken) {
57+
logger.error('Failed to get Zoho Desk access token', { credentialId })
58+
return {
59+
ok: false,
60+
response: NextResponse.json(
61+
{ error: 'Could not retrieve access token', authRequired: true },
62+
{ status: 401 }
63+
),
64+
}
65+
}
66+
67+
// Service-account mints carry `apiDomain`; an OAuth connection stores the same
68+
// value on its account row instead. Falling through to `undefined` lets
69+
// getZohoDeskApiBase apply the US default rather than guessing a host.
70+
const apiDomain = tokenResult.apiDomain ?? (await readOAuthApiDomain(credentialId))
71+
72+
return {
73+
ok: true,
74+
credential: {
75+
accessToken: tokenResult.accessToken,
76+
apiBase: getZohoDeskApiBase({ apiDomain }),
77+
},
78+
}
79+
}
80+
81+
async function readOAuthApiDomain(credentialId: string): Promise<string | undefined> {
82+
try {
83+
const resolved = await resolveOAuthAccountId(credentialId)
84+
if (!resolved?.accountId) return undefined
85+
const [row] = await db
86+
.select({ scope: account.scope })
87+
.from(account)
88+
.where(eq(account.id, resolved.accountId))
89+
.limit(1)
90+
return extractZohoDeskBaseFromScope(row?.scope)
91+
} catch (error) {
92+
logger.warn('Failed to resolve Zoho Desk data center from credential', { error })
93+
return undefined
94+
}
95+
}

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

Lines changed: 0 additions & 47 deletions
This file was deleted.

0 commit comments

Comments
 (0)