CI HUBCI HUB SDK
ExamplesFrontify

Frontify Example

Full walkthrough of a production adapter built with the CI HUB Integration SDK. Frontify connects to a brand management platform with a GraphQL API, multi-tenant OAuth 2.0 + PKCE, a layered folder/collection model, and Brand Hub support.

All adapter names are renamed to Frontify. Config values are redacted.


Config

{
  "serverBaseUrl": "http://localhost:8080",
  "Frontify": {
    "contact": {
      "text": "If you experience any problems, please contact support.",
      "url": { "link": "https://your-platform.example.com/help/", "email": "support@your-platform.example.com" }
    },
    "authEndpoint": "/api/oauth/authorize",
    "tokenEndpoint": "/api/oauth/accesstoken",
    "revokeEndpoint": "/api/oauth/revoke",
    "refreshEndpoint": "/api/oauth/refresh",
    "graphBaseEndpoint": "/graphql",
    "clientId": "your-client-id",
    "defaultServerUrl": "https://your-platform.example.com/",
    "maxQuerySize": 100,
    "maxFileSizeInMB": 5242880
  }
}

Config keys are validated at startup — missing keys throw immediately:

const configKeys = [
  'Frontify.contact',
  'Frontify.authEndpoint',
  'Frontify.tokenEndpoint',
  'Frontify.revokeEndpoint',
  'Frontify.refreshEndpoint',
  'Frontify.graphBaseEndpoint',
  'Frontify.clientId',
  'Frontify.maxQuerySize',
  'Frontify.maxFileSizeInMB',
]
const missingKeys = configKeys.filter(key => !config.has(key)).join(' and ')

if (missingKeys) {
  throw new Error(`Missing configuration: ${missingKeys}`)
}

Capabilities

defineIntegration({
  name: 'Frontify',
  version: '0.0.0',
  logo,
  glyph,
  contact,
  capabilities: {
    category: CategoryEnum.DAM,
    assetUploadLimitInMB: config.get('Frontify.maxFileSizeInMB') as number,
    enableCustomUpdateFilenames: true,
    directAssetDownload: true,
    supportsBrandHub: true,
    assetHashAlgorithm: HashAlgorithmEnum.FILE_ATTRIBUTES,
    assetSearch: {
      help: {
        en: 'You can use the operators AND, OR, NOT in capital letters within your search in order to combine different search terms.',
        de: 'Sie können die Operatoren AND, OR, NOT in Großbuchstaben innerhalb Ihrer Suche verwenden, um verschiedene Suchbegriffe zu kombinieren.'
      },
      supportsParentId: true
    },
    description: {
      en: 'Frontify is a brand-building platform where a user-friendly DAM meets customized portals',
      de: 'Frontify ist eine Plattform zum Markenaufbau, bei der ein benutzerfreundliches DAM auf maßgeschneiderte Portale trifft'
    }
  },
  // ... handlers
})

Key decisions:

  • directAssetDownload: true — the platform returns publicly accessible download URLs; no proxy through CI HUB needed
  • assetHashAlgorithm: HashAlgorithmEnum.FILE_ATTRIBUTES — deduplication based on file size + modification date, not content hash
  • supportsBrandHub: true — enables the getBrandConfig and getBrandAssets handlers
  • enableCustomUpdateFilenames: true — users can rename an asset when uploading a new version
  • assetSearch.supportsParentId: true — search can be scoped to a specific folder

See Capabilities Reference → for the full flags reference.


Authentication — OAuth 2.0 + PKCE with multi-step login

The platform is multi-tenant — each customer has their own portal URL (e.g., acme.your-platform.example.com). The server URL must be collected before OAuth can start, and after token exchange, the user selects a brand workspace. This makes the login flow multi-step.

OAuth client setup

const SCOPES = ['basic:read', 'basic:write']

const getAuthClient = (url: string) => new oauth2.AuthorizationCode({
  client: {
    id: config.get('Frontify.clientId'),
    secret: ""
  },
  auth: {
    tokenHost: url,
    tokenPath: config.get('Frontify.tokenEndpoint') as string,
    authorizePath: config.get('Frontify.authEndpoint') as string,
    revokePath: config.get('Frontify.revokeEndpoint') as string
  },
  options: {
    bodyFormat: 'form'
  }
})

No client secret — this adapter uses PKCE exclusively. The tokenHost is the user's portal URL, set dynamically.

