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 neededassetHashAlgorithm: HashAlgorithmEnum.FILE_ATTRIBUTES— deduplication based on file size + modification date, not content hashsupportsBrandHub: true— enables thegetBrandConfigandgetBrandAssetshandlersenableCustomUpdateFilenames: true— users can rename an asset when uploading a new versionassetSearch.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 approvalthumbnailUrl— also appends$NO_AUTH$capabilities— per-asset based oncurrentUserPermissionsfrom the API and whether the asset is expiredconversions— computed from predefined rendition sizes based on the asset's aspect ratiodownloadHashFileAttributes— JSON of{ fileSize, modified }forFileAttributesdedupvalues— 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 value | What it represents |
|---|---|
root | Top 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:
Library-scoped search
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 }
}Brand-wide search
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
}