CI HUBCI HUB SDK
Building an Adapter

Asset Operations

Handlers for modifying assets after they exist: delete, version history, lock/unlock, rename, and move. Each requires a capability flag to appear in the plugin UI.

Capability Flags

Enable each operation in your adapter's capabilities object:

capabilities: {
  canDeleteAsset: true,
  canLockAsset: true,
  canRenameAsset: true,
  canMoveAsset: true
}

getAssetVersions has no capability flag — it's always available when implemented.

deleteAsset

Permanently removes an asset from the platform. CI HUB calls this when the user clicks Delete on an asset in the plugin UI.

Inputs

FieldDescription
requestData.params.assetIdAsset ID to delete
requestData.query.parentId or requestData.body.parentIdParent folder ID (some platforms need this to distinguish collections from folders)

Response

return sendResult(null)   // or return sendStatus(200)

Patterns

Example - REST API, strips version suffix from compound IDs:

deleteAsset: async (locals, requestData) => {
  const assetId = requestData.params.assetId.split('::')[0]

  try {
    await callApi(locals, 'DELETE', `/api/v4/media/${assetId}/`, {})

    return sendResult(null)
  } catch (err) {
    return sendError(`Could not delete asset ${assetId} (${err})`, 403)
  }
},

Example - DAM that handles collection vs folder context:

deleteAsset: async (locals, requestData) => {
  try {
    const { assetId } = requestData.params
    const parentFolderId = requestData.query.parentId || requestData.body.parentId || ''

    if (parentFolderId.startsWith('collection:')) {
      const collectionId = parentFolderId.replace('collection:', '')

      await callApi(locals, { query: queries.removeCollectionAssetsMutation, variables: { input: { collectionId, assetIds: [ assetId ] } } })
    } else {
      await callApi(locals, { query: queries.deleteAssetMutation, variables: { input: { id: assetId } } })
    }
    return sendStatus(200)
  } catch (error) {
    return sendError(error, 400)
  }
},

UI Behavior

On success, the asset disappears from the current folder/search results. On error, the plugin UI shows the error message.

Delete Asset Schema


getAssetVersions

Returns version history for an asset. CI HUB calls this when the user opens the version panel for an asset.

Inputs

FieldDescription
requestData.query.assetIdSingle asset ID
requestData.query.assetIds or requestData.body.assetIdsArray of asset IDs (batch mode)
requestData.query.withMaster"true" to include the current/master version

Response

Return an object with id, name, and versions array. Each version uses the same shape as extractAssetsData output plus masterId:

return send({
  id: assetId,
  name: 'filename.jpg',
  versions: [
    {
      ...assetData,       // standard asset fields (thumbnailUrl, fileSize, etc.)
      masterId: assetId,  // always the root asset ID
      id: 'asset123::2',  // version-specific ID
      version: 2,
    }
  ]
})

Patterns

Example - DAM that iterates mediaItems for version history:

const getAssetVersions = async (locals, requestData, assetId) => {
  const withMaster = requestData.query.withMaster === 'true'

  try {
    const asset = await callApi(locals, 'GET', `/api/v4/media/${assetId}`, {
      versions: true
    })

    const assetInfo = extractAssetData(locals, asset)

    const result = {
      id: assetId,
      name: '',
      versions: []
    }

    result.versions = asset.mediaItems.
      filter(item => item.type === 'original').
      sort((itemA, itemB) => itemB.version - itemA.version).
      map((item, idx) => {
        const versionId = `${assetId}::${item.version}`
        const version = Object.assign({}, assetInfo, {
          masterId: assetId,
          id: versionId,
          version: item.version,
          fileSize: item.size,
          xSizePx: item.width,
          ySizePx: item.height,
          thumbnailUrl: item.thumbnails.thul && `${item.thumbnails.thul}$NO_AUTH$`,
          downloadUrl: getDownloadUrl(locals, versionId),
          lowresUrl: getDownloadUrl(locals, versionId, '?lowres=1')
        })

        if (idx) {
          version.created = new Date(item.dateCreated).getTime()
          version.modified = new Date(item.dateCreated).getTime()
          version.downloadHashFileAttributes = JSON.stringify({ fileSize: version.fileSize, modified: version.modified })
        }

        return version
      })
    if (!withMaster) {
      result.versions.shift()
    }

    return result
  } catch (err) {
    throw new Error(`Could not get asset versions for ${assetId} (${err})`)
  }
}

Example - DAM that returns single version when platform doesn't support version history:

getAssetVersions: async (locals, requestData) => {
  const { assetId } = requestData.query
  const withMaster = requestData.query.withMaster === 'true'
  const result = { id: assetId, name: undefined, versions: [] }

  if (!withMaster) {
    return sendError('DAM doesn\'t support versioning', 400)
  }
  try {
    const asset = await callApi(locals, { query: queries.getAssetByIdQuery, variables: { assetId } })

    result.versions = asset.data.asset && extractAssetsData(locals, [ asset.data.asset ]).map(assetData => ({ ...assetData, masterId: assetId }))

    result.name = result.versions[0]?.name
    return send(result)
  } catch (error) {
    return sendError(error, 400)
  }
},

UI Behavior

The version panel lists all returned versions with thumbnails, dates, and file sizes. Users can download or place any version. The masterId links versions back to the root asset.

Asset Versions Schema


lockAsset

Locks (checks out) or unlocks (checks in) an asset. Prevents concurrent edits on platforms that support exclusive checkout.

