# CI HUB Integration SDK (/integration)
Write an adapter that translates your platform's API into CI HUB's handler format using `defineIntegration`. CI HUB calls your handlers; you return data in the expected shape.
```
Your Platform API ←→ Your Adapter ←→ CI HUB
```
## Quick start [#quick-start]
Scaffold a new project:
```bash
npm create @ci-hub/integration my-integration
```
```bash
pnpm create @ci-hub/integration my-integration
```
```bash
yarn create @ci-hub/integration my-integration
```
```bash
bun create @ci-hub/integration my-integration
```
```bash
cd my-integration
npm run dev
```
## Minimum handlers [#minimum-handlers]
Five handlers are required for a working adapter:
| Handler | Purpose |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `login` | Complete login flow — render login screen, exchange credentials or OAuth code for tokens |
| `checkToken` | Validate the stored token. Return an error to force re-login |
| `refreshToken` | Called when `checkToken` fails. Refresh the access token, or return an error to force re-login |
| `logout` | End the session. If your platform has no logout endpoint, return `sendStatus(200)` — CI HUB deletes stored tokens automatically |
| `info` | Return basic metadata about the authenticated user and the remote system |
Add `search` for read-only asset browsing. Add `createAsset` / `updateAsset` for write support. Every other handler is optional.
## Handler categories [#handler-categories]
| Category | Handlers |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------- |
| **Auth** | `login`, `logout`, `checkToken`, `refreshToken` |
| **Assets** | `search`, `getFolder`, `createAsset`, `updateAsset`, `deleteAsset`, `download` |
| **Folders** | `createFolder`, `renameFolder`, `deleteFolder`, `moveFolder` |
| **Asset actions** | `lockAsset`, `renameAsset`, `moveAsset`, `getAssetVersions` |
| **Tasks** | `searchTasks`, `getTask`, `getTaskAssets`, `addTaskComment`, `addTaskAsset`, `deleteTaskAsset`, `updateCustomTaskState` |
| **Brand** | `getBrandConfig`, `getBrandAssets` |
| **Advanced** | `doTransformation`, `additionalRendition`, `saveAssetRelations` |
## Next steps [#next-steps]
# Asset Types (/integration/advanced/asset-types)
The `type` field on an asset controls how CI HUB displays it and what actions are available. Three modes exist: default (file), `PRODUCT`, and `SNIPPET`.
## Default (file asset) [#default-file-asset]
When `type` is omitted or `undefined`, CI HUB treats the asset as a regular file — it has a thumbnail, a downloadable file, and can be placed into creative documents.
This is the most common type. Most DAM adapters return file assets exclusively.
## `PRODUCT` [#product]
Entity/composite content that has no single downloadable file. The asset card shows metadata and nested child assets instead of a download button.
Use `PRODUCT` when:
* The item is a content object containing references to other files
* The platform models structured content (product data, content items) alongside media files
When `type: 'PRODUCT'`, set `thumbnailUrl` and `downloadUrl` to `undefined`. The asset's `values` array becomes the primary display — these are the metadata entries shown on the asset card. Child files appear as `values` entries with `type: 'ASSET'`.
**Example** - some adapters uses `PRODUCT` for ContentItem and Virtual content types:
```ts
if (item.content && ['ContentItem', 'Virtual'].includes(item.contentType)) {
const content = item.content
asset.thumbnailUrl = undefined
asset.downloadUrl = undefined
asset.type = 'PRODUCT'
asset.values = await getValues(req, content, selectedSystem)
}
```
The `getValues` function resolves linked content into an array of metadata entries. Linked files become `values` entries with `type: 'ASSET'` containing a mini-asset object (`id`, `name`, `thumbnailUrl`, `downloadUrl`, `conversions`, `capabilities`):
```ts
const getValues = async (req, item, selectedSystem) => {
let values = []
const contentIds = []
for (const [key, value] of Object.entries(item || {})) {
if (value instanceof Array) {
value.forEach(entry => {
if (entry instanceof Object && entry._targetId) {
contentIds.push(entry._targetId)
}
})
} else if (value instanceof Object && value._targetId) {
contentIds.push(value._targetId)
} else if (value['x-default']) {
values.push({
id: key,
name: key,
type: 'TEXT',
value: getXDefault(item[key])
})
}
}
if (contentIds.length > 0) {
const contentValues = await getContentValues(req, selectedSystem, contentIds)
values = values.concat(contentValues)
}
return values
}
```
## `SNIPPET` [#snippet]
Non-file metadata-only content. The asset has no downloadable file and no thumbnail. CI HUB shows the asset name and metadata but disables download and placement actions.
Use `SNIPPET` when the item is purely textual or structural — notes, text blocks, non-renderable entries.
**Example** - some adapters marks OneNote items as `SNIPPET`:
```ts
const asset = {
id: item.id,
name: item.name,
fileSize: item.size,
mimeType: (item.file && item.file.mimeType) || mime.lookup(item.name) || 'application/octet-stream',
version: 1,
type: (isOneNote && 'SNIPPET') || undefined,
values: [],
capabilities: { ...defaultAssetCapabilities, canUpdateAsset: !isOneNote, canRenameAsset: !isOneNote }
}
```
## Summary [#summary]
| `type` | Has file | Thumbnail | Download | Placement | Primary display |
| --------- | -------- | --------- | -------- | --------- | ----------------------------------------- |
| *(empty)* | Yes | Yes | Yes | Yes | File preview |
| `PRODUCT` | No | No | No | No | `values` array (metadata + nested assets) |
| `SNIPPET` | No | No | No | No | Asset name + metadata |
See [Metadata Values](/integration/advanced/metadata-values) for how to populate the `values` array on `PRODUCT` assets.
# Brand Hub (/integration/advanced/brand-hub)
Brand Hub is a CI HUB feature that lets users browse structured brand assets — logos, color palettes, fonts, and branded imagery — organized by category. It surfaces your platform's brand guidelines directly in creative tools.
## When to implement [#when-to-implement]
Implement Brand Hub if your platform has:
* A structured brand asset library with categorized content (logos, icons, color swatches)
* Assets tagged or organized by brand category type
If your platform is a general-purpose DAM without brand-specific structure, you can skip Brand Hub — it's fully optional.
## Enabling Brand Hub [#enabling-brand-hub]
Set `supportsBrandHub: true` in your integration capabilities:
```ts
defineIntegration({
capabilities: {
category: CategoryEnum.DAM,
supportsBrandHub: true,
// ...
},
// ...
})
```
This tells CI HUB to show the Brand Hub tab and call `getBrandConfig` and `getBrandAssets`.
## Brand asset tagging [#brand-asset-tagging]
Brand Hub uses a tagging convention to categorize assets. Assets in your platform are tagged with two types of prefixes:
| Prefix | Meaning | Example tag |
| ---------- | ----------------------- | -------------------------------------- |
| `CIH_BCT_` | Brand Category **Type** | `CIH_BCT_logo`, `CIH_BCT_color` |
| `CIH_BCN_` | Brand Category **Name** | `CIH_BCN_Primary`, `CIH_BCN_Secondary` |
Your platform stores these as regular tags on assets. When CI HUB requests brand assets, filter by these prefixes.
## `getBrandConfig` [#getbrandconfig]
Returns the brand structure — what categories and sub-categories of brand assets exist. All parameters come from `requestData.query`:
```ts
getBrandConfig: async (locals, requestData) => {
try {
const brandAssets = await callApi(locals, '/api/assets', {
params: { tags: 'CIH_BCT_' },
})
const categories = extractBrandCategories(brandAssets)
return send({
categories,
})
} catch (error) {
return sendError(`Error fetching brand config: ${error}`, 400)
}
},
```
## `getBrandAssets` [#getbrandassets]
Returns assets for a specific brand category. The `folderId` and `categoryType` come from `requestData.query`:
```ts
getBrandAssets: async (locals, requestData) => {
const { folderId, categoryType } = requestData.query
const size = Math.min(parseInt(requestData.query.size || '25', 10), 100)
const more = parseInt(requestData.query.more || '1', 10)
try {
const results = await callApi(locals, '/api/assets', {
params: {
tags: `CIH_BCN_${folderId}`,
size,
page: more,
},
})
return send({
categoryType,
assets: results.items.map(mapToAsset),
totalAssetsCount: results.total,
more: results.hasNextPage ? more + 1 : undefined,
})
} catch (error) {
return sendError(`Error fetching brand assets: ${error}`, 400)
}
},
```
## Extracting brand categories from tags [#extracting-brand-categories-from-tags]
A helper to derive the brand category tree from tagged assets:
```ts
const extractBrandCategories = (assets: any[]) => {
const typeSet = new Set()
const nameMap = new Map>()
assets.forEach(asset => {
const tags: string[] = asset.tags ?? []
const type = tags.find(t => t.startsWith('CIH_BCT_'))?.replace('CIH_BCT_', '')
const name = tags.find(t => t.startsWith('CIH_BCN_'))?.replace('CIH_BCN_', '')
if (type) typeSet.add(type)
if (type && name) {
if (!nameMap.has(type)) nameMap.set(type, new Set())
nameMap.get(type)!.add(name)
}
})
return Array.from(typeSet).map(type => ({
id: type,
name: type.charAt(0).toUpperCase() + type.slice(1),
children: Array.from(nameMap.get(type) ?? []).map(name => ({
id: name,
name: name,
})),
}))
}
```
**Example** - DAM queries brand collections from a search facet and filters by `CIH_BCT_` prefix:
```ts
getBrandConfig: async (locals, requestData) => {
try {
const result = {
dataModelVersion: '1.0.0',
provider: locals.provider,
categories: [],
errors: []
}
const collectionTypes = (await callApi(locals, 'POST', '/api/search', null, {
defaults: 'CIHubAssetSearchConfig',
culture: defaultLanguage,
skip: 0,
take: 1,
view: 'table',
component: 819
})).facets.find(facet => facet.propertyName === 'CollectionType')?.children
if (!collectionTypes) {
return sendError('There are no Collections available')
}
const brandTypes = collectionTypes
.map(collectionType => collectionType.value)
.filter(item => item.startsWith(BRAND_TAG_PREFIXES.CATEGORY_TYPE.toLowerCase()))
const brandCollections = await callApi(locals, 'POST', '/api/search', null, {
defaults: 'CIHubAssetSearchConfig',
culture: defaultLanguage,
skip: 0,
take: 1000,
view: 'table',
component: 819,
filters: [{
name: 'properties.invariant.collectiontype',
label: 'Collection type',
type: 'InFilter',
values: brandTypes,
operator: 'AnyOf',
group: 'properties.invariant.collectiontype',
visible: true
}]
})
result.categories = brandCollections.items.map(folder => ({
folderId: `${folder.id}`,
title: folder.properties.CollectionName,
categoryType: folder.properties.CollectionType.identifier
.replace(BRAND_TAG_PREFIXES.CATEGORY_TYPE, '')
})).filter(collection => ['colors', 'images'].includes(collection.categoryType))
return send(result)
} catch (error) {
return sendError('Error getting folders and items:', error)
}
},
```
## Brand asset metadata [#brand-asset-metadata]
Brand assets use the same `values` array as regular assets. See [Metadata Values](/integration/advanced/metadata-values) for grouping patterns and value types.
Brand Hub is additive. Start without it and add it later once your core asset browsing works. It requires no changes to your existing handlers.
# Capabilities Reference (/integration/advanced/capabilities-reference)
Capabilities control what features CI HUB enables for your adapter — which buttons appear, which actions are allowed, and how the plugin UI behaves. They exist at three levels: integration-wide, per-folder, and per-asset.
## Integration-level capabilities [#integration-level-capabilities]
Returned once in the `info` handler inside `capabilities`. These are static for the entire connection session.
| Key | Type | UI effect |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| `category` | `'DAM'` \| `'Cloud Filehosting'` \| `'Stock Provider'` \| `'Service Provider'` \| `'Work Management'` \| `'PIM'` \| `'Automation'` \| `'Custom'` | Groups the adapter in the connection list |
| `assetUploadLimitInMB` | `number` | Maximum upload file size. CI HUB rejects files exceeding this before calling `createAsset` |
| `enableCustomUpdateFilenames` | `boolean` | When `true`, allows updating an asset with a file that has a different filename |
| `directAssetDownload` | `boolean` | When `true`, the client downloads assets directly from the provider URL without proxying through the server |
| `directAssetUpload` | `boolean` | When `true`, files upload directly to the provider instead of through the server |
| `supportsBrandHub` | `boolean` | When `true`, shows the Brand Hub tab and calls `getBrandConfig`/`getBrandAssets` |
| `assetHashAlgorithm` | `'Md5'` \| `'Sha1'` \| `'Sha256Split4MB'` \| `'Sha256First16MB'` \| `'Sha256'` \| `'Sha512'` \| `'FileAttributes'` \| `'Crc32'` | Hash algorithm for file content checksums. See [Download Hashes](/integration/advanced/download-hashes) |
| `assetSearch` | `object \| false` | Search configuration. `false` disables search entirely |
| `description` | `Record` | Localized description shown in the connection list (`{ "en": "...", "de": "..." }`) |
| `uploadWithRelinkOptionEnabled` | `boolean` | Shows the "Upload & Relink" option when placing a local file |
| `requestHeaders` | `Record` | Additional HTTP headers for direct provider requests |
| `assetComment` | `boolean` | When `true`, shows a comment field on the upload dialog |
| `disableDuplicateCheck` | `boolean` | When `true`, allows uploading even when a file with the same name exists in the target folder |
| `folderNavigationDisabled` | `boolean` | When `true`, hides folder navigation — only search is available |
| `tasking` | `boolean` | When `true`, enables the Tasks tab and task-related handlers |
| `restrictSameNameUpdatesOnly` | `boolean` | When `true`, asset updates are only allowed when the replacement file has the exact same filename |
| `maxMultipleAssetVersions` | `number` | Maximum assets in a single batch asset versions request |
| `preferredNavigationMode` | `'search'` \| `'folder'` \| `'tasks'` | Default view when the user connects |
### `assetSearch` sub-options [#assetsearch-sub-options]
When `assetSearch` is an object:
| Key | Type | UI effect |
| ------------------------ | ------------------------ | -------------------------------------------------------------------------------- |
| `help` | `Record` | Localized placeholder text in the search bar |
| `supportsParentId` | `boolean` | Search can be scoped to a specific folder |
| `alwaysSearchInFolder` | `boolean` | All searches are scoped to the current folder. Requires `supportsParentId: true` |
| `similarSearch` | `boolean` | Enables visual similarity search |
| `externalUrl` | `string` | Link to external search syntax documentation — shown as a help icon |
| `autoSearchQuery` | `string` | Default query that runs when search view opens |
| `canParallelSearch` | `boolean` | Set to `false` to disable parallel search requests (default `true`) |
| `supportsParallelRelink` | `boolean` | Enables multiple concurrent relink operations |
| `parallelRelinkMax` | `number` | Max concurrent relink operations |
| `searchByHash` | `string` | Hash algorithm name for hash-based asset search |
## Per-folder capabilities [#per-folder-capabilities]
Returned on each folder object in `search` and `getFolder` responses. Control what actions are available in the folder context menu.
| Key | Type | Default | UI effect |
| ----------------- | --------- | ----------- | -------------------------------------------------------------------------------------------- |
| `canDeleteFolder` | `boolean` | `false` | Shows "Delete folder" in context menu |
| `canAddFolder` | `boolean` | `false` | Shows "Create folder" button |
| `canAddAsset` | `boolean` | `false` | Shows upload button in that folder. In search results, enables uploading without a folder ID |
| `canRenameFolder` | `boolean` | `undefined` | Shows "Rename folder" in context menu |
The SDK provides `defaultFolderCapabilities` with all values set to `false`:
```ts
import { defaultFolderCapabilities } from '@ci-hub/integration-sdk'
const folder = {
id: item.id,
name: item.name,
capabilities: {
...defaultFolderCapabilities,
canAddAsset: item.permissions.includes('upload'),
canAddFolder: item.permissions.includes('create'),
},
}
```
## Per-asset capabilities [#per-asset-capabilities]
Returned on each asset object. Control which actions appear in the asset context menu.
| Key | Type | Default | UI effect |
| ------------------ | ---------- | ----------- | ------------------------------------------------------- |
| `canDeleteAsset` | `boolean` | `false` | Shows "Delete" in context menu |
| `canUpdateAsset` | `boolean` | `false` | Shows "Update" (replace file) in context menu |
| `canLockAsset` | `boolean` | `false` | Shows "Lock" / "Unlock" toggle |
| `canUnlockAsset` | `boolean` | `false` | Shows "Unlock" option |
| `canRenameAsset` | `boolean` | `undefined` | Shows "Rename" in context menu |
| `uploadExtensions` | `string[]` | `undefined` | Restricts which file extensions can be used for updates |
The SDK provides `defaultAssetCapabilities` with all values set to `false`:
```ts
import { defaultAssetCapabilities } from '@ci-hub/integration-sdk'
const asset = {
id: item.id,
name: item.name,
capabilities: {
...defaultAssetCapabilities,
canUpdateAsset: true,
canDeleteAsset: item.userCanDelete,
},
}
```
## Real patterns [#real-patterns]
**Example** - some adapters checks per-item permissions from the API:
```ts
capabilities: {
...defaultAssetCapabilities,
canDeleteAsset: folderPermissions.canRemoveAsset,
}
```
**Example** - some adapters disables update/rename for non-file assets:
```ts
capabilities: {
...defaultAssetCapabilities,
canUpdateAsset: !isOneNote,
canRenameAsset: !isOneNote,
}
```
See [Integration Info](/integration/reference/integration-info) for the full `info` response schema.
# Custom Metadata (/integration/advanced/custom-metadata)
Custom metadata lets you expose platform-specific fields that aren't part of the standard CI HUB asset schema — things like project codes, expiry dates, approval status, or any domain-specific attribute your platform tracks.
`customMetadata` in the `info` response defines the **schema** — what fields exist and their types. The `values` array on each asset is the **runtime display** of metadata. These are two separate systems: `customMetadata` enables editing and filtering; `values` controls what appears in the asset detail panel. See [Metadata Values](/integration/advanced/metadata-values) for the `values` array reference.
## Overview [#overview]
Custom metadata involves two things:
**Declare the fields** in the `info` handler — tells CI HUB what fields exist and their types
**Return the values** on each asset — populated per-asset in `search`, `getFolder`, etc.
## Declaring fields in `info` [#declaring-fields-in-info]
The `info` handler is called once when the user connects. Return `customMetadata` as a list of field definitions:
```ts
info: async (locals) => {
return send({
customMetadata: [
{
id: 'projectCode',
name: 'Project Code',
type: 'TEXT',
},
{
id: 'expiryDate',
name: 'Expiry Date',
type: 'DATE',
},
{
id: 'approved',
name: 'Approved',
type: 'BOOLEAN',
},
{
id: 'region',
name: 'Region',
type: 'SELECT',
options: [
{ id: 'eu', name: 'Europe' },
{ id: 'us', name: 'Americas' },
{ id: 'apac', name: 'Asia Pacific' },
],
},
],
})
},
```
### Field types [#field-types]
| Type | Description |
| -------------- | ----------------------------------------------------- |
| `TEXT` | Free-form string |
| `DATE` | ISO date string (`2024-03-15`) |
| `BOOLEAN` | `true` / `false` |
| `SELECT` | One value from a predefined list — requires `options` |
| `MULTI_SELECT` | Multiple values from a predefined list |
## Returning values on assets [#returning-values-on-assets]
In `search`, `getFolder`, and other content handlers, include `customMetadata` on each asset:
```ts
const mapToAsset = (item: any) => ({
id: item.id,
name: item.title,
// ...standard asset fields...
customMetadata: {
projectCode: item.project?.code ?? null,
expiryDate: item.expiryDate ?? null,
approved: item.status === 'APPROVED',
region: item.regionId ?? null,
},
})
```
Return `null` for missing fields rather than omitting them — this ensures the field appears in the UI as empty rather than disappearing.
## Editable metadata [#editable-metadata]
To allow users to edit custom metadata fields in CI HUB, declare them in `updateAssetOptions` in the `info` response:
```ts
info: async (locals) => {
return send({
customMetadata: [
{ id: 'projectCode', name: 'Project Code', type: 'TEXT' },
{ id: 'expiryDate', name: 'Expiry Date', type: 'DATE' },
],
updateAssetOptions: {
editableFields: ['projectCode', 'expiryDate'],
},
})
},
```
The `updateAsset` handler will then receive the changed values in `requestData.body.customMetadata`:
```ts
updateAsset: async (locals, requestData) => {
const { assetId } = requestData.params
const { customMetadata } = requestData.body
try {
if (customMetadata) {
await callApi(locals, `/api/assets/${assetId}/metadata`, {
method: 'PATCH',
data: {
projectCode: customMetadata.projectCode,
expiryDate: customMetadata.expiryDate,
},
})
}
return send({ id: assetId })
} catch (error) {
return sendError(`Error updating metadata: ${error}`, 400)
}
},
```
## Localized (multi-language) metadata [#localized-multi-language-metadata]
If your platform stores metadata in multiple languages, return them as an object keyed by locale code:
```ts
const mapToAsset = (item: any) => ({
id: item.id,
name: item.title,
customMetadata: {
description: {
en: item.descriptions?.en ?? null,
de: item.descriptions?.de ?? null,
fr: item.descriptions?.fr ?? null,
},
keywords: {
en: item.keywords?.en?.join(', ') ?? null,
de: item.keywords?.de?.join(', ') ?? null,
},
},
})
```
You must also declare the available locales in the `info` response so CI HUB shows the locale switcher:
```ts
info: async (locals) => {
return send({
customMetadata: [
{ id: 'description', name: 'Description', type: 'TEXT' },
{ id: 'keywords', name: 'Keywords', type: 'TEXT' },
],
dataLocales: ['en', 'de', 'fr'],
})
},
```
## Custom metadata as search filters [#custom-metadata-as-search-filters]
You can also expose custom metadata fields as searchable filters — see [Filters →](/integration/building-an-adapter/filters) for how to read active filter values and pass them to your API.
A typical pattern is to list the metadata field as both a `customMetadata` declaration (for display) and as a filter option (for searching):
```ts
info: async (locals) => {
const regions = await callApi(locals, '/api/regions')
return send({
customMetadata: [
{
id: 'region',
name: 'Region',
type: 'SELECT',
options: regions.map((r: any) => ({ id: r.id, name: r.label })),
},
],
searchConfigs: [
{
id: 'region',
name: 'Region',
type: 'SELECT',
options: regions.map((r: any) => ({ id: r.id, name: r.label })),
},
],
})
},
```
# Download Hashes (/integration/advanced/download-hashes)
Download hashes let CI HUB detect whether a placed asset has changed since it was last downloaded. When a user opens a document, CI HUB compares the stored hash against the current hash from the adapter — if they differ, the asset is flagged as updated.
## Hash fields [#hash-fields]
Return one hash field per asset. The field name determines the algorithm:
| Field | Algorithm | Description |
| ----------------------------- | -------------------- | ------------------------------------------------------------------ |
| `downloadHashMd5` | MD5 | Standard MD5 hash of the full file |
| `downloadHashSha1` | SHA-1 | SHA-1 hash of the full file |
| `downloadHashSha256` | SHA-256 | SHA-256 hash of the full file |
| `downloadHashSha512` | SHA-512 | SHA-512 hash of the full file |
| `downloadHashCrc32` | CRC-32 | CRC-32 checksum of the full file |
| `downloadHashSha256Split4MB` | SHA-256 (split) | SHA-256 hashes of 4MB chunks, concatenated |
| `downloadHashSha256First16MB` | SHA-256 (first 16MB) | SHA-256 of only the first 16MB of the file |
| `downloadHashFileAttributes` | File attributes | JSON string of `{ fileSize, modified }` — not a cryptographic hash |
## `assetHashAlgorithm` in capabilities [#assethashalgorithm-in-capabilities]
Declare which algorithm your adapter uses in the `info` response:
```ts
capabilities: {
category: 'DAM',
assetHashAlgorithm: 'Sha256',
}
```
Valid values: `'Md5'`, `'Sha1'`, `'Sha256Split4MB'`, `'Sha256First16MB'`, `'Sha256'`, `'Sha512'`, `'FileAttributes'`, `'Crc32'`
This tells CI HUB which hash to compute locally when comparing placed assets. It must match the hash field you return on assets.
## Returning hashes on assets [#returning-hashes-on-assets]
Add the hash field directly on the asset object:
```ts
const asset = {
id: item.id,
name: item.name,
downloadUrl: item.downloadUrl,
downloadHashSha256: item.sha256Checksum,
}
```
## `downloadHashFileAttributes` [#downloadhashfileattributes]
A non-cryptographic approach. Instead of hashing file content, store the file size and modification timestamp as a JSON string. Useful when your platform doesn't expose content hashes but does track file metadata:
```ts
const asset = {
id: item.id,
name: item.name,
downloadHashFileAttributes: JSON.stringify({
fileSize: item.size,
modified: new Date(item.lastModifiedDateTime).getTime(),
}),
}
```
CI HUB compares these attributes to detect changes. Less reliable than cryptographic hashes (a file could be modified without changing size) but requires no server-side hashing.
Do not use this algorithm unless your platform doesn't expose content hashes.
## `searchByHash` [#searchbyhash]
If your adapter supports looking up assets by their content hash, set `searchByHash` in the `assetSearch` configuration to the algorithm name:
```ts
capabilities: {
assetSearch: {
searchByHash: 'Sha256',
},
assetHashAlgorithm: 'Sha256',
}
```
CI HUB uses this for relink operations — when a placed asset's link is broken, the client can search by hash to find the matching asset on the remote system.
# Metadata Values (/integration/advanced/metadata-values)
The `values` array on an asset displays metadata in the CI HUB asset detail panel. Each entry is a key-value row. Grouping, nesting, and rich content are controlled by conventions in the `name` and `type` fields.
## Value structure [#value-structure]
Each entry in the `values` array:
```ts
{
id: string, // unique identifier
name: string, // display label
type: 'TEXT' | 'ASSET' | 'DRAFT_JS' | 'DATE' | 'DATETIME' | 'TIME',
value: string | number | object // depends on type
}
```
## Value types [#value-types]
| Type | `value` field | UI display |
| ---------- | ------------------ | -------------------------------------------------------- |
| `TEXT` | `string \| number` | Plain text row |
| `DATE` | `string` | Formatted date |
| `DATETIME` | `string` | Formatted date + time |
| `TIME` | `string` | Formatted time |
| `DRAFT_JS` | `string` | Rich text (Draft.js serialized content) |
| `ASSET` | mini-asset object | Embedded asset with thumbnail, download, and conversions |
### `TEXT` — the most common type [#text--the-most-common-type]
Most metadata entries use `TEXT`. The `value` can be a string or number:
```ts
values: [
{ id: 'description', name: 'Description', type: 'TEXT', value: item.description },
{ id: 'pageCount', name: 'Page Count', type: 'TEXT', value: item.pageCount },
{ id: 'duration', name: 'Duration', type: 'TEXT', value: item.duration },
]
```
### `ASSET` — nested mini-assets [#asset--nested-mini-assets]
Used on `PRODUCT`-type assets to embed downloadable files inside metadata. The `value` is a mini-asset object:
```ts
{
id: 'linkedFile',
name: 'Linked File',
type: 'ASSET',
value: {
id: file.id,
name: file.name,
version: 1,
mimeType: file.mimeType,
thumbnailUrl: file.thumbnailUrl,
downloadUrl: file.downloadUrl,
conversions: file.conversions,
capabilities: defaultAssetCapabilities,
}
}
```
This renders the mini-asset with its own thumbnail, download button, and conversion options inside the parent asset's detail panel.
## Grouping with `\u2003` (em space) [#grouping-with-u2003-em-space]
CI HUB uses the em space character (`\u2003`) as an indent prefix in the `name` field to create visual grouping. The pattern:
1. **Header row** — no `\u2003` prefix, empty or no `value`. Acts as a group label
2. **Child rows** — `name` prefixed with `\u2003`. Displayed indented under the header
```ts
// Header row (no value, no indent)
{ id: 'copyright', name: 'copyright', type: 'TEXT' },
// Child rows (indented with \u2003)
{ id: 'notice', name: '\u2003Notice', value: item.copyright.notice, type: 'TEXT' },
{ id: 'copyrightStatus', name: '\u2003Status', value: item.copyright.status, type: 'TEXT' },
```
This renders as:
```
copyright
Notice: MIT License
Status: Active
```
### Filtering out empty groups [#filtering-out-empty-groups]
Header rows with no children are noise. Filter them out:
```ts
const values = [
...parseCustomMetadata(item.customMetadata || []),
...item.copyright.status || item.copyright.notice ?
[{
id: 'copyright',
name: 'copyright',
type: 'TEXT'
}, {
id: 'notice',
name: '\u2003Notice',
value: item.copyright.notice,
type: 'TEXT'
}, {
id: 'copyrightStatus',
name: '\u2003Status',
value: item.copyright.status,
type: 'TEXT'
}] :
[]
].filter(metadate => metadate.value || ['copyright'].includes(metadate.id))
```
## Real adapter patterns [#real-adapter-patterns]
### Example - `createValue` helper [#example---createvalue-helper]
some adapters uses a `createValue` helper that conditionally adds the `\u2003` prefix:
```ts
const createValue = (id, name, value = '', indent) => ({
id,
name: `${indent ? '\u2003' : ''}${name}`,
type: 'TEXT',
value,
})
```
Metadata templates produce grouped values — a header row for the template name, then indented child rows for each field:
```ts
if (metadata && (showHidden || !templateHidden)) {
const prefix = `${scope}#${templateKey}`
acc.push(createValue(prefix, templateName))
acc.push(...(template.fields || []).
filter(field => field && (showHidden || !field.hidden) && isValue(metadata[field.key])).
map(field => createValue(
`${prefix}#${field.key}`,
field.displayName,
getValue(metadata[field.key], field.type),
true
)))
}
```
### Example - grouped custom fields [#example---grouped-custom-fields]
some adapters groups custom fields by field set. A new header is added when the field set name changes:
```ts
if (useGroupAndSort && fieldSetName && (!lastFieldSetName || fieldSetName !== lastFieldSetName)) {
lastFieldSetName = fieldSetName
customFieldsToAdd.push({ id: `group-${fieldSetId}`, name: fieldSetName, type: 'TEXT' })
}
customFieldsToAdd.push({
id: fieldId,
name: `${fieldSetName ? '\u2003' : ''}${getCustomFieldName(customFieldsMapping, fieldId, uiLocale)}`,
value,
type: 'TEXT',
})
```
### Example - layer-based metadata [#example---layer-based-metadata]
some adapters iterates metadata layers and nests properties under each layer name:
```ts
Object.keys(item.metadata).
filter(entry => !['xmpMetadata', 'exifMetadata', 'autoTagging'].includes(entry)).
forEach(metadataId => {
let groupHeaderAdded = false
Object.entries(item.metadata[metadataId]).forEach(([key, value]) => {
if (key !== '_displayValues' && isValidPropertyValue(value)) {
if (!groupHeaderAdded) {
values.push({ id: `${metadataId}`, name: `${getLayerName(layerSchemaData, metadataId)}`, type: 'TEXT', value: `` })
groupHeaderAdded = true
}
values.push({
id: `${metadataId}:${key}`,
name: `\u2003${getLayerPropertyName(layerSchemaData, metadataId, key)}`,
type: 'TEXT',
value: getPropertyValueAsString(value)
})
}
})
})
```
### Example - multi-level nesting [#example---multi-level-nesting]
some adapters nest up to three levels deep using multiple `\u2003` characters:
```ts
// Level 0: item name (header)
values.push({ id: item.reference, name: item.name, type: 'TEXT', value: '' })
// Level 1: property key
Object.entries(item || {}).forEach(([key, value]) => {
if (Array.isArray(value)) {
values.push({ id: `item.reference#${key}`, name: `\u2003${key}`, type: 'TEXT', value: '' })
// Level 2: array entry name
value?.forEach?.(option => {
values.push({ id: `item.reference#${key}#type_name`, name: `\u2003\u2003${option.type_name}`, type: 'TEXT', value: '' })
// Level 3: nested properties
Object.entries(option || {}).forEach(([innerKey, innerValue]) => {
if (!['type_name'].includes(innerKey) && innerKey.indexOf('reference') === -1) {
values.push({ id: `item.reference#${key}#${innerKey}`, name: `\u2003\u2003\u2003${innerKey}`, type: 'TEXT', value: innerValue })
}
})
})
} else {
values.push({ id: `item.reference#${key}`, name: `\u2003${key}`, type: 'TEXT', value })
}
})
```
This renders as:
```
Brochure A4
options
Lamination Matte
price: 0.05 EUR
weight: 2g
Binding Stapled
price: 0.02 EUR
quantity
500
```
## Best practices for IDs [#best-practices-for-ids]
* Use unique, stable IDs — the `id` field is used for change detection and filtering
* Prefix group IDs to avoid collisions: `group-${setId}`, `${scope}#${templateKey}`
* For nested values, use compound IDs: `${parentId}#${childKey}`
* Keep IDs valid as HTML id attributes — no spaces, no special characters beyond `#`, `-`, `_`
See [Search Schema](/integration/schemas/assets/search) | [Custom Metadata](/integration/advanced/custom-metadata)
# Tips and Tricks (/integration/advanced/tips-and-tricks)
Patterns and pitfalls collected from real adapter implementations.
**Validate all config keys at startup.** Use the `configKeys.filter(key => !config.has(key))` pattern at module level — before `defineIntegration`. A misconfigured deployment should fail loudly at startup, not silently on the first user request.
**Use `config.has()` before `config.get()` for optional keys.** `config.get()` throws if the key doesn't exist. For optional features, always guard with `has()`:
```ts
const uploadLimit = config.has('myIntegration.uploadLimitMB')
? config.get('myIntegration.uploadLimitMB')
: 500
```
**Read from the correct source.** Each handler has typed `requestData` — the SDK knows whether a field comes from `query`, `body`, or `params`. Read from the source that matches the handler's contract.
```ts
search: async (locals, requestData) => {
const query = requestData.query.query || ''
const page = parseInt(requestData.query.more || '1', 10)
// ...
},
deleteAsset: async (locals, requestData) => {
const { assetId } = requestData.params
// ...
},
```
The only exception is `createAsset`/`updateAsset` where metadata may arrive in `query` when the body carries a binary buffer.
**Always run user-provided server URLs through `ensureHttps()`.** Users copy URLs from browser address bars or documentation — they might include `http://`, a trailing slash, or a path. Normalize before storing:
```ts
import { ensureHttps } from '@ci-hub/integration-sdk'
const normalizedUrl = ensureHttps(userProvidedUrl, true, true)
```
**Strip trailing slashes from your base URL** to avoid double-slash issues when building paths:
```ts
const base = resourceEndpoint.replace(/\/$/, '')
const url = `${base}/api/assets/${assetId}`
```
**`checkToken` is on the critical path of every request.** It's called before every handler. Use the fastest, cheapest endpoint your API has — a profile or status endpoint that returns a small payload. Avoid fetching collections or asset lists here.
**In `refreshToken`, `locals.authPayload` is the refresh token** — the SDK swaps the access and refresh tokens before calling the handler. Don't try to swap them yourself.
**Keep `adapterData` small.** It's encrypted and stored per session, but it's not a general-purpose cache. Store only what you need on every request: the resource endpoint URL, org/tenant ID, and any similar session-identifying data.
**Handle `canceled` and auth errors at the top of `login`.** Before any rendering or redirect logic, check for these conditions and return early. The recommended order is:
```ts
login: async (locals, requestData) => {
const authError = requestData.query.error_description || requestData.query.error
if (authError) return sendAccessToken(authError)
if (requestData.body.canceled || requestData.query.canceled) {
return sendAccessToken()
}
// ... phase logic follows
},
```
**`state` is the correlation key.** It ties all redirect hops together. Mint it once with `getStateCodeAsync()`, pass it through every redirect, and use `setStateAdapterDataAsync`/`getStateAdapterDataAsync` to carry data between phases.
**State is server-side only.** Use `setStateAdapterDataAsync` to carry tokens and credentials between redirect phases. Never put sensitive data in the URL.
**Cap `size` at a safe maximum.** Clients can request arbitrarily large pages — protect your API:
```ts
const size = Math.min(parseInt(requestData.query.size || '25', 10), 100)
```
**Use `undefined` for `more` on the last page, not `null`.** Returning `null` signals to CI HUB that more results exist and shows the "Load more" button. Use `undefined` to end pagination:
```ts
more: data.hasNextPage ? page + 1 : undefined // ✅
more: data.hasNextPage ? page + 1 : null // ❌ shows "Load more" on last page
```
**Wrap each item in list-mapping code inside `try/catch`.** One malformed asset from your API shouldn't fail the entire search response:
```ts
const assets = rawItems.map(item => {
try {
return mapToAsset(item)
} catch {
return undefined
}
}).filter((a): a is MappedAsset => a !== undefined)
```
**CI HUB expects flat arrays** for assets and folders. Flatten nested structures inside your adapter — don't pass nested objects.
**Include the original error in every `sendError` message.** It appears in the CI HUB error UI and gives users enough context to file a useful bug report:
```ts
} catch (error) {
return sendError(`Error uploading asset: ${error}`, 400)
}
```
**Return `sendError` from all `catch` blocks — never `throw`.** If a handler throws, the request ends in an unhandled error. Your `catch` block is the last line of defense.
**Set `maxContentLength: Infinity` and `maxBodyLength: Infinity` on upload requests.** Axios has conservative default limits that will reject large files:
```ts
await axios.put(uploadUrl, buffer, {
headers: { 'Content-Type': mimeType },
maxContentLength: Infinity,
maxBodyLength: Infinity,
})
```
**Request a fresh pre-signed URL on every upload attempt.** Pre-signed URLs expire (typically in minutes). Never cache them across requests.
**Use `$NO_AUTH$` suffix for public download URLs.** When an asset's download URL is publicly accessible (no auth header needed), append `$NO_AUTH$` to signal this to CI HUB. The client then downloads directly without proxying through your adapter:
```ts
const downloadUrl = `${asset.downloadUrl}$NO_AUTH$`
```
**Generate download URLs on demand.** If your download URLs expire, return a fresh one from the `download` handler rather than storing it at search time. The user might open the download hours after the search — stored URLs will have expired by then.
**Use clear sentinels to distinguish phases.** Test for the presence of `state`, `code`, and org/tenant IDs in that logical order. Make each `if` block mutually exclusive:
```ts
if (authError) { /* surface error */ }
else if (canceled) { /* abort */ }
else if (!state && !serverUrl) { /* Phase 1 */ }
else if (serverUrl && !state) { /* Phase 2 */ }
else if (code && state && !orgId) { /* Phase 3 */ }
else if (orgId && state) { /* Phase 4 */ }
else { return sendAccessToken('Unexpected login state. Please try again.') }
```
# URL Suffixes (/integration/advanced/url-suffixes)
URL suffixes are special tokens appended to `downloadUrl` and `thumbnailUrl` that tell the CI HUB client how to handle authentication and caching when fetching the file. The client strips the suffix before making the actual HTTP request.
## Reference [#reference]
| Suffix | Client behavior |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `$AUTH_SESSION_ID$` | Appends the server session ID to the URL. Used by server-side adapters where downloads are proxied through the CI HUB backend |
| `$FORCE_DOWNLOAD$` | Forces the client to download the file (not display inline). Sets the `Content-Disposition: attachment` behavior |
| `$REQUEST_HEADERS$` | Reads custom headers from a `headers` query parameter on the URL (JSON-encoded). Supports `type=merge` (default), `type=replace`, or `type=overwrite` to control how custom headers combine with existing headers |
| `$NO_AUTH$` | Signals that the URL is publicly accessible — no auth headers are sent. The client downloads directly without proxying |
| `$AUTH_PAYLOAD$` | Replaces the suffix with the URL-encoded access token as a query parameter value |
| `$AUTH_PAYLOAD_BEARER$` | Replaces the suffix with URL-encoded `Bearer {token}` as a query parameter value |
| `$AUTH_HEADER_PAYLOAD_BEARER$` | Strips the suffix and adds an `Authorization: Bearer {token}` header |
| `$AUTH_HEADER_PAYLOAD_APITOKEN$` | Strips the suffix and adds an `Authorization: apiToken {token}` header |
| `$NO_CACHE$` | Strips the suffix and adds a `Cache-Control: no-cache` header |
## Common patterns [#common-patterns]
### Public URLs [#public-urls]
When your platform returns pre-signed or publicly accessible download URLs, append `$NO_AUTH$` so CI HUB downloads directly:
```ts
const downloadUrl = `${asset.publicUrl}$NO_AUTH$`
```
### Bearer token in header [#bearer-token-in-header]
When the download endpoint requires the same OAuth token your adapter uses, append `$AUTH_HEADER_PAYLOAD_BEARER$`. The client adds the `Authorization: Bearer` header automatically:
```ts
const downloadUrl = `https://api.example.com/assets/${asset.id}/download$AUTH_HEADER_PAYLOAD_BEARER$`
```
### Token as query parameter [#token-as-query-parameter]
Some APIs expect the token in the URL itself. Use `$AUTH_PAYLOAD$` inside a query parameter:
```ts
const downloadUrl = `https://api.example.com/assets/${asset.id}/content?token=$AUTH_PAYLOAD$`
```
### Custom headers via `$REQUEST_HEADERS$` [#custom-headers-via-request_headers]
For APIs that need non-standard headers (e.g. API keys, custom auth schemes), encode headers as JSON in a `headers` query parameter:
```ts
const customHeaders = encodeURIComponent(JSON.stringify({
'X-Api-Key': apiKey,
'X-Custom-Auth': customToken,
}))
const downloadUrl = `https://api.example.com/assets/${asset.id}/download?headers=${customHeaders}$REQUEST_HEADERS$`
```
The `type` parameter controls header merging:
* `merge` (default) — merges custom headers with existing ones, custom headers win on conflict
* `replace` — uses only the custom headers, discards existing ones
* `overwrite` — existing headers win on conflict
### Force download [#force-download]
Append `$FORCE_DOWNLOAD$` when the URL would otherwise render inline (e.g., images, PDFs) and you want to ensure a file download:
```ts
const downloadUrl = `${asset.viewUrl}$FORCE_DOWNLOAD$`
```
### Disable caching [#disable-caching]
Append `$NO_CACHE$` for URLs that return different content each time (e.g., latest version redirects):
```ts
const thumbnailUrl = `${asset.previewUrl}$NO_CACHE$`
```
## How the client processes suffixes [#how-the-client-processes-suffixes]
The client runs `parseUrl()` which processes suffixes in this order:
1. `$AUTH_SESSION_ID$` — stripped (SDK adapters don't use server sessions)
2. `$FORCE_DOWNLOAD$` — stripped, flags the download as forced
3. `$HEADER_VALUE_STATIC_ENCODED_*` — static header extraction (internal)
4. `$NO_CACHE$` — stripped, adds `Cache-Control: no-cache`
5. Auth suffixes (mutually exclusive — only the first match applies):
* `$REQUEST_HEADERS$`
* `$NO_AUTH$`
* `$AUTH_PAYLOAD$`
* `$AUTH_PAYLOAD_BEARER$`
* `$AUTH_HEADER_PAYLOAD_BEARER$`
* `$AUTH_HEADER_PAYLOAD_APITOKEN$`
Auth suffixes are mutually exclusive. Use only one per URL. Non-auth suffixes (`$FORCE_DOWNLOAD$`, `$NO_CACHE$`) can be combined with any auth suffix.
# Advanced Handlers (/integration/building-an-adapter/advanced-handlers)
Handlers for rendition management, asset relationship tracking, and file transformations. These extend the upload pipeline and enable platform-specific features like linked-asset detection and format conversion.
## Upload Pipeline with Renditions [#upload-pipeline-with-renditions]
When an asset is uploaded, CI HUB can trigger additional processing steps. The flow:
## getAdditionalRenditionsNeeded [#getadditionalrenditionsneeded]
Called after an asset upload to determine if additional files need to be uploaded alongside the main asset. Typically used for InDesign, Illustrator, and Photoshop files that have linked assets.
### Inputs [#inputs]
| Field | Description |
| --------------------------- | ------------------------------ |
| `requestData.query.assetId` | ID of the just-uploaded asset |
| `requestData.query.name` | Filename of the uploaded asset |
### Response [#response]
Return an array of rendition descriptors. Each has a `type` and a `url` that CI HUB will POST the rendition data to:
```ts
return send([
{
type: 'DocumentSummary',
url: `${locals.endpointUrl}/saveAssetRelations?type=${fileExtension}&assetId=${encodeURIComponent(assetId)}`
}
])
```
Return `undefined` or an empty array if no additional renditions are needed.
### Patterns [#patterns]
**Example** - Triggers for InDesign, Illustrator, and Photoshop files:
```ts
const createAdditionalRenditions = (locals, name, assetId) => {
const renditions = []
const fileExtension = path.extname(name).toLowerCase().
replace('.', '')
if ([ 'ai', 'indd', 'psd' ].includes(fileExtension)) {
renditions.push({
type: 'DocumentSummary',
url: `${locals.endpointUrl}/saveAssetRelations?type=${fileExtension}&assetId=${encodeURIComponent(assetId)}`
})
return renditions
}
}
getAdditionalRenditionsNeeded: async (locals, requestData) => {
try {
const { assetId, name } = requestData.query
return send(createAdditionalRenditions(locals, name, assetId))
} catch (error) {
return sendError(error, 400)
}
},
```
**Example** - Always returns a `DocumentSummary` rendition for every file type:
```ts
const createAdditionalRenditions = (locals, name, assetId) => {
const result = []
const fileExtension = path.extname(name).toLowerCase().
replace('.', '')
result.push({
type: 'DocumentSummary',
url: `${locals.endpointUrl}/saveAssetRelations?type=${fileExtension}&assetId=${encodeURIComponent(assetId)}`
})
return result
}
getAdditionalRenditionsNeeded: async (locals, requestData) => {
try {
const { assetId, name } = requestData.query
return send(createAdditionalRenditions(locals, name, assetId))
} catch (error) {
return sendError(error, 400)
}
},
```
***
## additionalRendition [#additionalrendition]
Receives and uploads one additional rendition file (e.g., a preview image, a linked asset). CI HUB calls this for each rendition returned by `getAdditionalRenditionsNeeded`.
### Inputs [#inputs-1]
| Field | Description |
| ------------------------------- | ---------------------------- |
| `requestData.body.assetId` | Parent asset ID |
| `requestData.body.quality` | Rendition quality identifier |
| `requestData.body.name` | Rendition filename |
| `requestData.getFileAsBuffer()` | Binary file data |
### Response [#response-1]
```ts
return sendResult()
```
### Patterns [#patterns-1]
**Example** - Uploads rendition as multipart form data:
```ts
additionalRendition: async (locals, requestData) => {
const quality = requestData.body.quality
const asset_id = requestData.body.assetId
const name = requestData.body.name
const dataBuffer = requestData.getFileAsBuffer()
const formData = new FormDataNode()
try {
formData.append('file', dataBuffer, name || '')
await callApi(locals, 'POST', '/cihub/updateAssetMedia', {
asset_id,
quality
}, formData, formData.getHeaders())
return sendResult()
} catch (err) {
return sendError(err, 400)
}
},
```
***
## saveAssetRelations [#saveassetrelations]
Called after CI HUB extracts the document structure from InDesign, Illustrator, or Photoshop files. Receives the document model with linked assets and creates relationship records in the DAM.
### Inputs [#inputs-2]
| Field | Description |
| ----------------------------------------------------------- | ----------------------------------------------------- |
| `requestData.query.type` | File type: `'indd'`, `'ai'`, or `'psd'` |
| `requestData.query.assetId` | Parent asset ID |
| `requestData.body.document` or `requestData.query.document` | Document structure with spreads, artboards, and links |
The `document` object structure varies by type:
* **InDesign (`indd`)**: `document.spreads[].pages[].links[]` with master spread inheritance
* **Illustrator (`ai`)**: `document.artBoards[].links[]`
* **Photoshop (`psd`)**: `document.links[]`
Each `link` contains `linkData` with `providerId`, `remoteSystemPrefix`, and `id` identifying the linked asset.
### Response [#response-2]
```ts
return sendStatus(200)
```
### Patterns [#patterns-2]
**Example** - Extracts links from all document types and creates asset relations via REST API:
```ts
saveAssetRelations: async (locals, requestData) => {
const { type, assetId, name } = requestData.query
const document = requestData.body.document || requestData.query.document
try {
if (type && !name && [ 'indd', 'ai', 'psd' ].includes(type)) {
const systemPrefix = remoteSystemPrefix(locals)
let links = []
if (type === 'indd') {
const recursiveGetLinksFromMaster = (masterSpreadId, side) => {
const masterSpread = masterSpreadId && (document?.masterSpreads || []).find(entry => entry.id === masterSpreadId)
const page = masterSpread?.pages?.length && masterSpread.pages[masterSpread.pages.length > 1 && side === 'RIGHT_HAND' ? 1 : 0]
return (page?.links && [ ...page.links, ...recursiveGetLinksFromMaster(page.masterSpreadId, side) ]) || []
}
(document?.spreads || []).forEach(spread =>
(spread?.pages || []).forEach(page => {
links = links.concat(page.links, recursiveGetLinksFromMaster(page.masterSpreadId, page.side))
}))
} else if (type === 'ai') {
(document?.artBoards || []).forEach(artBoard => {
links = links.concat(artBoard.links || [])
})
} else if (type === 'psd') {
links = document?.links || []
}
const children = links.map(link => {
const linkData = link && link.linkData
if (linkData && linkData.providerId === 'exampleAdapter1' && linkData.remoteSystemPrefix === systemPrefix) {
return { href: new URL(`api/entities/${linkData.id}`, locals.adapterData.systemUrl).toString() }
}
return undefined
}).filter(item => item)
if (children.length) {
await callApi(locals, 'PUT', `/api/entities/${assetId}/relations/AssetToLinkedAsset`, null, { children, self: { href: new URL(`api/entities/${assetId}/relations/AssetToLinkedAsset`, locals.adapterData.systemUrl) } })
}
}
return sendStatus(200)
} catch (err) {
return sendError(`saveAssetRelations failed: ${err}`, 400)
}
},
```
**Example** - Extracts links and creates bidirectional asset associations:
```ts
saveAssetRelations: async (locals, requestData) => {
const { type, assetId } = requestData.query
const document = requestData.body.document || requestData.query.document
try {
if (type && [ 'indd', 'ai', 'psd' ].includes(type)) {
let links = []
if (type === 'indd') {
const recursiveGetLinksFromMaster = (masterSpreadId, side) => {
const masterSpread = masterSpreadId && (document?.masterSpreads || []).find(entry => entry.id === masterSpreadId)
const page = masterSpread?.pages?.length && masterSpread.pages[masterSpread.pages.length > 1 && side === 'RIGHT_HAND' ? 1 : 0]
return (page?.links && [ ...page.links, ...recursiveGetLinksFromMaster(page.masterSpreadId, side) ]) || []
}
(document?.spreads || []).forEach(spread =>
(spread?.pages || []).forEach(page => {
links = links.concat(page.links, recursiveGetLinksFromMaster(page.masterSpreadId, page.side))
}))
} else if (type === 'ai') {
(document?.artBoards || []).forEach(artBoard => {
links = links.concat(artBoard.links || [])
})
} else if (type === 'psd') {
links = document?.links || []
}
let relations = []
links.forEach(link => {
const linkData = link && link.linkData
if (linkData && linkData.id && !relations.includes(linkData.id)) {
relations.push(linkData.id)
}
})
if (relations.length) {
relations = relations.map(relation => [ assetId, relation ])
relations.forEach(async relation => {
await callApi(locals, 'POST', '/assets/associate', null, { assetIds: relation })
})
}
}
return sendStatus(200)
} catch (err) {
return sendError(`saveAssetRelations failed: ${err}`, 400)
}
},
```
The InDesign link extraction is recursive — master spreads can reference other master spreads, so `recursiveGetLinksFromMaster` traverses the chain.
***
## getAvailableTransformations [#getavailabletransformations]
Returns the list of transformations the user can apply to assets (e.g., named presets, format conversions). CI HUB displays these as options in the transformation UI.
### Response [#response-3]
Return an array of transformation descriptors:
```ts
return send([
{ id: 'named:my-preset', name: 'my-preset', details: { ... } },
{ id: 'format:png', name: 'PNG', extension: 'png' },
{ id: 'format:jpg', name: 'JPEG', extension: 'jpg' }
])
```
### Patterns [#patterns-3]
**Example** - Fetches named transformations from the platform API and adds format conversions:
```ts
getAvailableTransformations: async (locals, requestData) => {
const client = getClient(locals)
const result = []
try {
const transformationList = await client.api.transformations({ named: 'true' })
for (const transformation of transformationList.transformations) {
const transformationId = `${transformation.name.startsWith('t_') ? transformation.name.substring(2) : transformation.name}`
const transformationDetails = await client.api.transformation(transformationId)
const listItem = {
id: `named:${transformationId}`,
name: transformationId,
details: transformationDetails.info
}
result.push(listItem)
}
result.push(...formatConversions)
return send(result)
} catch (err) {
console.error(err)
return sendError(err, 400)
}
},
```
***
## doTransformation [#dotransformation]
Applies a transformation to an asset and returns the transformed file as a stream. CI HUB calls this when the user selects a transformation and confirms.
### Inputs [#inputs-3]
| Field | Description |
| -------------------------------------------------------------- | ------------------------------------------------------ |
| `requestData.query.transformationId` | Transformation ID (from `getAvailableTransformations`) |
| `requestData.body.dataBuffer` or `requestData.body.dataBase64` | Source file data |
| `requestData.body.name` | Source filename |
### Response [#response-4]
Return the transformed file as a stream:
```ts
const { data } = await axios.get(downloadUrl, { responseType: 'stream', headers: { Accept: 'application/octet-stream' } })
return send(data)
```
### Patterns [#patterns-4]
**Example** - Uploads source file, applies named transformation or format conversion, pipes result:
```ts
doTransformation: async (locals, requestData) => {
const { transformationId } = requestData.query
const { dataBuffer, dataBase64, name } = requestData.body
const client = getClient(locals)
const [ transformationType, transformationData ] = transformationId.split(':')
try {
const nativeAssetId = await uploadAsset(locals, dataBuffer, dataBase64, name, undefined, undefined, false, true)
const { publicId, version, resourceType, uploadType } = splitAssetId(nativeAssetId)
let transformationParams
if (transformationType === 'format') {
transformationParams = { format: transformationData }
} else {
transformationParams = { transformation: transformationData }
}
const downloadUrl = getTransformedImageUrl(client, publicId, version, resourceType, uploadType, transformationParams)
const { data } = await axios.get(downloadUrl, { responseType: 'stream', headers: { Accept: 'application/octet-stream' } })
return send(data)
} catch (err) {
if (err && err.error && err.error.message) {
return sendError(err.error.message, 400)
} else {
return sendError(err, 400)
}
}
},
```
The `transformationId` format uses a prefix to distinguish types: `named:preset-name` for named transformations, `format:png` for format conversions. Split on `:` to extract the type and value.
[Create Asset Schema](/integration/schemas/assets/create-asset)
# Asset Operations (/integration/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 [#capability-flags]
Enable each operation in your adapter's `capabilities` object:
```ts
capabilities: {
canDeleteAsset: true,
canLockAsset: true,
canRenameAsset: true,
canMoveAsset: true
}
```
`getAssetVersions` has no capability flag — it's always available when implemented.
## deleteAsset [#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 [#inputs]
| Field | Description |
| ----------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `requestData.params.assetId` | Asset ID to delete |
| `requestData.query.parentId` or `requestData.body.parentId` | Parent folder ID (some platforms need this to distinguish collections from folders) |
### Response [#response]
```ts
return sendResult(null) // or return sendStatus(200)
```
### Patterns [#patterns]
**Example** - REST API, strips version suffix from compound IDs:
```ts
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:
```ts
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 [#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](/integration/schemas/assets/delete-asset)
***
## getAssetVersions [#getassetversions]
Returns version history for an asset. CI HUB calls this when the user opens the version panel for an asset.
### Inputs [#inputs-1]
| Field | Description |
| ----------------------------------------------------------- | ---------------------------------------------- |
| `requestData.query.assetId` | Single asset ID |
| `requestData.query.assetIds` or `requestData.body.assetIds` | Array of asset IDs (batch mode) |
| `requestData.query.withMaster` | `"true"` to include the current/master version |
### Response [#response-1]
Return an object with `id`, `name`, and `versions` array. Each version uses the same shape as `extractAssetsData` output plus `masterId`:
```ts
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 [#patterns-1]
**Example** - DAM that iterates `mediaItems` for version history:
```ts
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:
```ts
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 [#ui-behavior-1]
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](/integration/schemas/assets/get-asset-versions)
***
## lockAsset [#lockasset]
Locks (checks out) or unlocks (checks in) an asset. Prevents concurrent edits on platforms that support exclusive checkout.
### Inputs [#inputs-2]
| Field | Description |
| ---------------------------- | --------------------------------- |
| `requestData.params.assetId` | Asset ID |
| `requestData.body.locked` | `true` to lock, `false` to unlock |
### Response [#response-2]
```ts
return send({ id: assetId, lockedBy: 'username' }) // locked
return send({ id: assetId, lockedBy: null }) // unlocked
```
### Patterns [#patterns-2]
**Example** - DAM with checkout/checkin endpoint, polls for lock confirmation:
```ts
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:
```ts
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 [#ui-behavior-2]
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](/integration/schemas/assets/lock-asset)
***
## renameAsset [#renameasset]
Changes an asset's display name or filename.
### Inputs [#inputs-3]
| Field | Description |
| ---------------------------- | ----------- |
| `requestData.params.assetId` | Asset ID |
| `requestData.body.name` | New name |
### Response [#response-3]
```ts
return send({ id: assetId, name: newName })
```
### Patterns [#patterns-3]
**Example** - DAM that distinguishes filename vs title based on extension:
```ts
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:
```ts
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 [#ui-behavior-3]
On success, the asset's name updates immediately in the plugin UI (folder view and detail panel).
[Rename Asset Schema](/integration/schemas/assets/rename-asset)
***
## moveAsset [#moveasset]
Moves an asset from one folder to another.
### Inputs [#inputs-4]
| Field | Description |
| ---------------------------------- | --------------------- |
| `requestData.body.assetId` | Asset ID |
| `requestData.body.targetParentId` | Destination folder ID |
| `requestData.body.currentParentId` | Source folder ID |
### Response [#response-4]
```ts
return send({ id: assetId, folderId: targetParentId })
```
### Patterns [#patterns-4]
**Example** - DAM that replaces old category with new one:
```ts
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 [#ui-behavior-4]
On success, the asset disappears from the current folder view and appears in the target folder.
[Move Asset Schema](/integration/schemas/assets/move-asset)
# Auth Handlers (/integration/building-an-adapter/auth-handlers)
Three handlers that complete the auth lifecycle after login: `checkToken`, `refreshToken`, and `logout`.
## checkToken [#checktoken]
Called before every session resume — CI HUB verifies the stored access token is still valid.
Make an API call to the platform's "current user" endpoint. On success, return user info via `send()`. On failure, return `sendError()` to force re-login.
### Inputs [#inputs]
| Field | Description |
| -------------------- | ------------------------------------------ |
| `locals.authPayload` | Stored access token (string or object) |
| `locals.adapterData` | Adapter-specific data persisted from login |
### Response [#response]
```ts
// Success — session resumes, plugin UI shows user display name
return send({ adapter: 'AdapterName', source: 'hostname-or-label', user: 'displayName' })
// Failure — forces re-login
return sendError(err, 400)
```
The `source` field appears in the plugin UI connection list. Typically derived from the server URL hostname.
### Patterns [#patterns]
**Example** - OAuth adapter, validates token via REST call:
```ts
checkToken: async (locals) => {
try {
const data = await callApi(locals, 'GET', '/api/v4/currentUser/', {})
return send({ adapter: 'ExampleAdapter', source: remoteSystemPrefix(locals), user: data.username })
} catch (err) {
return sendError(err, 400)
}
},
```
**Example** - OAuth adapter, validates via SDK client:
```ts
checkToken: async (locals) => {
const accessToken = locals.authPayload
sstk.setAccessToken(accessToken)
try {
const usersApi = new sstk.UsersApi()
const user = await usersApi.getUser()
return send({ adapter: 'ExampleAdapter', source: remoteSystemPrefix, user: user.username })
} catch (err) {
return sendError(err, 400)
}
},
```
**Example** - Credential-based adapter (API key/secret), no API call needed:
```ts
checkToken: async (locals) => {
try {
const cloudName = locals.authPayload.cloudName
return send({ adapter: 'ExampleAdapter', source: remoteSystemPrefix, user: cloudName })
} catch (err) {
return sendError(err, 400)
}
},
```
For credential-based adapters where the token is an object (not an OAuth access token), extract the display name directly — no API validation needed.
### UI Behavior [#ui-behavior]
On success, the plugin UI restores the previous session and shows the `user` value in the connection header. On failure, CI HUB drops the session and shows the login screen.
***
## refreshToken [#refreshtoken]
Called when `checkToken` fails and a stored refresh token exists. Exchange the refresh token for a new access token.
Return the new token via `sendAccessToken()`. Return `sendError()` to abandon refresh and force re-login.
### Inputs [#inputs-1]
| Field | Description |
| -------------------- | ------------------------------------------ |
| `locals.authPayload` | Stored refresh token |
| `locals.adapterData` | Adapter-specific data persisted from login |
### Response [#response-1]
```ts
// Success — new access token stored, session resumes
return sendAccessToken(null, accessToken, expiresIn, refreshToken, refreshExpiresIn, adapterData)
// Failure — forces re-login
return sendError(err, 400)
```
Parameter order for `sendAccessToken`: error, token, expiresIn, refreshToken, refreshExpiresIn, adapterData.
### Patterns [#patterns-1]
**Example** - Standard OAuth refresh with adapterData preservation:
```ts
refreshToken: async (locals) => {
const adapterData = locals.adapterData
if (!adapterData.resourceEndpoint) {
return sendError('Could not refresh token, please re-add the connection.', 400)
}
try {
const oauth = getAuthClient(adapterData.resourceEndpoint)
const accessToken = oauth.createToken({
refresh_token: locals.authPayload
})
const { token } = await accessToken.refresh()
if (!token || !token.access_token) {
throw new Error('No access_token found')
}
return sendAccessToken(null, token.access_token, token.expires_in, undefined, undefined, adapterData)
} catch (err) {
return sendError(err, 400)
}
},
```
Validate `adapterData` before attempting refresh — stale sessions missing required fields should error immediately with a clear message.
**Example** - OAuth refresh with client credentials:
```ts
refreshToken: async (locals) => {
const clientKey = config.get('exampleAdapter2.clientKey')
const clientSecret = config.get('exampleAdapter2.clientSecret')
const refreshToken = locals.authPayload
const oauth = getAuthClient()
const token = oauth.createToken({
refresh_token: refreshToken
})
try {
const newToken = (await token.refresh({
client_id: clientKey,
client_secret: clientSecret
})).token
const accessToken = newToken.access_token
const tokenExpiresIn = newToken.expires_in
return sendAccessToken(null, accessToken, tokenExpiresIn)
} catch (err) {
return sendAccessToken(err)
}
},
```
**Example** - Credential-based adapter, no actual refresh:
```ts
refreshToken: async (locals) => {
try {
const token = locals.authPayload
const adapterData = locals.adapterData
return sendAccessToken(null, token, null, null, null, adapterData)
} catch (err) {
return sendError(err, 400)
}
},
```
Credential-based adapters (API key + secret) have no expiring OAuth token. Re-send the same token and adapterData to keep the session alive.
### UI Behavior [#ui-behavior-1]
On success, the session resumes transparently — the user never sees a login screen. On failure, CI HUB falls back to the login flow.
***
## logout [#logout]
Called when the user disconnects from the adapter in the plugin UI. Revoke tokens if the platform supports it, then return `sendStatus(200)`.
CI HUB deletes all stored tokens regardless of the handler's response.
### Inputs [#inputs-2]
| Field | Description |
| -------------------------------- | ----------------------------------- |
| `locals.authPayload` | Stored access token |
| `requestData.body.refresh_token` | Stored refresh token (if available) |
### Response [#response-2]
```ts
// Always return 200
return sendStatus(200)
```
### Patterns [#patterns-2]
**Example** - Revokes refresh token via OAuth client:
```ts
logout: async (locals, requestData) => {
const access_token = locals.authPayload
const { refresh_token } = requestData.body
try {
const oauth = getAuthClient()
const token = oauth.createToken({ access_token, refresh_token })
await token.revoke('refresh_token')
return sendStatus(200)
} catch (err) {
return sendError(err, 400)
}
},
```
**Example** - No revocation endpoint, return immediately:
```ts
logout: async (locals, requestData) => {
return sendStatus(200)
},
```
**Example** - No revocation, wrapped in try/catch:
```ts
logout: async (locals, requestData) => {
try {
return sendStatus(200)
} catch (err) {
return sendError(err, 400)
}
},
```
If the platform has no token revocation endpoint, `sendStatus(200)` is sufficient. CI HUB cleans up stored tokens on its side.
### UI Behavior [#ui-behavior-2]
After logout completes, the plugin UI removes the connection and returns to the adapter selection or login screen.
***
## Auth Flow Sequence [#auth-flow-sequence]
***
[Check Token Schema](/integration/schemas/auth/check-token) | [Refresh Token Schema](/integration/schemas/auth/refresh-token) | [Logout Schema](/integration/schemas/auth/logout)
# Download (/integration/building-an-adapter/download)
Most adapters don't need a `download` handler. CI HUB fetches assets directly from the `downloadUrl` returned in asset data. Implement `download` only when the URL requires server-side resolution — signed/temporary URLs, proxy downloads, or protected content that needs a fresh token exchange.
CI HUB calls this handler when the plugin needs to download an asset and the adapter has registered a download handler.
## Inputs [#inputs]
| Field | Description |
| ------------------------------ | ------------------------------------------------ |
| `requestData.query.assetId` | Asset to download |
| `requestData.query.lowres` | `"1"` for low-resolution preview |
| `requestData.query.noRedirect` | `"true"` to return JSON instead of HTTP redirect |
| `requestData.query.localId` | Optional rendition/conversion ID |
You can add additional query parameters when constructing the download URL.
## Response [#response]
Two modes based on `noRedirect`:
```ts
// Default — redirect the client to the download URL
return sendRedirectUri(downloadUrl)
// noRedirect=true — return JSON with the URL
return sendResult(null, { downloadUrl: `${freshUrl}$NO_AUTH$` })
```
Append `$NO_AUTH$` to the URL when it's pre-signed or publicly accessible — tells CI HUB not to inject auth headers when fetching.
## Patterns [#patterns]
**Example** - DAM with versioned assets, parses compound `assetId::version` format:
```ts
download: async (locals, requestData) => {
const id = requestData.query.assetId
const parts = id.split('::')
const assetId = parts[0]
const version = parts.length === 2 ? parts[1] : -1
const lowres = requestData.query.lowres === '1'
const noRedirect = requestData.query.noRedirect === 'true'
try {
const downloadUrl = await getDownloadLocation(locals, assetId, version, lowres)
return noRedirect
? sendResult(null, { downloadUrl: downloadUrl && `${downloadUrl}$NO_AUTH$` })
: sendRedirectUri(downloadUrl)
} catch (err) {
return sendError(err, 404)
}
},
```
**Example** - DAM with rendition-based downloads:
```ts
download: async (locals, requestData) => {
const noRedirect = requestData.query.noRedirect === 'true'
const assetId = requestData.query.assetId
const localId = requestData.query.localId
try {
const downloadUrl = await getRenditionUrl(locals, assetId, localId)
return noRedirect
? sendResult(null, { downloadUrl: downloadUrl && `${downloadUrl}$NO_AUTH$` })
: sendRedirectUri(downloadUrl)
} catch (err) {
return sendResult(err)
}
},
```
**Example** - stock platform, checks availability before redirecting:
```ts
download: async (locals, requestData) => {
const id = requestData.query.assetId
const lowres = requestData.query.lowres === '1'
const noRedirect = requestData.query.noRedirect === 'true'
try {
const downloadUrl = await getDownloadLocation(system, locals, id, lowres)
if (!downloadUrl) {
return sendError('Download not available', 404)
}
return noRedirect
? sendResult(null, { downloadUrl: downloadUrl && `${downloadUrl}$NO_AUTH$` })
: sendRedirectUri(downloadUrl)
} catch (err) {
const msg = (err || '').toString() || 'unexpected error'
return sendError(msg, 400)
}
},
```
## URL Suffixes [#url-suffixes]
Append suffixes to `thumbnailUrl` and `downloadUrl` in asset data to control how CI HUB authenticates and caches the request. These are processed by CI HUB's URL parser before fetching.
| Suffix | Auth behavior | When to use |
| -------------------------------- | ---------------------------------------------- | ---------------------------------------- |
| `$NO_AUTH$` | No auth added — URL is used as-is | CDN thumbnails, pre-signed S3/Azure URLs |
| `$AUTH_PAYLOAD$` | Token appended as query parameter | Legacy APIs with token-in-URL auth |
| `$AUTH_PAYLOAD_BEARER$` | `Bearer {token}` as query parameter | Legacy Bearer-in-URL pattern |
| `$AUTH_HEADER_PAYLOAD_BEARER$` | `Authorization: Bearer {token}` header | Google Drive, Box, Sitecore |
| `$AUTH_HEADER_PAYLOAD_APITOKEN$` | `Authorization: apiToken {token}` header | Admiral Cloud, Picturepark |
| `$AUTH_SESSION_ID$` | Session ID injected | Session-based auth |
| `$FORCE_DOWNLOAD$` | Forces `Content-Disposition: attachment` | Thumbnail pipeline, forced save |
| `$REQUEST_HEADERS$` | Custom headers from `info.requestHeaders` | Picturepark (complex auth headers) |
| `$NO_CACHE$` | `Cache-Control: no-cache` header, skip caching | Volatile/expiring URLs |
### Suffix Examples in Asset Data [#suffix-examples-in-asset-data]
```ts
// Pre-signed URL — no auth needed
thumbnailUrl: `https://cdn.example.com/thumb/abc123.jpg$NO_AUTH$`
// Bearer token auth via header
downloadUrl: `https://api.example.com/assets/123/download$AUTH_HEADER_PAYLOAD_BEARER$`
// API token auth
thumbnailUrl: `https://api.example.com/renditions/456$AUTH_HEADER_PAYLOAD_APITOKEN$`
// No caching + no auth (expiring CDN URL)
downloadUrl: `https://cdn.example.com/temp/signed-url$NO_AUTH$$NO_CACHE$`
// Force download disposition
thumbnailUrl: `https://api.example.com/preview/789$FORCE_DOWNLOAD$`
```
Suffixes can be combined. CI HUB strips them from the URL before making the request and applies the corresponding auth/cache behavior.
### How CI HUB Processes Suffixes [#how-ci-hub-processes-suffixes]
The auth suffixes are mutually exclusive — CI HUB applies the first match:
1. `$REQUEST_HEADERS$` — parses custom headers from the URL's `headers` query param
2. `$NO_AUTH$` — strips suffix, sends request without auth
3. `$AUTH_PAYLOAD$` — replaces suffix with URL-encoded token
4. `$AUTH_PAYLOAD_BEARER$` — replaces suffix with URL-encoded `Bearer {token}`
5. `$AUTH_HEADER_PAYLOAD_BEARER$` — strips suffix, adds `Authorization: Bearer {token}` header
6. `$AUTH_HEADER_PAYLOAD_APITOKEN$` — strips suffix, adds `Authorization: apiToken {token}` header
`$FORCE_DOWNLOAD$`, `$NO_CACHE$`, and `$AUTH_SESSION_ID$` are processed independently and can be combined with any auth suffix.
## UI Behavior [#ui-behavior]
* User places/downloads an asset → CI HUB checks for `download` handler
* If handler exists → calls it with `assetId`, uses response URL
* If no handler → fetches directly from `downloadUrl` in asset data, applying URL suffix auth
* `noRedirect=true` used internally for JSON-based URL resolution
See [Download Schema](/integration/schemas/assets/download)
# Filters (/integration/building-an-adapter/filters)
Filters let users narrow search and folder results in the plugin UI. They are returned in every `search` and `getFolder` response and **re-rendered after each call** — you rebuild the full filter list every time.
## How It Works [#how-it-works]
1. Your handler returns `result.filters` — an array of filter groups, each with `options`.
2. The UI renders them and sends back **only the IDs** of selected options in `requestData.query.filters[]`.
3. You parse those IDs, apply them to your API call, and return updated filters with `isActive` set correctly on every option.
If you don't set `isActive`, the UI won't show what's selected after the next search.
## parseFilters Utility [#parsefilters-utility]
The SDK exports `parseFilters` from `@ci-hub/integration-sdk` — a simple utility for single-select filters:
```ts
import { parseFilters } from '@ci-hub/integration-sdk'
parseFilters(['status:active', 'order:newest'])
// → { status: 'active', order: 'newest' }
```
`parseFilters` splits each string on `:` and builds a `Record`. It works for single-select filters where each key has one value. If the same key appears multiple times, the last value wins.
### multi$ Prefix [#multi-prefix]
For multi-select filters, prefix the filter key with `multi$` in the option ID. The SDK's built-in `parseFilters` does **not** handle `multi$` — use the custom parser shown in the canonical example below. Strip `multi$` from the key and collect values into an array.
## Static Filters — Canonical Example [#static-filters--canonical-example]
This is the standard pattern for filters with known, fixed options:
```ts
const activeFilters: string[] = requestData.query.filters || []
const parseFilters = (filters: string[]) =>
filters.reduce((acc: Record, filter: string) => {
const [key, value] = filter.split(':')
if (key.startsWith('multi$')) {
const multiKey = key.replace('multi$', '')
acc[multiKey] = acc[multiKey] || []
;(acc[multiKey] as string[]).push(value)
} else {
acc[key] = value
}
return acc
}, {})
const parsedFilters = parseFilters(activeFilters)
const buildStaticFilters = (parsedFilters: Record) => [
{
id: 'status',
name: 'Status',
type: 'SELECT',
options: [
{ id: 'status:active', name: 'Active', isActive: parsedFilters.status === 'active' },
{ id: 'status:archived', name: 'Archived', isActive: parsedFilters.status === 'archived' },
{ id: 'status:draft', name: 'Draft', isActive: parsedFilters.status === 'draft' },
],
},
{
id: 'mediaType',
name: 'Media Type',
type: 'SELECT',
options: [
{ id: 'multi$mediaType:image', name: 'Image', isActive: (parsedFilters.mediaType || []).includes('image') },
{ id: 'multi$mediaType:video', name: 'Video', isActive: (parsedFilters.mediaType || []).includes('video') },
{ id: 'multi$mediaType:document', name: 'Document', isActive: (parsedFilters.mediaType || []).includes('document') },
],
},
]
```
Key points:
* Single-select (`status`): option ID is `key:value`, compare with `===`
* Multi-select (`mediaType`): option ID is `multi$key:value`, compare with `.includes()`
* Every option has `isActive` set — even when `false`
## Dynamic Filters from API [#dynamic-filters-from-api]
When filter options come from API responses (facets, categories, etc.), build the filter array dynamically. This pattern from ExampleAdapter1 converts API facets into CI HUB filters:
```ts
const encodeId = (str) => {
if (!str) return ''
const encoded = str.replace(/[^A-Za-z0-9\-_:.]/g, char =>
`_x${char.charCodeAt(0).toString(16).padStart(4, '0')}_`
)
return `id_${encoded}`
}
const decodeId = (encodedStr) => {
return encodedStr
.replace(/^id_/, '')
.replace(/_x([0-9a-f]{4})_/g, (_, hex) =>
String.fromCharCode(parseInt(hex, 16))
)
}
const getFiltersFromFacets = (activeFiltersJson = {}, facets = {}) => {
const filters = []
Object.keys(facets)
.filter(key => facets[key].buckets?.length > 0)
.forEach(key => {
filters.push({
id: encodeId(key),
name: key,
options: facets[key].buckets.filter(bucket => bucket.key).map(bucket => ({
id: `${encodeId(key)}:${encodeId(bucket.key)}`,
name: bucket.key,
count: bucket.doc_count,
isActive: activeFiltersJson[encodeId(key)] === encodeId(bucket.key)
}))
})
})
return filters.filter(filter => filter.options.length > 0 && filter.id)
}
```
This adapter also converts selected filters back into API query terms:
```ts
const filtersToTerms = (activeFiltersJson = {}) => {
const terms = []
Object.keys(activeFiltersJson).forEach(key => {
if (key.startsWith('id_')) {
const filterKey = decodeId(key)
const filterValue = decodeId(activeFiltersJson[key])
if (filterValue && filterKey) {
terms.push(`(${filterKey}:"${filterValue}")`)
}
}
})
return terms
}
```
Notice the `encodeId`/`decodeId` pattern — because the API returns facet names with spaces and special characters, the adapter encodes them into safe HTML IDs and decodes them back when building API queries.
## Conditional Filters [#conditional-filters]
ExampleAdapter2 pre-defines all possible filters and uses guard functions to show/hide them based on context (search type, asset type, config flags):
```ts
const allFilters = [
{
...assetTypeFilter,
guard: (searchType) => searchType === 'text' || searchType === 'collection',
singleOption: true
},
{
id: 'order',
name: 'Sort Order',
type: 'SELECT',
options: [
{ id: 'order:best_match', name: 'Best Match' },
{ id: 'order:newest', name: 'Newest' },
{ id: 'order:oldest', name: 'Oldest' },
]
},
// ... more filters
]
// In search handler:
const filters = allFilters.filter(filter => {
if (filter.guard) return filter.guard(searchType, assetType)
return true
})
for (const filter of filters) {
for (const option of filter.options) {
const [key, value] = option.id.split(':')
option.isActive = filterOptions[key] === value
}
}
result.filters = filters
```
Some filter options are populated dynamically (from API artist/event facets) while the filter structure itself is predefined.
## ID Rules [#id-rules]
All filter and option IDs must be **unique** and valid **HTML id attributes**.
Avoid separator conflicts: if you use `:` as your ID separator (the standard pattern) but your API returns dynamic IDs containing `:`, your parsing will break. The `encodeId`/`decodeId` pattern from the dynamic filters example above solves this.
## Pre-search Filters [#pre-search-filters]
Filters returned from the `info` handler are **pre-search filters** — set once when the adapter initializes, not updatable during search. Use them only for filters that apply universally (e.g., a global content-type toggle). For anything that depends on search context, return filters from `search`/`getFolder` instead.
## Pagination and Subfolders [#pagination-and-subfolders]
The `more` parameter in `search` and `getFolder` responses is for **assets only**. For subfolders, fetch all subfolders in a loop before returning — the UI does not paginate subfolders.
***
[Search Schema](/integration/schemas/assets/search) | [Info Schema](/integration/schemas/assets/info)
# Folder Navigation (/integration/building-an-adapter/folder-navigation)
`getFolder` returns the contents of a folder — subfolders, assets, filters, and a pagination cursor. CI HUB calls it when the user clicks a folder in the plugin UI, starting with `folderId = "root"` at the top level.
## Inputs [#inputs]
| Field | Description |
| ---------------------------- | ------------------------------------------------ |
| `requestData.query.folderId` | Folder ID to open. `"root"` for top level |
| `requestData.query.size` | Page size (max assets per page) |
| `requestData.query.more` | Pagination cursor from previous response |
| `requestData.query.filters` | Active filter selections (same format as search) |
## Response [#response]
Same shape as search:
```ts
{
id: string, // echo back the folderId
name: string, // display name
folders: Folder[], // subfolders
assets: Asset[], // assets in this folder
filters: Filter[], // available filters for this folder
more: number | string | undefined, // pagination cursor for next page, undefined = last page
capabilities: FolderCapabilities, // what operations are allowed
totalAssetsCount?: number
}
```
`more` applies to **assets only**. Always return all subfolders in a single response (see [Subfolder Pagination](#subfolder-pagination)).
## Hardcoded Root Folders [#hardcoded-root-folders]
Some adapters define virtual root folders that map to different API endpoints or content types.
**Example** - boards, collections, and license categories:
```ts
getFolder: async (locals, requestData) => {
try {
const folderNames = {
'all': 'All content',
'downloads': 'Downloaded content',
'boards': 'Boards',
'collections': 'Collections',
'licensed': 'Licensed Assets',
'videos:creative': 'Creative',
'videos:editorial': 'Editorial',
'images:creative': 'Creative',
'images:editorial': 'Editorial'
}
const folderId = requestData.query.folderId || 'root'
const page = Number.parseInt(requestData.query.more || 0, 10)
const size = Math.min(Number.parseInt(requestData.query.size || 50, 10), config.get('maxQuerySize'))
let result = {
folders: [],
assets: [],
more: undefined,
capabilities: defaultFolderCapabilities
}
if (folderId === 'root') {
result.folders = Object.keys(folderNames)
.filter(folder => !['videos:creative', 'videos:editorial', 'images:creative', 'images:editorial'].includes(folder))
.map(folder => ({ id: folder, name: folderNames[folder], uploadUrl: '' }))
}
// ... handle each folder type
}
}
```
**Example** - DAM with collections, assets, work-in-progress, projects, and entities:
```ts
getFolder: async (locals, requestData) => {
try {
let size = Math.min(parseInt(requestData.query.size || 50, 10), config.get('maxQuerySize'))
const more = parseInt(requestData.query.more || 0, 10) || 0
const facetQuery = getFilterQuery(requestData.query)
const folderCapabilities = await getFolderCapabilities(locals)
const { canAddAsset, canAddFolder, canDeleteFolder } = folderCapabilities
const result = {
id: `${requestData.query.folderId}`,
name: `${requestData.query.folderId}`,
more: undefined,
assets: [],
folders: [],
filters: [],
capabilities: { ...defaultFolderCapabilities, canAddAsset },
totalAssetsCount: 0
}
if (requestData.query.folderId === 'root') {
result.folders = [
{ id: 'collections', name: 'Collections' },
{ id: 'assets', name: 'Assets' },
{ id: 'wip', name: 'Work In Progress' },
{ id: 'projects', name: 'Projects' },
{ id: 'entities', name: 'Entities' }
]
}
// ... handle each folder type with different API calls and query configs
}
}
```
## Folder ID Prefixing [#folder-id-prefixing]
When a single adapter exposes multiple content types (libraries, collections, boards), use prefixed IDs to route to the correct API in subsequent `getFolder` calls.
**Example** - DAM with libraries and collections:
```ts
getFolder: async (locals, requestData) => {
try {
const folderId = (requestData.query.folderId as string).toString()
const size = Math.min(parseInt((requestData.query.size as string) || '25', 10), config.get('maxQuerySize'))
const more = parseInt((requestData.query.more as string) || '1', 10)
const { brandId } = locals.adapterData
const result: any = {
id: folderId, assets: [], folders: [], filters: [],
capabilities: { ...defaultFolderCapabilities, ...defaultAssetCapabilities },
more: undefined, totalAssetsCount: 0
}
if (folderId === 'root') {
const libraries = await callApi(locals, {
query: queries.getGetBrandLibrariesQuery,
variables: { brandId, page: more, limit: size }
})
// Prefix library IDs so getFolder can route back to the library API
result.folders = libraries.data.brand.libraries.items.map(
(library: any) => ({ id: `library:${library.id}`, name: library.name })
)
result.more = libraries.data.brand.libraries.hasNextPage ? more + 1 : undefined
} else if (folderId.startsWith('library:')) {
const libraryId = folderId.replace('library:', '')
const assetsRes = await callApi(locals, {
query: queries.getGetLibraryAssetsQuery,
variables: { libraryId, page: more, limit: size }
})
const { library } = assetsRes.data
const { browse, collections, currentUserPermissions } = library
result.assets = extractAssetsData(locals, browse.assets.items)
result.more = browse.assets.hasNextPage ? more + 1 : undefined
result.folders = browse.folders.items?.map(
(folder: any) => ({ id: `${folder.id}`, name: folder.name })
)
if (collections.total > 0) {
result.folders.push({ id: `collections:${libraryId}`, name: 'Collections' })
}
result.capabilities.canAddFolder = true
result.capabilities.canAddAsset = currentUserPermissions.canCreateAssets
} else if (folderId.startsWith('collections:')) {
const libraryId = folderId.replace('collections:', '')
const collections = await callApi(locals, {
query: queries.getGetLibraryCollectionsQuery,
variables: { libraryId }
})
result.folders = collections.data.library.collections.items.map(
(collection: any) => ({ id: `collection:${collection.id}`, name: collection.name })
)
} else if (folderId.startsWith('collection:')) {
const collectionId = folderId.replace('collection:', '')
const assets = await callApi(locals, {
query: queries.getCollectionAssetsQuery,
variables: { collectionId, page: more, limit: size }
})
const canDeleteAsset = assets.data.node.currentUserPermissions.canRemoveAssets || false
result.assets = extractAssetsData(locals, assets.data.node.assets.items, { canDeleteAsset })
result.more = assets.data.node.assets.hasNextPage ? more + 1 : undefined
}
// ...
return send(result)
}
}
```
The prefix → API routing pattern: `library:abc` → Libraries API, `collection:xyz` → Collections API, `board:123` → Boards API.
**Example** - uses the same approach for boards and collections:
```ts
} else if (folderId === 'boards') {
const boards = await getBoards(system, locals)
for (const board of boards.boards) {
result.folders.push({
id: `board:${board.id}`,
name: board.name,
uploadUrl: ''
})
}
} else if (folderId === 'collections') {
const collections = await getCollections(system, locals)
collections.collections.forEach(collection => {
result.folders.push({
id: `collection:${collection.code};${collection.asset_family}`,
name: collection.name,
uploadUrl: ''
})
})
} else if (folderId.startsWith('board:')) {
const id = folderId.substring(6)
const board = await getBoard(system, locals, id)
if (board.ids.length > 0) {
result = await getAssets(system, locals, board.ids, undefined, undefined, undefined, `/Boards/${board.name}`, page, size)
}
} else if (folderId.startsWith('collection:')) {
const id = folderId.substring(11)
result = await getAssets(system, locals, undefined, id, undefined, undefined, `/Collections/${id.split(';')[0]}`, page, size)
}
```
## Subfolder Pagination [#subfolder-pagination]
`more` in the response only applies to assets. Always fetch **all** subfolders — use a while-loop if the platform paginates folder listings:
```ts
let allFolders = []
let folderPage = 1
let hasMore = true
while (hasMore) {
const res = await callApi(locals, {
query: queries.getSubfolders,
variables: { folderId, page: folderPage, limit: 100 }
})
allFolders = allFolders.concat(res.data.folders.items)
hasMore = res.data.folders.hasNextPage
folderPage++
}
result.folders = allFolders.map(f => ({ id: f.id, name: f.name }))
result.more = assetsResponse.hasNextPage ? nextAssetCursor : undefined
```
If the API requests to get all subfolders are costly and slow, you can return all subfolders only in the first getFolder call.
in the first `getFolder` call you will have `more = undefined`.
example:
```ts
getFolder: async (locals, requestData) => {
try {
const more = requestData.query.more
if (!more) {
// get all subfolders
}
return send(result)
}
}
```
This works because the CI HUB plugin will merge the results of the first `getFolder` call with the results of the subsequent `getFolder` calls.
## Per-Folder Capabilities [#per-folder-capabilities]
Spread `defaultFolderCapabilities` and override per folder type. The defaults are all `false`:
```ts
import { defaultFolderCapabilities, defaultAssetCapabilities } from '@ci-hub/integration-sdk'
// defaultFolderCapabilities = { canDeleteFolder: false, canAddFolder: false, canAddAsset: false }
// defaultAssetCapabilities = { canDeleteAsset: false, canUpdateAsset: false, canLockAsset: false, canUnlockAsset: false }
```
Override capabilities based on the folder type and user permissions:
```ts
// Read-only folder (boards listing)
result.capabilities = defaultFolderCapabilities
// Writable folder with upload
result.capabilities = {
...defaultFolderCapabilities,
canAddFolder: true,
canAddAsset: currentUserPermissions.canCreateAssets
}
// Full permissions subfolder
result.capabilities = {
...defaultFolderCapabilities,
canAddFolder: true,
canAddAsset: true,
canDeleteFolder: true,
canDeleteAsset: true,
canUpdateAsset: true
}
```
## UI Behavior [#ui-behavior]
* `folderId = "root"` → CI HUB shows the top-level folder list after login
* Clicking a folder → calls `getFolder` with that folder's `id`
* `folders` array → rendered as clickable folder items
* `assets` array → rendered as asset thumbnails/list items
* `more` defined → "Load More" button appears, clicks send `more` value back
* `capabilities.canAddAsset = true` → upload button appears in folder
* `capabilities.canAddFolder = true` → "New Folder" option appears
See [Get Folder Schema](/integration/schemas/folders/get-folder) | [Create Folder Schema](/integration/schemas/folders/create-folder)
# Folder Operations (/integration/building-an-adapter/folder-operations)
Handlers for creating, deleting, renaming, and moving folders. Each requires a capability flag returned from `getFolder` in the folder's `capabilities` object.
## Capability Flags [#capability-flags]
Return these per-folder in `getFolder` response:
```ts
return send({
id: folderId,
folders: [...],
assets: [...],
capabilities: {
canCreateFolder: true,
canDeleteFolder: true,
canRenameFolder: true,
canMoveFolder: true
}
})
```
Capabilities are per-folder — some folders (root, virtual containers) may disable operations that others allow.
## createFolder [#createfolder]
Creates a new folder inside the specified parent.
### Inputs [#inputs]
| Field | Description |
| --------------------------- | ---------------- |
| `requestData.body.name` | New folder name |
| `requestData.body.parentId` | Parent folder ID |
### Response [#response]
```ts
return send({ id: newFolderId, name: folderName })
```
### Patterns [#patterns]
**Example** - DAM that supports both library folders and collections:
```ts
createFolder: async (locals, requestData) => {
try {
const result = { id: '', name: '' }
let { name, parentId } = requestData.body
parentId = !folderIdStartsWith(parentId || '', [ 'root', 'collection:' ]) ? parentId : undefined
if (!parentId) {
throw new Error('You can not create a folder in this location')
}
if (parentId.startsWith('collections:')) {
parentId = parentId.replace('collections:', '')
const collection = await callApi(locals, {
query: queries.createCollectionMutation,
variables: { input: { name, parentId } }
})
result.id = `collection:${collection.data.createCollection.collection.id}`
result.name = collection.data.createCollection.collection.name
} else {
parentId = parentId.replace('library:', '')
const folder = await callApi(locals, {
query: queries.createFolderMutation,
variables: { input: { name, parentId } }
})
result.id = folder.data.createFolder.folder.id
result.name = folder.data.createFolder.folder.name
}
return send(result)
} catch (error) {
return sendError(error, 400)
}
},
```
**Example** - DAM that supports path-based folder creation:
```ts
createFolder: async (locals, requestData) => {
try {
const { parentId, name } = requestData.body
const parentPath = parentId === 'root' ? '' : (await callApi(locals, 'POST', '/files/get_metadata', null, { path: parentId })).path_lower
const folderCreateResponse = await callApi(locals, 'POST', '/files/create_folder_v2', null, { path: `${parentPath}/${name}` })
return sendResult(null, { id: folderCreateResponse.metadata.id, name: folderCreateResponse.metadata.name })
} catch (err) {
return sendError(err, 400)
}
},
```
### UI Behavior [#ui-behavior]
On success, the new folder appears in the current folder view. The plugin UI uses the returned `id` and `name` for display.
[Create Folder Schema](/integration/schemas/folders/create-folder)
***
## deleteFolder [#deletefolder]
Permanently removes a folder. Some platforms recursively delete contents; others require the folder to be empty.
### Inputs [#inputs-1]
| Field | Description |
| ----------------------------- | ------------------- |
| `requestData.params.folderId` | Folder ID to delete |
### Response [#response-1]
```ts
return sendStatus(200)
```
### Patterns [#patterns-1]
**Example** - DAM that handles collection and folder types, blocks deletion of root-level folders:
```ts
deleteFolder: async (locals, requestData) => {
try {
const { folderId } = requestData.params
const id = folderIdStartsWith(folderId, [ 'root', 'collections', 'library' ]) ? undefined : folderId
if (!id) {
throw new Error('You can not delete this folder')
}
if (folderId.startsWith('collection:')) {
const collectionId = folderId.replace('collection:', '')
await callApi(locals, { query: queries.deleteCollectionMutation, variables: { input: { collectionId } } })
} else {
await callApi(locals, { query: queries.deleteFolderMutation, variables: { input: { ids: [ id ] } } })
}
return sendStatus(200)
} catch (error) {
return sendError(error, 400)
}
},
```
**Example** - DAM that supports path-based deletion:
```ts
deleteFolder: async (locals, requestData) => {
try {
const { folderId } = requestData.params
await callApi(locals, 'POST', '/files/delete_v2', null, { path: folderId })
return sendResult(null)
} catch (err) {
return sendError(err, 400)
}
},
```
Protect virtual/root folder IDs from deletion. Check the `folderId` against known non-deletable values before calling the platform API.
### UI Behavior [#ui-behavior-1]
On success, the folder disappears from the parent folder view.
[Delete Folder Schema](/integration/schemas/folders/delete-folder)
***
## renameFolder [#renamefolder]
Changes a folder's display name.
### Inputs [#inputs-2]
| Field | Description |
| ----------------------------- | ----------- |
| `requestData.params.folderId` | Folder ID |
| `requestData.body.name` | New name |
### Response [#response-2]
```ts
return send({ id: folderId, name: newName })
```
### Patterns [#patterns-2]
**Example** - DAM that supports both collections and library folders:
```ts
renameFolder: async (locals, requestData) => {
try {
const { name } = requestData.body
const { folderId } = requestData.params
const id = folderIdStartsWith(folderId, [ 'root', 'collections', 'library' ]) ? undefined : folderId
if (!id) {
throw new Error('You can not rename this folder')
}
const result = { id: '', name: '' }
if (folderId.startsWith('collection:')) {
const collectionId = folderId.replace('collection:', '')
const collection = await callApi(locals, {
query: queries.updateCollectionMutation,
variables: { input: { id: collectionId, data: { name } } }
})
result.id = collection.data.updateCollection.collection.id ? `collection:${collection.data.updateCollection.collection.id}` : folderId
result.name = collection.data.updateCollection.collection.name || name
} else {
const folder = await callApi(locals, {
query: queries.updateFolderMutation,
variables: { input: { id, data: { name } } }
})
result.id = folder.data.updateFolder.folder.id || folderId
result.name = folder.data.updateFolder.folder.name || name
}
return send(result)
} catch (error) {
return sendError(error, 400)
}
},
```
**Example** - DAM that supports JSON Patch:
```ts
renameFolder: async (locals, requestData) => {
const { folderId } = requestData.params
const { name } = requestData.body
try {
await callApi(locals, 'PATCH', `/categories/${folderId}`, null, [{ op: 'replace', path: '/tree/name', value: name }])
return send({ id: folderId, name })
} catch (err) {
return sendError(err, 400)
}
},
```
### UI Behavior [#ui-behavior-2]
On success, the folder's name updates in the folder tree and breadcrumbs.
[Rename Folder Schema](/integration/schemas/folders/rename-folder)
***
## moveFolder [#movefolder]
Moves a folder to a different parent folder.
### Inputs [#inputs-3]
| Field | Description |
| --------------------------------- | ---------------------------- |
| `requestData.body.folderId` | Folder ID to move |
| `requestData.body.targetParentId` | Destination parent folder ID |
### Response [#response-3]
```ts
return send({ id: folderId, targetParentId })
```
### Patterns [#patterns-3]
**Example** - DAM that supports JSON Patch to update parent:
```ts
moveFolder: async (locals, requestData) => {
try {
const { folderId, targetParentId } = requestData.body
if (typeof targetParentId !== 'string' || !targetParentId || targetParentId.startsWith('lightbox') || [ 'root', 'categories', 'lightboxes', 'home', 'mostViewed', 'recentlyUploaded' ].includes(targetParentId)) {
return sendError('Invalid folder ID', 400)
}
await callApi(locals, 'PATCH', `/categories/${folderId}`, null, [{ op: 'replace', path: '/parentId', value: targetParentId }])
return send({ id: folderId, targetParentId })
} catch (err) {
return sendError(err, 400)
}
},
```
Validate `targetParentId` before calling the API. Virtual folder IDs (`root`, `lightboxes`, etc.) are not valid move destinations.
### UI Behavior [#ui-behavior-3]
On success, the folder disappears from its current location and appears under the target parent. Enable via `canMoveFolder: true` in the folder's capabilities.
[Move Folder Schema](/integration/schemas/folders/move-folder)
# Info Handler (/integration/building-an-adapter/info-handler)
Called after successful login. Returns adapter metadata and optional pre-configuration that shapes the plugin UI for this connection.
```ts
info?: (locals: Locals) => HandlerResult
```
CI HUB calls `info` once after a successful login (and after `checkToken` on reconnect). The response configures filters, locale pickers, upload options, and other UI elements for the duration of the session.
## Response [#response]
Return via `sendResult`:
```ts
return sendResult(null, {
remoteSystemPrefix: 'dam.example.com',
// all other fields optional
})
```
### `remoteSystemPrefix` (required) [#remotesystemprefix-required]
Base URL hostname of the remote system. CI HUB uses this to build "open in source system" links on assets. Typically derived from the user's connection endpoint:
```ts
const remoteSystemPrefix = (locals: Locals) =>
new URL(locals.adapterData.resourceEndpoint).hostname
```
### `filters` [#filters]
Pre-search filters — dropdown menus shown in the plugin UI **before** the user triggers a search. Set once at login, **not updatable during search**. Use only for always-applicable filters like "Library" or "Search Operation".
Each filter has an `id`, `name`, and `options` array. Option IDs use the `filterId:value` convention:
```ts
const filters = [
{
id: 'library',
name: 'Library',
options: [
{ id: 'library:all', name: 'All Libraries' },
{ id: 'library:123', name: 'Brand Assets' },
{ id: 'library:456', name: 'Marketing' },
]
}
]
```
Options support `default: true` to pre-select, `isDisabled` to gray out, and `isActive` for toggling. Supports `i18nName` for localized display names.
These filters are static for the session. For filters that change based on search results (facets), use dynamic filters in the [search handler](/integration/building-an-adapter/search).
### `dataLocales` [#datalocales]
Locale picker options shown in the plugin UI. Each locale has an `id`, optional `name`/`displayName`, and an optional `default` flag:
```ts
const dataLocales = [
{ id: 'en', name: 'en', default: true },
{ id: 'de', name: 'de' },
{ id: 'fr', name: 'fr' },
]
```
The selected locale is passed to subsequent handler calls (e.g. search) so the adapter can return localized metadata.
### `createAssetOptions` / `updateAssetOptions` [#createassetoptions--updateassetoptions]
Extra form fields shown in the plugin UI during upload (create) or re-upload (update). Each option defines an input with type `select`, `string`, or `date`:
```ts
const createAssetOptions = [
{
id: 'metadataTemplateId',
name: 'Metadata Template',
options: [
{ id: 'metadataTemplateId:tmpl_1', name: 'Product Photography' },
{ id: 'metadataTemplateId:tmpl_2', name: 'Marketing Campaign' },
]
},
{
id: 'securityTemplateId',
name: 'Security Template',
options: [
{ id: 'securityTemplateId:sec_1', name: 'Internal Only' },
{ id: 'securityTemplateId:sec_2', name: 'Public' },
]
}
]
```
The user's selection is passed to the `createAsset`/`updateAsset` handler in `requestData.body`.
### `nativeFolderOrder` [#nativefolderorder]
If `true`, CI HUB displays folders in the order returned by the provider API instead of sorting alphabetically. Use when the source system has meaningful ordering (custom sort, priority-based).
### `customMetadata` [#custommetadata]
Metadata field definitions used for detail panels and filter configurations. Contains `groups` (grouping headers) and `fields` (individual metadata fields, optionally assigned to a group via `groupId`).
See [Custom Metadata](/integration/advanced/custom-metadata) for full details.
### `searchConfigs` [#searchconfigs]
Multiple search mode configurations. Adds configuration dropdowns to the search UI. `searchQueryRequired: true` prevents searching without a query string.
```ts
const searchConfigs = {
searchQueryRequired: false,
configs: [
{
id: 'single:searchMode',
name: 'Search Mode',
i18nName: { en: 'Search Mode' },
type: 'select',
options: [
{ id: 'all', name: 'All', isActive: false },
{ id: 'any', name: 'Any', isActive: false },
{ id: 'exact', name: 'Exact', isActive: false },
{ id: 'default', name: 'Default', isActive: true },
]
}
]
}
```
### `requestHeaders` [#requestheaders]
Custom HTTP headers the CI HUB client sends when making direct requests to asset URLs (thumbnails, downloads). Use when the provider requires authentication or cache-control headers on asset URLs:
```ts
requestHeaders: {
'X-AdmiralCloud-ClientId': 'your-client-key',
'Cache-Control': 'no-cache'
}
```
### `transformation` [#transformation]
Transformation config for adapters that support on-the-fly asset transformations (e.g. Cloudinary). Contains URLs for fetching available transformations and executing them:
```ts
const transformation = {
availableTransformationsUrl: `${adapterBaseUrl}/getAvailableTransformations`,
actionUrl: `${adapterBaseUrl}/doTransformation`
}
```
### `showPdfPreviewOptions` [#showpdfpreviewoptions]
If `true`, shows PDF preview options in the plugin UI.
### `assetSearchHelpUrl` [#assetsearchhelpurl]
URL linking to external documentation about the provider's search syntax. Shown as a help link in the search UI.
## Patterns [#patterns]
### Example - Filters from API data [#example---filters-from-api-data]
Fetches brand libraries from the API and builds a pre-search filter:
```ts
info: async (locals) => {
try {
const { brandId } = locals.adapterData
const libraries = await callApi(locals, { query: queries.getGetBrandLibrariesQuery, variables: { brandId, page: 1, limit: 100 } })
const libraryFilter = {
id: 'library',
name: 'Library',
options: [{ id: 'library:all', name: 'All Libraries' }].concat(libraries.data.brand.libraries.items.map((library: any) => ({ id: `library:${library.id}`, name: library.name })))
}
return sendResult(null, { remoteSystemPrefix: remoteSystemPrefix(locals), filters: [ libraryFilter ] })
} catch (error) {
return sendError(`Error getting info: ${error}`, 400)
}
}
```
### Example - createAssetOptions from metadata templates [#example---createassetoptions-from-metadata-templates]
Fetches metadata and security templates from the API, builds upload form options dynamically:
```ts
info: async (locals) => {
try {
const createAssetOptions = []
const filters = [
{
id: 'searchOperation',
name: 'Operation',
options: [
{ id: 'operation:AND', name: 'AND' },
{ id: 'operation:OR', name: 'OR' }
]
},
{
id: 'searchField',
name: 'Search Field',
options: [
{ id: 'keywordSearchField:all', name: 'Everything', default: true },
{ id: 'keywordSearchField:filename', name: 'File Name' },
{ id: 'keywordSearchField:assetId', name: 'Asset ID' },
{ id: 'keywordSearchField:metadata', name: 'Metadata' },
{ id: 'keywordSearchField:content', name: 'Content' }
]
}
]
const metadata = await callApi(locals, 'GET', '/v1/metadata/template')
const security = await callApi(locals, 'GET', '/v1/security/template')
const optionalMetadata = metadata.filter(templateTypeFilter).filter(nonRequired)
if (optionalMetadata.length > 0) {
createAssetOptions.push({
id: 'metadataTemplateId',
name: 'Metadata Template',
options: optionalMetadata.map(item => ({
id: `metadataTemplateId:${item.templateId}`,
name: item.templateName
}))
})
}
if (security?.length > 0) {
createAssetOptions.push({
id: 'securityTemplateId',
name: 'Security Template',
options: security?.map(item => ({
id: `securityTemplateId:${item.templateId}`,
name: item.templateName
}))
})
}
return sendResult(null, { remoteSystemPrefix: remoteSystemPrefix(locals), filters, createAssetOptions })
} catch (error) {
return sendError(error, 400)
}
}
```
### Example - dataLocales and customMetadata [#example---datalocales-and-custommetadata]
Fetches available locales from the API and builds custom metadata field definitions:
```ts
info: async (locals) => {
let customMetadata
let dataLocales
const availableLocales = await getAvailableLocales(locals, locals.authPayload)
dataLocales = availableLocales.supportedLocals.
split(',').
map(locale => locale.trim()).
map(locale => ({
id: locale,
name: locale,
default: locale === availableLocales.defaultLocale
}))
try {
customMetadata = { fields: [], groups: [] }
const uiLocale = await getUiLocale(locals, locals.authPayload)
const customFieldClient = await createSoapClient(locals && locals.authPayload, 'apiCustomFieldService')
const customFieldsMapping = await getCustomFieldMapping(customFieldClient)
customMetadata.fields = [
{ id: 'assetType', name: translate('File Category', uiLocale) },
{ id: 'mediaTypeName', name: translate('Asset Type', uiLocale) },
{ id: 'assetAvailability', name: translate('Asset Availability', uiLocale) }
]
customFieldsMapping.forEach(customFieldMap => {
const { id: groupId, name: groupName } = customFieldMap.attributes
customMetadata.groups.push({ id: groupId, name: groupName })
customFieldMap.CustomField.forEach(customField => {
const fieldId = customField.attributes.id
const fieldName = getCustomFieldName(customFieldsMapping, fieldId, uiLocale)
customMetadata.fields.push({ id: `${fieldId}`, name: fieldName, groupId })
})
})
} catch {
// Suppress errors — custom metadata is optional
}
return sendResult(null, {
remoteSystemPrefix: remoteSystemPrefix(locals.authPayload),
showPdfPreviewOptions: true,
dataLocales,
customMetadata
})
}
```
## UI Behavior [#ui-behavior]
| Field | Plugin UI Effect |
| ----------------------- | -------------------------------------------------------- |
| `remoteSystemPrefix` | "Open in source" links on assets |
| `filters` | Dropdown menus above search results (static for session) |
| `dataLocales` | Language picker in the plugin toolbar |
| `createAssetOptions` | Extra form fields in the upload dialog |
| `updateAssetOptions` | Extra form fields in the re-upload dialog |
| `nativeFolderOrder` | Folders keep provider ordering instead of A–Z sort |
| `customMetadata` | Metadata panels on asset details, filter options |
| `searchConfigs` | Configuration dropdowns in the search bar |
| `requestHeaders` | Sent with every direct asset URL request |
| `transformation` | Enables transformation UI (Cloudinary) |
| `showPdfPreviewOptions` | Shows PDF preview toggles |
[Info Schema](/integration/schemas/assets/info) | [Custom Metadata](/integration/advanced/custom-metadata)
# Login Flow (/integration/building-an-adapter/login-flow)
The `login` handler authenticates the user and returns tokens + adapterData to CI HUB.
CI HUB calls it when the user clicks **Connect** in the plugin UI.
## `sendAccessToken` [#sendaccesstoken]
```ts
sendAccessToken(error?, accessToken?, expiresIn?, refreshToken?, refreshExpiresIn?, adapterData?)
```
| Parameter | Type | Description |
| ------------------ | ------------------------- | ---------------------------------------------------------------------------------------------- |
| `error` | `string \| null` | Error message — if set, login fails and the plugin UI shows the message |
| `accessToken` | `string` | Token (or credential object) stored and passed to all future handlers via `locals.authPayload` |
| `expiresIn` | `number` | Token lifetime in seconds |
| `refreshToken` | `string` | Refresh token for `refreshToken` handler |
| `refreshExpiresIn` | `number` | Refresh token lifetime in seconds |
| `adapterData` | `Record` | Persisted object available in every future handler call via `locals.adapterData` |
## adapterData [#adapterdata]
The `adapterData` object is persisted across all future handler calls. Access it via `locals.adapterData`. Typical fields: `resourceEndpoint`, `orgId`, `brandId`, API base URLs — anything your handlers need that isn't the auth token itself.
## State Management [#state-management]
OAuth flows require multiple redirects. State helpers persist data across them:
```ts
const state = await getStateCodeAsync()
await setStateAdapterDataAsync(state, { serverUrl, verifier })
const { serverUrl } = await getStateAdapterDataAsync(state)
```
| Function | Description |
| --------------------------------------- | ---------------------------------------------------------- |
| `getStateCodeAsync()` | Generates a random state code |
| `setStateAdapterDataAsync(state, data)` | Persists data keyed to the state code — survives redirects |
| `getStateAdapterDataAsync(state)` | Retrieves the persisted data |
## `render(template, data)` [#rendertemplate-data]
Renders an ECT template to show login forms to the user. The first argument is the template path without `.ect` extension. See [Login Templates](/integration/templates/overview).
```ts
return render(path.join(import.meta.dirname, 'select-serverurl'), {
title: 'Select Portal',
action: urlObject.toString(),
serverUrl: defaultUrl,
logo,
contact
})
```
## `sendRedirectUri(url)` [#sendredirecturiurl]
Redirects the user's browser to an external URL — typically an OAuth authorization endpoint.
```ts
return sendRedirectUri(authUrl)
```
## Error Handling [#error-handling]
Pass an error string as the first argument to `sendAccessToken`. The plugin UI displays it to the user.
```ts
return sendAccessToken('Invalid credentials')
```
## Patterns [#patterns]
### OAuth + server URL form [#oauth--server-url-form]
User selects a server URL via a rendered form, then is redirected to the OAuth provider. After authorization, the code is exchanged for tokens.
Template (`select-serverurl.ect`):
```html
<% extend 'login-template.ect' %>
Please enter the URL to your ExampleAdapter1 portal.
```
Handler:
```ts
login: async (locals, requestData) => {
const code = requestData.query.code
const authError = requestData.query.error || requestData.query.error_description ? `${requestData.query.error_description || 'Auth error'} (${requestData.query.error || '???'})` : ''
let serverUrl = ensureHttps(((requestData.body.serverUrl || requestData.query.serverUrl || '') as string).trim(), true, true)
const canceled = requestData.body.canceled || requestData.query.canceled
const state = requestData.body.state || requestData.query.state
if (authError) {
return sendAccessToken(authError)
}
if (canceled) {
return sendAccessToken()
}
// First call: create state and redirect to re-invoke the login handler
if (!state) {
const urlObject = new URL(locals.endpointUrl)
urlObject.searchParams.set('state', await getStateCodeAsync())
urlObject.searchParams.set('serverUrl', serverUrl)
return sendRedirectUri(urlObject.toString())
}
// No server URL yet — render the form
const serverUrlError = serverUrl && !await checkIsExampleAdapter1Portal(serverUrl)
if (!serverUrl && !code || serverUrlError) {
const urlObject = new URL(locals.endpointUrl)
urlObject.searchParams.set('state', state)
return render(path.join(import.meta.dirname, 'select-serverurl'), {
title: 'Select ExampleAdapter1 Portal',
action: urlObject.toString(),
serverUrl: serverUrl || config.get('exampleAdapter1.defaultServerUrl'),
logo,
contact,
error: serverUrlError ? 'Invalid Url (System is not a valid ExampleAdapter1 system).' : undefined
})
}
// Retrieve serverUrl from state
if (!serverUrl) {
serverUrl = (await getStateAdapterDataAsync(state)).serverUrl
}
try {
const oauth = getAuthClient(serverUrl)
// No code yet — save serverUrl in state and redirect to OAuth provider
if (!code) {
await setStateAdapterDataAsync(state, {
serverUrl
})
const authUrl = oauth.authorizeURL({
redirect_uri: `${locals.endpointUrl}`,
scope: SCOPES,
state
})
return sendRedirectUri(authUrl)
}
// Code received — exchange for tokens
const { token } = await oauth.getToken({
code,
redirect_uri: locals.endpointUrl
})
return sendAccessToken(null, token.access_token, token.expires_in, token.refresh_token, undefined, { resourceEndpoint: serverUrl })
} catch (err) {
return sendAccessToken(err)
}
},
```
If your system does not send the `expires_in` for `access_token` and `refresh_expires_in` for `refresh_token`, you can hardcode them. It is not recommended but it is valid.
### API key / credentials [#api-key--credentials]
User enters credentials via a rendered form. Credentials are validated against the platform API before completing login. The credential object itself is stored as the access token.
Template (`login.ect`):
```html
<% extend 'login-template.ect' %>
Please enter your credentials.
```
Handler:
```ts
login: async (locals, requestData) => {
const endpointUrlWithParam = param => {
const urlObject = new URL(locals.endpointUrl)
Object.entries(param).forEach(([ key, value ]) => {
urlObject.searchParams.set(key, value)
})
return urlObject.toString()
}
const { cloudName, apiKey, apiSecret } = requestData.body
const token = {
cloudName,
apiKey,
apiSecret
}
const adapterData = { resourceEndpoint: 'https://your-platform.example.com/api' }
const state = requestData.body.state || requestData.query.state
const canceled = (requestData.body.canceled || requestData.query.canceled) === 'true'
try {
// First call: create state
if (!state) {
return sendRedirectUri(endpointUrlWithParam({ state: await getStateCodeAsync() }))
}
// Render login form (reused for validation errors)
const renderForm = error => render(path.join(import.meta.dirname, 'login'),
Object.assign({
title: 'ExampleAdapter2 Login',
action: endpointUrlWithParam({ state }),
logo,
providerName: 'ExampleAdapter2',
error
}, token))
if (canceled) {
return sendAccessToken('Login was cancelled')
}
// Incomplete credentials — show form
if (!cloudName || !apiKey || !apiSecret) {
return renderForm()
}
// Validate credentials against the platform API
try {
await DAMService.ping({
cloud_name: cloudName,
api_key: apiKey,
api_secret: apiSecret
})
} catch (err) {
if (err && err.error && err.error.message) {
switch (err.error.message) {
case 'cloud_name mismatch':
return renderForm('ExampleAdapter2: Invalid Cloud Name')
case 'unknown api_key':
return renderForm('ExampleAdapter2: Invalid API-Key')
case 'api_secret mismatch':
return renderForm('ExampleAdapter2: Secret Mismatch')
default:
break
}
}
return renderForm('ExampleAdapter2: Invalid Login Data')
}
// Credential object stored as the access token
return sendAccessToken(null, token, null, null, null, adapterData)
} catch (err) {
return sendError(err, 400)
}
},
```
### OAuth PKCE + multi-step (server URL → OAuth → brand select) [#oauth-pkce--multi-step-server-url--oauth--brand-select]
Multi-step flow: user enters a server URL, gets redirected for PKCE-based OAuth, then selects a brand. State helpers persist data across each redirect.
```ts
login: async (locals, requestData) => {
const { code } = requestData.query
const authError = requestData.query.error || requestData.query.error_description ? `${requestData.query.error_description || 'Auth error'} (${requestData.query.error || '???'})` : ''
let serverUrl = ensureHttps(((requestData.body.serverUrl || requestData.query.serverUrl || '') as string).trim(), true, true)
const canceled = requestData.body.canceled || requestData.query.canceled
const state = requestData.body.state || requestData.query.state
const brandId = requestData.body.brandId || requestData.query.brandId || ''
if (authError) {
return sendAccessToken(authError)
}
if (canceled) {
return sendAccessToken()
}
// Step 1: create state and redirect
if (!state) {
const urlObject = new URL(locals.endpointUrl)
urlObject.searchParams.set('state', await getStateCodeAsync())
urlObject.searchParams.set('serverUrl', serverUrl)
return sendRedirectUri(urlObject.toString())
}
// Step 2: no server URL yet — render the form
if (!serverUrl && !code) {
const urlObject = new URL(locals.endpointUrl)
urlObject.searchParams.set('state', state as string)
return render(path.join(import.meta.dirname, 'select-serverurl'), {
title: 'Select ExampleAdapter3 Portal',
action: urlObject.toString(),
serverUrl: serverUrl || config.get('exampleAdapter3.defaultServerUrl'),
logo,
contact
})
}
if (!serverUrl) {
({ serverUrl } = await getStateAdapterDataAsync(state as string) as { serverUrl: string })
}
try {
const oauth = getAuthClient(serverUrl)
let tokenData = (await getStateAdapterDataAsync(state as string))?.tokenData as oauth2.Token
// Step 3: no code yet — generate PKCE challenge and redirect to OAuth provider
if (!code) {
const verifier = createCodeVerifier()
const challenge = createCodeChallenge(verifier)
await setStateAdapterDataAsync(state as string, {
serverUrl,
verifier
})
const authUrl = oauth.authorizeURL({
redirect_uri: `${locals.endpointUrl}`,
code_challenge_method: 'S256',
code_challenge: challenge,
state: state as string,
scope: SCOPES
})
return sendRedirectUri(authUrl)
}
// Step 4: code received — exchange with PKCE verifier
if (!tokenData) {
const { verifier } = await getStateAdapterDataAsync(state as string) as { verifier: string }
const { token } = await oauth.getToken({
code: code as string,
client_id: 'your-client-id',
redirect_uri: locals.endpointUrl,
code_verifier: verifier
})
tokenData = token
}
// Step 5: no brand selected yet — fetch brands and render selection form
if (!brandId) {
await setStateAdapterDataAsync(state as string, {
tokenData,
serverUrl
})
const brands = await axios.post(`${serverUrl}/graphql`, {
query: queries.getBrandsQuery
}, {
headers: {
'Authorization': `Bearer ${tokenData.access_token}`,
'Content-Type': 'application/json'
}
})
const urlObject = new URL(locals.endpointUrl)
urlObject.searchParams.set('state', state as string)
urlObject.searchParams.set('serverUrl', serverUrl)
urlObject.searchParams.set('code', code as string)
return render(path.join(import.meta.dirname, 'select-serverurl'), {
title: 'Select Brand',
brands: brands.data.data.brands,
authMethod: 'brands',
serverUrl,
state,
action: urlObject.toString(),
logo,
contact
})
}
// Step 6: complete — send tokens with brandId in adapterData
return sendAccessToken(null, tokenData.access_token as string, tokenData.expires_in as number, tokenData.refresh_token as string, undefined, { resourceEndpoint: serverUrl, brandId })
} catch (error: any) {
return sendAccessToken(`Error logging in: ${error.message}`)
}
},
```
***
[Login Schema](/integration/schemas/auth/login) | [Login Templates](/integration/templates/overview)
# Search (/integration/building-an-adapter/search)
The search handler returns assets matching a text query.
Called when the user types in the search bar in the CI HUB plugin UI.
## Inputs [#inputs]
| Parameter | Type | Description |
| ----------------------------- | ---------- | ------------------------------------------------------------ |
| `requestData.query.query` | `string` | Search text entered by the user |
| `requestData.query.page` | `string` | Current page number |
| `requestData.query.size` | `string` | Requested page size |
| `requestData.query.filters` | `string[]` | Selected filter option IDs |
| `requestData.query.parentId` | `string` | Folder scope — limits search to a subfolder |
| `requestData.body.dataBase64` | `string` | Base64-encoded image for similarity/visual search (optional) |
## Response [#response]
```ts
return send({ assets, folders, filters, more })
```
| Field | Type | Description |
| --------- | --------------------- | ---------------------------------------------------------------------------- |
| `assets` | `Asset[]` | Matching assets |
| `folders` | `Folder[]` | Subfolders relevant to the query |
| `filters` | `Filter[]` | Filter definitions with options and active state |
| `more` | `number \| undefined` | Next page token. Present when more **assets** exist. `undefined` = last page |
## The `extractAssetsData` Pattern [#the-extractassetsdata-pattern]
Every adapter needs a function that maps remote API objects to the CI HUB `Asset` shape. By convention this function is called `extractAssetData` (single) or `extractAssetsData` (batch). It is **not** an SDK export — you write it in your adapter. Both `search` and `getFolder` reuse it.
### Asset Object Fields [#asset-object-fields]
Every field the mapping function should produce:
| Field | Type | Purpose |
| ---------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | `string` | Unique identifier from the remote system |
| `name` | `string` | Display name. **Sanitize** with `.replace(/[/\\:*?"<>\|]/g, '_')` — creative tools reject special characters in file names |
| `downloadUrl` | `string \| null` | URL to fetch the file binary. Append [URL suffixes](#url-suffixes) as needed. `null` if download is not permitted |
| `thumbnailUrl` | `string \| null` | Preview image URL. For InDesign/IDMS files, CI HUB can generate thumbnails server-side via `getThumbnailUrl()` — pass the source system thumbnail or an empty string |
| `mimeType` | `string` | Derived from file extension or the remote API. Fall back to `'application/octet-stream'` |
| `xSizePx` / `ySizePx` | `number` | Pixel dimensions (width / height) |
| `fileSize` | `number` | Size in bytes |
| `created` / `modified` | `number` | Unix millisecond timestamps. Convert ISO date strings with `new Date(dateString).getTime()` |
| `version` | `number` | Version number. Default to `1` when the platform doesn't support versioning |
| `values` | `Array<{id, name, type, value}>` | Metadata entries shown in the asset detail panel. See [Custom Metadata](/integration/advanced/custom-metadata) |
| `conversions` | `Array<{id, name, url, extension}>` | Available renditions/derivatives of the asset |
| `capabilities` | `AssetCapabilities` | Per-asset permissions. Spread `defaultAssetCapabilities` and override what the platform supports |
| `keywords` | `string[]` | Tags — displayed as chips in the plugin UI |
| `states` | `string[]` | Workflow / lifecycle state labels |
| `assetDetailsExternalUrl` | `string \| null` | "Open in source system" link |
| `downloadHashFileAttributes` | `string` | `JSON.stringify({ fileSize, modified })` — used by CI HUB for change detection |
### URL Suffixes [#url-suffixes]
Append these to `downloadUrl` or `thumbnailUrl` to control how CI HUB fetches the file:
* `$NO_AUTH$` — URL is publicly accessible, skip auth headers
* `$AUTH_PAYLOAD_BEARER$` — attach the stored token as a Bearer header
* `$FORCE_DOWNLOAD$` — force download instead of streaming
Full list in the [Search Schema](/integration/schemas/assets/search).
## Pagination [#pagination]
`more` controls asset pagination. Return a page token (typically `page + 1`) when more assets exist; return `undefined` on the last page.
For **subfolders**: return all matching folders in a single response — don't paginate folders.
## `canAddAsset` in Search [#canaddasset-in-search]
If the platform allows uploading assets without a folder context (e.g. a flat asset library), set `canAddAsset: true` in the search result's `capabilities`:
```ts
capabilities: { canAddAsset: true }
```
This shows the upload button in the plugin UI even when not inside a folder.
## Patterns [#patterns]
### Example - `extractAssetData` [#example---extractassetdata]
Maps a single remote API object to the CI HUB Asset shape. Derives the name, thumbnail, MIME type, conversions, states, and metadata values from the remote item.
```ts
const stateProps = [
{ id: 'watermarked', name: 'Watermarked' },
{ id: 'limited', name: 'Limited' },
{ id: 'archive', name: 'Archived' },
{ id: 'isPublic', name: 'Public' }
]
const getValues = item => stateProps.
filter(prop => item[prop.id]).
map(prop => ({ id: prop.id, name: prop.name, type: 'TEXT', value: '✓' })).
concat(Object.entries(item).
filter(([ key, value ]) => key.startsWith('property_') && value).
map(([ key, value ]) => ({ id: key, name: `${key[9].toUpperCase()}${key.substring(10)}`, type: 'TEXT', value: Array.isArray(value) ? value.join(', ') : value })))
const extractAssetData = (locals, item) => {
let name = (item.name || '').replace(/[/\\:*?"<>|]/g, '_')
const currentExtension = (path.extname(name) || '').toLowerCase().replace('.', '')
if (item.extension && currentExtension !== `${item.extension}`.toLowerCase()) {
name += `.${item.extension}`
}
const thumbnailVideoUrl = item && item.videoPreviewURLs && item.videoPreviewURLs.length && item.videoPreviewURLs[0]
const downloadUrl = getDownloadUrl(locals, item.id)
const thumbnailUrl = getThumbnailUrl(downloadUrl, name, [ 'indd', 'idms' ], item.thumbnails.thul && `${item.thumbnails.thul}$NO_AUTH$`)
const fileSize = item.fileSize
const modified = new Date(item.dateModified).getTime()
return {
id: item.id,
name,
parentPath: '/Assets',
fileSize,
xSizePx: item.width,
ySizePx: item.height,
created: new Date(item.dateCreated).getTime(),
modified,
mimeType: mime.lookup(name) || 'application/octet-stream',
version: item.version || 1,
caption: item.description && item.description.trim() || undefined,
copyright: item.copyright && item.copyright.trim() || undefined,
title: item.name,
thumbnailUrl,
thumbnailVideoUrl,
downloadUrl,
downloadHashFileAttributes: JSON.stringify({ fileSize, modified }),
lowresUrl: !thumbnailVideoUrl ? getDownloadUrl(locals, item.id, '?lowres=1') : undefined,
assetDetailsExternalUrl: getAssetDetailsExternalUrl(locals, item.id),
conversions: getConversions(item),
keywords: item.tags,
states: stateProps.filter(entry => item[entry.id]).map(entry => entry.name),
capabilities: defaultAssetCapabilities,
values: getValues(item)
}
}
```
### Example - `extractAssetsData` [#example---extractassetsdata]
Batch variant that maps an array of remote items, merges per-asset permissions with `defaultAssetCapabilities`, and filters out broken items.
```ts
const extractAssetsData = (locals, items, overrideCapabilities = {}, useFileName = false) => items.map((item) => {
const expired = item.expiresAt ? new Date(item.expiresAt).getTime() < Date.now() : false
const [ key, fallback ] = useFileName ? [ 'title', 'filename' ] : [ 'filename', 'title' ]
const assetName = item[key] || item[fallback]
const downloadUrl = item.downloadUrl?.concat('$NO_AUTH$') ?? getDownloadUrl(locals)
const thumbnailUrl = item.thumbnailUrl?.concat('$NO_AUTH$')
const extension = item.extension || mimeTypes.extension(item.filename)
const fileSize = item.size
const modified = new Date(item.modifiedAt).getTime()
const readableId = getHumanReadableId(item.id.toString())
const canDownload = !expired && (item.currentUserPermissions?.canDownload ?? true)
const capabilities = {
canUpdateAsset: !expired && (item.currentUserPermissions?.canEdit || false),
canDeleteAsset: !expired && (item.currentUserPermissions?.canDelete || false)
}
try {
const asset = {
id: item.id.toString(),
name: assetName,
downloadUrl: canDownload ? downloadUrl : null,
thumbnailUrl: thumbnailUrl,
mimeType: mimeTypes.lookup(extension) || mimeTypes.lookup(assetName) || 'application/octet-stream',
title: item.title,
xSizePx: item.width,
ySizePx: item.height,
fileSize,
created: new Date(item.createdAt).getTime(),
modified,
version: 1,
values: getValues(item),
conversions: expired ? [] : getConversions(item, extension, thumbnailUrl),
keywords: item.tags?.map((tag) => tag.value).filter((tag) => tag),
states: [ item.workflowTask?.status?.name, item.licenses?.map((license) => license.title), expired ? 'Expired' : '' ].flat().filter((state) => state),
capabilities: { ...defaultAssetCapabilities, ...capabilities, ...overrideCapabilities },
downloadHashFileAttributes: JSON.stringify({ fileSize, modified }),
assetDetailsExternalUrl: readableId ? new URL(`/screen/${readableId}`, locals.adapterData.resourceEndpoint).toString() : null
}
return asset
} catch {
return undefined
}
}).filter((item) => item)
```
### Example - Search Handler [#example---search-handler]
Parses query parameters, calls the remote API, maps results through `extractAssetData`, builds filters, and determines pagination.
```ts
search: async (locals, requestData) => {
const query = requestData.query.query || ''
const page = Number.parseInt(requestData.query.more || 0, 10)
const size = Math.min(Number.parseInt(requestData.query.size || 50, 10), config.get('exampleAdapter1.maxQuerySize'))
const activeFilters = requestData.query.filters || []
try {
const result = await getAssets(locals, null, query, page, size, activeFilters)
return sendResult(null, result)
} catch (err) {
return sendError(err, 400)
}
}
// getAssets builds the full result object:
const getAssets = async (locals, collection, search, page, size, activeFilters = []) => {
const capabilities = Object.assign({}, defaultFolderCapabilities, {
canAddAsset: true,
canDeleteFolder: collection !== ''
})
const result = { folders: [], assets: [], filters: [], capabilities, totalAssetsCount: 0 }
const query = {
limit: size,
page: page + 1,
count: true,
total: true,
orderBy: 'dateCreated desc'
}
const assets = await callApi(locals, 'GET', '/api/v4/media/', query)
result.totalAssetsCount = assets.count.total
// Pagination: check if more assets exist
if (page * size + assets.media.length < result.totalAssetsCount) {
result.more = page + 1
}
// Map each remote item to CI HUB Asset
for (const item of assets.media) {
const asset = extractAssetData(locals, item)
result.assets.push(asset)
}
return result
}
```
### Example - Search Handler [#example---search-handler-1]
SDK-based adapter with filter parsing, folder scoping, and two search paths (library search vs. brand-wide search).
```ts
search: async (locals, requestData) => {
const { brandId } = locals.adapterData
const isFileNameSearch = requestData.query.type === 'fileName'
const query = (requestData.query.query || '').replace(isFileNameSearch ? /^"|"$/g : '', '')
const activeFilters = requestData.query.filters || []
const activeFiltersJson = parseOptions(activeFilters)
const rFolderId = (requestData.query.parentId || '').toString()
const folderId = folderIdStartsWith(rFolderId) ? undefined : rFolderId
const more = parseInt((requestData.query.more || '1'), 10) || 1
const size = Math.min(parseInt((requestData.query.size || '25'), 10), config.get('exampleAdapter2.maxQuerySize'))
const result = {
assets: [],
folders: [],
filters: [],
more: undefined,
capabilities: {},
totalAssetsCount: 0
}
try {
const searchQuery = {
...folderId ? { inFolder: { id: folderId } } : {},
search: query
}
const assets = await callApi(locals, {
query: queries.getSearchAssetsQuery,
variables: { libraryId, query: searchQuery, page: more, limit: size }
})
result.assets = extractAssetsData(locals, assets.data.library.assets.items)
result.more = assets.data.library.assets.hasNextPage ? more + 1 : undefined
result.totalAssetsCount = assets.data.library.assets.total
result.filters = getFilters(assets.data, activeFiltersJson)
return send(result)
} catch (error) {
return sendError(`Error searching: ${error}`, 400)
}
}
```
***
[Search Schema](/integration/schemas/assets/search) | [Filters](/integration/building-an-adapter/filters) | [Custom Metadata](/integration/advanced/custom-metadata)
# Tasks (/integration/building-an-adapter/tasks)
Task handlers connect CI HUB's tasking UI to work management platforms. Users can search tasks, view task details, attach/remove assets, add comments, and update task states — all from inside the creative tool.
## Enabling Tasks [#enabling-tasks]
Set `tasking: true` and configure `taskSearch` in capabilities:
```ts
capabilities: {
tasking: true,
taskSearch: {
help: {
en: 'Search for tasks by typing a name',
de: 'Suchen Sie nach Aufgaben indem Sie einen Namen eintippen'
}
}
}
```
## searchTasks [#searchtasks]
Returns a filtered, paginated list of tasks. CI HUB calls this when the user types in the task search bar or applies filters.
### Inputs [#inputs]
| Field | Description |
| --------------------------- | -------------------------------------------------------------------- |
| `requestData.query.query` | Search text |
| `requestData.query.filters` | Applied filter option IDs (array of `"filterName:optionId"` strings) |
| `requestData.query.more` | Pagination cursor from previous response |
| `requestData.query.size` | Page size |
### Response [#response]
```ts
return send({
tasks: [...], // array of task objects
filters: [...], // filter definitions with selected state
more: 'cursor-or-offset', // pagination cursor, undefined if no more
totalTasksCount: 0 // total count (if known)
})
```
### Patterns [#patterns]
**Example** - Parses filters, supports workspace scoping and completion status:
```ts
const performTaskSearch = async (locals, requestData) => {
const query = requestData.query.query || ''
const appliedFilters = requestData.query.filters || []
const more = requestData.query.more || undefined
const size = Math.min(Number.parseInt(requestData.query.size || 50, 10), config.get('adapter.maxQuerySize'))
const parsedFilters = appliedFilters.reduce((acc, optionId) => {
const [ filterName, optionName ] = optionId.split(':')
acc[filterName] = optionName
return acc
}, {})
const result = { tasks: [], filters: [ getGlobalTaskListFilter(parsedFilters), getCompletedStatusFilter(parsedFilters) ], more: undefined, totalTasksCount: 0 }
const workspaceId = parsedFilters.workspace || await getDefaultWorkspaceId(locals)
const completed = [ undefined, 'completedAndIncomplete' ].includes(parsedFilters.completedStatusFilter) ? undefined : parsedFilters.completedStatusFilter === 'completed'
const userID = parsedFilters.globalTaskListFilter === 'myTasks' ? await getUserId(locals) : undefined
const data = (await callApi(locals, 'GET', `/workspaces/${workspaceId}/tasks/search`, {
'created_at.before': more,
'limit': size,
'text': query,
'opt_fields': optFieldsTask,
'sort_by': 'created_at',
'assignee.any': userID,
completed
})).data || []
result.tasks = await extractTasksData(locals, data)
if (result.tasks.length && result.tasks.length === size) {
result.more = new Date(result.tasks[result.tasks.length - 1].created || 0).toISOString()
}
return result
}
searchTasks: async (locals, requestData) => {
try {
const result = await performTaskSearch(locals, requestData)
return send(result)
} catch (err) {
return sendError(err, 400)
}
},
```
The handler delegates to `performTaskSearch` which is reused by both `searchTasks` and the `search` handler (when tasking is enabled in folder navigation).
[Search Tasks Schema](/integration/schemas/tasks/search-tasks)
***
## getTask [#gettask]
Returns full details for a single task.
### Inputs [#inputs-1]
| Field | Description |
| --------------------------- | ----------- |
| `requestData.params.taskId` | Task ID |
### Response [#response-1]
Return a single task object (same shape as items in `searchTasks`).
### Patterns [#patterns-1]
**Example** - Returns full details for a single task:
```ts
getTask: async (locals, requestData) => {
try {
const { taskId } = requestData.params
const data = (await callApi(locals, 'GET', `/tasks/${taskId}`)).data
const task = (await extractTasksData(locals, [ data ]))[0]
if (!task) {
return sendError('Error processing task', 400)
}
return send(task)
} catch (err) {
return sendError(err, 400)
}
},
```
[Get Task Schema](/integration/schemas/tasks/get-task)
***
## getTaskAssets [#gettaskassets]
Returns the assets (attachments) linked to a task.
### Inputs [#inputs-2]
| Field | Description |
| --------------------------- | ----------------- |
| `requestData.params.taskId` | Task ID |
| `requestData.query.size` | Page size |
| `requestData.query.more` | Pagination cursor |
### Response [#response-2]
```ts
return send({
assets: [...],
more: 'next-offset',
totalAssetsCount: 0
})
```
### Patterns [#patterns-2]
**Example** - Returns the assets linked to a task:
```ts
getTaskAssets: async (locals, requestData) => {
try {
const { taskId } = requestData.params
const limit = Math.min(Number.parseInt(requestData.query.size || 50, 10), config.get('adapter.maxQuerySize'))
const result = {
assets: [],
more: undefined,
totalAssetsCount: 0
}
const taskAssets = await callApi(locals, 'GET', `/tasks/${taskId}/attachments`, {
opt_fields: optFieldsAttachment,
offset: (requestData.query.more !== '' && requestData.query.more) || undefined,
limit
}) || {}
if (taskAssets.data && taskAssets.data.length) {
result.assets = extractAssetData(locals, taskAssets.data).map(asset => ({
...asset,
capabilities: {
...asset.capabilities,
canDeleteAsset: true
}
}))
}
if (taskAssets.next_page) {
result.more = taskAssets.next_page.offset
}
return send(result)
} catch (err) {
return sendError(err, 400)
}
},
```
Set `canDeleteAsset: true` on task assets to enable the remove button in the task assets panel.
[Get Task Assets Schema](/integration/schemas/tasks/get-task-assets)
***
## addTaskComment [#addtaskcomment]
Posts a text comment to a task.
### Inputs [#inputs-3]
| Field | Description |
| --------------------------- | ------------ |
| `requestData.params.taskId` | Task ID |
| `requestData.body.comment` | Comment text |
### Response [#response-3]
```ts
return sendStatus(200)
```
### Patterns [#patterns-3]
**Example** - Adds a comment to a task:
```ts
addTaskComment: async (locals, requestData) => {
try {
const { taskId } = requestData.params
const { comment } = requestData.body
const data = {
data: {
is_pinned: false,
text: comment
}
}
await callApi(locals, 'POST', `/tasks/${taskId}/stories`, null, data)
return sendStatus(200)
} catch (err) {
return sendError(err, 400)
}
},
```
[Add Task Comment Schema](/integration/schemas/tasks/add-task-comment)
***
## addTaskAsset [#addtaskasset]
Attaches an asset to a task. The asset is linked as an external reference using CI HUB's view URL system.
### Inputs [#inputs-4]
| Field | Description |
| ------------------------------------- | ----------------------------- |
| `requestData.params.taskId` | Task ID |
| `requestData.body.name` | Asset display name |
| `requestData.body.providerId` | Source adapter provider ID |
| `requestData.body.remoteSystemPrefix` | Source adapter system prefix |
| `requestData.body.assetId` | Asset ID in the source system |
| `requestData.body.viewUrl` | Base view URL for the asset |
### Response [#response-4]
```ts
return sendStatus(200)
```
### Patterns [#patterns-4]
**Example** - Attaches as an external link using multipart form data:
```ts
addTaskAsset: async (locals, requestData) => {
try {
const { taskId } = requestData.params
const { name, providerId, remoteSystemPrefix: systemPrefix, assetId, viewUrl } = requestData.body
if ([ taskId, name, providerId, systemPrefix, assetId, viewUrl ].every(val => ![ undefined, null ].includes(val))) {
const assetViewUrl = createAssetViewUrl(viewUrl, providerId, systemPrefix, assetId)
const fd = new FormDataNode()
fd.append('parent', taskId)
fd.append('name', name)
fd.append('resource_subtype', 'external')
fd.append('url', assetViewUrl)
await callApi(locals, 'POST', '/attachments', null, fd, fd.getHeaders())
return sendStatus(200)
}
return sendError('Not implemented', 400)
} catch (err) {
return sendError(err, 400)
}
},
```
`createAssetViewUrl()` generates a CI HUB view URL that links back to the asset in its source system. Use it to create cross-adapter asset references.
[Add Task Asset Schema](/integration/schemas/tasks/add-task-asset)
***
## deleteTaskAsset [#deletetaskasset]
Removes an asset attachment from a task.
### Inputs [#inputs-5]
| Field | Description |
| ---------------------------- | ----------------------- |
| `requestData.params.taskId` | Task ID |
| `requestData.params.assetId` | Attachment ID to remove |
### Response [#response-5]
```ts
return sendStatus(200)
```
### Patterns [#patterns-5]
**Example** - Removes an asset attachment from a task:
```ts
deleteTaskAsset: async (locals, requestData) => {
try {
const { assetId } = requestData.params
await callApi(locals, 'DELETE', `/attachments/${assetId}`)
return sendStatus(200)
} catch (err) {
return sendError(err, 400)
}
},
```
[Delete Task Asset Schema](/integration/schemas/tasks/delete-task-asset)
***
## updateCustomTaskState [#updatecustomtaskstate]
Updates a task's status or custom field value. CI HUB calls this when the user changes a state dropdown in the task detail panel.
### Inputs [#inputs-6]
| Field | Description |
| ------------------------------------ | ---------------------- |
| `requestData.params.taskId` | Task ID |
| `requestData.query.stateId` | State field identifier |
| `requestData.query.selectedOptionId` | New value/option ID |
### Response [#response-6]
```ts
return sendStatus(200)
```
### Patterns [#patterns-6]
**Example** - Supports both built-in fields and custom fields (prefixed with `custom_userField_id:`):
```ts
updateCustomTaskState: async (locals, requestData) => {
try {
const { taskId } = requestData.params
const { stateId, selectedOptionId } = requestData.query
let data
if (stateId.startsWith('custom_userField_id:')) {
const [ , fieldID ] = stateId.split(':')
data = {
data: {
custom_fields: { [fieldID]: selectedOptionId }
}
}
} else {
data = {
data: {
[stateId]: selectedOptionId
}
}
}
await callApi(locals, 'PUT', `/tasks/${taskId}`, null, data)
return sendStatus(200)
} catch (err) {
return sendError(err, 400)
}
},
```
**Example** - Simpler API, maps directly to a status field:
```ts
updateCustomTaskState: async (locals, requestData) => {
try {
const { taskId } = requestData.params
const { selectedOptionId } = requestData.query
await callApi(locals, 'PUT', '/task', { ID: taskId, updates: JSON.stringify({ status: selectedOptionId }) })
return sendStatus(200)
} catch (err) {
return sendError(err, 400)
}
},
```
The `stateId` and `selectedOptionId` values come from the task's `states` array, which is populated in `extractTasksData`. Each state has `options` with selectable values.
[Update Custom Task State Schema](/integration/schemas/tasks/update-custom-task-state)
# Upload (/integration/building-an-adapter/upload)
`createAsset` uploads a new file into a folder. `updateAsset` replaces an existing asset's file. CI HUB calls these when the user uploads from the plugin UI.
## Enabling Upload in the UI [#enabling-upload-in-the-ui]
Upload visibility is controlled by capabilities returned from `getFolder` and `info`:
| Capability | Where | Effect |
| ---------------------- | ---------------------- | -------------------------------------- |
| `canAddAsset: true` | Folder capabilities | Upload button appears in that folder |
| `canUpdateAsset: true` | Per-asset capabilities | "Update" option appears on right-click |
## Inputs [#inputs]
### createAsset [#createasset]
| Field | Description |
| ------------------------------------- | ---------------------------------------------------- |
| `requestData.body.name` | Original filename |
| `requestData.body.dataBase64` | File as base64 data URI (fallback) |
| `requestData.body.dataBuffer` | File as Buffer (preferred) |
| `requestData.body.parentId` | Target folder ID |
| `requestData.body.createAssetOptions` | Filter-format options from `info.createAssetOptions` |
### updateAsset [#updateasset]
| Field | Description |
| ----------------------------- | ---------------------------------- |
| `requestData.params.assetId` | ID of asset to update |
| `requestData.body.name` | New filename |
| `requestData.body.dataBase64` | File as base64 data URI (fallback) |
| `requestData.body.dataBuffer` | File as Buffer (preferred) |
## Response [#response]
```ts
return send({
id: string, // new or updated asset ID
name: string, // filename
additionalRenditionsNeeded?: Array<{ // triggers linked-file upload flow
type: string,
url: string
}>
})
```
## Patterns [#patterns]
### Standard Chunked Upload [#standard-chunked-upload]
**ExampleAdapter1** — init upload → PUT chunks to pre-signed URLs → complete:
```ts
createAsset: async (locals, requestData) => {
try {
const { name, dataBase64, dataBuffer, createAssetOptions } = requestData.body
const buffer = dataBuffer || Buffer.from(dataBase64.split(',')[1], 'base64')
const assetOptions = parseFilters(createAssetOptions || [])
const { data } = await callApi(locals, 'post', 'v1/assets/asset', {}, {
filename: name,
metadataTemplateId: assetOptions.metadataTemplateId,
securityTemplateIds: [assetOptions.securityTemplateId],
metadata: [],
filesize: buffer.length
})
const { fileId, filename, originalFileId, uploadId } = data
const urls = data.urls || []
const parts = []
if (data.url && urls.length === 0) {
urls.push({ url: data.url, partNumber: 1 })
}
await Promise.all(
urls.map(async url => {
const start = maxChunkSize * (url.partNumber - 1)
const end = Math.min(maxChunkSize * url.partNumber, buffer.length)
const chunk = buffer.subarray(start, end)
const response = await axios.put(url.url, chunk, {
headers: { 'Content-Type': 'application/octet-stream' }
})
parts.push({
etag: response.headers.etag.replace(/"/g, ''),
partNumber: url.partNumber
})
})
)
await callApi(locals, 'post', '/v1/asset/complete', null, {
fileId, filename, filesize: buffer.length,
action: 'complete', originalFileId, uploadId, parts
})
const additionalRenditionsNeeded = createAdditionalRenditions(locals, filename, fileId)
return send({ id: fileId, name: filename, additionalRenditionsNeeded })
} catch (err) {
return sendError(err)
}
},
```
### updateAsset — Same Flow with Existing Metadata [#updateasset--same-flow-with-existing-metadata]
**Example** - fetches existing asset metadata, creates new version with same template/security settings:
```ts
updateAsset: async (locals, requestData) => {
try {
const { assetId } = requestData.params
const { name, dataBase64, dataBuffer } = requestData.body
const buffer = dataBuffer || Buffer.from(dataBase64.split(',')[1], 'base64')
const asset = (await callApi(locals, 'GET', `/v1/assets/asset/${assetId}`)).data || {}
const { data } = await callApi(locals, 'post', 'v1/assets/asset', {}, {
filename: name,
metadataTemplateId: asset.metadataTemplateId,
securityTemplateIds: asset.securityTemplateIds,
metadata: (asset.metadata || []).filter(
metadata => !['System Process Source', 'System Layout Links'].includes(metadata.name)
),
filesize: buffer.length,
originalFileId: asset.originalFileId
})
const { fileId, filename, originalFileId, uploadId } = data
const urls = data.urls || []
const parts = []
if (data.url && urls.length === 0) {
urls.push({ url: data.url, partNumber: 1 })
}
await Promise.all(
urls.map(async url => {
const start = maxChunkSize * (url.partNumber - 1)
const end = Math.min(maxChunkSize * url.partNumber, buffer.length)
const chunk = buffer.subarray(start, end)
const response = await axios.put(url.url, chunk, {
headers: { 'Content-Type': 'application/octet-stream' }
})
parts.push({
etag: response.headers.etag.replace(/"/g, ''),
partNumber: url.partNumber
})
})
)
await callApi(locals, 'post', '/v1/asset/complete', null, {
fileId, filename, filesize: buffer.length,
action: 'complete', originalFileId, uploadId, parts
})
const additionalRenditionsNeeded = createAdditionalRenditions(locals, filename, fileId)
return send({ id: fileId, name: filename, additionalRenditionsNeeded })
} catch (err) {
return sendError(err)
}
},
```
### Simple FormData Upload [#simple-formdata-upload]
**Example** - single POST with multipart form data:
```ts
const upload = async (locals, requestData) => {
try {
const { name, dataBase64, dataBuffer, parentId } = requestData.body
const assetId = requestData.params?.assetId
const buffer = dataBuffer || Buffer.from(dataBase64.split(',').pop(), 'base64')
const fileSize = buffer.length
const formData = new FormDataNode()
let versionGroup
formData.append('asset', buffer, name || '')
if (assetId) {
versionGroup = (await callApi(locals, 'GET', `/assets/${assetId}`)).asset.versionGroup
}
const id = (await callApi(locals, 'POST', '/assets/upload', {
estimateTime: 1, size: fileSize, totalSize: fileSize,
folderId: assetId ? null : parentId, versionGroup
}, formData, formData.getHeaders()))[0].asset.id
return send({ id, name, additionalRenditionsNeeded: createAdditionalRenditions(locals, name, id) })
} catch (err) {
return sendError(err, 400)
}
}
```
This adapter reuses the same function for both `createAsset` and `updateAsset` — when `assetId` is present, it fetches the version group to create a new version.
## Additional Renditions [#additional-renditions]
When uploading InDesign, Illustrator, or Photoshop files, CI HUB can extract linked files and associate them with the uploaded asset. This is driven by `additionalRenditionsNeeded` in the upload response.
### Flow [#flow]
### Creating the Renditions List [#creating-the-renditions-list]
Check the file extension — return renditions only for supported creative formats:
```ts
const createAdditionalRenditions = (locals, fileName, assetId) => {
const fileExtension = path.extname(fileName).toLowerCase().replace('.', '')
if (['ai', 'indd', 'psd'].includes(fileExtension)) {
return [{
type: 'DocumentSummary',
url: `${locals.endpointUrl}/saveAssetRelations?type=${fileExtension}&assetId=${encodeURIComponent(assetId)}`
}]
}
}
```
The `url` points to the adapter's own `saveAssetRelations` handler, which receives the document structure from the plugin and creates asset relationships.
### getAdditionalRenditionsNeeded Handler [#getadditionalrenditionsneeded-handler]
When using `directAccessHelper` (browser-side upload), the renditions list isn't computed server-side during upload. Instead, CI HUB calls this handler after upload completes:
```ts
getAdditionalRenditionsNeeded: async (locals, requestData) => {
try {
const { assetId, name } = requestData.query
return send(createAdditionalRenditions(locals, name, assetId))
} catch (error) {
return sendError(error, 400)
}
}
```
### directAccessHelper [#directaccesshelper]
The `directAccessHelper` is not part of the SDK yet.
For large files, adapters can provide a `directAccessHelper` — a JavaScript function that runs in the browser and uploads directly to the platform's API (bypassing the CI HUB server). Returned as a string in the adapter's capabilities. The browser-side function receives `(axios, payload, sessionId, data, onProgress, adapterData, accessToken, helpers)` and returns `{ id, name, additionalRenditionsNeeded }`.
The DAH calls `getAdditionalRenditionsNeeded` via the CI HUB backend:
```ts
const additionalRenditions = async (name, assetId) => await axios.get(
helpers.getBackendServerUrl() + '/api/v1/system/providerInternal/getAdditionalRenditionsNeeded',
{
headers: {
Authorization: 'Bearer '.concat(helpers.getAccessToken()),
'Provider-Authorization': 'Bearer '.concat(helpers.getProviderAccessToken() || helpers.getPayload())
},
params: { name, assetId }
}
)
```
## UI Behavior [#ui-behavior]
* Folder with `canAddAsset: true` → upload button visible
* User clicks upload → file picker → `createAsset` called with file buffer and `parentId`
* Asset with `canUpdateAsset: true` → right-click shows "Update" option
* User clicks Update → file picker → `updateAsset` called with `assetId` and new file buffer
* Upload response with `additionalRenditionsNeeded` → plugin automatically posts document structure to each rendition URL
See [Create Asset Schema](/integration/schemas/assets/create-asset) | [Update Asset Schema](/integration/schemas/assets/update-asset)
# Worked Examples (/integration/examples)
Full integration walkthroughs showing how real adapters were built. These are reference implementations — read them alongside the [main guide](/integration) to understand how the patterns apply in production.
New to the SDK? Start with the [main guide](/integration) first. Come back here once you understand the core concepts.
## Available examples [#available-examples]
## How to use these examples [#how-to-use-these-examples]
Each example documents:
* The **capabilities** the adapter declares and why
* The **auth flow** chosen and the decisions behind it
* **Folder and asset mapping** — how the remote data model maps to the CI HUB schema
* **API patterns** specific to that platform (GraphQL, REST, pagination)
* **Upload flow** — server-side, client-side (directAccessHelper), or both
* **Edge cases** and how they were handled
Use them as a reference when building your own integration — not as a copy-paste starting point. Your platform's API will have different shapes, auth requirements, and data models.
# Installation (/integration/getting-started/installation)
## Prerequisites [#prerequisites]
* Node.js 20.19+ or 22.12+
* TypeScript 5 or later
* An existing DAM or content platform with an API you can call
## Scaffold a new project (recommended) [#scaffold-a-new-project-recommended]
The fastest way to get started is the `create` command. It scaffolds a ready-to-run TypeScript project with the SDK pre-configured:
```bash
npm create @ci-hub/integration my-integration
```
```bash
pnpm create @ci-hub/integration my-integration
```
```bash
yarn create @ci-hub/integration my-integration
```
```bash
bun create @ci-hub/integration my-integration
```
### CLI options [#cli-options]
| Flag | Description |
| -------------- | ---------------------------------------------------------------- |
| `--overwrite` | Remove existing files in the target directory before scaffolding |
| `--install` | Auto-install dependencies (default: interactive prompt) |
| `--no-install` | Skip dependency installation |
After scaffolding, start the development server:
```bash
cd my-integration
npm run dev
```
The SDK starts a local server and exposes your adapter at `http://localhost:8080`. You can point a CI HUB development environment at this URL to test your adapter end to end.
## Manual install [#manual-install]
If you prefer to set up a project from scratch:
```bash
npm install @ci-hub/integration-sdk
```
```bash
pnpm add @ci-hub/integration-sdk
```
```bash
yarn add @ci-hub/integration-sdk
```
```bash
bun add @ci-hub/integration-sdk
```
## TypeScript configuration [#typescript-configuration]
The scaffolded project includes a `tsconfig.json`. If you're setting up manually, your config should have at minimum:
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
```
The SDK ships with full type definitions — you get autocomplete and type checking for all handler signatures, `locals`, `requestData`, and response helpers.
## The full `defineIntegration` skeleton [#the-full-defineintegration-skeleton]
Below is the complete list of handlers you can implement. Start with the ones your platform supports and add more over time. Handlers you don't implement are simply omitted.
Every handler inside `defineIntegration` is fully typed — the SDK infers the correct `locals` and `requestData` types for each handler, so you don't need to annotate them manually.
```ts
import {
defineIntegration,
send, sendResult, sendError, sendStatus, sendAccessToken, sendRedirectUri,
defaultFolderCapabilities, defaultAssetCapabilities,
config, fs, path, CategoryEnum,
} from '@ci-hub/integration-sdk'
const imageBase64 = (name: string) =>
`data:image/png;base64,${Buffer.from(fs.readFileSync(path.join(import.meta.dirname, name))).toString('base64')}`
const logo = { data: imageBase64('logo.png'), width: 600, height: 134, backgroundColor: '#FFFFFF' }
const glyph = { data: imageBase64('glyph.png'), width: 80, height: 80, backgroundColor: '#FFFFFF' }
export default defineIntegration({
name: 'My DAM',
version: '1.0.0',
logo,
glyph,
contact: config.get('myIntegration.contact'),
capabilities: {
category: CategoryEnum.DAM,
assetUploadLimitInMB: 500,
directAssetDownload: true,
assetSearch: {
supportsParentId: true,
},
description: {
en: 'Connect to My DAM from CI HUB.',
},
},
// ─── Auth (required) ─────────────────────────────────────────────────────
login: async (locals, requestData) => { /* ... */ },
logout: async (locals, requestData) => { /* ... */ },
checkToken: async (locals) => { /* ... */ },
refreshToken: async (locals) => { /* ... */ }, // optional — OAuth only
// ─── Content (implement what your platform supports) ──────────────────────
search: async (locals, requestData) => { /* ... */ },
getFolder: async (locals, requestData) => { /* ... */ },
download: async (locals, requestData) => { /* ... */ },
createAsset: async (locals, requestData) => { /* ... */ },
updateAsset: async (locals, requestData) => { /* ... */ },
deleteAsset: async (locals, requestData) => { /* ... */ },
// ─── Folders ─────────────────────────────────────────────────────────────
createFolder: async (locals, requestData) => { /* ... */ },
renameFolder: async (locals, requestData) => { /* ... */ },
deleteFolder: async (locals, requestData) => { /* ... */ },
moveFolder: async (locals, requestData) => { /* ... */ },
// ─── Asset actions ───────────────────────────────────────────────────────
lockAsset: async (locals, requestData) => { /* ... */ },
renameAsset: async (locals, requestData) => { /* ... */ },
moveAsset: async (locals, requestData) => { /* ... */ },
getAssetVersions: async (locals, requestData) => { /* ... */ },
// ─── Tasks ───────────────────────────────────────────────────────────────
searchTasks: async (locals, requestData) => { /* ... */ },
getTask: async (locals, requestData) => { /* ... */ },
getTaskAssets: async (locals, requestData) => { /* ... */ },
addTaskComment: async (locals, requestData) => { /* ... */ },
addTaskAsset: async (locals, requestData) => { /* ... */ },
deleteTaskAsset: async (locals, requestData) => { /* ... */ },
updateCustomTaskState: async (locals, requestData) => { /* ... */ },
// ─── Brand Hub ───────────────────────────────────────────────────────────
getBrandConfig: async (locals, requestData) => { /* ... */ },
getBrandAssets: async (locals, requestData) => { /* ... */ },
// ─── Advanced ────────────────────────────────────────────────────────────
doTransformation: async (locals, requestData) => { /* ... */ },
additionalRendition: async (locals, requestData) => { /* ... */ },
saveAssetRelations: async (locals, requestData) => { /* ... */ },
})
```
## config.json [#configjson]
The SDK reads configuration from `config.json` in your project root (or `src/config.json`). Structure it with a top-level `serverBaseUrl` and an adapter-specific section keyed by your adapter name:
```json
{
"serverBaseUrl": "http://localhost:8080",
"myAdapter": {
"contact": {
"text": "If you experience any problems, please contact our support team.",
"url": { "link": "https://your-platform.example.com/help", "email": "support@your-platform.example.com" }
},
"authEndpoint": "https://your-platform.example.com/oauth/authorize",
"tokenEndpoint": "https://your-platform.example.com/oauth/token",
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"maxQuerySize": 100,
"maxFileSizeInMB": 5242880
}
}
```
Access values with `config.get()` and `config.has()` using dot-separated paths:
```ts
config.get('myAdapter.clientId') // returns the value or throws if missing
config.get('myAdapter.maxQuerySize') // nested keys work the same way
config.has('myAdapter.clientSecret') // returns boolean, never throws
```
`config.get()` throws if the key doesn't exist. Pass a default value as the second argument to avoid the throw: `config.get('myAdapter.timeout', 30000)`.
## Config validation [#config-validation]
Validate required config keys at the top of your `index.ts`, before `defineIntegration`. This fails fast on startup instead of at runtime when a handler is called:
```ts
{
const requiredKeys = ['myAdapter.clientId', 'myAdapter.contact', 'myAdapter.authEndpoint', 'myAdapter.tokenEndpoint']
const missing = requiredKeys.filter(k => !config.has(k))
if (missing.length) throw new Error(`Missing configuration: ${missing.join(', ')}`)
}
```
The block scope `{}` keeps `requiredKeys` and `missing` out of module scope.
## Project structure [#project-structure]
Keep all adapter logic in `index.ts`. Split only for long static data: GraphQL query strings (`queries.ts`), static filter/rendition lists (`renditions.ts`), or `.ect` login templates.
## Available scripts [#available-scripts]
The scaffolded project includes these npm scripts:
| Script | Command | Description |
| ------ | ------------- | ---------------------------------- |
| `dev` | `npm run dev` | Start the local development server |
# Project Structure (/integration/getting-started/project-structure)
Running `npm create @ci-hub/integration my-integration` scaffolds:
## index.ts [#indexts]
All adapter logic lives in `index.ts`. The file contains `defineIntegration`, every handler, and all helper functions.
Split only for long static data (see below).
## When to split [#when-to-split]
You might need to split your code into multiple files if you have a lot of static data like GraphQL queries, predefined renditions, or predefined filters.
| File | When to extract |
| --------------- | -------------------------------------------------------------------------------------------- |
| `queries.ts` | Long GraphQL query strings. if your API uses GraphQL. |
| `renditions.ts` | Static rendition/conversion lists (predefined sizes, aspect ratios). |
| `*.ect` | Login screen templates (ECT format). See [Login Templates](/integration/templates/overview). |
`queries.ts` and `renditions.ts` are just examples, if needed you can create any file you need to store your static data.
## config.json [#configjson]
Adapter-specific configuration. Keys are accessed via `config.get('adapterName.key')`.
```json
{
"serverBaseUrl": "http://localhost:8080",
"myIntegration": {
"contact": {
"text": "Support description",
"url": { "link": "https://your-platform.example.com/help", "email": "support@example.com" }
},
"authEndpoint": "/api/oauth/authorize",
"tokenEndpoint": "/api/oauth/accesstoken",
"clientId": "your-client-id",
"maxQuerySize": 100
}
}
```
```ts
import { config } from '@ci-hub/integration-sdk'
const apiUrl = config.get('myIntegration.apiUrl')
const limit = config.has('myIntegration.uploadLimitMB')
? config.get('myIntegration.uploadLimitMB')
: 500
```
## Logo and glyph [#logo-and-glyph]
Images are inlined as base64 data URLs. Place `logo.png` (600×134) and `glyph.png` (80×80) next to the `index.ts` file.
```ts
import { fs, path } from '@ci-hub/integration-sdk'
const imageBase64 = (name: string) =>
`data:image/png;base64,${Buffer.from(
fs.readFileSync(path.join(import.meta.dirname, name))
).toString('base64')}`
const logo = { data: imageBase64('logo.png'), width: 600, height: 134, backgroundColor: '#FFFFFF' }
const glyph = { data: imageBase64('glyph.png'), width: 80, height: 80, backgroundColor: '#FFFFFF' }
```
| Field | Description |
| ------------------ | ------------------------------------------------------------ |
| `data` | Base64-encoded PNG as a data URI |
| `width` / `height` | Pixel dimensions of the source image |
| `backgroundColor` | Hex color — used when the image has a transparent background |
Use PNG format. Keep logos under 100 KB. The `backgroundColor` field matters for dark mode rendering in CI HUB.
# Integration Info (/integration/reference/integration-info)
## Fields [#fields]
### logo [#logo]
Logo image displayed in the integration list
### glyph [#glyph]
Small icon displayed next to the connection name
### contact [#contact]
Support contact information for this integration
### capabilities [#capabilities]
Static feature flags and configuration for this integration
### capabilities.assetSearch [#capabilitiesassetsearch]
When search is enabled, set `assetSearch` to an object with these fields. Set `assetSearch: false` to disable search entirely.
# SDK Packages & Exports (/integration/reference/sdk-packages)
Everything you need is available from `@ci-hub/integration-sdk`. You don't need to install Node.js built-ins or most third-party libraries separately.
```ts
import {
// Integration
defineIntegration,
// Response helpers
send, sendResult, sendError, sendStatus, sendAccessToken, sendRedirectUri,
// Auth state helpers
getStateCodeAsync, getStateAdapterDataAsync, setStateAdapterDataAsync,
// Rendering
render, ensureHttps,
// Capabilities
defaultFolderCapabilities, defaultAssetCapabilities,
// Enums
CategoryEnum, HashAlgorithmEnum, FieldTypeEnum,
// Node.js built-ins
fs, path, crypto,
// Third-party
axios, config, mimeTypes, oauth2,
} from '@ci-hub/integration-sdk'
```
Import only what you use.
***
## Integration [#integration]
### `defineIntegration(config)` [#defineintegrationconfig]
The entry point for every adapter. Accepts a configuration object with metadata and all handler functions. Returns the integration definition.
All handler signatures are fully typed — the SDK infers the correct `locals` and `requestData` types for each handler, so you don't need to annotate them manually.
```ts
export default defineIntegration({
name: 'My DAM',
version: '1.0.0',
logo,
glyph,
contact,
capabilities: { /* ... */ },
login: async (locals, requestData) => { /* ... */ },
// ... other handlers
})
```
***
## Response helpers [#response-helpers]
### `send(data)` [#senddata]
Successful response with a payload. Use for all content handlers that return data.
```ts
return send({ assets: [], folders: [], filters: [], more: undefined })
```
### `sendResult(error, data)` [#sendresulterror-data]
Explicit null-error form of `send`. Pass `null` as the first argument on success.
```ts
return sendResult(null, { filters: [] })
```
### `sendError(message, statusCode)` [#senderrormessage-statuscode]
Handler failed. The message is shown to the user in the CI HUB UI.
```ts
return sendError(`Error searching assets: ${error}`, 400)
```
### `sendStatus(code)` [#sendstatuscode]
Success with no response body. Use for `deleteAsset`, `deleteFolder`, `logout`.
```ts
return sendStatus(200)
```
### `sendAccessToken(error?, token?, expiresIn?, refreshToken?, refreshExpiresIn?, adapterData?)` [#sendaccesstokenerror-token-expiresin-refreshtoken-refreshexpiresin-adapterdata]
Auth handlers only. Stores the session or reports an auth error.
```ts
// Success
return sendAccessToken(null, accessToken, expiresIn, refreshToken, undefined, {
resourceEndpoint: serverUrl,
orgId: selectedOrg,
})
// Failure
return sendAccessToken('Invalid credentials. Please try again.')
// Clean abort (user canceled)
return sendAccessToken()
```
### `sendRedirectUri(url)` [#sendredirecturiurl]
Redirects the user to a URL. Used during OAuth flows to redirect to the authorization endpoint, or to redirect back to the adapter endpoint to initialize state.
```ts
return sendRedirectUri(authorizationUrl)
```
***
## Auth state helpers [#auth-state-helpers]
These persist data between redirect steps in multi-phase login flows. Data is stored server-side only.
### `getStateCodeAsync()` [#getstatecodeasync]
Generates an opaque, cryptographically random state token.
```ts
const state = await getStateCodeAsync()
```
### `setStateAdapterDataAsync(state, data)` [#setstateadapterdataasyncstate-data]
Stores arbitrary data against a state token.
```ts
await setStateAdapterDataAsync(state, { serverUrl, verifier })
```
### `getStateAdapterDataAsync(state)` [#getstateadapterdataasyncstate]
Retrieves data stored for a state token.
```ts
const { serverUrl, verifier } = await getStateAdapterDataAsync(state)
```
***
## Rendering [#rendering]
### `render(templatePath, data)` [#rendertemplatepath-data]
Renders an `.ect` HTML template with the provided data. Returns the rendered HTML as a response. The `.ect` extension is added automatically.
```ts
return render(path.join(import.meta.dirname, 'login'), {
action: locals.endpointUrl,
logo,
contact: config.get('myIntegration.contact'),
})
```
### `ensureHttps(serverUrl, originOnly, dontExcept)` [#ensurehttpsserverurl-originonly-dontexcept]
Normalizes a URL to HTTPS. Use on all user-provided server URLs before storing or calling them.
| Parameter | Type | Description |
| ------------ | --------- | ----------------------------------------------------------------------------------- |
| `serverUrl` | `string` | The URL to normalize |
| `originOnly` | `boolean` | If `true`, returns only the origin (protocol + hostname), stripping path/query/hash |
| `dontExcept` | `boolean` | If `true`, returns empty string on invalid URL instead of throwing |
```ts
const normalized = ensureHttps(userUrl, true, true)
// 'https://example.com' — origin only, no throw on bad input
```
***
## Capabilities [#capabilities]
### `defaultFolderCapabilities` [#defaultfoldercapabilities]
Default capability object for folders. All destructive flags default to `false`. Spread this first, then override:
```ts
const caps = { ...defaultFolderCapabilities, canAddAsset: true, canAddFolder: true }
```
### `defaultAssetCapabilities` [#defaultassetcapabilities]
Default capability object for assets. Spread this first, then override with values derived from your system's permission data:
```ts
const caps = { ...defaultAssetCapabilities, canUpdateAsset: item.permissions.canEdit }
```
***
## Types [#types]
### `Locals` [#locals]
Type for the first argument of every handler. Inferred automatically inside `defineIntegration` — only import it when you need to type a helper function outside the integration definition:
```ts
import type { Locals } from '@ci-hub/integration-sdk'
async function callApi(locals: Locals, endpoint: string) { /* ... */ }
```
### `TypedRequestData` [#typedrequestdataq-b-p]
Generic type for the second argument of handlers. Each handler inside `defineIntegration` gets a specialized version automatically (e.g., `TypedRequestData` for `search`).
Import this when you need to type a helper that receives request data:
```ts
import type { TypedRequestData, SearchQuery, SearchBody } from '@ci-hub/integration-sdk'
function parseSearchParams(requestData: TypedRequestData) {
const query = requestData.query.query || ''
// ...
}
```
### `IntegrationInfo` [#integrationinfo]
Type for the return value of the `info` handler.
***
## Enums [#enums]
### `CategoryEnum` [#categoryenum]
Integration category. Use in `capabilities.category`:
```ts
capabilities: { category: CategoryEnum.DAM }
```
### `HashAlgorithmEnum` [#hashalgorithmenum]
Asset deduplication strategy. Use in `capabilities.assetHashAlgorithm`:
```ts
capabilities: { assetHashAlgorithm: HashAlgorithmEnum.FILE_ATTRIBUTES }
```
### `FieldTypeEnum` [#fieldtypeenum]
Custom metadata field types. Use in `checkToken` values and custom metadata declarations:
```ts
values: [{ id: 'email', name: 'Email', value: user.email, type: FieldTypeEnum.TEXT }]
```
***
## Node.js built-ins [#nodejs-built-ins]
| Export | Module | Common use |
| -------- | ------------- | -------------------------------------------------------- |
| `fs` | `node:fs` | `fs.readFileSync` — loading logo/glyph images |
| `path` | `node:path` | `path.join(import.meta.dirname, 'logo.png')` |
| `crypto` | `node:crypto` | `crypto.randomBytes`, `crypto.createHash` — PKCE helpers |
***
## Third-party libraries [#third-party-libraries]
### `axios` [#axios]
Pre-configured Axios instance for HTTP requests.
```ts
const { data } = await axios.get(url, { headers, params })
const { data } = await axios.post(url, body, { headers })
await axios.put(uploadUrl, buffer, {
headers: { 'Content-Type': mimeType },
maxContentLength: Infinity,
maxBodyLength: Infinity,
})
```
### `config` [#config]
Typed config reader backed by [node-config](https://github.com/node-config/node-config).
```ts
config.has('myIntegration.clientId') // boolean — always check optional keys
config.get('myIntegration.clientId') // string | number | object — throws if missing
config.get('myIntegration.limit') // typed get
```
### `mimeTypes` [#mimetypes]
MIME type lookup from [`mime-types`](https://github.com/jshttp/mime-types).
```ts
mimeTypes.lookup('photo.jpg') // → 'image/jpeg'
mimeTypes.extension('image/jpeg') // → 'jpeg'
mimeTypes.lookup(filename) || 'application/octet-stream' // safe fallback
```
### `oauth2` [#oauth2]
OAuth 2.0 client from [`simple-oauth2`](https://github.com/lelylan/simple-oauth2). Used to implement authorization code + PKCE flows.
```ts
const client = new oauth2.AuthorizationCode({
client: { id: 'clientId', secret: '' },
auth: { tokenHost, tokenPath, authorizePath, revokePath },
options: { bodyFormat: 'form' },
})
const authUrl = client.authorizeURL({ redirect_uri, scope, state, code_challenge })
const { token } = await client.getToken({ code, redirect_uri, code_verifier })
const existing = client.createToken({ refresh_token })
const { token: t} = await existing.refresh({ grant_type: 'refresh_token' })
const t = client.createToken({ access_token, refresh_token })
await t.revokeAll()
```
See [Login Flow →](/integration/building-an-adapter/login-flow) for the full OAuth 2.0 + PKCE implementation.
# Template Examples (/integration/templates/examples)
Five production templates showing common login screen patterns. Each is a complete `.ect` file.
## 1. Server URL Form [#1-server-url-form]
Single URL field — user enters their portal URL, then the handler redirects to OAuth.
```html
<% extend 'login-template.ect' %>
Please enter the URL to your ExampleAdapter1 portal.
```
Rendered with:
```ts
return render(path.join(import.meta.dirname, 'select-serverurl'), {
title: 'Select ExampleAdapter1 Portal',
action: urlObject.toString(),
serverUrl: serverUrl || 'https://your-instance.example.com',
logo,
contact,
error: serverUrlError ? 'Invalid Url (System is not a valid ExampleAdapter1 system).' : undefined
})
```
## 2. Credentials Form [#2-credentials-form]
Three credential fields — cloud name, API key, and API secret. Validates against the platform API before completing login.
```html
<% extend 'login-template.ect' %>
Please enter your credentials.
```
The handler re-renders this template with an `error` message on validation failure:
```ts
const render = error => res.render(path.join(import.meta.dirname, 'login'),
Object.assign({
title: 'ExampleAdapter2 Login',
action: endpointUrlWithParam({ state }),
logo,
providerName: 'ExampleAdapter2',
error
}, token))
```
## 3. Tenant URL + Environment Selector [#3-tenant-url--environment-selector]
Conditional dropdown for server type (production/UAT) plus a tenant URL field. The `@displayDropDown` flag controls whether the environment selector appears.
```html
<% extend 'login-template.ect' %>
Please enter your ExampleAdapter3 Tenant URL.
```
Key pattern: `<% if @displayDropDown : %>` conditionally shows form sections based on adapter configuration.
## 4. Organization Select [#4-organization-select]
Dynamic dropdown populated from an array of organizations fetched after initial authentication.
```html
<% extend 'login-template.ect' %>
Please select your organization.
```
The handler passes the organizations array:
```ts
return render(path.join(import.meta.dirname, 'select-org'), {
action: urlObject.toString(),
organizations: orgs.map(o => ({ id: o.id, name: o.name })),
logo,
contact
})
```
Key patterns:
* `@organizations?.length` — safe-navigates and checks for a non-empty array
* `<% for entry in @organizations : %>` — loops to generate `