PKCE helpers

const base64URLEncode = (str: Buffer) => str.toString('base64')
  .replace(/\+/g, '-')
  .replace(/\//g, '_')
  .replace(/=/g, '')

const sha256 = (buffer: Buffer) => crypto.createHash('sha256').update(buffer).digest()
const createCodeVerifier = () => base64URLEncode(crypto.randomBytes(64))
const createCodeChallenge = (verifier: any) => base64URLEncode(sha256(verifier))

Login handler

The login handler has six phases:

Auth error / cancel — If the OAuth provider returned an error or the user canceled, handle immediately:

if (authError) {
  return sendAccessToken(authError)
}
if (canceled) {
  return sendAccessToken()
}

No state — First hit. Generate a state code and redirect to the adapter endpoint with it:

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 and no code — Render the server URL selection 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 Frontify Portal',
    action: urlObject.toString(),
    serverUrl: serverUrl || config.get('Frontify.defaultServerUrl'),
    logo,
    contact
  })
}

Server URL but no code — Validate URL, generate PKCE verifier + challenge, store verifier in state, redirect to OAuth:

if (!code) {
  const verifier = createCodeVerifier()
  const challenge = createCodeChallenge(verifier)

  await setStateAdapterDataAsync(state as string, { serverUrl, verifier })

  const authUrl = oauth.authorizeURL({
    redirect_uri: `${locals.endpointUrl}`,
    // @ts-expect-error
    code_challenge_method: 'S256',
    code_challenge: challenge,
    state: state as string,
    scope: SCOPES
  })

  return sendRedirectUri(authUrl)
}

Code received — Exchange the code for tokens using the stored verifier, then fetch the user's available brands and render the brand selection form:

if (!tokenData) {
  const { verifier } = await getStateAdapterDataAsync(state as string) as { verifier: string }
  const { token } = await oauth.getToken({
    code: code as string,
    // @ts-expect-error
    client_id: config.get('Frontify.clientId'),
    redirect_uri: locals.endpointUrl,
    code_verifier: verifier
  })
  tokenData = token
}

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
  })
}

Brand selected — Store resourceEndpoint and brandId in adapterData via sendAccessToken:

return sendAccessToken(
  null,
  tokenData.access_token as string,
  tokenData.expires_in as number,
  tokenData.refresh_token as string,
  undefined,
  { resourceEndpoint: serverUrl, brandId }
)

The brandId scopes all subsequent API calls. Every handler reads it via locals.adapterData.brandId.

See Login Flow → and Template Examples →.

select-serverurl.ect template

This single .ect file serves both the server URL prompt and the brand selection dropdown. The authMethod variable controls which form is shown:

<% extend 'login-template.ect' %>

<p>Please enter the URL to your Frontify portal.</p>
<br>
<form action="<%- @action %>" method="post">
  <% if @authMethod=="brands" : %>
    <div class="field">
      <label class="label">Select a brand</label>
      <div class="control">
        <div class="select">
          <select name="brandId" required>
            <option value="">Select a brand</option>
            <% for brand in @brands : %>
              <option value="<%= brand.id %>"><%= brand.name %></option>
            <% end %>
          </select>
        </div>
      </div>
    </div>
  <% else : %>
  <div class="field">
    <label class="label">Frontify portal URL</label>
    <div class="control">
      <input class="inputfield" type="url" name="serverUrl" value="<%= @serverUrl %>" required>
    </div>
  </div>
  <% end %>
  <div class="field is-grouped">
    <div class="control">
      <button class="button is-link" type="submit">Login</button>
    </div>
  </div>
</form>

checkToken

checkToken: async (locals) => {
  try {
    const currentUser = await callApi(locals, {
      query: queries.getCurrentUser,
      variables: { BrandId: locals.adapterData.brandId }
    })

    const values = [
      {
        id: 'email',
        name: 'User Email',
        value: currentUser.data.currentUser.email,
        type: FieldTypeEnum.TEXT
      }
    ]
    const adapter = `Frontify (${currentUser.data.brand.name})`
    return send({ adapter, source: remoteSystemPrefix(locals), user: currentUser.data.currentUser.name, values })
  } catch (error: any) {
    return sendError(`Error getting current user: ${error.message}`, 400)
  }
}

refreshToken