Inputs

FieldDescription
requestData.params.assetIdAsset ID
requestData.body.lockedtrue to lock, false to unlock

Response

return send({ id: assetId, lockedBy: 'username' })  // locked
return send({ id: assetId, lockedBy: null })         // unlocked

Patterns

Example - DAM with checkout/checkin endpoint, polls for lock confirmation:

lockAsset: async (locals, requestData) => {
  const assetId = requestData.params.assetId
  const locked = requestData.body.locked
  const action = locked ? 'checkout' : 'checkin'

  try {
    await callApi(locals, 'POST', `content/dam/${assetId}.checkout.json`, { action })

    let retries = maxRetries
    let lockedBy

    if (locked) {
      while (!lockedBy && retries) {
        if (retries !== maxRetries) {
          await promisify(setTimeout)(1000)
        }
        lockedBy = (await callApi(locals, 'GET', `/platform/content/dam/${assetId};resource=metadata`))?.['aem:checkedOutBy']
        retries -= 1
      }
      if (retries === 0) {
        throw new Error(`${locked ? 'Lock (checkout)' : 'Unlock (checkin)'} operation failed after a ${maxRetries} second(s) waiting period.`)
      }
    }

    return send({ id: assetId, lockedBy })
  } catch (err) {
    return sendResult(err)
  }
},

Example - DAM that supports separate lock/unlock endpoints:

lockAsset: async (locals, requestData) => {
  const assetId = requestData.params.assetId
  const locked = requestData.body.locked
  const result = { id: null, lockedBy: null }

  try {
    const currentUser = (await callApi(locals, 'GET', '/users/current')).payload

    if (locked) {
      const lockResponse = await callApi(locals, 'POST', `/assets/${assetId}/checkOut`)

      if (lockResponse.errors && lockResponse.errors.length) {
        return sendError(lockResponse.errors.join(', '), 400)
      }

      result.id = assetId
      result.lockedBy = `You (${currentUser.userName})`
    } else {
      const unlockResponse = await callApi(locals, 'PATCH', `/assets/${assetId}`, null, [{ op: 'remove', path: '/checkedOutBy' }])

      if (unlockResponse.errors && unlockResponse.errors.length) {
        return sendError(unlockResponse.errors.join(', '), 400)
      }
      result.id = assetId
    }
  } catch (err) {
    return sendError(err, 400)
  }
  return send(result)
},

UI Behavior

When locked, the plugin UI shows a lock icon on the asset and displays lockedBy. Only the lock owner (or an admin, depending on platform) can unlock. The lock prevents other users from uploading new versions.

Lock Asset Schema


renameAsset

Changes an asset's display name or filename.

Inputs

FieldDescription
requestData.params.assetIdAsset ID
requestData.body.nameNew name

Response

return send({ id: assetId, name: newName })

Patterns

Example - DAM that distinguishes filename vs title based on extension:

renameAsset: async (locals, requestData) => {
  try {
    const { name } = requestData.body
    const { assetId } = requestData.params

    const hasExtension = Boolean(mime.lookup(name))

    const asset = await callApi(locals, {
      query: queries.updateAssetMutation,
      variables: { input: { id: assetId, data: hasExtension ? { filename: name } : { title: name } } }
    })

    const updatedAsset = asset.data.updateAsset.asset

    return send({ id: updatedAsset.id.toString(), name: hasExtension ? updatedAsset.filename : updatedAsset.title })
  } catch (error) {
    return sendError(error, 400)
  }
},

Example — REST API, straightforward PUT:

renameAsset: async (locals, requestData) => {
  try {
    const { name } = requestData.body
    const { assetId } = requestData.params
    const { id } = getAssetIdInfo(assetId)

    await callApi(locals, 'PUT', `/files/${id}`, null, { name })

    return send({ id: assetId, name })
  } catch (error) {
    return sendError(error, 400)
  }
},

UI Behavior

On success, the asset's name updates immediately in the plugin UI (folder view and detail panel).

Rename Asset Schema


moveAsset

Moves an asset from one folder to another.

Inputs

FieldDescription
requestData.body.assetIdAsset ID
requestData.body.targetParentIdDestination folder ID
requestData.body.currentParentIdSource folder ID

Response

return send({ id: assetId, folderId: targetParentId })

Patterns

Example - DAM that replaces old category with new one:

moveAsset: async (locals, requestData) => {
  try {
    const { assetId, targetParentId: folderId, currentParentId } = requestData.body
    if (typeof folderId !== 'string' || !folderId || folderId.startsWith('lightbox') || [ 'root', 'categories', 'lightboxes', 'home', 'mostViewed', 'recentlyUploaded' ].includes(folderId)) {
      return sendError('Invalid folder ID', 400)
    }

    const assetCategories = ((await callApi(locals, 'GET', `/assets/${assetId}/versions/basic`))?.payload?.[0]?.categoryIds || []).filter(category => category !== currentParentId?.toString()).concat(folderId.toString())

    await callApi(locals, 'PUT', `/assets/${assetId}/categories`, null, assetCategories)

    return send({ id: assetId, folderId })
  } catch (err) {
    return sendError(err, 400)
  }
},

Validate targetParentId before making API calls. Some virtual folder IDs (root, lightbox:*) are not valid move targets.

UI Behavior

On success, the asset disappears from the current folder view and appears in the target folder.

Move Asset Schema

On this page