From 131d2a773c44f53dcb9ff7d7d5a59ae8563f97f4 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 15:56:00 -0700 Subject: [PATCH 1/5] feat(jsm): add Atlassian Assets (Insight/CMDB) tools for asset management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add nine JSM Assets tools so workflows can read and write Atlassian Assets (Insight/CMDB) objects — the foundation for keeping JSM asset tables in sync for software/hardware asset management. Tools (wired into the Jira Service Management block): - jsm_list_object_schemas, jsm_get_object_schema - jsm_list_object_types, jsm_get_object_type_attributes - jsm_search_objects_aql (AQL search with pagination) - jsm_get_object, jsm_create_object, jsm_update_object, jsm_delete_object Each tool proxies through an internal route that resolves the Jira cloudId and the Assets workspaceId, then calls the Assets API via the OAuth 2.0 (3LO) gateway form (/ex/jira/{cloudId}/jsm/assets/workspace/{workspaceId}/v1). Adds the CMDB OAuth scopes to the jira provider (read/write/delete cmdb-object, read cmdb-schema/type/attribute) with descriptions, contract schemas for each route, and block operations/subBlocks/outputs. Bumps the API-validation route baseline for the nine new routes. --- .../integrations/jira_service_management.mdx | 266 ++++++++++++++ .../api/tools/jsm/assets/attributes/route.ts | 90 +++++ .../tools/jsm/assets/object-types/route.ts | 89 +++++ .../tools/jsm/assets/object/create/route.ts | 85 +++++ .../tools/jsm/assets/object/delete/route.ts | 73 ++++ .../api/tools/jsm/assets/object/get/route.ts | 80 +++++ .../tools/jsm/assets/object/update/route.ts | 89 +++++ .../app/api/tools/jsm/assets/schema/route.ts | 75 ++++ .../app/api/tools/jsm/assets/schemas/route.ts | 89 +++++ .../app/api/tools/jsm/assets/search/route.ts | 114 ++++++ .../blocks/blocks/jira_service_management.ts | 332 ++++++++++++++++++ apps/sim/lib/api/contracts/selectors/jsm.ts | 111 ++++++ apps/sim/lib/integrations/integrations.json | 38 +- apps/sim/lib/oauth/oauth.ts | 6 + apps/sim/lib/oauth/utils.ts | 6 + apps/sim/tools/jsm/create_object.ts | 97 +++++ apps/sim/tools/jsm/delete_object.ts | 84 +++++ apps/sim/tools/jsm/get_object.ts | 88 +++++ apps/sim/tools/jsm/get_object_schema.ts | 102 ++++++ .../tools/jsm/get_object_type_attributes.ts | 136 +++++++ apps/sim/tools/jsm/index.ts | 18 + apps/sim/tools/jsm/list_object_schemas.ts | 126 +++++++ apps/sim/tools/jsm/list_object_types.ts | 117 ++++++ apps/sim/tools/jsm/search_objects_aql.ts | 150 ++++++++ apps/sim/tools/jsm/types.ts | 187 ++++++++++ apps/sim/tools/jsm/update_object.ts | 104 ++++++ apps/sim/tools/jsm/utils.ts | 106 ++++++ apps/sim/tools/registry.ts | 18 + scripts/check-api-validation-contracts.ts | 4 +- 29 files changed, 2877 insertions(+), 3 deletions(-) create mode 100644 apps/sim/app/api/tools/jsm/assets/attributes/route.ts create mode 100644 apps/sim/app/api/tools/jsm/assets/object-types/route.ts create mode 100644 apps/sim/app/api/tools/jsm/assets/object/create/route.ts create mode 100644 apps/sim/app/api/tools/jsm/assets/object/delete/route.ts create mode 100644 apps/sim/app/api/tools/jsm/assets/object/get/route.ts create mode 100644 apps/sim/app/api/tools/jsm/assets/object/update/route.ts create mode 100644 apps/sim/app/api/tools/jsm/assets/schema/route.ts create mode 100644 apps/sim/app/api/tools/jsm/assets/schemas/route.ts create mode 100644 apps/sim/app/api/tools/jsm/assets/search/route.ts create mode 100644 apps/sim/tools/jsm/create_object.ts create mode 100644 apps/sim/tools/jsm/delete_object.ts create mode 100644 apps/sim/tools/jsm/get_object.ts create mode 100644 apps/sim/tools/jsm/get_object_schema.ts create mode 100644 apps/sim/tools/jsm/get_object_type_attributes.ts create mode 100644 apps/sim/tools/jsm/list_object_schemas.ts create mode 100644 apps/sim/tools/jsm/list_object_types.ts create mode 100644 apps/sim/tools/jsm/search_objects_aql.ts create mode 100644 apps/sim/tools/jsm/update_object.ts diff --git a/apps/docs/content/docs/en/integrations/jira_service_management.mdx b/apps/docs/content/docs/en/integrations/jira_service_management.mdx index 46440071f19..b22676bbbc1 100644 --- a/apps/docs/content/docs/en/integrations/jira_service_management.mdx +++ b/apps/docs/content/docs/en/integrations/jira_service_management.mdx @@ -988,6 +988,272 @@ Copy forms from one Jira issue to another | `copiedForms` | json | Array of successfully copied forms | | `errors` | json | Array of errors encountered during copy | +### `jsm_list_object_schemas` + +List Assets (Insight/CMDB) object schemas in Jira Service Management + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `startAt` | number | No | Pagination start index \(e.g., 0, 50\) | +| `maxResults` | number | No | Maximum schemas to return \(e.g., 25, 50\) | +| `includeCounts` | boolean | No | Include object and object-type counts per schema | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `schemas` | array | List of Assets object schemas | +| ↳ `id` | string | Schema ID | +| ↳ `name` | string | Schema name | +| ↳ `objectSchemaKey` | string | Schema key | +| ↳ `status` | string | Schema status | +| ↳ `description` | string | Schema description | +| ↳ `objectCount` | number | Number of objects | +| ↳ `objectTypeCount` | number | Number of object types | +| `total` | number | Total number of schemas | +| `isLast` | boolean | Whether this is the last page | + +### `jsm_get_object_schema` + +Get a single Assets (Insight/CMDB) object schema by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `schemaId` | string | Yes | The Assets object schema ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `schema` | json | The Assets object schema | +| ↳ `id` | string | Schema ID | +| ↳ `name` | string | Schema name | +| ↳ `objectSchemaKey` | string | Schema key | +| ↳ `status` | string | Schema status | +| ↳ `description` | string | Schema description | +| ↳ `objectCount` | number | Number of objects | +| ↳ `objectTypeCount` | number | Number of object types | + +### `jsm_list_object_types` + +List object types within an Assets (Insight/CMDB) object schema + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `schemaId` | string | Yes | The Assets object schema ID to list object types for | +| `excludeAbstract` | boolean | No | Exclude abstract object types from the result | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `objectTypes` | array | List of object types in the schema | +| ↳ `id` | string | Object type ID | +| ↳ `name` | string | Object type name | +| ↳ `description` | string | Object type description | +| ↳ `objectSchemaId` | string | Parent schema ID | +| ↳ `objectCount` | number | Number of objects | +| ↳ `abstractObjectType` | boolean | Whether the type is abstract | +| ↳ `inherited` | boolean | Whether the type inherits attributes | +| `total` | number | Total number of object types | + +### `jsm_get_object_type_attributes` + +Get the attribute definitions for an Assets (Insight/CMDB) object type. Use the returned attribute IDs to build create/update payloads or map columns. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `objectTypeId` | string | Yes | The Assets object type ID | +| `onlyValueEditable` | boolean | No | Return only attributes whose values can be edited | +| `query` | string | No | Filter attributes by a search query | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `attributes` | array | Attribute definitions for the object type | +| ↳ `id` | string | Attribute definition ID — use as objectTypeAttributeId in create/update | +| ↳ `name` | string | Attribute name | +| ↳ `label` | boolean | Whether this attribute is the object label | +| ↳ `type` | number | Data type discriminator \(integer enum\) | +| ↳ `defaultType` | json | Default data type \{ id, name \} | +| ↳ `editable` | boolean | Whether the value is editable | +| ↳ `minimumCardinality` | number | Minimum number of values \(>= 1 means required\) | +| ↳ `maximumCardinality` | number | Maximum number of values | +| ↳ `uniqueAttribute` | boolean | Whether values must be unique | +| `total` | number | Total number of attributes | + +### `jsm_search_objects_aql` + +Search Assets (Insight/CMDB) objects using AQL (Assets Query Language), e.g. objectType = + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `qlQuery` | string | Yes | AQL query string \(e.g., objectType = "Host" AND "Operating System" = "Ubuntu"\) | +| `page` | number | No | Page number \(1-based, defaults to 1\) | +| `resultsPerPage` | number | No | Results per page \(e.g., 25, 50\) | +| `includeAttributes` | boolean | No | Include resolved attribute values on each object \(defaults to true\) | +| `objectTypeId` | string | No | Optionally scope the search to a single object type ID | +| `objectSchemaId` | string | No | Optionally scope the search to a single object schema ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `objects` | array | Matching Assets objects | +| ↳ `id` | string | Object ID | +| ↳ `label` | string | Object label | +| ↳ `objectKey` | string | Object key \(e.g., HOST-123\) | +| ↳ `objectType` | json | Object type metadata | +| ↳ `attributes` | json | Resolved attribute values | +| `total` | number | Total number of matching objects \(totalFilterCount\) | +| `pageNumber` | number | Current page number | +| `pageSize` | number | Number of objects on this page | + +### `jsm_get_object` + +Get a single Assets (Insight/CMDB) object by ID, including its attribute values + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `objectId` | string | Yes | The Assets object ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `object` | json | The Assets object | +| ↳ `id` | string | Object ID | +| ↳ `label` | string | Human-readable object label | +| ↳ `objectKey` | string | Object key \(e.g., HOST-123\) | +| ↳ `globalId` | string | Global object ID | +| ↳ `objectType` | json | Object type metadata | +| ↳ `attributes` | json | Resolved attribute values for the object | +| ↳ `hasAvatar` | boolean | Whether the object has an avatar | +| ↳ `created` | string | Creation timestamp | +| ↳ `updated` | string | Last update timestamp | +| ↳ `link` | string | Self link to the object | + +### `jsm_create_object` + +Create an Assets (Insight/CMDB) object of a given object type. Attributes use objectTypeAttributeId values from the object type definition. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `objectTypeId` | string | Yes | The object type ID to create the object under | +| `attributes` | json | Yes | Array of attributes: \[\{ objectTypeAttributeId, objectAttributeValues: \[\{ value \}\] \}\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `object` | json | The created Assets object | +| ↳ `id` | string | Object ID | +| ↳ `label` | string | Human-readable object label | +| ↳ `objectKey` | string | Object key \(e.g., HOST-123\) | +| ↳ `globalId` | string | Global object ID | +| ↳ `objectType` | json | Object type metadata | +| ↳ `attributes` | json | Resolved attribute values for the object | +| ↳ `hasAvatar` | boolean | Whether the object has an avatar | +| ↳ `created` | string | Creation timestamp | +| ↳ `updated` | string | Last update timestamp | +| ↳ `link` | string | Self link to the object | + +### `jsm_update_object` + +Update an existing Assets (Insight/CMDB) object. Provide the attributes to change using their objectTypeAttributeId values. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `objectId` | string | Yes | The Assets object ID to update | +| `attributes` | json | Yes | Array of attributes to set: \[\{ objectTypeAttributeId, objectAttributeValues: \[\{ value \}\] \}\] | +| `objectTypeId` | string | No | Optional object type ID \(only if changing the type\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `object` | json | The updated Assets object | +| ↳ `id` | string | Object ID | +| ↳ `label` | string | Human-readable object label | +| ↳ `objectKey` | string | Object key \(e.g., HOST-123\) | +| ↳ `globalId` | string | Global object ID | +| ↳ `objectType` | json | Object type metadata | +| ↳ `attributes` | json | Resolved attribute values for the object | +| ↳ `hasAvatar` | boolean | Whether the object has an avatar | +| ↳ `created` | string | Creation timestamp | +| ↳ `updated` | string | Last update timestamp | +| ↳ `link` | string | Self link to the object | + +### `jsm_delete_object` + +Delete an Assets (Insight/CMDB) object by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance | +| `workspaceId` | string | No | Assets workspace ID \(resolved automatically when omitted\) | +| `objectId` | string | Yes | The Assets object ID to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `objectId` | string | The deleted object ID | +| `deleted` | boolean | Whether the object was deleted | + ## Triggers diff --git a/apps/sim/app/api/tools/jsm/assets/attributes/route.ts b/apps/sim/app/api/tools/jsm/assets/attributes/route.ts new file mode 100644 index 00000000000..7714958ebfa --- /dev/null +++ b/apps/sim/app/api/tools/jsm/assets/attributes/route.ts @@ -0,0 +1,90 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { jsmObjectTypeAttributesContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { getAssetsApiBaseUrl, getJsmHeaders, resolveAssetsContext } from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmAssetsAttributesAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseRequest(jsmObjectTypeAttributesContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: cloudIdParam, + workspaceId: workspaceIdParam, + objectTypeId, + onlyValueEditable, + query: searchQuery, + } = parsed.data.body + + const { cloudId, workspaceId } = await resolveAssetsContext( + domain, + accessToken, + cloudIdParam, + workspaceIdParam + ) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const query = new URLSearchParams() + if (onlyValueEditable !== undefined) { + query.append('onlyValueEditable', String(onlyValueEditable)) + } + if (searchQuery) query.append('query', searchQuery) + + const url = `${getAssetsApiBaseUrl(cloudId, workspaceId)}/objecttype/${encodeURIComponent( + objectTypeId + )}/attributes${query.toString() ? `?${query.toString()}` : ''}` + + const response = await fetch(url, { method: 'GET', headers: getJsmHeaders(accessToken) }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Assets API error getting attributes', { status: response.status, errorText }) + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + const attributes = Array.isArray(data) ? data : (data.values ?? []) + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + attributes, + total: attributes.length, + }, + }) + } catch (error) { + logger.error('Error getting Assets attributes', { error: toError(error).message }) + return NextResponse.json( + { error: getErrorMessage(error, 'Internal server error'), success: false }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/jsm/assets/object-types/route.ts b/apps/sim/app/api/tools/jsm/assets/object-types/route.ts new file mode 100644 index 00000000000..e3f54842394 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/assets/object-types/route.ts @@ -0,0 +1,89 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { jsmListObjectTypesContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { getAssetsApiBaseUrl, getJsmHeaders, resolveAssetsContext } from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmAssetsObjectTypesAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseRequest(jsmListObjectTypesContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: cloudIdParam, + workspaceId: workspaceIdParam, + schemaId, + excludeAbstract, + } = parsed.data.body + + const { cloudId, workspaceId } = await resolveAssetsContext( + domain, + accessToken, + cloudIdParam, + workspaceIdParam + ) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const query = new URLSearchParams() + if (excludeAbstract !== undefined) query.append('excludeAbstract', String(excludeAbstract)) + + const url = `${getAssetsApiBaseUrl(cloudId, workspaceId)}/objectschema/${encodeURIComponent( + schemaId + )}/objecttypes${query.toString() ? `?${query.toString()}` : ''}` + + const response = await fetch(url, { method: 'GET', headers: getJsmHeaders(accessToken) }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Assets API error listing object types', { + status: response.status, + errorText, + }) + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + const objectTypes = Array.isArray(data) ? data : (data.values ?? []) + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + objectTypes, + total: objectTypes.length, + }, + }) + } catch (error) { + logger.error('Error listing Assets object types', { error: toError(error).message }) + return NextResponse.json( + { error: getErrorMessage(error, 'Internal server error'), success: false }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/jsm/assets/object/create/route.ts b/apps/sim/app/api/tools/jsm/assets/object/create/route.ts new file mode 100644 index 00000000000..7327513ee69 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/assets/object/create/route.ts @@ -0,0 +1,85 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { jsmCreateObjectContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { + getAssetsApiBaseUrl, + getJsmHeaders, + mapAssetObject, + resolveAssetsContext, +} from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmAssetsCreateObjectAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseRequest(jsmCreateObjectContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: cloudIdParam, + workspaceId: workspaceIdParam, + objectTypeId, + attributes, + } = parsed.data.body + + const { cloudId, workspaceId } = await resolveAssetsContext( + domain, + accessToken, + cloudIdParam, + workspaceIdParam + ) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const url = `${getAssetsApiBaseUrl(cloudId, workspaceId)}/object/create` + + const response = await fetch(url, { + method: 'POST', + headers: getJsmHeaders(accessToken), + body: JSON.stringify({ objectTypeId, attributes }), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Assets API error creating object', { status: response.status, errorText }) + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + return NextResponse.json({ + success: true, + output: { ts: new Date().toISOString(), object: mapAssetObject(data) }, + }) + } catch (error) { + logger.error('Error creating Assets object', { error: toError(error).message }) + return NextResponse.json( + { error: getErrorMessage(error, 'Internal server error'), success: false }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/jsm/assets/object/delete/route.ts b/apps/sim/app/api/tools/jsm/assets/object/delete/route.ts new file mode 100644 index 00000000000..7c2c8859356 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/assets/object/delete/route.ts @@ -0,0 +1,73 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { jsmDeleteObjectContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { getAssetsApiBaseUrl, getJsmHeaders, resolveAssetsContext } from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmAssetsDeleteObjectAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseRequest(jsmDeleteObjectContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: cloudIdParam, + workspaceId: workspaceIdParam, + objectId, + } = parsed.data.body + + const { cloudId, workspaceId } = await resolveAssetsContext( + domain, + accessToken, + cloudIdParam, + workspaceIdParam + ) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const url = `${getAssetsApiBaseUrl(cloudId, workspaceId)}/object/${encodeURIComponent(objectId)}` + + const response = await fetch(url, { method: 'DELETE', headers: getJsmHeaders(accessToken) }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Assets API error deleting object', { status: response.status, errorText }) + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + return NextResponse.json({ + success: true, + output: { ts: new Date().toISOString(), objectId, deleted: true }, + }) + } catch (error) { + logger.error('Error deleting Assets object', { error: toError(error).message }) + return NextResponse.json( + { error: getErrorMessage(error, 'Internal server error'), success: false }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/jsm/assets/object/get/route.ts b/apps/sim/app/api/tools/jsm/assets/object/get/route.ts new file mode 100644 index 00000000000..2470c2c1213 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/assets/object/get/route.ts @@ -0,0 +1,80 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { jsmGetObjectContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { + getAssetsApiBaseUrl, + getJsmHeaders, + mapAssetObject, + resolveAssetsContext, +} from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmAssetsGetObjectAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseRequest(jsmGetObjectContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: cloudIdParam, + workspaceId: workspaceIdParam, + objectId, + } = parsed.data.body + + const { cloudId, workspaceId } = await resolveAssetsContext( + domain, + accessToken, + cloudIdParam, + workspaceIdParam + ) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const url = `${getAssetsApiBaseUrl(cloudId, workspaceId)}/object/${encodeURIComponent(objectId)}` + + const response = await fetch(url, { method: 'GET', headers: getJsmHeaders(accessToken) }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Assets API error getting object', { status: response.status, errorText }) + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + return NextResponse.json({ + success: true, + output: { ts: new Date().toISOString(), object: mapAssetObject(data) }, + }) + } catch (error) { + logger.error('Error getting Assets object', { error: toError(error).message }) + return NextResponse.json( + { error: getErrorMessage(error, 'Internal server error'), success: false }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/jsm/assets/object/update/route.ts b/apps/sim/app/api/tools/jsm/assets/object/update/route.ts new file mode 100644 index 00000000000..3b1552eb4e0 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/assets/object/update/route.ts @@ -0,0 +1,89 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { jsmUpdateObjectContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { + getAssetsApiBaseUrl, + getJsmHeaders, + mapAssetObject, + resolveAssetsContext, +} from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmAssetsUpdateObjectAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseRequest(jsmUpdateObjectContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: cloudIdParam, + workspaceId: workspaceIdParam, + objectId, + objectTypeId, + attributes, + } = parsed.data.body + + const { cloudId, workspaceId } = await resolveAssetsContext( + domain, + accessToken, + cloudIdParam, + workspaceIdParam + ) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const url = `${getAssetsApiBaseUrl(cloudId, workspaceId)}/object/${encodeURIComponent(objectId)}` + + const body: Record = { attributes } + if (objectTypeId) body.objectTypeId = objectTypeId + + const response = await fetch(url, { + method: 'PUT', + headers: getJsmHeaders(accessToken), + body: JSON.stringify(body), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Assets API error updating object', { status: response.status, errorText }) + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + return NextResponse.json({ + success: true, + output: { ts: new Date().toISOString(), object: mapAssetObject(data) }, + }) + } catch (error) { + logger.error('Error updating Assets object', { error: toError(error).message }) + return NextResponse.json( + { error: getErrorMessage(error, 'Internal server error'), success: false }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/jsm/assets/schema/route.ts b/apps/sim/app/api/tools/jsm/assets/schema/route.ts new file mode 100644 index 00000000000..286ddf7af84 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/assets/schema/route.ts @@ -0,0 +1,75 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { jsmGetObjectSchemaContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { getAssetsApiBaseUrl, getJsmHeaders, resolveAssetsContext } from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmAssetsSchemaAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseRequest(jsmGetObjectSchemaContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: cloudIdParam, + workspaceId: workspaceIdParam, + schemaId, + } = parsed.data.body + + const { cloudId, workspaceId } = await resolveAssetsContext( + domain, + accessToken, + cloudIdParam, + workspaceIdParam + ) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const url = `${getAssetsApiBaseUrl(cloudId, workspaceId)}/objectschema/${encodeURIComponent(schemaId)}` + + const response = await fetch(url, { method: 'GET', headers: getJsmHeaders(accessToken) }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Assets API error getting schema', { status: response.status, errorText }) + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + return NextResponse.json({ + success: true, + output: { ts: new Date().toISOString(), schema: data ?? null }, + }) + } catch (error) { + logger.error('Error getting Assets schema', { error: toError(error).message }) + return NextResponse.json( + { error: getErrorMessage(error, 'Internal server error'), success: false }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/jsm/assets/schemas/route.ts b/apps/sim/app/api/tools/jsm/assets/schemas/route.ts new file mode 100644 index 00000000000..7f19cbd9c6d --- /dev/null +++ b/apps/sim/app/api/tools/jsm/assets/schemas/route.ts @@ -0,0 +1,89 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { jsmListObjectSchemasContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { getAssetsApiBaseUrl, getJsmHeaders, resolveAssetsContext } from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmAssetsSchemasAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseRequest(jsmListObjectSchemasContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: cloudIdParam, + workspaceId: workspaceIdParam, + startAt, + maxResults, + includeCounts, + } = parsed.data.body + + const { cloudId, workspaceId } = await resolveAssetsContext( + domain, + accessToken, + cloudIdParam, + workspaceIdParam + ) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const query = new URLSearchParams() + if (startAt !== undefined) query.append('startAt', String(startAt)) + if (maxResults !== undefined) query.append('maxResults', String(maxResults)) + if (includeCounts !== undefined) query.append('includeCounts', String(includeCounts)) + + const url = `${getAssetsApiBaseUrl(cloudId, workspaceId)}/objectschema/list${ + query.toString() ? `?${query.toString()}` : '' + }` + + const response = await fetch(url, { method: 'GET', headers: getJsmHeaders(accessToken) }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Assets API error listing schemas', { status: response.status, errorText }) + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + schemas: data.values ?? [], + total: data.total ?? (data.values?.length || 0), + isLast: data.isLast ?? true, + }, + }) + } catch (error) { + logger.error('Error listing Assets schemas', { error: toError(error).message }) + return NextResponse.json( + { error: getErrorMessage(error, 'Internal server error'), success: false }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/jsm/assets/search/route.ts b/apps/sim/app/api/tools/jsm/assets/search/route.ts new file mode 100644 index 00000000000..2551a0ce291 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/assets/search/route.ts @@ -0,0 +1,114 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { jsmSearchObjectsAqlContract } from '@/lib/api/contracts/selectors/jsm' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { + getAssetsApiBaseUrl, + getJsmHeaders, + mapAssetObject, + resolveAssetsContext, +} from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmAssetsSearchAPI') + +/** Coerce a string|number|boolean param into a number, falling back when unset */ +function toNumber(value: string | number | undefined, fallback: number): number { + if (value === undefined) return fallback + const parsed = typeof value === 'number' ? value : Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseRequest(jsmSearchObjectsAqlContract, request, {}) + if (!parsed.success) return parsed.response + + const { + domain, + accessToken, + cloudId: cloudIdParam, + workspaceId: workspaceIdParam, + qlQuery, + page, + resultsPerPage, + includeAttributes, + objectTypeId, + objectSchemaId, + } = parsed.data.body + + const { cloudId, workspaceId } = await resolveAssetsContext( + domain, + accessToken, + cloudIdParam, + workspaceIdParam + ) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const includeAttrs = + includeAttributes === undefined ? true : String(includeAttributes) === 'true' + + const body: Record = { + qlQuery, + page: toNumber(page, 1), + resultsPerPage: toNumber(resultsPerPage, 25), + includeAttributes: includeAttrs, + } + if (objectTypeId) body.objectTypeId = objectTypeId + if (objectSchemaId) body.objectSchemaId = objectSchemaId + + const url = `${getAssetsApiBaseUrl(cloudId, workspaceId)}/object/aql` + + const response = await fetch(url, { + method: 'POST', + headers: getJsmHeaders(accessToken), + body: JSON.stringify(body), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Assets API error running AQL search', { status: response.status, errorText }) + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + objects: Array.isArray(data.objectEntries) ? data.objectEntries.map(mapAssetObject) : [], + total: data.totalFilterCount ?? (data.objectEntries?.length || 0), + pageNumber: data.pageNumber ?? 1, + pageSize: data.pageSize ?? (data.objectEntries?.length || 0), + }, + }) + } catch (error) { + logger.error('Error running Assets AQL search', { error: toError(error).message }) + return NextResponse.json( + { error: getErrorMessage(error, 'Internal server error'), success: false }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/blocks/blocks/jira_service_management.ts b/apps/sim/blocks/blocks/jira_service_management.ts index cad695a1832..f5aa2a31228 100644 --- a/apps/sim/blocks/blocks/jira_service_management.ts +++ b/apps/sim/blocks/blocks/jira_service_management.ts @@ -5,6 +5,28 @@ import { AuthMode, IntegrationType } from '@/blocks/types' import type { JsmResponse } from '@/tools/jsm/types' import { getTrigger } from '@/triggers' +/** + * Parse the Assets attributes input into the API payload array. Accepts either a + * JSON string (from the block input) or an already-parsed array (from a dynamic + * reference). Throws a clear error when the value is not a valid array. + */ +function parseAssetAttributes(value: unknown): unknown[] { + if (Array.isArray(value)) return value + if (typeof value === 'string') { + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + throw new Error('Attributes must be a valid JSON array') + } + if (!Array.isArray(parsed)) { + throw new Error('Attributes must be a JSON array') + } + return parsed + } + throw new Error('Attributes are required') +} + export const JiraServiceManagementBlock: BlockConfig = { type: 'jira_service_management', name: 'Jira Service Management', @@ -57,6 +79,15 @@ export const JiraServiceManagementBlock: BlockConfig = { { label: 'Externalise Form', id: 'externalise_form' }, { label: 'Internalise Form', id: 'internalise_form' }, { label: 'Copy Forms', id: 'copy_forms' }, + { label: 'List Asset Schemas', id: 'list_object_schemas' }, + { label: 'Get Asset Schema', id: 'get_object_schema' }, + { label: 'List Asset Object Types', id: 'list_object_types' }, + { label: 'Get Asset Object Type Attributes', id: 'get_object_type_attributes' }, + { label: 'Search Assets (AQL)', id: 'search_objects_aql' }, + { label: 'Get Asset Object', id: 'get_object' }, + { label: 'Create Asset Object', id: 'create_object' }, + { label: 'Update Asset Object', id: 'update_object' }, + { label: 'Delete Asset Object', id: 'delete_object' }, ], value: () => 'get_service_desks', }, @@ -564,6 +595,164 @@ Return ONLY the comment text - no explanations.`, ], }, }, + { + id: 'assetSchemaId', + title: 'Schema ID', + type: 'short-input', + placeholder: 'e.g., 1', + required: { field: 'operation', value: ['get_object_schema', 'list_object_types'] }, + condition: { field: 'operation', value: ['get_object_schema', 'list_object_types'] }, + }, + { + id: 'assetObjectTypeId', + title: 'Object Type ID', + type: 'short-input', + placeholder: 'e.g., 23', + required: { field: 'operation', value: ['get_object_type_attributes', 'create_object'] }, + condition: { + field: 'operation', + value: [ + 'get_object_type_attributes', + 'create_object', + 'update_object', + 'search_objects_aql', + ], + }, + }, + { + id: 'assetObjectId', + title: 'Object ID', + type: 'short-input', + placeholder: 'e.g., 1234', + required: { field: 'operation', value: ['get_object', 'update_object', 'delete_object'] }, + condition: { field: 'operation', value: ['get_object', 'update_object', 'delete_object'] }, + }, + { + id: 'assetQlQuery', + title: 'AQL Query', + type: 'long-input', + placeholder: 'objectType = "Host" AND "Operating System" = "Ubuntu"', + required: { field: 'operation', value: 'search_objects_aql' }, + condition: { field: 'operation', value: 'search_objects_aql' }, + wandConfig: { + enabled: true, + placeholder: 'Describe which assets to find', + prompt: + 'Generate an Atlassian Assets AQL (Assets Query Language) query for the user request. Use attribute = "value" comparisons, AND/OR, IN, LIKE, and objectType filters. Example: objectType = "Host" AND Status = "Running". Return ONLY the AQL query - no explanations, no extra text.', + }, + }, + { + id: 'assetAttributes', + title: 'Attributes', + type: 'long-input', + placeholder: + '[{ "objectTypeAttributeId": "135", "objectAttributeValues": [{ "value": "Server-1" }] }]', + required: { field: 'operation', value: ['create_object', 'update_object'] }, + condition: { field: 'operation', value: ['create_object', 'update_object'] }, + wandConfig: { + enabled: true, + generationType: 'json-object', + placeholder: 'Describe the attribute values to set', + prompt: + 'Generate a JSON array of Atlassian Assets object attributes. Each element is { "objectTypeAttributeId": "", "objectAttributeValues": [{ "value": "" }] }. Use objectTypeAttributeId values from the object type attribute definitions. Return ONLY the JSON array - no explanations, no extra text.', + }, + }, + { + id: 'assetStartAt', + title: 'Start At', + type: 'short-input', + placeholder: 'Pagination start index (default: 0)', + mode: 'advanced', + condition: { field: 'operation', value: 'list_object_schemas' }, + }, + { + id: 'assetMaxResults', + title: 'Max Results', + type: 'short-input', + placeholder: 'Maximum schemas to return (default: 25)', + mode: 'advanced', + condition: { field: 'operation', value: 'list_object_schemas' }, + }, + { + id: 'assetIncludeCounts', + title: 'Include Counts', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + mode: 'advanced', + condition: { field: 'operation', value: 'list_object_schemas' }, + }, + { + id: 'assetExcludeAbstract', + title: 'Exclude Abstract Types', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + mode: 'advanced', + condition: { field: 'operation', value: 'list_object_types' }, + }, + { + id: 'assetOnlyValueEditable', + title: 'Only Editable Attributes', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + mode: 'advanced', + condition: { field: 'operation', value: 'get_object_type_attributes' }, + }, + { + id: 'assetAttributeQuery', + title: 'Attribute Filter', + type: 'short-input', + placeholder: 'Filter attributes by name', + mode: 'advanced', + condition: { field: 'operation', value: 'get_object_type_attributes' }, + }, + { + id: 'assetPage', + title: 'Page', + type: 'short-input', + placeholder: 'Page number (default: 1)', + mode: 'advanced', + condition: { field: 'operation', value: 'search_objects_aql' }, + }, + { + id: 'assetResultsPerPage', + title: 'Results Per Page', + type: 'short-input', + placeholder: 'Results per page (default: 25)', + mode: 'advanced', + condition: { field: 'operation', value: 'search_objects_aql' }, + }, + { + id: 'assetIncludeAttributes', + title: 'Include Attributes', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'true', + mode: 'advanced', + condition: { field: 'operation', value: 'search_objects_aql' }, + }, + { + id: 'assetObjectSchemaId', + title: 'Object Schema ID', + type: 'short-input', + placeholder: 'Scope the search to a schema ID', + mode: 'advanced', + condition: { field: 'operation', value: 'search_objects_aql' }, + }, ...getTrigger('jsm_request_created').subBlocks, ...getTrigger('jsm_request_updated').subBlocks, ...getTrigger('jsm_request_commented').subBlocks, @@ -606,6 +795,15 @@ Return ONLY the comment text - no explanations.`, 'jsm_externalise_form', 'jsm_internalise_form', 'jsm_copy_forms', + 'jsm_list_object_schemas', + 'jsm_get_object_schema', + 'jsm_list_object_types', + 'jsm_get_object_type_attributes', + 'jsm_search_objects_aql', + 'jsm_get_object', + 'jsm_create_object', + 'jsm_update_object', + 'jsm_delete_object', ], config: { tool: (params) => { @@ -678,6 +876,24 @@ Return ONLY the comment text - no explanations.`, return 'jsm_internalise_form' case 'copy_forms': return 'jsm_copy_forms' + case 'list_object_schemas': + return 'jsm_list_object_schemas' + case 'get_object_schema': + return 'jsm_get_object_schema' + case 'list_object_types': + return 'jsm_list_object_types' + case 'get_object_type_attributes': + return 'jsm_get_object_type_attributes' + case 'search_objects_aql': + return 'jsm_search_objects_aql' + case 'get_object': + return 'jsm_get_object' + case 'create_object': + return 'jsm_create_object' + case 'update_object': + return 'jsm_update_object' + case 'delete_object': + return 'jsm_delete_object' default: return 'jsm_get_service_desks' } @@ -1109,6 +1325,83 @@ Return ONLY the comment text - no explanations.`, })() : undefined, } + case 'list_object_schemas': + return { + ...baseParams, + startAt: params.assetStartAt ? Number.parseInt(params.assetStartAt) : undefined, + maxResults: params.assetMaxResults + ? Number.parseInt(params.assetMaxResults) + : undefined, + includeCounts: params.assetIncludeCounts === 'true' ? true : undefined, + } + case 'get_object_schema': + if (!params.assetSchemaId) { + throw new Error('Schema ID is required') + } + return { ...baseParams, schemaId: params.assetSchemaId } + case 'list_object_types': + if (!params.assetSchemaId) { + throw new Error('Schema ID is required') + } + return { + ...baseParams, + schemaId: params.assetSchemaId, + excludeAbstract: params.assetExcludeAbstract === 'true' ? true : undefined, + } + case 'get_object_type_attributes': + if (!params.assetObjectTypeId) { + throw new Error('Object type ID is required') + } + return { + ...baseParams, + objectTypeId: params.assetObjectTypeId, + onlyValueEditable: params.assetOnlyValueEditable === 'true' ? true : undefined, + query: params.assetAttributeQuery || undefined, + } + case 'search_objects_aql': + if (!params.assetQlQuery) { + throw new Error('AQL query is required') + } + return { + ...baseParams, + qlQuery: params.assetQlQuery, + page: params.assetPage ? Number.parseInt(params.assetPage) : undefined, + resultsPerPage: params.assetResultsPerPage + ? Number.parseInt(params.assetResultsPerPage) + : undefined, + includeAttributes: params.assetIncludeAttributes === 'false' ? false : undefined, + objectTypeId: params.assetObjectTypeId || undefined, + objectSchemaId: params.assetObjectSchemaId || undefined, + } + case 'get_object': + if (!params.assetObjectId) { + throw new Error('Object ID is required') + } + return { ...baseParams, objectId: params.assetObjectId } + case 'create_object': + if (!params.assetObjectTypeId) { + throw new Error('Object type ID is required') + } + return { + ...baseParams, + objectTypeId: params.assetObjectTypeId, + attributes: parseAssetAttributes(params.assetAttributes), + } + case 'update_object': + if (!params.assetObjectId) { + throw new Error('Object ID is required') + } + return { + ...baseParams, + objectId: params.assetObjectId, + attributes: parseAssetAttributes(params.assetAttributes), + objectTypeId: params.assetObjectTypeId || undefined, + } + case 'delete_object': + if (!params.assetObjectId) { + throw new Error('Object ID is required') + } + return { ...baseParams, objectId: params.assetObjectId } default: return baseParams } @@ -1167,6 +1460,21 @@ Return ONLY the comment text - no explanations.`, searchQuery: { type: 'string', description: 'Filter request types by name' }, groupId: { type: 'string', description: 'Filter by request type group ID' }, expand: { type: 'string', description: 'Comma-separated fields to expand' }, + assetSchemaId: { type: 'string', description: 'Assets object schema ID' }, + assetObjectTypeId: { type: 'string', description: 'Assets object type ID' }, + assetObjectId: { type: 'string', description: 'Assets object ID' }, + assetQlQuery: { type: 'string', description: 'AQL query string' }, + assetAttributes: { type: 'string', description: 'JSON array of Assets object attributes' }, + assetStartAt: { type: 'string', description: 'Schema pagination start index' }, + assetMaxResults: { type: 'string', description: 'Maximum schemas to return' }, + assetIncludeCounts: { type: 'string', description: 'Include object/type counts per schema' }, + assetExcludeAbstract: { type: 'string', description: 'Exclude abstract object types' }, + assetOnlyValueEditable: { type: 'string', description: 'Return only editable attributes' }, + assetAttributeQuery: { type: 'string', description: 'Filter attributes by name' }, + assetPage: { type: 'string', description: 'AQL search page number' }, + assetResultsPerPage: { type: 'string', description: 'AQL search results per page' }, + assetIncludeAttributes: { type: 'string', description: 'Include attribute values in results' }, + assetObjectSchemaId: { type: 'string', description: 'Scope AQL search to a schema ID' }, }, outputs: { ts: { type: 'string', description: 'Timestamp of the operation' }, @@ -1250,6 +1558,30 @@ Return ONLY the comment text - no explanations.`, errors: { type: 'json', description: 'Array of errors from copy forms operation' }, sourceIssueIdOrKey: { type: 'string', description: 'Source issue ID or key' }, targetIssueIdOrKey: { type: 'string', description: 'Target issue ID or key' }, + schemas: { + type: 'json', + description: 'Array of Assets object schemas (id, name, objectSchemaKey, status)', + }, + schema: { type: 'json', description: 'Single Assets object schema' }, + objectTypes: { + type: 'json', + description: 'Array of Assets object types (id, name, objectSchemaId, objectCount)', + }, + attributes: { + type: 'json', + description: + 'Array of object type attribute definitions (id, name, type, minimumCardinality)', + }, + objects: { + type: 'json', + description: 'Array of Assets objects from an AQL search (id, label, objectKey, attributes)', + }, + object: { + type: 'json', + description: 'Single Assets object (id, label, objectKey, objectType, attributes)', + }, + objectId: { type: 'string', description: 'Assets object ID (delete operation)' }, + isLast: { type: 'boolean', description: 'Whether this is the last page of schemas' }, }, triggers: { enabled: true, diff --git a/apps/sim/lib/api/contracts/selectors/jsm.ts b/apps/sim/lib/api/contracts/selectors/jsm.ts index f8fc3c40e93..dea80b9344c 100644 --- a/apps/sim/lib/api/contracts/selectors/jsm.ts +++ b/apps/sim/lib/api/contracts/selectors/jsm.ts @@ -185,6 +185,80 @@ export const jsmCopyFormsBodySchema = jsmBaseBodySchema.extend({ formIds: z.array(z.string(), { error: 'formIds must be an array of form UUIDs' }).optional(), }) +const jsmAssetsBaseBodySchema = jsmBaseBodySchema.extend({ + workspaceId: z.string().optional(), +}) + +const jsmAssetsPaginationField = z.union([z.string(), z.number()]).optional() + +export const jsmListObjectSchemasBodySchema = jsmAssetsBaseBodySchema.extend({ + startAt: jsmAssetsPaginationField, + maxResults: jsmAssetsPaginationField, + includeCounts: z.union([z.string(), z.boolean()]).optional(), +}) + +export const jsmObjectSchemaBodySchema = jsmAssetsBaseBodySchema.extend({ + schemaId: z.string({ error: 'Schema ID is required' }).min(1, 'Schema ID is required'), +}) + +export const jsmObjectTypesBodySchema = jsmAssetsBaseBodySchema.extend({ + schemaId: z.string({ error: 'Schema ID is required' }).min(1, 'Schema ID is required'), + excludeAbstract: z.union([z.string(), z.boolean()]).optional(), +}) + +export const jsmObjectTypeAttributesBodySchema = jsmAssetsBaseBodySchema.extend({ + objectTypeId: z + .string({ error: 'Object type ID is required' }) + .min(1, 'Object type ID is required'), + onlyValueEditable: z.union([z.string(), z.boolean()]).optional(), + query: z.string().optional(), +}) + +export const jsmSearchObjectsAqlBodySchema = jsmAssetsBaseBodySchema.extend({ + qlQuery: z.string({ error: 'AQL query is required' }).min(1, 'AQL query is required'), + page: jsmAssetsPaginationField, + resultsPerPage: jsmAssetsPaginationField, + includeAttributes: z.union([z.string(), z.boolean()]).optional(), + objectTypeId: z.string().optional(), + objectSchemaId: z.string().optional(), +}) + +export const jsmGetObjectBodySchema = jsmAssetsBaseBodySchema.extend({ + objectId: z.string({ error: 'Object ID is required' }).min(1, 'Object ID is required'), +}) + +const jsmAssetAttributeInputSchema = z.object({ + objectTypeAttributeId: z + .string({ error: 'objectTypeAttributeId is required' }) + .min(1, 'objectTypeAttributeId is required'), + objectAttributeValues: z + .array(z.object({ value: z.unknown() }), { + error: 'objectAttributeValues must be an array of { value } entries', + }) + .min(1, 'Each attribute needs at least one value'), +}) + +export const jsmCreateObjectBodySchema = jsmAssetsBaseBodySchema.extend({ + objectTypeId: z + .string({ error: 'Object type ID is required' }) + .min(1, 'Object type ID is required'), + attributes: z + .array(jsmAssetAttributeInputSchema, { error: 'attributes is required' }) + .min(1, 'At least one attribute is required'), +}) + +export const jsmUpdateObjectBodySchema = jsmAssetsBaseBodySchema.extend({ + objectId: z.string({ error: 'Object ID is required' }).min(1, 'Object ID is required'), + objectTypeId: z.string().optional(), + attributes: z + .array(jsmAssetAttributeInputSchema, { error: 'attributes is required' }) + .min(1, 'At least one attribute is required'), +}) + +export const jsmDeleteObjectBodySchema = jsmAssetsBaseBodySchema.extend({ + objectId: z.string({ error: 'Object ID is required' }).min(1, 'Object ID is required'), +}) + export const defineJsmToolContract = (path: string, body: TBody) => definePostSelector(path, body, z.unknown()) @@ -314,6 +388,43 @@ export const jsmCopyFormsContract = defineJsmToolContract( jsmCopyFormsBodySchema ) +export const jsmListObjectSchemasContract = defineJsmToolContract( + '/api/tools/jsm/assets/schemas', + jsmListObjectSchemasBodySchema +) +export const jsmGetObjectSchemaContract = defineJsmToolContract( + '/api/tools/jsm/assets/schema', + jsmObjectSchemaBodySchema +) +export const jsmListObjectTypesContract = defineJsmToolContract( + '/api/tools/jsm/assets/object-types', + jsmObjectTypesBodySchema +) +export const jsmObjectTypeAttributesContract = defineJsmToolContract( + '/api/tools/jsm/assets/attributes', + jsmObjectTypeAttributesBodySchema +) +export const jsmSearchObjectsAqlContract = defineJsmToolContract( + '/api/tools/jsm/assets/search', + jsmSearchObjectsAqlBodySchema +) +export const jsmGetObjectContract = defineJsmToolContract( + '/api/tools/jsm/assets/object/get', + jsmGetObjectBodySchema +) +export const jsmCreateObjectContract = defineJsmToolContract( + '/api/tools/jsm/assets/object/create', + jsmCreateObjectBodySchema +) +export const jsmUpdateObjectContract = defineJsmToolContract( + '/api/tools/jsm/assets/object/update', + jsmUpdateObjectBodySchema +) +export const jsmDeleteObjectContract = defineJsmToolContract( + '/api/tools/jsm/assets/object/delete', + jsmDeleteObjectBodySchema +) + export type JsmServiceDesksBody = ContractBody export type JsmQueuesBody = ContractBody export type JsmRequestTypesBody = ContractBody diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 64e1d3ebc1c..5f11ba3c480 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -8445,9 +8445,45 @@ { "name": "Copy Forms", "description": "Copy forms from one Jira issue to another" + }, + { + "name": "List Asset Schemas", + "description": "List Assets (Insight/CMDB) object schemas in Jira Service Management" + }, + { + "name": "Get Asset Schema", + "description": "Get a single Assets (Insight/CMDB) object schema by ID" + }, + { + "name": "List Asset Object Types", + "description": "List object types within an Assets (Insight/CMDB) object schema" + }, + { + "name": "Get Asset Object Type Attributes", + "description": "Get the attribute definitions for an Assets (Insight/CMDB) object type. Use the returned attribute IDs to build create/update payloads or map columns." + }, + { + "name": "Search Assets (AQL)", + "description": "Search Assets (Insight/CMDB) objects using AQL (Assets Query Language), e.g. objectType = " + }, + { + "name": "Get Asset Object", + "description": "Get a single Assets (Insight/CMDB) object by ID, including its attribute values" + }, + { + "name": "Create Asset Object", + "description": "Create an Assets (Insight/CMDB) object of a given object type. Attributes use objectTypeAttributeId values from the object type definition." + }, + { + "name": "Update Asset Object", + "description": "Update an existing Assets (Insight/CMDB) object. Provide the attributes to change using their objectTypeAttributeId values." + }, + { + "name": "Delete Asset Object", + "description": "Delete an Assets (Insight/CMDB) object by ID" } ], - "operationCount": 34, + "operationCount": 43, "triggers": [ { "id": "jsm_request_created", diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index dc8eed7cc8c..a9a71270042 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -547,6 +547,12 @@ export const OAUTH_PROVIDERS: Record = { 'write:request.participant:jira-service-management', 'read:request.approval:jira-service-management', 'write:request.approval:jira-service-management', + 'read:cmdb-object:jira', + 'write:cmdb-object:jira', + 'delete:cmdb-object:jira', + 'read:cmdb-schema:jira', + 'read:cmdb-type:jira', + 'read:cmdb-attribute:jira', ], }, }, diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index d9c4d76a61e..28276bc28fd 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -204,6 +204,12 @@ export const SCOPE_DESCRIPTIONS: Record = { 'Add and remove participants from customer requests', 'read:request.approval:jira-service-management': 'View approvals on customer requests', 'write:request.approval:jira-service-management': 'Approve or decline customer requests', + 'read:cmdb-object:jira': 'View Assets objects and run AQL searches', + 'write:cmdb-object:jira': 'Create and update Assets objects', + 'delete:cmdb-object:jira': 'Delete Assets objects', + 'read:cmdb-schema:jira': 'View Assets object schemas', + 'read:cmdb-type:jira': 'View Assets object types', + 'read:cmdb-attribute:jira': 'View Assets object type attributes', // Microsoft scopes 'User.Read': 'Read Microsoft user', diff --git a/apps/sim/tools/jsm/create_object.ts b/apps/sim/tools/jsm/create_object.ts new file mode 100644 index 00000000000..74c1dd497f0 --- /dev/null +++ b/apps/sim/tools/jsm/create_object.ts @@ -0,0 +1,97 @@ +import type { JsmCreateObjectParams, JsmCreateObjectResponse } from '@/tools/jsm/types' +import { ASSET_OBJECT_PROPERTIES } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmCreateObjectTool: ToolConfig = { + id: 'jsm_create_object', + name: 'JSM Create Asset Object', + description: + 'Create an Assets (Insight/CMDB) object of a given object type. Attributes use objectTypeAttributeId values from the object type definition.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + objectTypeId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The object type ID to create the object under', + }, + attributes: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Array of attributes: [{ objectTypeAttributeId, objectAttributeValues: [{ value }] }]', + }, + }, + + request: { + url: '/api/tools/jsm/assets/object/create', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + objectTypeId: params.objectTypeId?.trim(), + attributes: params.attributes, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), object: null }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { ts: new Date().toISOString(), object: null }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + object: { + type: 'json', + description: 'The created Assets object', + properties: ASSET_OBJECT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/jsm/delete_object.ts b/apps/sim/tools/jsm/delete_object.ts new file mode 100644 index 00000000000..0568a016d00 --- /dev/null +++ b/apps/sim/tools/jsm/delete_object.ts @@ -0,0 +1,84 @@ +import type { JsmDeleteObjectParams, JsmDeleteObjectResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmDeleteObjectTool: ToolConfig = { + id: 'jsm_delete_object', + name: 'JSM Delete Asset Object', + description: 'Delete an Assets (Insight/CMDB) object by ID', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + objectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Assets object ID to delete', + }, + }, + + request: { + url: '/api/tools/jsm/assets/object/delete', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + objectId: params.objectId?.trim(), + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), objectId: '', deleted: false }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { ts: new Date().toISOString(), objectId: '', deleted: false }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + objectId: { type: 'string', description: 'The deleted object ID' }, + deleted: { type: 'boolean', description: 'Whether the object was deleted' }, + }, +} diff --git a/apps/sim/tools/jsm/get_object.ts b/apps/sim/tools/jsm/get_object.ts new file mode 100644 index 00000000000..182e509cd5b --- /dev/null +++ b/apps/sim/tools/jsm/get_object.ts @@ -0,0 +1,88 @@ +import type { JsmGetObjectParams, JsmGetObjectResponse } from '@/tools/jsm/types' +import { ASSET_OBJECT_PROPERTIES } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmGetObjectTool: ToolConfig = { + id: 'jsm_get_object', + name: 'JSM Get Asset Object', + description: 'Get a single Assets (Insight/CMDB) object by ID, including its attribute values', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + objectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Assets object ID', + }, + }, + + request: { + url: '/api/tools/jsm/assets/object/get', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + objectId: params.objectId?.trim(), + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), object: null }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { ts: new Date().toISOString(), object: null }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + object: { + type: 'json', + description: 'The Assets object', + properties: ASSET_OBJECT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/jsm/get_object_schema.ts b/apps/sim/tools/jsm/get_object_schema.ts new file mode 100644 index 00000000000..62906cf00db --- /dev/null +++ b/apps/sim/tools/jsm/get_object_schema.ts @@ -0,0 +1,102 @@ +import type { JsmGetObjectSchemaParams, JsmGetObjectSchemaResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmGetObjectSchemaTool: ToolConfig< + JsmGetObjectSchemaParams, + JsmGetObjectSchemaResponse +> = { + id: 'jsm_get_object_schema', + name: 'JSM Get Asset Schema', + description: 'Get a single Assets (Insight/CMDB) object schema by ID', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + schemaId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Assets object schema ID', + }, + }, + + request: { + url: '/api/tools/jsm/assets/schema', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + schemaId: params.schemaId?.trim(), + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), schema: null }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { ts: new Date().toISOString(), schema: null }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + schema: { + type: 'json', + description: 'The Assets object schema', + properties: { + id: { type: 'string', description: 'Schema ID' }, + name: { type: 'string', description: 'Schema name' }, + objectSchemaKey: { type: 'string', description: 'Schema key' }, + status: { type: 'string', description: 'Schema status' }, + description: { type: 'string', description: 'Schema description', optional: true }, + objectCount: { type: 'number', description: 'Number of objects', optional: true }, + objectTypeCount: { + type: 'number', + description: 'Number of object types', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/jsm/get_object_type_attributes.ts b/apps/sim/tools/jsm/get_object_type_attributes.ts new file mode 100644 index 00000000000..5a58bd71bf3 --- /dev/null +++ b/apps/sim/tools/jsm/get_object_type_attributes.ts @@ -0,0 +1,136 @@ +import type { + JsmGetObjectTypeAttributesParams, + JsmGetObjectTypeAttributesResponse, +} from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmGetObjectTypeAttributesTool: ToolConfig< + JsmGetObjectTypeAttributesParams, + JsmGetObjectTypeAttributesResponse +> = { + id: 'jsm_get_object_type_attributes', + name: 'JSM Get Asset Object Type Attributes', + description: + 'Get the attribute definitions for an Assets (Insight/CMDB) object type. Use the returned attribute IDs to build create/update payloads or map columns.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + objectTypeId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Assets object type ID', + }, + onlyValueEditable: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return only attributes whose values can be edited', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter attributes by a search query', + }, + }, + + request: { + url: '/api/tools/jsm/assets/attributes', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + objectTypeId: params.objectTypeId?.trim(), + onlyValueEditable: params.onlyValueEditable, + query: params.query, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), attributes: [], total: 0 }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { ts: new Date().toISOString(), attributes: [], total: 0 }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + attributes: { + type: 'array', + description: 'Attribute definitions for the object type', + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Attribute definition ID — use as objectTypeAttributeId in create/update', + }, + name: { type: 'string', description: 'Attribute name' }, + label: { type: 'boolean', description: 'Whether this attribute is the object label' }, + type: { type: 'number', description: 'Data type discriminator (integer enum)' }, + defaultType: { + type: 'json', + description: 'Default data type { id, name }', + optional: true, + }, + editable: { type: 'boolean', description: 'Whether the value is editable' }, + minimumCardinality: { + type: 'number', + description: 'Minimum number of values (>= 1 means required)', + }, + maximumCardinality: { type: 'number', description: 'Maximum number of values' }, + uniqueAttribute: { + type: 'boolean', + description: 'Whether values must be unique', + optional: true, + }, + }, + }, + }, + total: { type: 'number', description: 'Total number of attributes' }, + }, +} diff --git a/apps/sim/tools/jsm/index.ts b/apps/sim/tools/jsm/index.ts index e11696f0f3a..6f4fe50bc0b 100644 --- a/apps/sim/tools/jsm/index.ts +++ b/apps/sim/tools/jsm/index.ts @@ -5,9 +5,11 @@ import { jsmAddParticipantsTool } from '@/tools/jsm/add_participants' import { jsmAnswerApprovalTool } from '@/tools/jsm/answer_approval' import { jsmAttachFormTool } from '@/tools/jsm/attach_form' import { jsmCopyFormsTool } from '@/tools/jsm/copy_forms' +import { jsmCreateObjectTool } from '@/tools/jsm/create_object' import { jsmCreateOrganizationTool } from '@/tools/jsm/create_organization' import { jsmCreateRequestTool } from '@/tools/jsm/create_request' import { jsmDeleteFormTool } from '@/tools/jsm/delete_form' +import { jsmDeleteObjectTool } from '@/tools/jsm/delete_object' import { jsmExternaliseFormTool } from '@/tools/jsm/externalise_form' import { jsmGetApprovalsTool } from '@/tools/jsm/get_approvals' import { jsmGetCommentsTool } from '@/tools/jsm/get_comments' @@ -17,6 +19,9 @@ import { jsmGetFormAnswersTool } from '@/tools/jsm/get_form_answers' import { jsmGetFormStructureTool } from '@/tools/jsm/get_form_structure' import { jsmGetFormTemplatesTool } from '@/tools/jsm/get_form_templates' import { jsmGetIssueFormsTool } from '@/tools/jsm/get_issue_forms' +import { jsmGetObjectTool } from '@/tools/jsm/get_object' +import { jsmGetObjectSchemaTool } from '@/tools/jsm/get_object_schema' +import { jsmGetObjectTypeAttributesTool } from '@/tools/jsm/get_object_type_attributes' import { jsmGetOrganizationsTool } from '@/tools/jsm/get_organizations' import { jsmGetParticipantsTool } from '@/tools/jsm/get_participants' import { jsmGetQueuesTool } from '@/tools/jsm/get_queues' @@ -28,10 +33,14 @@ import { jsmGetServiceDesksTool } from '@/tools/jsm/get_service_desks' import { jsmGetSlaTool } from '@/tools/jsm/get_sla' import { jsmGetTransitionsTool } from '@/tools/jsm/get_transitions' import { jsmInternaliseFormTool } from '@/tools/jsm/internalise_form' +import { jsmListObjectSchemasTool } from '@/tools/jsm/list_object_schemas' +import { jsmListObjectTypesTool } from '@/tools/jsm/list_object_types' import { jsmReopenFormTool } from '@/tools/jsm/reopen_form' import { jsmSaveFormAnswersTool } from '@/tools/jsm/save_form_answers' +import { jsmSearchObjectsAqlTool } from '@/tools/jsm/search_objects_aql' import { jsmSubmitFormTool } from '@/tools/jsm/submit_form' import { jsmTransitionRequestTool } from '@/tools/jsm/transition_request' +import { jsmUpdateObjectTool } from '@/tools/jsm/update_object' export { jsmAddCommentTool, @@ -41,9 +50,11 @@ export { jsmAnswerApprovalTool, jsmAttachFormTool, jsmCopyFormsTool, + jsmCreateObjectTool, jsmCreateOrganizationTool, jsmCreateRequestTool, jsmDeleteFormTool, + jsmDeleteObjectTool, jsmExternaliseFormTool, jsmGetApprovalsTool, jsmGetCommentsTool, @@ -53,6 +64,9 @@ export { jsmGetFormStructureTool, jsmGetFormTemplatesTool, jsmGetIssueFormsTool, + jsmGetObjectTool, + jsmGetObjectSchemaTool, + jsmGetObjectTypeAttributesTool, jsmGetOrganizationsTool, jsmGetParticipantsTool, jsmGetQueuesTool, @@ -64,8 +78,12 @@ export { jsmGetSlaTool, jsmGetTransitionsTool, jsmInternaliseFormTool, + jsmListObjectSchemasTool, + jsmListObjectTypesTool, jsmReopenFormTool, jsmSaveFormAnswersTool, + jsmSearchObjectsAqlTool, jsmSubmitFormTool, jsmTransitionRequestTool, + jsmUpdateObjectTool, } diff --git a/apps/sim/tools/jsm/list_object_schemas.ts b/apps/sim/tools/jsm/list_object_schemas.ts new file mode 100644 index 00000000000..d08fbdb8527 --- /dev/null +++ b/apps/sim/tools/jsm/list_object_schemas.ts @@ -0,0 +1,126 @@ +import type { JsmListObjectSchemasParams, JsmListObjectSchemasResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmListObjectSchemasTool: ToolConfig< + JsmListObjectSchemasParams, + JsmListObjectSchemasResponse +> = { + id: 'jsm_list_object_schemas', + name: 'JSM List Asset Schemas', + description: 'List Assets (Insight/CMDB) object schemas in Jira Service Management', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + startAt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Pagination start index (e.g., 0, 50)', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum schemas to return (e.g., 25, 50)', + }, + includeCounts: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include object and object-type counts per schema', + }, + }, + + request: { + url: '/api/tools/jsm/assets/schemas', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + startAt: params.startAt, + maxResults: params.maxResults, + includeCounts: params.includeCounts, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), schemas: [], total: 0, isLast: true }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { + ts: new Date().toISOString(), + schemas: [], + total: 0, + isLast: true, + }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + schemas: { + type: 'array', + description: 'List of Assets object schemas', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Schema ID' }, + name: { type: 'string', description: 'Schema name' }, + objectSchemaKey: { type: 'string', description: 'Schema key' }, + status: { type: 'string', description: 'Schema status' }, + description: { type: 'string', description: 'Schema description', optional: true }, + objectCount: { type: 'number', description: 'Number of objects', optional: true }, + objectTypeCount: { + type: 'number', + description: 'Number of object types', + optional: true, + }, + }, + }, + }, + total: { type: 'number', description: 'Total number of schemas' }, + isLast: { type: 'boolean', description: 'Whether this is the last page' }, + }, +} diff --git a/apps/sim/tools/jsm/list_object_types.ts b/apps/sim/tools/jsm/list_object_types.ts new file mode 100644 index 00000000000..324963549c1 --- /dev/null +++ b/apps/sim/tools/jsm/list_object_types.ts @@ -0,0 +1,117 @@ +import type { JsmListObjectTypesParams, JsmListObjectTypesResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmListObjectTypesTool: ToolConfig< + JsmListObjectTypesParams, + JsmListObjectTypesResponse +> = { + id: 'jsm_list_object_types', + name: 'JSM List Asset Object Types', + description: 'List object types within an Assets (Insight/CMDB) object schema', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + schemaId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Assets object schema ID to list object types for', + }, + excludeAbstract: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Exclude abstract object types from the result', + }, + }, + + request: { + url: '/api/tools/jsm/assets/object-types', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + schemaId: params.schemaId?.trim(), + excludeAbstract: params.excludeAbstract, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), objectTypes: [], total: 0 }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { ts: new Date().toISOString(), objectTypes: [], total: 0 }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + objectTypes: { + type: 'array', + description: 'List of object types in the schema', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Object type ID' }, + name: { type: 'string', description: 'Object type name' }, + description: { type: 'string', description: 'Object type description', optional: true }, + objectSchemaId: { type: 'string', description: 'Parent schema ID' }, + objectCount: { type: 'number', description: 'Number of objects', optional: true }, + abstractObjectType: { + type: 'boolean', + description: 'Whether the type is abstract', + optional: true, + }, + inherited: { + type: 'boolean', + description: 'Whether the type inherits attributes', + optional: true, + }, + }, + }, + }, + total: { type: 'number', description: 'Total number of object types' }, + }, +} diff --git a/apps/sim/tools/jsm/search_objects_aql.ts b/apps/sim/tools/jsm/search_objects_aql.ts new file mode 100644 index 00000000000..872cc90cc22 --- /dev/null +++ b/apps/sim/tools/jsm/search_objects_aql.ts @@ -0,0 +1,150 @@ +import type { JsmSearchObjectsAqlParams, JsmSearchObjectsAqlResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmSearchObjectsAqlTool: ToolConfig< + JsmSearchObjectsAqlParams, + JsmSearchObjectsAqlResponse +> = { + id: 'jsm_search_objects_aql', + name: 'JSM Search Assets (AQL)', + description: + 'Search Assets (Insight/CMDB) objects using AQL (Assets Query Language), e.g. objectType = "Host" AND Status = "Running". Supports pagination.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + qlQuery: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'AQL query string (e.g., objectType = "Host" AND "Operating System" = "Ubuntu")', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page number (1-based, defaults to 1)', + }, + resultsPerPage: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Results per page (e.g., 25, 50)', + }, + includeAttributes: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include resolved attribute values on each object (defaults to true)', + }, + objectTypeId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optionally scope the search to a single object type ID', + }, + objectSchemaId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optionally scope the search to a single object schema ID', + }, + }, + + request: { + url: '/api/tools/jsm/assets/search', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + qlQuery: params.qlQuery, + page: params.page, + resultsPerPage: params.resultsPerPage, + includeAttributes: params.includeAttributes, + objectTypeId: params.objectTypeId, + objectSchemaId: params.objectSchemaId, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { + ts: new Date().toISOString(), + objects: [], + total: 0, + pageNumber: 0, + pageSize: 0, + }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { + ts: new Date().toISOString(), + objects: [], + total: 0, + pageNumber: 0, + pageSize: 0, + }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + objects: { + type: 'array', + description: 'Matching Assets objects', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Object ID' }, + label: { type: 'string', description: 'Object label', optional: true }, + objectKey: { type: 'string', description: 'Object key (e.g., HOST-123)', optional: true }, + objectType: { type: 'json', description: 'Object type metadata', optional: true }, + attributes: { type: 'json', description: 'Resolved attribute values', optional: true }, + }, + }, + }, + total: { type: 'number', description: 'Total number of matching objects (totalFilterCount)' }, + pageNumber: { type: 'number', description: 'Current page number' }, + pageSize: { type: 'number', description: 'Number of objects on this page' }, + }, +} diff --git a/apps/sim/tools/jsm/types.ts b/apps/sim/tools/jsm/types.ts index a0160854bba..6f809bd0f92 100644 --- a/apps/sim/tools/jsm/types.ts +++ b/apps/sim/tools/jsm/types.ts @@ -1080,3 +1080,190 @@ export type JsmResponse = | JsmCopyFormsResponse | JsmGetFormAnswersResponse | JsmReopenFormResponse + +/** + * JSM Assets (Insight / CMDB) tool types. + * + * The Assets API is keyed by an Assets `workspaceId` (resolved server-side from + * the Jira `cloudId`). All tools share {@link JsmAssetsBaseParams}. + */ + +/** Base params shared by every JSM Assets tool */ +export interface JsmAssetsBaseParams { + accessToken: string + domain: string + /** Jira Cloud ID (resolved server-side from the domain when omitted) */ + cloudId?: string + /** Assets workspace ID (resolved server-side from the cloudId when omitted) */ + workspaceId?: string +} + +/** A single attribute value entry on an Assets object */ +export interface AssetObjectAttributeValue { + value: string | null + displayValue: string | null + searchValue?: string | null + referencedType?: boolean + referencedObject?: Record | null +} + +/** A resolved attribute on an Assets object (read shape) */ +export interface AssetObjectAttribute { + id: string + objectTypeAttributeId: string + objectAttributeValues: AssetObjectAttributeValue[] +} + +/** An Assets object as returned by get/create/update */ +export interface AssetObject { + id: string + label: string | null + objectKey: string | null + globalId: string | null + created: string | null + updated: string | null + hasAvatar: boolean + objectType: Record | null + attributes: AssetObjectAttribute[] + link: string | null +} + +/** Attribute payload for creating/updating an Assets object */ +export interface AssetObjectAttributeInput { + objectTypeAttributeId: string + objectAttributeValues: Array<{ value: unknown }> +} + +/** Output property descriptors reused across Assets object responses */ +export const ASSET_OBJECT_PROPERTIES = { + id: { type: 'string', description: 'Object ID' }, + label: { type: 'string', description: 'Human-readable object label', optional: true }, + objectKey: { type: 'string', description: 'Object key (e.g., HOST-123)', optional: true }, + globalId: { type: 'string', description: 'Global object ID', optional: true }, + objectType: { type: 'json', description: 'Object type metadata', optional: true }, + attributes: { type: 'json', description: 'Resolved attribute values for the object' }, + hasAvatar: { type: 'boolean', description: 'Whether the object has an avatar', optional: true }, + created: { type: 'string', description: 'Creation timestamp', optional: true }, + updated: { type: 'string', description: 'Last update timestamp', optional: true }, + link: { type: 'string', description: 'Self link to the object', optional: true }, +} as const + +export interface JsmListObjectSchemasParams extends JsmAssetsBaseParams { + startAt?: number + maxResults?: number + includeCounts?: boolean +} + +export interface JsmListObjectSchemasResponse extends ToolResponse { + output: { + ts: string + schemas: Array> + total: number + isLast: boolean + } +} + +export interface JsmGetObjectSchemaParams extends JsmAssetsBaseParams { + schemaId: string +} + +export interface JsmGetObjectSchemaResponse extends ToolResponse { + output: { + ts: string + schema: Record | null + } +} + +export interface JsmListObjectTypesParams extends JsmAssetsBaseParams { + schemaId: string + excludeAbstract?: boolean +} + +export interface JsmListObjectTypesResponse extends ToolResponse { + output: { + ts: string + objectTypes: Array> + total: number + } +} + +export interface JsmGetObjectTypeAttributesParams extends JsmAssetsBaseParams { + objectTypeId: string + onlyValueEditable?: boolean + query?: string +} + +export interface JsmGetObjectTypeAttributesResponse extends ToolResponse { + output: { + ts: string + attributes: Array> + total: number + } +} + +export interface JsmSearchObjectsAqlParams extends JsmAssetsBaseParams { + qlQuery: string + page?: number + resultsPerPage?: number + includeAttributes?: boolean + objectTypeId?: string + objectSchemaId?: string +} + +export interface JsmSearchObjectsAqlResponse extends ToolResponse { + output: { + ts: string + objects: Array> + total: number + pageNumber: number + pageSize: number + } +} + +export interface JsmGetObjectParams extends JsmAssetsBaseParams { + objectId: string +} + +export interface JsmGetObjectResponse extends ToolResponse { + output: { + ts: string + object: AssetObject | null + } +} + +export interface JsmCreateObjectParams extends JsmAssetsBaseParams { + objectTypeId: string + attributes: AssetObjectAttributeInput[] +} + +export interface JsmCreateObjectResponse extends ToolResponse { + output: { + ts: string + object: AssetObject | null + } +} + +export interface JsmUpdateObjectParams extends JsmAssetsBaseParams { + objectId: string + attributes: AssetObjectAttributeInput[] + objectTypeId?: string +} + +export interface JsmUpdateObjectResponse extends ToolResponse { + output: { + ts: string + object: AssetObject | null + } +} + +export interface JsmDeleteObjectParams extends JsmAssetsBaseParams { + objectId: string +} + +export interface JsmDeleteObjectResponse extends ToolResponse { + output: { + ts: string + objectId: string + deleted: boolean + } +} diff --git a/apps/sim/tools/jsm/update_object.ts b/apps/sim/tools/jsm/update_object.ts new file mode 100644 index 00000000000..4457cee9828 --- /dev/null +++ b/apps/sim/tools/jsm/update_object.ts @@ -0,0 +1,104 @@ +import type { JsmUpdateObjectParams, JsmUpdateObjectResponse } from '@/tools/jsm/types' +import { ASSET_OBJECT_PROPERTIES } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmUpdateObjectTool: ToolConfig = { + id: 'jsm_update_object', + name: 'JSM Update Asset Object', + description: + 'Update an existing Assets (Insight/CMDB) object. Provide the attributes to change using their objectTypeAttributeId values.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Assets workspace ID (resolved automatically when omitted)', + }, + objectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Assets object ID to update', + }, + attributes: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Array of attributes to set: [{ objectTypeAttributeId, objectAttributeValues: [{ value }] }]', + }, + objectTypeId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional object type ID (only if changing the type)', + }, + }, + + request: { + url: '/api/tools/jsm/assets/object/update', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + workspaceId: params.workspaceId, + objectId: params.objectId?.trim(), + objectTypeId: params.objectTypeId?.trim(), + attributes: params.attributes, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), object: null }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { ts: new Date().toISOString(), object: null }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + object: { + type: 'json', + description: 'The updated Assets object', + properties: ASSET_OBJECT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/jsm/utils.ts b/apps/sim/tools/jsm/utils.ts index aaf4dad250a..203d48cf188 100644 --- a/apps/sim/tools/jsm/utils.ts +++ b/apps/sim/tools/jsm/utils.ts @@ -2,6 +2,62 @@ * Shared utilities for Jira Service Management tools */ +import { getJiraCloudId } from '@/tools/jira/utils' +import type { AssetObject } from '@/tools/jsm/types' + +/** + * Resolve the Jira `cloudId` and Assets `workspaceId` needed for an Assets API + * call, using the request params when present and falling back to discovery. + * @param domain - The Jira site domain + * @param accessToken - The OAuth access token + * @param cloudIdParam - Optional cloudId already supplied by the caller + * @param workspaceIdParam - Optional workspaceId already supplied by the caller + */ +export async function resolveAssetsContext( + domain: string, + accessToken: string, + cloudIdParam?: string, + workspaceIdParam?: string +): Promise<{ cloudId: string; workspaceId: string }> { + const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken)) + const workspaceId = workspaceIdParam || (await getAssetsWorkspaceId(cloudId, accessToken)) + return { cloudId, workspaceId } +} + +/** + * Normalize a raw Assets object (from get/create/update) into the trimmed + * {@link AssetObject} shape returned by the tools. + * @param data - The raw object payload from the Assets API + */ +export function mapAssetObject(data: Record): AssetObject { + return { + id: data.id, + label: data.label ?? null, + objectKey: data.objectKey ?? null, + globalId: data.globalId ?? null, + created: data.created ?? null, + updated: data.updated ?? null, + hasAvatar: data.hasAvatar ?? false, + objectType: data.objectType ?? null, + attributes: Array.isArray(data.attributes) + ? data.attributes.map((attr: Record) => ({ + id: attr.id, + objectTypeAttributeId: attr.objectTypeAttributeId, + objectAttributeValues: Array.isArray(attr.objectAttributeValues) + ? attr.objectAttributeValues.map((v: Record) => ({ + value: v.value ?? null, + displayValue: v.displayValue ?? null, + searchValue: v.searchValue ?? null, + referencedType: v.referencedType ?? false, + referencedObject: v.referencedObject ?? null, + })) + : [], + })) + : [], + link: data._links?.self ?? null, + } +} + /** * Build the base URL for JSM Service Desk API * @param cloudId - The Jira Cloud ID @@ -33,3 +89,53 @@ export function getJsmHeaders(accessToken: string): Record { 'X-ExperimentalApi': 'opt-in', } } + +/** + * Build the base URL for the JSM Assets (Insight/CMDB) API. + * + * Uses the OAuth 2.0 (3LO) gateway form `/ex/jira/{cloudId}/...` — matching + * {@link getJsmApiBaseUrl} — keyed by both the Jira `cloudId` and the Assets + * `workspaceId` (resolved via {@link getAssetsWorkspaceId}). + * @param cloudId - The Jira Cloud ID + * @param workspaceId - The Assets workspace ID + * @returns The base URL for the Assets API (v1) + */ +export function getAssetsApiBaseUrl(cloudId: string, workspaceId: string): string { + return `https://api.atlassian.com/ex/jira/${cloudId}/jsm/assets/workspace/${workspaceId}/v1` +} + +/** + * Resolve the Assets `workspaceId` for a Jira site. + * + * Calls the Service Desk discovery endpoint, which returns one workspace per + * site. Requires the `read:servicedesk-request` scope (already granted by the + * `jira` provider). + * @param cloudId - The Jira Cloud ID + * @param accessToken - The OAuth access token + * @returns The Assets workspace ID for the site + * @throws If discovery fails or no workspace is provisioned + */ +export async function getAssetsWorkspaceId(cloudId: string, accessToken: string): Promise { + const response = await fetch( + `https://api.atlassian.com/ex/jira/${cloudId}/rest/servicedeskapi/assets/workspace`, + { method: 'GET', headers: getJsmHeaders(accessToken) } + ) + + if (!response.ok) { + const errorText = await response.text() + throw new Error( + `Failed to resolve Assets workspace: ${response.status} - ${errorText || response.statusText}` + ) + } + + const data = await response.json() + const workspaceId: string | undefined = data?.values?.[0]?.workspaceId + + if (!workspaceId) { + throw new Error( + 'No Assets workspace found for this site. Assets (Insight) may not be enabled on the Jira instance.' + ) + } + + return workspaceId +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 45fef1980aa..1f069c3be05 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1691,9 +1691,11 @@ import { jsmAnswerApprovalTool, jsmAttachFormTool, jsmCopyFormsTool, + jsmCreateObjectTool, jsmCreateOrganizationTool, jsmCreateRequestTool, jsmDeleteFormTool, + jsmDeleteObjectTool, jsmExternaliseFormTool, jsmGetApprovalsTool, jsmGetCommentsTool, @@ -1703,6 +1705,9 @@ import { jsmGetFormTemplatesTool, jsmGetFormTool, jsmGetIssueFormsTool, + jsmGetObjectSchemaTool, + jsmGetObjectTool, + jsmGetObjectTypeAttributesTool, jsmGetOrganizationsTool, jsmGetParticipantsTool, jsmGetQueuesTool, @@ -1714,10 +1719,14 @@ import { jsmGetSlaTool, jsmGetTransitionsTool, jsmInternaliseFormTool, + jsmListObjectSchemasTool, + jsmListObjectTypesTool, jsmReopenFormTool, jsmSaveFormAnswersTool, + jsmSearchObjectsAqlTool, jsmSubmitFormTool, jsmTransitionRequestTool, + jsmUpdateObjectTool, } from '@/tools/jsm' import { kalshiAmendOrderTool, @@ -4228,6 +4237,15 @@ export const tools: Record = { jsm_externalise_form: jsmExternaliseFormTool, jsm_internalise_form: jsmInternaliseFormTool, jsm_copy_forms: jsmCopyFormsTool, + jsm_list_object_schemas: jsmListObjectSchemasTool, + jsm_get_object_schema: jsmGetObjectSchemaTool, + jsm_list_object_types: jsmListObjectTypesTool, + jsm_get_object_type_attributes: jsmGetObjectTypeAttributesTool, + jsm_search_objects_aql: jsmSearchObjectsAqlTool, + jsm_get_object: jsmGetObjectTool, + jsm_create_object: jsmCreateObjectTool, + jsm_update_object: jsmUpdateObjectTool, + jsm_delete_object: jsmDeleteObjectTool, kalshi_get_markets: kalshiGetMarketsTool, kalshi_get_markets_v2: kalshiGetMarketsV2Tool, kalshi_get_market: kalshiGetMarketTool, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index d82996804ca..f970921235e 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 828, - zodRoutes: 828, + totalRoutes: 837, + zodRoutes: 837, nonZodRoutes: 0, } as const From 94c74305fdbc6103461d3b06ff03d8e6a875ca93 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 16:03:40 -0700 Subject: [PATCH 2/5] refactor(jsm): harden Assets param coercion and response typing - Add toOptionalInt helper so non-numeric pagination inputs never emit NaN into the Assets query string (startAt/maxResults/page/resultsPerPage) - Replace Record in mapAssetObject with typed Raw* interfaces --- .../blocks/blocks/jira_service_management.ts | 22 +++++++++----- apps/sim/tools/jsm/types.ts | 30 +++++++++++++++++++ apps/sim/tools/jsm/utils.ts | 30 ++++++++----------- 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/apps/sim/blocks/blocks/jira_service_management.ts b/apps/sim/blocks/blocks/jira_service_management.ts index f5aa2a31228..75d2ceda320 100644 --- a/apps/sim/blocks/blocks/jira_service_management.ts +++ b/apps/sim/blocks/blocks/jira_service_management.ts @@ -5,6 +5,16 @@ import { AuthMode, IntegrationType } from '@/blocks/types' import type { JsmResponse } from '@/tools/jsm/types' import { getTrigger } from '@/triggers' +/** + * Coerce an optional numeric block input into an integer, returning undefined for + * empty or non-numeric values so no `NaN` reaches the API query string. + */ +function toOptionalInt(value: string | undefined): number | undefined { + if (!value) return undefined + const parsed = Number.parseInt(value, 10) + return Number.isNaN(parsed) ? undefined : parsed +} + /** * Parse the Assets attributes input into the API payload array. Accepts either a * JSON string (from the block input) or an already-parsed array (from a dynamic @@ -1328,10 +1338,8 @@ Return ONLY the comment text - no explanations.`, case 'list_object_schemas': return { ...baseParams, - startAt: params.assetStartAt ? Number.parseInt(params.assetStartAt) : undefined, - maxResults: params.assetMaxResults - ? Number.parseInt(params.assetMaxResults) - : undefined, + startAt: toOptionalInt(params.assetStartAt), + maxResults: toOptionalInt(params.assetMaxResults), includeCounts: params.assetIncludeCounts === 'true' ? true : undefined, } case 'get_object_schema': @@ -1365,10 +1373,8 @@ Return ONLY the comment text - no explanations.`, return { ...baseParams, qlQuery: params.assetQlQuery, - page: params.assetPage ? Number.parseInt(params.assetPage) : undefined, - resultsPerPage: params.assetResultsPerPage - ? Number.parseInt(params.assetResultsPerPage) - : undefined, + page: toOptionalInt(params.assetPage), + resultsPerPage: toOptionalInt(params.assetResultsPerPage), includeAttributes: params.assetIncludeAttributes === 'false' ? false : undefined, objectTypeId: params.assetObjectTypeId || undefined, objectSchemaId: params.assetObjectSchemaId || undefined, diff --git a/apps/sim/tools/jsm/types.ts b/apps/sim/tools/jsm/types.ts index 6f809bd0f92..fd0c6d8ee07 100644 --- a/apps/sim/tools/jsm/types.ts +++ b/apps/sim/tools/jsm/types.ts @@ -1134,6 +1134,36 @@ export interface AssetObjectAttributeInput { objectAttributeValues: Array<{ value: unknown }> } +/** Raw attribute value as returned by the Assets API (before normalization) */ +export interface RawAssetObjectAttributeValue { + value?: string | null + displayValue?: string | null + searchValue?: string | null + referencedType?: boolean + referencedObject?: Record | null +} + +/** Raw attribute as returned by the Assets API (before normalization) */ +export interface RawAssetObjectAttribute { + id?: string + objectTypeAttributeId?: string + objectAttributeValues?: RawAssetObjectAttributeValue[] +} + +/** Raw Assets object as returned by get/create/update/AQL (before normalization) */ +export interface RawAssetObject { + id: string + label?: string | null + objectKey?: string | null + globalId?: string | null + created?: string | null + updated?: string | null + hasAvatar?: boolean + objectType?: Record | null + attributes?: RawAssetObjectAttribute[] + _links?: { self?: string } | null +} + /** Output property descriptors reused across Assets object responses */ export const ASSET_OBJECT_PROPERTIES = { id: { type: 'string', description: 'Object ID' }, diff --git a/apps/sim/tools/jsm/utils.ts b/apps/sim/tools/jsm/utils.ts index 203d48cf188..44e813f8ffa 100644 --- a/apps/sim/tools/jsm/utils.ts +++ b/apps/sim/tools/jsm/utils.ts @@ -3,7 +3,7 @@ */ import { getJiraCloudId } from '@/tools/jira/utils' -import type { AssetObject } from '@/tools/jsm/types' +import type { AssetObject, RawAssetObject } from '@/tools/jsm/types' /** * Resolve the Jira `cloudId` and Assets `workspaceId` needed for an Assets API @@ -29,7 +29,7 @@ export async function resolveAssetsContext( * {@link AssetObject} shape returned by the tools. * @param data - The raw object payload from the Assets API */ -export function mapAssetObject(data: Record): AssetObject { +export function mapAssetObject(data: RawAssetObject): AssetObject { return { id: data.id, label: data.label ?? null, @@ -39,21 +39,17 @@ export function mapAssetObject(data: Record): AssetObject { updated: data.updated ?? null, hasAvatar: data.hasAvatar ?? false, objectType: data.objectType ?? null, - attributes: Array.isArray(data.attributes) - ? data.attributes.map((attr: Record) => ({ - id: attr.id, - objectTypeAttributeId: attr.objectTypeAttributeId, - objectAttributeValues: Array.isArray(attr.objectAttributeValues) - ? attr.objectAttributeValues.map((v: Record) => ({ - value: v.value ?? null, - displayValue: v.displayValue ?? null, - searchValue: v.searchValue ?? null, - referencedType: v.referencedType ?? false, - referencedObject: v.referencedObject ?? null, - })) - : [], - })) - : [], + attributes: (data.attributes ?? []).map((attr) => ({ + id: attr.id ?? '', + objectTypeAttributeId: attr.objectTypeAttributeId ?? '', + objectAttributeValues: (attr.objectAttributeValues ?? []).map((v) => ({ + value: v.value ?? null, + displayValue: v.displayValue ?? null, + searchValue: v.searchValue ?? null, + referencedType: v.referencedType ?? false, + referencedObject: v.referencedObject ?? null, + })), + })), link: data._links?.self ?? null, } } From 5539c1beca18d39df7a60190e3fdb4e772837fcd Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 16:07:49 -0700 Subject: [PATCH 3/5] fix(jsm): validate Assets workspaceId and honor `last` pagination flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review findings on the Assets tools: - Add validateAssetsWorkspaceId and guard the workspaceId in every Assets route before it is interpolated into the API path (mirrors the existing cloudId guard) — prevents a crafted workspaceId from escaping the workspace-scoped path - Object schema list now falls back to the `last` flag when `isLast` is absent, so pagination doesn't stop early --- .../api/tools/jsm/assets/attributes/route.ts | 10 ++++++++- .../tools/jsm/assets/object-types/route.ts | 10 ++++++++- .../tools/jsm/assets/object/create/route.ts | 10 ++++++++- .../tools/jsm/assets/object/delete/route.ts | 10 ++++++++- .../api/tools/jsm/assets/object/get/route.ts | 10 ++++++++- .../tools/jsm/assets/object/update/route.ts | 10 ++++++++- .../app/api/tools/jsm/assets/schema/route.ts | 10 ++++++++- .../app/api/tools/jsm/assets/schemas/route.ts | 12 +++++++++-- .../app/api/tools/jsm/assets/search/route.ts | 10 ++++++++- .../sim/lib/core/security/input-validation.ts | 21 +++++++++++++++++++ 10 files changed, 103 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/api/tools/jsm/assets/attributes/route.ts b/apps/sim/app/api/tools/jsm/assets/attributes/route.ts index 7714958ebfa..28804c427f2 100644 --- a/apps/sim/app/api/tools/jsm/assets/attributes/route.ts +++ b/apps/sim/app/api/tools/jsm/assets/attributes/route.ts @@ -4,7 +4,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { jsmObjectTypeAttributesContract } from '@/lib/api/contracts/selectors/jsm' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { + validateAssetsWorkspaceId, + validateJiraCloudId, +} from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getAssetsApiBaseUrl, getJsmHeaders, resolveAssetsContext } from '@/tools/jsm/utils' @@ -45,6 +48,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } + const workspaceIdValidation = validateAssetsWorkspaceId(workspaceId, 'workspaceId') + if (!workspaceIdValidation.isValid) { + return NextResponse.json({ error: workspaceIdValidation.error }, { status: 400 }) + } + const query = new URLSearchParams() if (onlyValueEditable !== undefined) { query.append('onlyValueEditable', String(onlyValueEditable)) diff --git a/apps/sim/app/api/tools/jsm/assets/object-types/route.ts b/apps/sim/app/api/tools/jsm/assets/object-types/route.ts index e3f54842394..328ccaa8c69 100644 --- a/apps/sim/app/api/tools/jsm/assets/object-types/route.ts +++ b/apps/sim/app/api/tools/jsm/assets/object-types/route.ts @@ -4,7 +4,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { jsmListObjectTypesContract } from '@/lib/api/contracts/selectors/jsm' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { + validateAssetsWorkspaceId, + validateJiraCloudId, +} from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getAssetsApiBaseUrl, getJsmHeaders, resolveAssetsContext } from '@/tools/jsm/utils' @@ -44,6 +47,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } + const workspaceIdValidation = validateAssetsWorkspaceId(workspaceId, 'workspaceId') + if (!workspaceIdValidation.isValid) { + return NextResponse.json({ error: workspaceIdValidation.error }, { status: 400 }) + } + const query = new URLSearchParams() if (excludeAbstract !== undefined) query.append('excludeAbstract', String(excludeAbstract)) diff --git a/apps/sim/app/api/tools/jsm/assets/object/create/route.ts b/apps/sim/app/api/tools/jsm/assets/object/create/route.ts index 7327513ee69..7c85f69a0fd 100644 --- a/apps/sim/app/api/tools/jsm/assets/object/create/route.ts +++ b/apps/sim/app/api/tools/jsm/assets/object/create/route.ts @@ -4,7 +4,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { jsmCreateObjectContract } from '@/lib/api/contracts/selectors/jsm' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { + validateAssetsWorkspaceId, + validateJiraCloudId, +} from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' import { @@ -49,6 +52,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } + const workspaceIdValidation = validateAssetsWorkspaceId(workspaceId, 'workspaceId') + if (!workspaceIdValidation.isValid) { + return NextResponse.json({ error: workspaceIdValidation.error }, { status: 400 }) + } + const url = `${getAssetsApiBaseUrl(cloudId, workspaceId)}/object/create` const response = await fetch(url, { diff --git a/apps/sim/app/api/tools/jsm/assets/object/delete/route.ts b/apps/sim/app/api/tools/jsm/assets/object/delete/route.ts index 7c2c8859356..cf4dd4b1d4a 100644 --- a/apps/sim/app/api/tools/jsm/assets/object/delete/route.ts +++ b/apps/sim/app/api/tools/jsm/assets/object/delete/route.ts @@ -4,7 +4,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { jsmDeleteObjectContract } from '@/lib/api/contracts/selectors/jsm' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { + validateAssetsWorkspaceId, + validateJiraCloudId, +} from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getAssetsApiBaseUrl, getJsmHeaders, resolveAssetsContext } from '@/tools/jsm/utils' @@ -43,6 +46,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } + const workspaceIdValidation = validateAssetsWorkspaceId(workspaceId, 'workspaceId') + if (!workspaceIdValidation.isValid) { + return NextResponse.json({ error: workspaceIdValidation.error }, { status: 400 }) + } + const url = `${getAssetsApiBaseUrl(cloudId, workspaceId)}/object/${encodeURIComponent(objectId)}` const response = await fetch(url, { method: 'DELETE', headers: getJsmHeaders(accessToken) }) diff --git a/apps/sim/app/api/tools/jsm/assets/object/get/route.ts b/apps/sim/app/api/tools/jsm/assets/object/get/route.ts index 2470c2c1213..fa7fa3759e9 100644 --- a/apps/sim/app/api/tools/jsm/assets/object/get/route.ts +++ b/apps/sim/app/api/tools/jsm/assets/object/get/route.ts @@ -4,7 +4,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { jsmGetObjectContract } from '@/lib/api/contracts/selectors/jsm' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { + validateAssetsWorkspaceId, + validateJiraCloudId, +} from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' import { @@ -48,6 +51,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } + const workspaceIdValidation = validateAssetsWorkspaceId(workspaceId, 'workspaceId') + if (!workspaceIdValidation.isValid) { + return NextResponse.json({ error: workspaceIdValidation.error }, { status: 400 }) + } + const url = `${getAssetsApiBaseUrl(cloudId, workspaceId)}/object/${encodeURIComponent(objectId)}` const response = await fetch(url, { method: 'GET', headers: getJsmHeaders(accessToken) }) diff --git a/apps/sim/app/api/tools/jsm/assets/object/update/route.ts b/apps/sim/app/api/tools/jsm/assets/object/update/route.ts index 3b1552eb4e0..bfc7a6286a8 100644 --- a/apps/sim/app/api/tools/jsm/assets/object/update/route.ts +++ b/apps/sim/app/api/tools/jsm/assets/object/update/route.ts @@ -4,7 +4,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { jsmUpdateObjectContract } from '@/lib/api/contracts/selectors/jsm' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { + validateAssetsWorkspaceId, + validateJiraCloudId, +} from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' import { @@ -50,6 +53,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } + const workspaceIdValidation = validateAssetsWorkspaceId(workspaceId, 'workspaceId') + if (!workspaceIdValidation.isValid) { + return NextResponse.json({ error: workspaceIdValidation.error }, { status: 400 }) + } + const url = `${getAssetsApiBaseUrl(cloudId, workspaceId)}/object/${encodeURIComponent(objectId)}` const body: Record = { attributes } diff --git a/apps/sim/app/api/tools/jsm/assets/schema/route.ts b/apps/sim/app/api/tools/jsm/assets/schema/route.ts index 286ddf7af84..d2eb97d01c7 100644 --- a/apps/sim/app/api/tools/jsm/assets/schema/route.ts +++ b/apps/sim/app/api/tools/jsm/assets/schema/route.ts @@ -4,7 +4,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { jsmGetObjectSchemaContract } from '@/lib/api/contracts/selectors/jsm' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { + validateAssetsWorkspaceId, + validateJiraCloudId, +} from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getAssetsApiBaseUrl, getJsmHeaders, resolveAssetsContext } from '@/tools/jsm/utils' @@ -43,6 +46,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } + const workspaceIdValidation = validateAssetsWorkspaceId(workspaceId, 'workspaceId') + if (!workspaceIdValidation.isValid) { + return NextResponse.json({ error: workspaceIdValidation.error }, { status: 400 }) + } + const url = `${getAssetsApiBaseUrl(cloudId, workspaceId)}/objectschema/${encodeURIComponent(schemaId)}` const response = await fetch(url, { method: 'GET', headers: getJsmHeaders(accessToken) }) diff --git a/apps/sim/app/api/tools/jsm/assets/schemas/route.ts b/apps/sim/app/api/tools/jsm/assets/schemas/route.ts index 7f19cbd9c6d..5219e97853d 100644 --- a/apps/sim/app/api/tools/jsm/assets/schemas/route.ts +++ b/apps/sim/app/api/tools/jsm/assets/schemas/route.ts @@ -4,7 +4,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { jsmListObjectSchemasContract } from '@/lib/api/contracts/selectors/jsm' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { + validateAssetsWorkspaceId, + validateJiraCloudId, +} from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getAssetsApiBaseUrl, getJsmHeaders, resolveAssetsContext } from '@/tools/jsm/utils' @@ -45,6 +48,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } + const workspaceIdValidation = validateAssetsWorkspaceId(workspaceId, 'workspaceId') + if (!workspaceIdValidation.isValid) { + return NextResponse.json({ error: workspaceIdValidation.error }, { status: 400 }) + } + const query = new URLSearchParams() if (startAt !== undefined) query.append('startAt', String(startAt)) if (maxResults !== undefined) query.append('maxResults', String(maxResults)) @@ -76,7 +84,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ts: new Date().toISOString(), schemas: data.values ?? [], total: data.total ?? (data.values?.length || 0), - isLast: data.isLast ?? true, + isLast: data.isLast ?? data.last ?? true, }, }) } catch (error) { diff --git a/apps/sim/app/api/tools/jsm/assets/search/route.ts b/apps/sim/app/api/tools/jsm/assets/search/route.ts index 2551a0ce291..3ddad2dae78 100644 --- a/apps/sim/app/api/tools/jsm/assets/search/route.ts +++ b/apps/sim/app/api/tools/jsm/assets/search/route.ts @@ -4,7 +4,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { jsmSearchObjectsAqlContract } from '@/lib/api/contracts/selectors/jsm' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { + validateAssetsWorkspaceId, + validateJiraCloudId, +} from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' import { @@ -60,6 +63,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } + const workspaceIdValidation = validateAssetsWorkspaceId(workspaceId, 'workspaceId') + if (!workspaceIdValidation.isValid) { + return NextResponse.json({ error: workspaceIdValidation.error }, { status: 400 }) + } + const includeAttrs = includeAttributes === undefined ? true : String(includeAttributes) === 'true' diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index 98ac9e1c982..57ba5939b98 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -627,6 +627,27 @@ export function validateJiraCloudId( }) } +/** + * Validates an Atlassian Assets workspace ID (a UUID-shaped, hyphenated + * alphanumeric identifier) before it is interpolated into an API path. + * + * @param value - The Assets workspace ID to validate + * @param paramName - Name of the parameter for error messages + * @returns ValidationResult + */ +export function validateAssetsWorkspaceId( + value: string | null | undefined, + paramName = 'workspaceId' +): ValidationResult { + return validatePathSegment(value, { + paramName, + allowHyphens: true, + allowUnderscores: false, + allowDots: false, + maxLength: 100, + }) +} + /** * Validates Jira issue keys (format: PROJECT-123 or PROJECT-KEY-123) * From e7054cd5bb156d601700aac131270788c9f8ffca Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 16:18:15 -0700 Subject: [PATCH 4/5] feat(jsm): allow overriding the auto-resolved Assets workspace Atlassian provisions one Assets workspace per site, so workspace discovery uses values[0] by design. For the rare multi-workspace site, expose an advanced "Assets Workspace ID" override on the block that flows through to every Assets operation, and document the single-workspace assumption. --- .../blocks/blocks/jira_service_management.ts | 50 +++++++++++++++---- apps/sim/tools/jsm/utils.ts | 8 +-- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/apps/sim/blocks/blocks/jira_service_management.ts b/apps/sim/blocks/blocks/jira_service_management.ts index 75d2ceda320..a32841ec48c 100644 --- a/apps/sim/blocks/blocks/jira_service_management.ts +++ b/apps/sim/blocks/blocks/jira_service_management.ts @@ -763,6 +763,27 @@ Return ONLY the comment text - no explanations.`, mode: 'advanced', condition: { field: 'operation', value: 'search_objects_aql' }, }, + { + id: 'assetWorkspaceId', + title: 'Assets Workspace ID', + type: 'short-input', + placeholder: 'Override the auto-resolved Assets workspace', + mode: 'advanced', + condition: { + field: 'operation', + value: [ + 'list_object_schemas', + 'get_object_schema', + 'list_object_types', + 'get_object_type_attributes', + 'search_objects_aql', + 'get_object', + 'create_object', + 'update_object', + 'delete_object', + ], + }, + }, ...getTrigger('jsm_request_created').subBlocks, ...getTrigger('jsm_request_updated').subBlocks, ...getTrigger('jsm_request_commented').subBlocks, @@ -914,6 +935,13 @@ Return ONLY the comment text - no explanations.`, domain: params.domain, } + // Assets tools accept an optional workspaceId override; when omitted the + // route resolves the site's single Assets workspace automatically. + const assetBaseParams = { + ...baseParams, + workspaceId: params.assetWorkspaceId || undefined, + } + switch (params.operation) { case 'get_service_desks': return { @@ -1337,7 +1365,7 @@ Return ONLY the comment text - no explanations.`, } case 'list_object_schemas': return { - ...baseParams, + ...assetBaseParams, startAt: toOptionalInt(params.assetStartAt), maxResults: toOptionalInt(params.assetMaxResults), includeCounts: params.assetIncludeCounts === 'true' ? true : undefined, @@ -1346,13 +1374,13 @@ Return ONLY the comment text - no explanations.`, if (!params.assetSchemaId) { throw new Error('Schema ID is required') } - return { ...baseParams, schemaId: params.assetSchemaId } + return { ...assetBaseParams, schemaId: params.assetSchemaId } case 'list_object_types': if (!params.assetSchemaId) { throw new Error('Schema ID is required') } return { - ...baseParams, + ...assetBaseParams, schemaId: params.assetSchemaId, excludeAbstract: params.assetExcludeAbstract === 'true' ? true : undefined, } @@ -1361,7 +1389,7 @@ Return ONLY the comment text - no explanations.`, throw new Error('Object type ID is required') } return { - ...baseParams, + ...assetBaseParams, objectTypeId: params.assetObjectTypeId, onlyValueEditable: params.assetOnlyValueEditable === 'true' ? true : undefined, query: params.assetAttributeQuery || undefined, @@ -1371,7 +1399,7 @@ Return ONLY the comment text - no explanations.`, throw new Error('AQL query is required') } return { - ...baseParams, + ...assetBaseParams, qlQuery: params.assetQlQuery, page: toOptionalInt(params.assetPage), resultsPerPage: toOptionalInt(params.assetResultsPerPage), @@ -1383,13 +1411,13 @@ Return ONLY the comment text - no explanations.`, if (!params.assetObjectId) { throw new Error('Object ID is required') } - return { ...baseParams, objectId: params.assetObjectId } + return { ...assetBaseParams, objectId: params.assetObjectId } case 'create_object': if (!params.assetObjectTypeId) { throw new Error('Object type ID is required') } return { - ...baseParams, + ...assetBaseParams, objectTypeId: params.assetObjectTypeId, attributes: parseAssetAttributes(params.assetAttributes), } @@ -1398,7 +1426,7 @@ Return ONLY the comment text - no explanations.`, throw new Error('Object ID is required') } return { - ...baseParams, + ...assetBaseParams, objectId: params.assetObjectId, attributes: parseAssetAttributes(params.assetAttributes), objectTypeId: params.assetObjectTypeId || undefined, @@ -1407,7 +1435,7 @@ Return ONLY the comment text - no explanations.`, if (!params.assetObjectId) { throw new Error('Object ID is required') } - return { ...baseParams, objectId: params.assetObjectId } + return { ...assetBaseParams, objectId: params.assetObjectId } default: return baseParams } @@ -1481,6 +1509,10 @@ Return ONLY the comment text - no explanations.`, assetResultsPerPage: { type: 'string', description: 'AQL search results per page' }, assetIncludeAttributes: { type: 'string', description: 'Include attribute values in results' }, assetObjectSchemaId: { type: 'string', description: 'Scope AQL search to a schema ID' }, + assetWorkspaceId: { + type: 'string', + description: 'Override the auto-resolved Assets workspace ID', + }, }, outputs: { ts: { type: 'string', description: 'Timestamp of the operation' }, diff --git a/apps/sim/tools/jsm/utils.ts b/apps/sim/tools/jsm/utils.ts index 44e813f8ffa..df9a46e4af3 100644 --- a/apps/sim/tools/jsm/utils.ts +++ b/apps/sim/tools/jsm/utils.ts @@ -103,9 +103,11 @@ export function getAssetsApiBaseUrl(cloudId: string, workspaceId: string): strin /** * Resolve the Assets `workspaceId` for a Jira site. * - * Calls the Service Desk discovery endpoint, which returns one workspace per - * site. Requires the `read:servicedesk-request` scope (already granted by the - * `jira` provider). + * Calls the Service Desk discovery endpoint and uses the first workspace. + * Atlassian provisions a single Assets workspace per site, so this is the + * canonical workspace; callers on a multi-workspace site can pass an explicit + * `workspaceId` to {@link resolveAssetsContext} to override it. Requires the + * `read:servicedesk-request` scope (already granted by the `jira` provider). * @param cloudId - The Jira Cloud ID * @param accessToken - The OAuth access token * @returns The Assets workspace ID for the site From 3933d55b584bd6d382a33cb20bfaf212ac2292f2 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 16:33:08 -0700 Subject: [PATCH 5/5] refactor(jsm): include Assets responses in the JsmResponse union Append the nine Assets tool response types to JsmResponse for completeness and consistency with the rest of the JSM tool surface. --- apps/sim/tools/jsm/types.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/sim/tools/jsm/types.ts b/apps/sim/tools/jsm/types.ts index fd0c6d8ee07..e35c97c74c6 100644 --- a/apps/sim/tools/jsm/types.ts +++ b/apps/sim/tools/jsm/types.ts @@ -1080,6 +1080,15 @@ export type JsmResponse = | JsmCopyFormsResponse | JsmGetFormAnswersResponse | JsmReopenFormResponse + | JsmListObjectSchemasResponse + | JsmGetObjectSchemaResponse + | JsmListObjectTypesResponse + | JsmGetObjectTypeAttributesResponse + | JsmSearchObjectsAqlResponse + | JsmGetObjectResponse + | JsmCreateObjectResponse + | JsmUpdateObjectResponse + | JsmDeleteObjectResponse /** * JSM Assets (Insight / CMDB) tool types.