refreshToken: async (locals) => {
  try {
    const serverUrl = locals.adapterData.resourceEndpoint
    const oauth = getAuthClient(serverUrl)
    const oldToken = oauth.createToken({ refresh_token: locals.authPayload })
    const { token } = await oldToken.refresh({
      // @ts-expect-error
      grant_type: 'refresh_token',
      scope: SCOPES,
      client_id: config.get('Frontify.clientId')
    })

    if (!token || !token.access_token) {
      throw new Error('No access_token found')
    }

    return sendAccessToken(null, token.access_token as string, token.expires_in as number, token.refresh_token as string, undefined, { resourceEndpoint: serverUrl })
  } catch (error) {
    return sendError(`Error refreshing token: ${error}`, 400)
  }
}

logout

logout: async (locals, requestData) => {
  try {
    const serverUrl = locals.adapterData.resourceEndpoint
    const oauth = getAuthClient(serverUrl)
    const token = oauth.createToken({
      access_token: locals.authPayload,
      refresh_token: requestData.body.refresh_token
    })
    await token.revokeAll()
    return sendStatus(200)
  } catch (error: any) {
    return sendError(`Error logging out: ${error.message}`, 400)
  }
}

GraphQL API (callApi)

All data is fetched via a GraphQL endpoint. The callApi helper builds the URL from the resource endpoint stored at login:

const callApi = async (locals: Locals, query: any) => {
  const { resourceEndpoint } = locals.adapterData
  const accessToken = locals.authPayload
  const url = new URL(config.get('Frontify.graphBaseEndpoint') as string, resourceEndpoint).toString()

  try {
    const response = await axios.post(url, {
      query: query.query,
      variables: query.variables
    }, {
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Content-Type': 'application/json',
        'X-Frontify-Beta': 'enabled'
      },
      maxContentLength: Infinity,
      maxBodyLength: Infinity
    })

    return response.data
  } catch (error: any) {
    let msg
    if (error.response && error.response.data && error.response.data.errors) {
      msg = Array.isArray(error.response.data.errors)
        ? error.response.data.errors.map((err: any) => err.message).join(', ')
        : error.response.data.errors.message
    } else {
      msg = error.message
    }
    throw new Error(`Error calling Frontify API: ${msg}`)
  }
}

The X-Frontify-Beta header is platform-specific — your adapter won't need it unless your API has a similar feature flag mechanism.

See Tips and Tricks →.


info handler

The info handler returns the remoteSystemPrefix and a list of libraries as a filter. The library filter drives search scoping — users pick a library or "All Libraries" to control whether search is brand-wide or library-scoped.

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)
  }
}

extractAssetsData

Maps raw API items to the CI HUB asset schema. Key aspects:

  • downloadUrl — appends $NO_AUTH$ to signal direct delivery, or falls back to a proxy URL if the asset requires download approval
  • thumbnailUrl — also appends $NO_AUTH$
  • capabilities — per-asset based on currentUserPermissions from the API and whether the asset is expired
  • conversions — computed from predefined rendition sizes based on the asset's aspect ratio
  • downloadHashFileAttributes — JSON of { fileSize, modified } for FileAttributes dedup
  • values — structured metadata including lifecycle, status, licenses, description, focal point, page count, bitrate, duration, custom metadata, and copyright
const extractAssetsData = (locals: Locals, items: any, overrideCapabilities = {}, useFileName = false) =>
  items.map((item: any) => {
    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: any) => tag.value).filter((tag: any) => tag),
        states: [
          item.workflowTask?.status?.name,
          item.licenses?.map((license: any) => license.title),
          expired ? 'Expired' : ''
        ].flat().filter((state: any) => 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: any) => item)

Folder hierarchy

The platform has libraries, collections, and subfolders. The adapter maps these to a flat folderId tree using string prefixes.

folderId valueWhat it represents
rootTop level — lists all libraries the user's brand has
library:<id>A library — shows assets, subfolders, and a link to its collections
collections:<libraryId>Collections list for a specific library
collection:<id>A single collection — shows its assets only
(plain UUID)A subfolder inside a library

getFolder handler

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('Frontify.maxQuerySize'))
    const more = parseInt((requestData.query.more as string) || '1', 10)
    const activeFilters = requestData.query.filters || []
    const activeFiltersJson = parseOptions(activeFilters as string[])
    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 }
      })
      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, query: { sortBy: activeFiltersJson.AssetQueryFilterSortType } }
      })
      const { assetQueryFilterSortType, library } = assetsRes.data
      const { browse, collections, currentUserPermissions } = library
      const { assets, folders } = browse

      result.assets = extractAssetsData(locals, assets.items)
      result.filters = getFilters({ assetQueryFilterSortType }, activeFiltersJson)
      result.more = assets.hasNextPage ? more + 1 : undefined
      result.totalAssetsCount = assets.total
      result.folders = 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
      }))
      result.capabilities.canAddFolder = collections.data.library.currentUserPermissions.canCreateCollections

    } 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
      result.totalAssetsCount = assets.data.node.assets.total
      result.capabilities.canDeleteAsset = canDeleteAsset

    } else {
      const assets = await callApi(locals, {
        query: queries.getGetSubfoldersQuery,
        variables: { folderId, page: more, limit: size, query: { sortBy: activeFiltersJson.AssetQueryFilterSortType } }
      })
      const { node, assetQueryFilterSortType } = assets.data
      const { assets: assetsData, folders } = node
      result.filters = getFilters({ assetQueryFilterSortType }, activeFiltersJson)
      result.assets = extractAssetsData(locals, assetsData.items)
      result.more = assetsData.hasNextPage ? more + 1 : undefined
      result.totalAssetsCount = assetsData.total
      result.folders = folders.items?.map((folder: any) => ({ id: `${folder.id}`, name: folder.name }))
      result.capabilities = {
        ...result.capabilities,
        canAddFolder: true, canAddAsset: true, canDeleteFolder: true, canDeleteAsset: true, canUpdateAsset: true
      }
    }

    return send(result)
  } catch (error) {
    return sendError(`Error creating folder: ${error}`, 400)
  }
}

Search filters

Two search modes depending on library filter state:

When a library filter is active, queries library.assets with typed filter conditions. Custom metadata filters come from each library's customMetadataProperties. Multi-select filter values (sent with multi$ prefix) become OR conditions; single values become AND conditions.

const parseSearchFilters = (filters: any) => {
  const result: any = { andConditions: [], orConditions: [] }

  const pushToResult = (obj: any) => {
    const { value } = obj
    if (Array.isArray(value) && value.length > 1) {
      value.forEach((item: any) => result.orConditions.push({ ...obj, value: item }))
    } else if (Array.isArray(value)) {
      result.andConditions.push({ ...obj, value: value[0] })
    } else {
      result.andConditions.push(obj)
    }
  }

  for (const [key, value] of Object.entries(filters)) {
    if (key === 'extension') {
      pushToResult({ type: 'FILE_EXTENSION', operator: 'IS', value })
    } else {
      pushToResult({ type: 'CUSTOM_METADATA_VALUE', operator: 'IS', value, customMetadataPropertyId: key })
    }
  }

  return { filter: result }
}

When no library filter is active or library:all is selected, queries brand.search across all libraries:

if (!isFileNameSearch && (!isBrandSearch || isLibrarySearch)) {
  // library-scoped search with full filter support
  const searchQuery = {
    ...folderId ? { inFolder: { id: folderId } } : {},
    search: query,
    ...activeFiltersJson.AssetQueryFilterSortType ? { sortBy: activeFiltersJson.AssetQueryFilterSortType } : {},
    ...activeFiltersJson.AssetType ? { types: activeFiltersJson.AssetType } : {},
    ...parseSearchFilters(omitFilter(activeFiltersJson, ['AssetType', 'AssetQueryFilterSortType', 'library']))
  }
  const assets = await callApi(locals, {
    query: queries.getSearchAssetsQuery,
    variables: { libraryId, query: searchQuery, page: more, limit: size }
  })
  // ...
} else {
  // brand-wide search
  const searchQuery = {
    term: query ? (isFileNameSearch ? query.split('.').slice(0, -1).join('.') : query) : null,
    ...activeFiltersJson.BrandQuerySortByInput ? { sortBy: activeFiltersJson.BrandQuerySortByInput } : {}
  }
  const assets = await callApi(locals, {
    query: queries.getSearchInBrandAssetsQuery,
    variables: { brandId, query: searchQuery, page: more, limit: size }
  })
  // ...
}

The extensionFilter function provides a static file extension multi-select filter:

const extensionFilter = (appliedFiltersJson: any) => {
  const extensionList = ['png', 'gif', 'jpg', 'jpeg', 'eps', 'ai', 'pdf', 'tif', 'tiff', 'svg', 'avi', 'mp4', 'mov', 'psd', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'psb', 'zip', 'indd', 'mp3', 'prproj', 'aep']

  return [{
    id: 'extension',
    name: 'File Extension',
    options: extensionList.map(extension => ({
      id: `multi$extension:${extension}`,
      name: extension.toUpperCase(),
      isActive: appliedFiltersJson.extension === extension || (appliedFiltersJson.extension || []).includes(extension)
    }))
  }]
}

See Filters → and Custom Metadata →.


Upload — createAsset / updateAsset

Two-step upload: request a pre-signed S3 URL via GraphQL, PUT the file to S3, then create/replace the asset record.

createAsset

createAsset: async (locals, requestData) => {
  try {
    let parentId: string | undefined = (requestData.body.parentId || requestData.query.parentId || '') as string
    parentId = folderIdStartsWith(parentId, ['root', 'collection']) ? undefined : parentId
    parentId = (parentId as string).replace('library:', '')

    if (!parentId) {
      throw new Error('You can not create an asset in this location')
    }

    const { name, dataBase64 } = requestData.body
    const dataBuffer = (requestData.body as any).dataBuffer
    const buffer = (dataBuffer || Buffer.from((dataBase64 as string).split(',')[1], 'base64')) as Buffer
    const size = buffer.length
    const title = (name as string).substring(0, (name as string).lastIndexOf('.')) || name

    // Step 1 — Request a pre-signed upload URL
    const uploadUrls = await callApi(locals, {
      query: queries.uploadFileMutation,
      variables: { input: { filename: name, size } }
    })

    // Step 2 — PUT the file directly to S3
    await axios.put(uploadUrls.data.uploadFile.urls[0], buffer, {
      headers: { 'Content-Type': 'application/octet-stream' }
    })

    // Step 3 — Create the asset record
    const asset = await callApi(locals, {
      query: queries.createAssetMutation,
      variables: { input: { fileId: uploadUrls.data.uploadFile.id, title, parentId } }
    })

    return send({ id: asset.data.createAsset.job.assetId.toString(), name })
  } catch (error) {
    return sendError(`Error updating asset: ${error}`, 400)
  }
}

updateAsset

Same pattern but uses replaceAssetMutation in step 3:

updateAsset: async (locals, requestData) => {
  try {
    const { name, dataBase64 } = requestData.body
    const { assetId } = requestData.params
    const dataBuffer = (requestData.body as any).dataBuffer
    const buffer = (dataBuffer || Buffer.from((dataBase64 as string).split(',')[1], 'base64')) as Buffer
    const size = buffer.length

    const uploadUrls = await callApi(locals, {
      query: queries.uploadFileMutation,
      variables: { input: { filename: name, size } }
    })

    await axios.put(uploadUrls.data.uploadFile.urls[0], buffer, {
      headers: { 'Content-Type': 'application/octet-stream' }
    })

    const asset = await callApi(locals, {
      query: queries.replaceAssetMutation,
      variables: {
        input: { assetId, fileId: uploadUrls.data.uploadFile.id },
        updateAsset: { id: assetId, data: { filename: name } }
      }
    })

    return send({ id: asset.data.replaceAsset.job.assetId.toString(), name: name as string })
  } catch (error) {
    return sendError(`Error updating asset: ${error}`, 400)
  }
}

See Upload → and Asset Operations →.


Download

Most assets return a CDN downloadUrl. The adapter appends $NO_AUTH$ to signal direct delivery. Some assets require approval — downloadUrl is null for those, so the download handler returns a human-readable error:

download: async (locals, requestData) => {
  return sendError(
    'This asset is protected and needs approval. Please open the asset in your web browser and request download permission.',
    404
  )
}

deleteAsset

Handles both regular assets and collection assets. If the asset is in a collection, removes it from the collection instead of deleting it outright:

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

    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: any) {
    return sendError(`Error deleting asset: ${error}`, 400)
  }
}

renameAsset

The API doesn't allow renaming assets without an extension. If the new name has a recognized extension, it updates filename; otherwise, title:

renameAsset: async (locals, requestData) => {
  try {
    const { name } = requestData.body
    const { assetId } = requestData.params
    const hasExtension = Boolean(mimeTypes.lookup(name as string))

    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: any) {
    return sendError(`Error renaming asset: ${error}`, 400)
  }
}

Folder operations — createFolder, renameFolder, deleteFolder

All three handle the library/collection/subfolder split using the folderId prefix convention.

createFolder

createFolder: async (locals, requestData) => {
  try {
    const result: any = { id: '', name: '' }
    let { name, parentId } = { ...requestData.body, ...requestData.query }

    parentId = !folderIdStartsWith((parentId || ''), ['root', 'collection:']) ? parentId : ''
    if (!parentId) {
      throw new Error('You can not create a folder in this location')
    }
    if ((parentId as string).startsWith('collections:')) {
      parentId = (parentId as string).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 as string).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 creating asset: ${error}`, 400)
  }
}

Brand Hub — getBrandConfig / getBrandAssets

The platform has structured brand libraries with guidelines and library pages. getBrandConfig fetches guidelines and maps them to CI HUB brand categories. getBrandAssets fetches assets within a specific guideline page.

getBrandConfig

getBrandConfig: async (locals, requestData) => {
  const { brandId } = locals.adapterData

  try {
    const result: any = {
      dataModelVersion: '1.0.0',
      provider: locals.provider,
      categories: [],
      errors: []
    }

    const guidelines = await callApi(locals, {
      query: queries.getBrandFoldersQuery,
      variables: { brandId }
    })

    const brandGuidelines = guidelines.data.brand.guidelines.items
    result.categories = brandGuidelines.flatMap((guideline: any) =>
      guideline.libraryPages.items.map((folder: any) => ({
        folderId: folder.id,
        title: `${guideline.name} - ${folder.title}`,
        categoryType: 'images'
      }))
    )

    return send(result)
  } catch (error) {
    return sendError(`Error getting brand folders: ${error}`, 400)
  }
}

getBrandAssets

getBrandAssets: async (locals, requestData) => {
  try {
    const { brandId } = locals.adapterData
    const { folderId, categoryType } = { ...requestData.params, ...requestData.query }
    const size = Math.min(parseInt((requestData.query.size as string) || '25', 10), config.get('Frontify.maxQuerySize'))
    const more = parseInt((requestData.query.more as string) || '1', 10)

    if (!folderId) {
      return sendError('Folder ID is required', 400)
    }

    const brandAssets = (await callApi(locals, {
      query: queries.getBrandFolderAssetsQuery,
      variables: { brandId, folderId, limit: size, page: more }
    })).data.brand.search

    const result: any = {
      categoryType,
      more,
      capabilities: defaultFolderCapabilities
    }

    result.assets = extractAssetsData(locals, brandAssets.items)
    result.totalAssetsCount = brandAssets.total
    result.more = brandAssets.hasNextPage ? more + 1 : undefined

    return send(result)
  } catch (err) {
    return sendError(`Error getting brand assets: ${err}`, 400)
  }
}

See Brand Hub →.


Key queries (queries.ts)

GraphQL queries and mutations used by the adapter. Key fragments:

Common asset fields fragment

const commonAssetFields = `
  id
  downloadUrl(permanent: false, validityInDays: 6)
  extension
  filename
  size
  expiresAt
  workflowTask { status { name } }
  thumbnailUrl
  copyright {
    notice
    status
  }
`

Assets fragment (with type-specific fields)

const assetsFragment = `
  ${assetCommonFields}
  ${customMetadataFragment}
  ... on Image {
    ${imageFields}
  }
  ... on Audio {
    ${audioFields}
  }
  ... on Document {
    ${documentFields}
  }
  ... on Video {
    ${videoFields}
  }
  ... on File {
    ${fileFields}
  }
  ... on EmbeddedContent {
    ${embeddedContentFields}
  }
`

Library assets query

const getGetLibraryAssetsQuery = `
query LibraryAssets($libraryId: ID!, $page: Int!, $limit: Int!, $query: FolderAssetQueryInput) {
  library(id: $libraryId) {
    ${customMetadataPropertiesFragment}
    browse {
      folders {
        hasNextPage
        items { id, name }
      }
      assets(page: $page, limit: $limit, query: $query) {
        total
        items {
          ${assetsPermissionsFragment}
          ${assetsFragment}
        }
        hasNextPage
      }
    }
    collections { total }
    currentUserPermissions { canCreateAssets, canCreateCollections }
  }
  ${assetQueryFilterSortType}
}
`

Upload + create mutations

const uploadFileMutation = `
mutation UploadFile($input: UploadFileInput!) {
  uploadFile(input: $input) { urls, id }
}
`

const createAssetMutation = `
mutation CreateAsset($input: CreateAssetInput!) {
  createAsset(input: $input) { job { assetId } }
}
`

const replaceAssetMutation = `
mutation ReplaceAsset($input: ReplaceAssetInput!, $updateAsset: UpdateAssetInput!) {
  replaceAsset(input: $input) { job { assetId } }
  updateAsset(input: $updateAsset) {
    asset {
      id
      title
      ... on Image { id, filename }
      ... on Document { filename }
      ... on Video { filename }
      ... on File { filename }
    }
  }
}
`

Brand Hub queries

const getBrandFoldersQuery = `
query BrandLibraries($brandId: ID!) {
  brand(id: $brandId) {
    id
    name
    guidelines {
      limit, page, total
      items {
        id
        name
        libraryPages {
          limit, page, total
          items { id, title }
        }
      }
    }
  }
}
`

const getBrandFolderAssetsQuery = `
query brandFolderAssets($brandId: ID!, $folderId: [ID!], $limit: Int!, $page: Int!) {
  brand(id: $brandId) {
    search(
      query: {filter: {sources: {type: LIBRARY_PAGES, ids: $folderId}}}
      limit: $limit
      page: $page
    ) {
      total
      items {
        ... on Image { ${imageFields} }
        ... on Document { ${documentFields} }
        ... on Video { ${videoFields} }
        ... on File { ${fileFields} }
      }
      hasNextPage
    }
  }
}
`

Renditions (renditions.ts)

Predefined rendition sizes keyed by aspect ratio. The adapter computes the asset's aspect ratio and returns matching renditions that are smaller than the original:

const predefinedRenditions = {
  '1:1': [
    { width: 24, height: 24, name: 'Extra small' },
    { width: 96, height: 96, name: 'Small' },
    { width: 256, height: 256, name: 'Medium' },
    { width: 512, height: 512, name: 'Large' },
    { width: 1200, height: 1200, name: 'Extra large' },
    { width: 1080, height: 1080, name: 'Social media' }
  ],
  '16:9': [
    { width: 1280, height: 720, name: 'small' },
    { width: 1920, height: 1080, name: 'medium' },
    { width: 3840, height: 2160, name: 'large' }
  ],
  '4:5': [
    { width: 800, height: 1000, name: 'small' },
    { width: 1200, height: 1500, name: 'medium' },
    { width: 2400, height: 3000, name: 'large' }
  ],
  '9:16': [
    { width: 720, height: 1280, name: 'small' },
    { width: 1080, height: 1920, name: 'medium' },
    { width: 2160, height: 3840, name: 'large' }
  ],
  '1:2': [
    { width: 500, height: 1000, name: 'small' },
    { width: 750, height: 1500, name: 'medium' },
    { width: 1500, height: 3000, name: 'large' }
  ],
  'fallback': [
    { width: 640, name: 'Small' },
    { width: 1280, name: 'Medium' },
    { width: 1920, name: 'Large' }
  ]
}

The getRenditions function computes available sizes, and getConversions maps them to Conversion objects with URLs derived from the preview URL:

const getRenditions = (originalWidth: number, originalHeight: number) => {
  if (!originalWidth || !originalHeight) return []
  const ratio = getAspectRatio(originalWidth, originalHeight)
  const renditions: any = []

  if (Object.keys(predefinedRenditions).includes(ratio)) {
    predefinedRenditions[ratio as keyof typeof predefinedRenditions].forEach((rendition: any) => {
      if (rendition.width === originalWidth && rendition.height === originalHeight) return
      if (rendition.width <= originalWidth && rendition.height <= originalHeight) {
        renditions.push({
          id: `${rendition.width}x${rendition.height}`,
          width: rendition.width,
          height: rendition.height,
          name: `${rendition.name} ${rendition.width}x${rendition.height}`
        })
      }
    })
  } else {
    predefinedRenditions.fallback.forEach(rendition => {
      if (rendition.width >= originalWidth) return
      renditions.push({
        id: `${rendition.width}xAuto`,
        width: rendition.width,
        height: 'auto',
        name: `${rendition.name} ${rendition.width}xAuto`
      })
    })
  }
  return renditions
}

On this page