# Changelog (/access/changelog) ## Stability [#stability] The Access SDK API is served under `/api/v1` and is not versioned beyond that prefix: changes ship in place rather than under a new version. Additive changes (new response fields, new optional parameters, new endpoints) are backward compatible and ship without notice, so read response fields by name and ignore any field you do not recognize. Breaking changes are avoided; when one becomes unavoidable, it is announced here before it ships. Notable changes are listed below, newest first. ## July 2026 [#july-2026] * **DAM login takes provider parameters.** The initiate call accepts `serverUrl`, which answers up front the instance prompt CI HUB would otherwise show the end user. Six providers read it. Omit it and the flow is unchanged. See [Provider parameters](/access/authentication/dam/login#provider-parameters). * **Email in the request body is documented.** Token exchange has always accepted `email` in the JSON body when the partner JWT carries no `email` claim, which is how partners who anonymize the address integrate. The behavior is unchanged; it now has [examples and the two constraints](/access/authentication/ci-hub/exchange-token#email-outside-the-jwt) written down. ## Phase 1 (June 2026) [#phase-1-june-2026] First release of the Access SDK: read-only access to a connected DAM. * **Authentication.** Token exchange: a partner backend signs a JWT and exchanges it for a CI HUB access token and refresh token (`POST /auth/exchangeToken`). Server to server, no browser redirect. * **DAM connection.** List available providers, initiate and poll a provider login, and read provider capabilities. * **Content.** Browse folders, keyword and similarity search, read asset detail and version history. Read-only. * **Errors.** One standard error envelope across every endpoint, with a `source` field that separates CI HUB errors from provider errors. # Client library (/access/client-library) `@ci-hub/access-sdk` is the official TypeScript/JavaScript client for the Access SDK API. It wraps every currently available endpoint from [Getting started](/access/getting-started) behind typed methods, owns the token lifecycle, and resolves download URLs for you. The declared `upload()` and `update()` are not implemented yet (see [Writing assets](#writing-assets)). Everything the library does can also be done with plain HTTP; the guides in this section show the wire-level detail, and the library is the shortcut past it. ```bash npm install @ci-hub/access-sdk ``` ## Runtime support [#runtime-support] The core depends only on `fetch` and runs in modern browsers, Office.js add-ins, Node 20+, Bun, and Deno. It never imports Node built-ins. The partner-JWT signer needs your private key, so it is server-only and lives on a separate subpath, `@ci-hub/access-sdk/node`. Keep it out of client bundles: the key must never reach a browser. ## Authenticate [#authenticate] On your server, mint the partner JWT described in [Partner registration](/access/authentication/ci-hub/partner-registration): ```ts import { signPartnerJwt } from '@ci-hub/access-sdk/node' const partnerJwt = await signPartnerJwt({ privateKeyPem: process.env.PARTNER_PRIVATE_KEY!, keyId: 'my-kid', claims: { iss: 'https://issuer.example.com', aud: 'https://api.ci-hub.com', sub: 'user-123', email: 'jane@customer.example.com', }, }) ``` `aud` is always `https://api.ci-hub.com`, on every environment. It identifies CI HUB in your partner registration and does not change with the client's `baseUrl`. In your application, exchange it for a CI HUB session ([Exchange token](/access/authentication/ci-hub/exchange-token)) and hand the result to the `TokenManager`, which refreshes both sessions from then on: ```ts import { CiHubAccessClient, TokenManager } from '@ci-hub/access-sdk' const client = new CiHubAccessClient({ baseUrl: 'https://stage.ci-hub.com/api/v1' }) const tokens = new TokenManager(client) const session = await client.exchangeToken({ partnerJwt }) await tokens.setCihubSession(session) ``` When your token carries no `email` claim, pass the address as `emailFallback` instead. The constraints on a pseudonymous address are in [Email outside the JWT](/access/authentication/ci-hub/exchange-token#email-outside-the-jwt): ```ts const session = await client.exchangeToken({ partnerJwt, emailFallback: 'u-8f21c4@anon.your-platform.example.com', }) ``` ## Connect a DAM [#connect-a-dam] The DAM login is a browser redirect plus polling, as described in [DAM authentication](/access/authentication/dam). The library runs the poll loop for you: ```ts const login = await tokens.withCihubAuth((token) => client.beginDamLogin({ provider: 'dropbox', accessToken: token }), ) // Open login.redirect_uri for the user, then: const damTokens = await client.waitForDamLogin({ state: login.state }) await tokens.setDamSession('dropbox', damTokens) ``` Providers that would prompt the end user for their DAM instance take the answer up front through `providerParams`, which removes that page from the flow. The providers that read it are listed in [Provider parameters](/access/authentication/dam/login#provider-parameters): ```ts const login = await tokens.withCihubAuth((token) => client.beginDamLogin({ provider: 'frontify', accessToken: token, options: { providerParams: { serverUrl: 'https://acme.frontify.com' } }, }), ) ``` ## Read assets [#read-assets] Every content call carries the [two tokens](/access/authentication). `withDamAuth` supplies fresh ones and retries once on an expired token: ```ts const results = await tokens.withDamAuth('dropbox', (cihubToken, damToken) => client.search({ accessToken: cihubToken, damToken, options: { query: 'logo' } }), ) const first = results.assets[0] if (first) { const response = await tokens.withDamAuth('dropbox', (cihubToken, damToken) => client.download({ url: first.downloadUrl, damToken, cihubToken }), ) const bytes = await response.arrayBuffer() } ``` `download` resolves the [URL placeholders](/access/concepts/url-placeholders) and attaches credentials only where they belong: the CI HUB proxy gets both tokens; provider-direct URLs go straight to the DAM. Folder browsing, similarity search, asset detail, and versions follow the same shape; see the [content reference](/access/content) for each endpoint. ## Token lifecycle [#token-lifecycle] `TokenManager` implements the full [refresh contract](/access/guides/handle-token-refresh). The CI HUB session refreshes proactively from the server's `expires_in`; DAM sessions report no lifetime at login and refresh reactively on the first 401. Both keep the old refresh token when a provider omits it, and a provider with no refresh path surfaces as a typed `DamReauthRequired`: run a fresh DAM login. Storage is pluggable; the default keeps tokens in memory. ## Errors [#errors] Every failing call throws `AccessSdkError` carrying the [structured error envelope](/access/errors): `status`, `code`, `source` (`cihub` or `integration`), `message`, and optional `details` and `provider`. ## Writing assets [#writing-assets] `upload()` and `update()` exist with their final signature and throw `NotImplementedError` (code `cihub-not-implemented`) until the write engine ships. Code written against them today keeps compiling when it does. ## Types [#types] All request and response types are generated from the same OpenAPI specification that produces this reference, so the library and these pages cannot drift apart. The raw `paths`, `components`, and `operations` types are exported for advanced use. # Getting started (/access/getting-started) This walkthrough orders the Access SDK endpoints into the path a partner platform follows the first time it connects a user to a DAM. Each step links to its full reference. No code is reproduced here; follow the links for request and response detail. Building in TypeScript or JavaScript? Read this page for the flow, then implement it with the [client library](/access/client-library), which wraps every step behind typed methods. The docs assistant answers Access SDK questions and cites the pages behind each answer. Open it from the **Ask AI** button in the bottom right of any page. Building against the API with an AI agent of your own? Point it at [`/access/llms.txt`](/access/llms.txt) for a page index, or [`/access/llms-full.txt`](/access/llms-full.txt) for every page as plain text. ## Before you start [#before-you-start] Two things are set up with CI HUB during onboarding: * **Partner registration.** CI HUB registers your issuer and the URL of your JWKS, so it can verify the JWTs your backend signs. See [Partner registration](/access/authentication/ci-hub/partner-registration). * **An SDK subscription.** Your platform's subscription is what authorizes SDK use. Commercial terms are agreed during onboarding. The walkthrough assumes you have both. ## 1. Exchange a token [#1-exchange-a-token] Your backend signs a short-lived JWT for the current user and posts it to CI HUB. CI HUB verifies the signature against your JWKS, checks the subscription, and returns a CI HUB access token and refresh token. This call is server to server, with no browser redirect. See [Exchange token](/access/authentication/ci-hub/exchange-token). The access token identifies the user inside CI HUB. It travels in the `Authorization` header on every later call. ## 2. List the providers [#2-list-the-providers] With the access token, ask CI HUB which DAM providers the user can connect to. The result drives your connection picker. Send the access token here: without it the endpoint returns only a single degraded entry, not the real list. See [Providers](/access/authentication/dam/providers). ## 3. Connect the user to a DAM [#3-connect-the-user-to-a-dam] The access token identifies the user inside CI HUB but does not grant access to any DAM. Each DAM authenticates the user separately. Start a login for the chosen provider. CI HUB returns a one-time URL and a `state` token. Open the URL in the user's browser so they sign in at the DAM, and poll with the `state` until the login completes. The completed poll returns a DAM connection token. See [DAM login](/access/authentication/dam/login) for the initiate and poll calls, and [DAM connection](/access/authentication/dam) for the model behind them. ## 4. Read the connection details [#4-read-the-connection-details] Once the user is connected, read the provider's runtime details (its host, the search filters it exposes, and other provider-specific settings) to shape your UI. See [Provider info](/access/authentication/dam/provider-info). ## 5. Make content calls [#5-make-content-calls] Content calls carry two tokens: the CI HUB access token in `Authorization`, and the DAM connection token from step 3 in `provider-authorization`. The first identifies the user, the second authorizes access to the DAM. The two-token pattern is described in the [authentication overview](/access/authentication). ## Keeping the session alive [#keeping-the-session-alive] The CI HUB access token expires after an hour. Use the refresh token to mint a new one without a fresh exchange. See [Refresh token](/access/authentication/ci-hub/refresh-token). The DAM connection token has its own lifetime per provider; renew it as covered under [DAM connection](/access/authentication/dam). ## Next [#next] # CI HUB Access SDK (/access) The Access SDK lets a partner platform surface CI HUB's DAM (digital asset management) connectivity to its users: browse, search, and download assets from connected providers (Bynder, Adobe AEM, Frontify, and others) without leaving the partner's interface. The recommended integration path for TypeScript and JavaScript is the [client library](/access/client-library), `@ci-hub/access-sdk`. It wraps every currently available endpoint behind typed methods and owns the token lifecycle. The HTTP API underneath is fully documented and remains the path for every other language. ## Audience [#audience] Senior partner developers building a partner platform that surfaces DAM assets to its users. The reference assumes working knowledge of HTTP, JSON, JWT, and RS256. Pages lead with the contract; explanations follow. ## What the SDK does today [#what-the-sdk-does-today] Covered by the client library and the HTTP API alike: * Token management: exchange, check, refresh, logout * DAM connection: provider listing, login, provider info * Content: folder browse, keyword and image search, asset detail, version history, and download More capabilities are in progress. This documentation is updated as functionality is released. ## What you get per asset [#what-you-get-per-asset] Beyond the file itself, every asset the content endpoints return carries: * **Thumbnail**: a preview URL (`thumbnailUrl`) for rendering a fast, low-cost preview in a visual UI, without fetching the full file. * **Metadata**: the provider's own fields, keywords, and tags (`values`), the same attributes users see in the DAM, so you can display and filter on them. * **Renditions**: alternate versions of a file that the DAM provider generates, such as a smaller size, a different format, or a low-resolution copy. The API returns them as the `conversions` array, each with its own URL. See [Asset model](/access/concepts) for the full asset shape and [Asset URLs](/access/concepts/url-placeholders) to resolve and fetch these URLs. ## Licensing [#licensing] Licensing is per partner platform. Commercial terms are agreed during onboarding. ## Base URLs [#base-urls] | Environment | URL | | ----------- | --------------------------------- | | Stage | `https://stage.ci-hub.com/api/v1` | | Production | `https://live.ci-hub.com/api/v1` | Integrations are built against stage. Production access is granted after end-to-end verification on stage. ## Start here [#start-here] The pages below are ordered as a recommended reading path. Each one ends with a link to the next. # Overview (/access/authentication) The Access SDK authenticates users via token exchange. The partner platform signs a JWT for the user. CI HUB validates the signature against the partner's JWKS, checks the SDK subscription, and returns a CI HUB access token and refresh token. Subsequent API calls use the CI HUB tokens. The [client library](/access/client-library) implements this flow: `exchangeToken` starts the session and `TokenManager` keeps both token kinds fresh. ## Flow [#flow] The partner JWT is consumed once per exchange and discarded. The CI HUB access token is a standard CI HUB JWT (HS256, 1 hour) and flows through the same middleware as panel sessions. The partner's JWKS is the root of trust for that partner; CI HUB caches the published keys for 10 minutes. ## Two-token pattern [#two-token-pattern] CI HUB tokens identify the user inside CI HUB. Each DAM provider authenticates separately and has its own credentials. ``` Authorization: Bearer (always) provider-authorization: (on content calls) ``` `provider-authorization` is required on calls that hit a specific DAM (folder browse, search, download). The CI HUB token alone is sufficient for endpoints that operate on CI HUB state (provider listing, token refresh, user session). The partner platform obtains and stores DAM connection tokens. CI HUB does not persist them server-side. ## CI HUB auth vs DAM auth: redirect behavior [#ci-hub-auth-vs-dam-auth-redirect-behavior] Token exchange itself has no browser redirect. It runs server-to-server between the partner backend and CI HUB. DAM provider login is a separate flow, covered under [DAM connection](/access/authentication/dam). Most DAMs use OAuth and require a browser-side redirect for the end user to authenticate at the provider. That redirect is part of the DAM login flow, not the CI HUB exchange. ## Token lifetimes [#token-lifetimes] | Token | Lifetime | Algorithm | Storage | | -------------------- | ------------------------------------ | --------- | ------------------------------------------------- | | Partner JWT | up to `maxTokenAge` (1 hour default) | RS256 | Minted per exchange, discarded | | CI HUB access token | 1 hour | HS256 | Cached by partner, sent on every call | | CI HUB refresh token | 30 days | HS256 | Cached by partner, used to mint new access tokens | The refresh flow uses the refresh token returned by the exchange against [`GET /auth/refreshToken`](/access/authentication/ci-hub/refresh-token). [Check token](/access/authentication/ci-hub/check-token) revalidates a session; [logout](/access/authentication/ci-hub/logout) signals the end of one. ## Next [#next] # Asset model (/access/concepts) The content endpoints return two kinds of item: folders and assets. Folder browse and search both return the same envelope, so once you can read one result you can read the other. This page describes that shape. The individual endpoint references describe the requests that produce it. ## Result envelope [#result-envelope] ```json { "folders": [ { "id": "folder-123", "name": "Brand Assets" } ], "assets": [ { "id": "asset-456", "name": "logo.png" } ], "filters": [], "more": "p2", "capabilities": {}, "totalAssetsCount": 218 } ``` | Field | Type | Meaning | | ------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `folders` | array | Subfolders at this level. Each carries at least an `id` and a `name`. Browse into one by passing its `id` to the folder endpoint. | | `assets` | array | Assets at this level. The asset shape is described below. | | `filters` | array | Search facets the connection exposes, mainly on search results (some providers also return them on folder browse). Same structure as the `filters` from [Provider info](/access/authentication/dam/provider-info). Render them in the search UI. Empty when the provider has no facets. | | `more` | string \| number | Opaque pagination cursor for the `assets` list. Present when more assets exist beyond this page, absent at the end. Pass it back unchanged. See [Pagination](/access/concepts/pagination). | | `capabilities` | object | What the current user may do in this folder (add an asset, add a subfolder, and so on). See [Capabilities](#capabilities). | | `totalAssetsCount` | number | Total assets in this folder or search across all pages. Counts assets, not folders. | The asset shape is normalized by CI HUB across every DAM, so the same field carries the same meaning whether the provider is Bynder, AEM, or Frontify. Each provider fills the fields its system supports and adds provider-specific fields beyond the common set. Read the fields your integration needs and ignore the rest. ## Folders and assets [#folders-and-assets] Folders form a tree. Start from the root folder, read its `folders` array, and browse into a child by its `id`. Assets are the leaves: the files a user selects, downloads, or inserts. Folder and asset IDs are opaque strings and can contain slashes, because some DAMs encode a path into the ID. Pass them through as you received them, without URL-encoding the slashes; the endpoints accept the ID as a catch-all path segment. ## Asset shape [#asset-shape] ```json { "id": "asset-456", "name": "logo.png", "parentPath": "/Brand Assets", "mimeType": "image/png", "fileSize": 48213, "xSizePx": 1200, "ySizePx": 630, "created": 1714003200000, "modified": 1715212800000, "version": 3, "thumbnailUrl": "https://stage.ci-hub.com/api/v1/assets/thumbnail?...", "downloadUrl": "https://stage.ci-hub.com/api/v1/assets/download/asset-456?cihubSig=...", "assetDetailsExternalUrl": "https://dam.example.com/assets/456", "downloadHashSha256": "9f86d081...", "conversions": [], "values": [], "capabilities": {} } ``` | Field | Type | Meaning | | ------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | Asset identifier. Opaque, may contain slashes. | | `name` | string | Display name. | | `parentPath` | string | Human-readable folder path. Optional. | | `mimeType` | string | Content type, for example `image/png`. | | `fileSize` | number | Size in bytes. | | `xSizePx` / `ySizePx` | number | Pixel dimensions, for image assets. | | `created` / `modified` | number | Epoch milliseconds. | | `version` | number | Version number of the asset. | | `thumbnailUrl` | string | URL for a preview image. See [Asset URLs](/access/concepts/url-placeholders). | | `downloadUrl` | string | URL for the full file. See [Asset URLs](/access/concepts/url-placeholders). | | `assetDetailsExternalUrl` | string | Link that opens the asset in the provider's own UI. | | `type` | string | Asset kind, for example `PRODUCT` on PIM-style providers. The value set is provider-dependent. | | `downloadHash*` | string | Content hash for integrity checks. Exactly one appears (for example `downloadHashSha256`); which one depends on the provider's `assetHashAlgorithm` (see [Provider info](/access/authentication/dam/provider-info)). | | `conversions` | array | Alternate renditions (sizes, formats), each with its own URL. May be empty. | | `values` | array | Provider metadata fields (custom attributes, tags). Optional. | | `capabilities` | object | Per-asset permissions. See [Capabilities](#capabilities). | Providers add their own fields beyond this set (a caption, a copyright string, a low-resolution URL). Treat the asset as provider-specific past the common fields and read only what you use. ## Capabilities [#capabilities] `capabilities` reports what the current user may do, as boolean flags. Use them to gate affordances in your UI rather than discovering a restriction when a call fails. Common flags: | Flag | Meaning | | ------------------------------------ | --------------------------------- | | `canAddAsset` | Upload an asset into this folder. | | `canAddFolder` | Create a subfolder. | | `canUpdateAsset` | Replace an asset's file. | | `canDeleteAsset` / `canDeleteFolder` | Delete the item. | | `canRenameAsset` / `canRenameFolder` | Rename the item. | | `canLockAsset` / `canUnlockAsset` | Lock or unlock the item. | A flag that is absent means the provider does not report on that action. Phase 1 of the SDK is read-only, so the write-related flags are informational for now. ## Metadata [#metadata] `values` is an optional array of the provider's metadata fields for an asset (custom attributes, keywords, tags), each entry carrying an identifier and a value. The set of fields is defined by the DAM and varies between connections, so read by field identifier rather than position. ## Provider-dependent behavior [#provider-dependent-behavior] Not every DAM supports every feature. Versioning, similarity search, and asset detail are present on some providers and absent on others. Read [Provider info](/access/authentication/dam/provider-info) once a connection exists to learn what the connected provider supports, and gate your UI on that. Most providers return plain file assets. Some PIM-style providers also expose products and snippets, marking them with a `type` (such as `PRODUCT`) and carrying their structured fields under `values`. The `type` value set is provider-dependent, so read it by value rather than assuming a fixed list. ## Next [#next] # Pagination (/access/concepts/pagination) Folder browse and search return one page of assets at a time. Two values control paging: `more` in the response, and `size` in the request. ## The more cursor [#the-more-cursor] When a result has more assets beyond the current page, the response carries a `more` value. To fetch the next page, send it back as the `more` query parameter on the same call. When the response omits `more`, you have reached the last page. ``` GET /assets/search?query=logo → { assets: [...], more: "p2" } GET /assets/search?query=logo&more=p2 → { assets: [...], more: "p3" } GET /assets/search?query=logo&more=p3 → { assets: [...] } (no more, last page) ``` Treat `more` as opaque. Its form (a string token or a number) and meaning differ between providers, so pass it back unchanged rather than computing your own offsets. ## The size parameter [#the-size-parameter] `size` sets how many assets a page contains. It is optional and defaults per provider. Each provider also caps it, so a large `size` is clamped to the provider's maximum rather than rejected. Keep `size` stable across the pages of one traversal. ``` GET /assets/folder/root?size=25 ``` ## more paginates assets, not folders [#more-paginates-assets-not-folders] `more` pages through the `assets` array only. The `folders` array is not paged by it: depending on the provider you receive the subfolders on the first page alone, or repeated on every page. Read subfolders from the first page and use `more` to keep pulling assets in the current folder. If you accumulate pages, collect only `assets` across them (as the example below does), or dedupe folders by `id`. ## Detecting the end [#detecting-the-end] The presence of `more` is the signal, not the page size. A short page can still be followed by another, and a full page can be the last. Loop until the response has no `more`. ```ts async function listAllAssets(folderId: string) { const assets = [] let more: string | number | undefined do { const url = new URL(`${BASE_URL}/assets/folder/${encodeURIComponent(folderId)}`) if (more !== undefined) url.searchParams.set('more', String(more)) const response = await fetch(url, { headers: { Authorization: `Bearer ${ciHubAccessToken}`, 'provider-authorization': `Bearer ${damProviderToken}`, }, }) if (!response.ok) { const { error } = await response.json() throw new Error(`${error.code}: ${error.message}`) } const page = await response.json() assets.push(...page.assets) more = page.more } while (more !== undefined) return assets } ``` ## Next [#next] # Asset URLs (/access/concepts/url-placeholders) `download()` in the [client library](/access/client-library) performs this entire resolution automatically. This page matters on the HTTP path, or when you need to understand what the library does underneath. You do not request a thumbnail or a download by asset ID. Each asset in a folder or search result already carries the URLs you need: `thumbnailUrl` for a preview image and `downloadUrl` for the full file. Conversions carry their own URLs too. To show a preview or download a file, take the URL from the result, run it through the resolution steps below, then fetch it. ## Resolution process [#resolution-process] Run these steps, in order, on any `thumbnailUrl`, `downloadUrl`, or conversion URL before you fetch it: 1. **Strip the CI HUB signature.** Keep `cihubSig` only on `/api/v1/assets/download` URLs; remove it from every other URL. 2. **Resolve placeholders.** Process any `$...$` placeholder in the URL (see below). At most one authentication placeholder appears per URL. 3. **Add CI HUB auth for CI HUB endpoints.** If the URL points at `/api/v1/assets/download` or `/api/v1/assets/thumbnail`, send the two CI HUB headers: `Authorization: Bearer ` and `provider-authorization: Bearer `. Keep the URL's own signature parameter (`cihubSig` on a `/api/v1/assets/download` URL, `sig` on a `/api/v1/assets/thumbnail` URL); a modified CI HUB URL fails its signature check with HTTP 400. For a `/api/v1/assets/download` URL you can append `noRedirect=true` to receive `{ "downloadUrl": "..." }` as JSON instead of the file bytes or an HTTP 302 to the provider host. Use that when you want a ready URL to hand to an `` tag or a browser download. Unlike the JSON endpoints, these media URLs reply with a plain HTTP status, and at most a short text body, rather than the structured [error envelope](/access/errors) when they fail: a 400 when the URL signature (`cihubSig` or `sig`) is missing or modified, a 404 when the proxy cannot resolve the asset. Branch on the HTTP status code here rather than parsing an `error` object. ### Worked example [#worked-example] A `downloadUrl` from a result points at the CI HUB proxy and carries a `cihubSig`: ``` https://stage.ci-hub.com/api/v1/assets/download/asset-456?cihubSig=abc123 ``` It has no `$...$` placeholder, so resolution is: keep `cihubSig` (step 1), nothing to replace (step 2), and add the two CI HUB headers because it is a `/api/v1/assets/download` URL (step 3). Then fetch: ```ts const res = await fetch(asset.downloadUrl, { headers: { Authorization: `Bearer ${ciHubAccessToken}`, 'provider-authorization': `Bearer ${damProviderToken}`, }, }) const bytes = await res.arrayBuffer() ``` ## The payload [#the-payload] Several placeholders inject a value called the payload. It is the `payload` claim inside the DAM connection token (the provider's own credential), not the whole token. Decode the connection token and read that claim: ```ts import { jwtDecode } from 'jwt-decode' const { payload } = jwtDecode(damProviderToken) ``` ## Placeholders [#placeholders] | Placeholder | Resolve by | | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `$AUTH_PAYLOAD$` | Replace with `encodeURIComponent(payload)`, as a query value. | | `$AUTH_PAYLOAD_BEARER$` | Replace with `encodeURIComponent('Bearer ' + payload)`, as a query value. | | `$AUTH_HEADER_PAYLOAD_BEARER$` | Remove the placeholder and send `Authorization: Bearer {payload}` as a request header. | | `$AUTH_HEADER_PAYLOAD_APITOKEN$` | Remove the placeholder and send `Authorization: apiToken {payload}` as a request header. | | `$NO_AUTH$` | Remove the placeholder. Fetch without authentication, unless the URL resolves to `/api/v1/assets/download` or `/api/v1/assets/thumbnail`, in which case add those endpoints' CI HUB headers. | | `$HEADER_VALUE_STATIC_ENCODED_{name}={value}$` | Remove the placeholder and send header `{name}: {value}`. URL-decode both `name` and `value` first. | | `$FORCE_DOWNLOAD$` | Remove the placeholder. Its presence signals the client should force a download rather than render inline. | | `$NO_CACHE$` | Remove the placeholder and send `Cache-Control: no-cache`. | ### `$REQUEST_HEADERS$` [#request_headers] Remove the placeholder and read two query parameters from the URL: * `headers`: a URL-encoded JSON object of header key-value pairs. * `type`: how to merge them with the request's existing headers. `merge` (default), `replace`, or `overwrite`. | `type` | Result | | ----------- | ------------------------------------------------------------------------------- | | `merge` | Existing headers plus the provided headers; provided headers win on a conflict. | | `replace` | Only the provided headers; existing headers are dropped. | | `overwrite` | Existing headers plus the provided headers; existing headers win on a conflict. | ``` Original: https://api.unsplash.com/photos/abc123/download$REQUEST_HEADERS$?headers=%7B%22X-API-Key%22%3A%22key-123%22%7D&type=merge Existing headers: { "Accept": "*/*", "User-Agent": "CI-HUB-Client/1.0" } Result: URL: https://api.unsplash.com/photos/abc123/download Headers: { "Accept": "*/*", "User-Agent": "CI-HUB-Client/1.0", "X-API-Key": "key-123" } ``` `$AUTH_PAYLOAD$`, `$AUTH_PAYLOAD_BEARER$`, and the header placeholders put the provider credential into the request, and the query-value forms put it in the URL. Resolve these in your backend or in trusted client code, and keep resolved URLs out of logs, analytics, and referrer headers. ### Resolving a payload placeholder [#resolving-a-payload-placeholder] A provider-direct URL can embed the credential as a query value. Replace `$AUTH_PAYLOAD$` with the URL-encoded `payload` claim, then fetch: ```ts const { payload } = jwtDecode(damProviderToken) const resolved = downloadUrl.replace('$AUTH_PAYLOAD$', encodeURIComponent(payload)) // https://api.example-dam.com/assets/123/download?access_token=$AUTH_PAYLOAD$ // becomes // https://api.example-dam.com/assets/123/download?access_token= const res = await fetch(resolved) ``` ## Conversions [#conversions] An asset's `conversions` array holds alternate renditions: a smaller size, a different format, a low-resolution copy. Each entry carries its own URL in the same forms described above. Resolve and fetch a conversion URL exactly as you would the asset's own `downloadUrl`. ## Verifying a download [#verifying-a-download] When an asset carries a content hash field, you can verify the bytes you received against it after download. The hash field and algorithm depend on the provider. Treat it as optional integrity, not a required step. ## Next [#next] # Errors (/access/errors) Every error response uses the envelope described below. Code against it as the default. CI HUB-originated errors (`source: "cihub"`) return it today. DAM-originated errors (`source: "integration"`) are still being migrated, so some still arrive in an older form or with envelope fields unset. Read the envelope defensively until that work completes. The `source` field distinguishes errors originating in CI HUB from errors originating in a DAM provider. ## Envelope [#envelope] ```json { "error": { "status": 403, "code": "integration-forbidden", "source": "integration", "message": "Access denied by the integration", "details": "403 Forbidden - insufficient_permissions", "provider": "bynder" } } ``` | Field | Always present | Meaning | | ---------- | ----------------------------------- | ----------------------------------------------------------------- | | `status` | yes | HTTP status mirrored in the body | | `code` | yes | Machine-readable identifier | | `source` | yes | `cihub` or `integration` | | `message` | yes | Human-readable summary, safe for end-user display | | `details` | no | Structured or string context. Useful for logs, not for end users. | | `provider` | only when `source` is `integration` | DAM that produced the error (`bynder`, `aem`, etc.) | The HTTP status in the response line equals `error.status`. The presence guarantees above hold for the structured envelope, which every `cihub` error and every migrated `integration` error returns. A DAM error still on the legacy path can instead arrive as `{ "message": "Error", "details": "..." }` with no `error` object, so check that `error` exists before reading `error.code`. ## The source field [#the-source-field] The support chain runs: ``` End user → Partner platform → CI HUB → DAM provider ``` * `source: "cihub"`: CI HUB rejected the request. Examples: missing token, expired subscription, rate limit. Direct support tickets to CI HUB. * `source: "integration"`: a DAM provider rejected the request. Examples: Bynder permission denied, AEM 404. Direct support to the DAM owner. ## Status codes [#status-codes] `cihub` errors use standard HTTP status conventions. `integration` errors forward the DAM's HTTP status when the integration classifies it as a known case; otherwise a default status applies. ## Backwards compatibility [#backwards-compatibility] Top-level fields are preserved for clients that pre-date the structured envelope. | Top-level field | Source | Notes | | --------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------- | | `errorCode` | mirrors `error.code` | Available for legacy clients that switch on a top-level field. | | `message` | constant `'Error'` | Not the human-readable message. The user-facing string is `error.message`. | | `details` | `route prefix + error.message` | The route prefix plus the underlying message. Different from `error.details`, which carries structured context. | New integrations should read `error.*` only. ## Code catalog [#code-catalog] Switch on `error.code`; display `error.message` to end users. The `source` column tells you who owns the failure. ### 400 Bad Request [#400-bad-request] | Code | Source | When | Action | | --------------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `cihub-sdk-email-missing` | cihub | No `email` in the JWT claim or the request body, or the resolved value fails email-format validation. | Include `email` in the JWT payload or send it in the request body. Verify it parses as a valid email. | | `cihub-validation-error` | cihub | A required parameter is missing or malformed. | Check the endpoint reference. The `details` field carries the validation message. | | `cihub-bad-request` | cihub | An ad-hoc validation rejected the request body or query (route-specific check that did not run through schema validation). | Check the endpoint reference. The `details` field names the issue. | | `integration-operation-failed` | integration | A DAM operation was rejected by the provider with a request-level error. | The `details` field carries the upstream message. Surface it to the user or route to the DAM owner. | ### 401 Unauthorized [#401-unauthorized] | Code | Source | When | Action | | ------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `cihub-sdk-token-missing` | cihub | `Authorization` header absent on the exchange call. | Send `Authorization: Bearer `. | | `cihub-sdk-token-invalid` | cihub | Partner JWT is malformed; signed with the wrong algorithm; missing required header or claim fields; signature verification failed; `iat` is more than 30s in the future; or `iat` is older than `maxTokenAge` (more than `maxTokenAge` seconds in the past). | Mint a fresh JWT. Verify `alg: RS256`, that `kid` matches a key in the JWKS, and that all required claims are present. Sign a new token per exchange so `iat` stays recent. | | `cihub-sdk-token-expired` | cihub | Partner JWT past `exp`. | Mint a new JWT with a fresh `iat` and `exp`. | | `cihub-access-token-missing` | cihub | `Authorization` header absent on a post-exchange API call. | Send `Authorization: Bearer `. | | `cihub-access-token-invalid` | cihub | CI HUB access token is present but invalid or expired. | Mint a new access token using the refresh token. If refresh fails, run the token exchange again. | | `cihub-refresh-token-invalid` | cihub | On `GET /auth/refreshToken`, the `provider-authorization` token is not a CI HUB refresh token (for example an access token sent in its place), or its `sub` does not match the access token. A signature failure, malformed token, or expired refresh token returns `provider-access-token-invalid` (403) instead. | Send the refresh token returned by exchange in `provider-authorization`. If it has expired, run the token exchange again. | | `integration-auth-failed` | integration | The DAM provider rejected the forwarded authentication, for example a token revoked at the DAM or a failed token refresh on the DAM side. `provider` names the integration. | Refresh the DAM token where supported, otherwise restart the DAM login flow. | ### 402 Payment Required [#402-payment-required] | Code | Source | When | Action | | --------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `cihub-sdk-no-subscription` | cihub | Partner company has no active SDK subscription, or the assigned product is not enabled for SDK use. | Contact the CI HUB partner manager. Resolution is operational. | | `cihub-license-required` | cihub | The user has no active license for the requested product. | Confirm the user is associated with the partner company and the subscription has seats available. | ### 403 Forbidden [#403-forbidden] | Code | Source | When | Action | | ----------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `cihub-sdk-partner-unknown` | cihub | The `iss` claim is not registered in `sdk.partners`. | Verify the issuer string matches the registered value exactly, including trailing slash. | | `cihub-sdk-audience-invalid` | cihub | The `aud` claim does not match the registered audience. | Set `aud` to the registered value (default `https://api.ci-hub.com`). | | `cihub-access-denied` | cihub | A whitelist or role check rejected the request. | The `details` field describes the rejected check. The fix is typically operational (account-level configuration). | | `provider-access-token-missing` | cihub | A content call requires a DAM connection token (`provider-authorization` header) but none was sent. | Complete the DAM login flow for this provider and send the resulting token in `provider-authorization`. | | `provider-access-token-invalid` | cihub | The `provider-authorization` token failed verification, was malformed, or has expired. Covers both a DAM connection token on content calls and a CI HUB refresh token whose signature or expiry failed on `GET /auth/refreshToken`. | For a DAM token, re-run the DAM login flow. For a CI HUB refresh token, run the token exchange again. | | `integration-forbidden` | integration | DAM provider rejected the call with a permission error. | Surface the message to the end user. Permission decisions are governed by the DAM. | ### 404 Not Found [#404-not-found] | Code | Source | When | Action | | --------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | | `cihub-not-found` | cihub | The path does not exist on CI HUB. | Check the endpoint reference for the correct route and casing. | | `integration-not-found` | integration | Asset or folder does not exist in the DAM, or the user cannot see it. | Re-query the parent folder. The asset may have been moved or deleted in the DAM. | | `integration-not-available` | integration | The asset exists in the DAM but the requested rendition or download form is not available. | Fall back to a different rendition. Check `providerInfo` for supported renditions. | ### 409 Conflict [#409-conflict] | Code | Source | When | Action | | ----------------------------------------- | ------ | ------------------------------------------------------------------------------------------------ | ------------------------------------------- | | `cihub-conflict` | cihub | An action conflicts with current state (resource already exists, trial already activated, etc.). | The `details` field describes the conflict. | ### 429 Too Many Requests [#429-too-many-requests] | Code | Source | When | Action | | ------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | `cihub-rate-limited` | cihub | Per-partner rate limit on the exchange endpoint exceeded (default 60 requests per minute, configurable per partner). | Back off and retry. Honor the standard rate-limit headers. | ### 500 Internal Server Error [#500-internal-server-error] | Code | Source | When | Action | | ----------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | `cihub-internal-error` | cihub | An explicit 500 emitted by route code. | Retry once. File a ticket with the `details` field and a recent timestamp if it persists. | | `cihub-unknown-error` | cihub | An unhandled exception reached the route safety net. The `status` echoes the underlying error's status (or 500 if missing). | Retry once. File a ticket with the timestamp; CI HUB logs carry the stack trace. | ### 501 Not Implemented [#501-not-implemented] | Code | Source | When | Action | | ------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | `integration-not-implemented` | integration | The DAM integration does not implement this operation. The capability is intended but absent today. | Gate the affordance in the host UI based on the provider's capability flags. | | `integration-not-supported` | integration | The DAM provider does not support this feature at all. | Permanent. Check `providerInfo` before exposing the affordance. | ## The details field [#the-details-field] `details` is optional. When present on an `integration` error, it carries the upstream message verbatim. Use `error.message` for end-user strings. Reserve `error.details` for logs and support tickets. ## Next [#next] # Asset detail (/access/content/asset) Support is sparse. As of May 2026 only a few DAM providers implement this endpoint, and coverage is expanding. On a provider that does not implement it, the call returns an error. Read the asset's fields from [folder browse](/access/content/folder) and [search](/access/content/search) results instead, which carry the same metadata and URLs. Check [Provider info](/access/authentication/dam/provider-info) before exposing a detail view for the connected provider. The response is a single asset object, the same shape returned inside `assets` by folder browse and search. See [Asset model](/access/concepts) for the field reference, and [Asset URLs](/access/concepts/url-placeholders) to resolve and fetch its `thumbnailUrl` and `downloadUrl`. ## Example [#example] ```ts import { CiHubAccessClient, TokenManager } from '@ci-hub/access-sdk' const client = new CiHubAccessClient({ baseUrl: 'https://stage.ci-hub.com/api/v1' }) const tokens = new TokenManager(client) // seeded during authentication const asset = await tokens.withDamAuth('dropbox', (accessToken, damToken) => client.getAsset({ accessToken, damToken, assetId: 'asset-456' }), ) ``` ```bash curl "https://stage.ci-hub.com/api/v1/assets/asset/asset-456" \ -H "Authorization: Bearer $CI_HUB_ACCESS_TOKEN" \ -H "provider-authorization: Bearer $DAM_PROVIDER_TOKEN" ``` ```ts const response = await fetch( 'https://stage.ci-hub.com/api/v1/assets/asset/asset-456', { headers: { Authorization: `Bearer ${ciHubAccessToken}`, 'provider-authorization': `Bearer ${damProviderToken}`, }, } ) if (!response.ok) { const { error } = await response.json() throw new Error(`${error.code}: ${error.message}`) } const asset = await response.json() ``` ## Next [#next] # Asset versions (/access/content/assetversions) ## Provider support [#provider-support] Versioning is provider-dependent: a DAM without it answers with an error rather than a list. Read [Provider info](/access/authentication/dam/provider-info) to learn whether the connected provider supports versioning, and gate the affordance in your UI on that. Each version is an asset object (see [Asset model](/access/concepts)) carrying its own `downloadUrl`; resolve and fetch it like any other (see [Asset URLs](/access/concepts/url-placeholders)). ## Reading the result [#reading-the-result] The response wraps the version list with the asset's `id` and `name`: `{ id, name, versions }`. Each entry in `versions` is a full asset object. Pass `withMaster=true` to include the current master version alongside the historical ones. ## Example [#example] ```ts import { CiHubAccessClient, TokenManager } from '@ci-hub/access-sdk' const client = new CiHubAccessClient({ baseUrl: 'https://stage.ci-hub.com/api/v1' }) const tokens = new TokenManager(client) // seeded during authentication const { id, name, versions } = await tokens.withDamAuth('dropbox', (accessToken, damToken) => client.getAssetVersions({ accessToken, damToken, assetId: 'asset-456' }), ) ``` ```bash curl "https://stage.ci-hub.com/api/v1/assets/assetversions/asset-456" \ -H "Authorization: Bearer $CI_HUB_ACCESS_TOKEN" \ -H "provider-authorization: Bearer $DAM_PROVIDER_TOKEN" ``` ```ts const response = await fetch( 'https://stage.ci-hub.com/api/v1/assets/assetversions/asset-456', { headers: { Authorization: `Bearer ${ciHubAccessToken}`, 'provider-authorization': `Bearer ${damProviderToken}`, }, } ) if (!response.ok) { const { error } = await response.json() throw new Error(`${error.code}: ${error.message}`) } const { id, name, versions } = await response.json() ``` ## Next [#next] # Folder browse (/access/content/folder) ## Reading the result [#reading-the-result] `folders` arrives in full on the first page; `more` pages through `assets` only. Read every subfolder from the first response, then keep pulling assets until `more` is absent (see [Pagination](/access/concepts/pagination)). Each asset carries its own `thumbnailUrl` and `downloadUrl`; resolve and fetch them as described in [Asset URLs](/access/concepts/url-placeholders). Folder IDs are opaque and can contain slashes, so pass them through as received; start a traversal from `root`. ## Example [#example] ```ts import { CiHubAccessClient, TokenManager } from '@ci-hub/access-sdk' const client = new CiHubAccessClient({ baseUrl: 'https://stage.ci-hub.com/api/v1' }) const tokens = new TokenManager(client) // seeded during authentication const { folders, assets, more } = await tokens.withDamAuth('dropbox', (accessToken, damToken) => client.getFolder({ accessToken, damToken, folderId: 'root' }), ) ``` ```bash curl "https://stage.ci-hub.com/api/v1/assets/folder/root" \ -H "Authorization: Bearer $CI_HUB_ACCESS_TOKEN" \ -H "provider-authorization: Bearer $DAM_PROVIDER_TOKEN" ``` ```ts const response = await fetch( 'https://stage.ci-hub.com/api/v1/assets/folder/root', { headers: { Authorization: `Bearer ${ciHubAccessToken}`, 'provider-authorization': `Bearer ${damProviderToken}`, }, } ) if (!response.ok) { const { error } = await response.json() throw new Error(`${error.code}: ${error.message}`) } const { folders, assets, more } = await response.json() ``` ## Next [#next] # Content (/access/content) The content endpoints read assets out of a connected DAM. They browse folders, search, and return the metadata and URLs a partner needs to preview and download files. Phase 1 of the SDK is read-only. Each endpoint is also a typed method on the [client library](/access/client-library); the reference pages show the client call next to the wire samples. ## Two tokens [#two-tokens] Every content call carries both tokens: ``` Authorization: Bearer provider-authorization: Bearer ``` The CI HUB access token comes from [Exchange token](/access/authentication/ci-hub/exchange-token) and identifies the user. The DAM connection token comes from a completed [DAM login](/access/authentication/dam/login) and authorizes access to that provider. The connection token also selects which provider the call targets, so there is no provider parameter on these routes. ## Reading results [#reading-results] Folder browse and search return the same shape: a list of folders and a list of assets, paged by a cursor. Each asset carries the URLs you use to show a preview or download the file. Read these before the endpoint references: ## Endpoints [#endpoints] # Search by image (/access/content/search-by-image) ## Reference image [#reference-image] The request body takes either a base64 data URI or an `http(s)` URL, which CI HUB fetches and converts before searching. The simplest reference is the `downloadUrl` of an asset already in a result, which lets a user search for more like a given asset. Results use the same envelope as [keyword search](/access/content/search); see [Asset model](/access/concepts) for the field reference and [Asset URLs](/access/concepts/url-placeholders) to fetch each asset's URLs. ## Example [#example] ```ts import { CiHubAccessClient, TokenManager } from '@ci-hub/access-sdk' const client = new CiHubAccessClient({ baseUrl: 'https://stage.ci-hub.com/api/v1' }) const tokens = new TokenManager(client) // seeded during authentication const { assets, more } = await tokens.withDamAuth('dropbox', (accessToken, damToken) => client.searchSimilar({ accessToken, damToken, dataBase64: referenceImageUrl }), ) ``` ```bash curl -X POST "https://stage.ci-hub.com/api/v1/assets/search" \ -H "Authorization: Bearer $CI_HUB_ACCESS_TOKEN" \ -H "provider-authorization: Bearer $DAM_PROVIDER_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "dataBase64": "https://example.com/reference.png" }' ``` ```ts const response = await fetch( 'https://stage.ci-hub.com/api/v1/assets/search', { method: 'POST', headers: { Authorization: `Bearer ${ciHubAccessToken}`, 'provider-authorization': `Bearer ${damProviderToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ dataBase64: referenceImageUrl }), } ) if (!response.ok) { const { error } = await response.json() throw new Error(`${error.code}: ${error.message}`) } const { assets, more } = await response.json() ``` ## From a local file [#from-a-local-file] To search with bytes you hold locally, build a base64 data URI and pass it as `dataBase64` instead of a URL: ```ts import { readFile } from 'node:fs/promises' const bytes = await readFile('reference.png') const dataUri = `data:image/png;base64,${bytes.toString('base64')}` // POST { dataBase64: dataUri } exactly as in the example above ``` ## Next [#next] # Search (/access/content/search) ## Reading the result [#reading-the-result] Results use the same envelope as [folder browse](/access/content/folder): a paged list of assets plus the search facets the connection exposes. Render `filters` in the search UI and pass the chosen option ids back as the `filters` query parameter to narrow the next search; the same facets appear in [Provider info](/access/authentication/dam/provider-info). See [Asset model](/access/concepts) for the field reference, [Asset URLs](/access/concepts/url-placeholders) to fetch each asset's URLs, and [Pagination](/access/concepts/pagination) for the `more` cursor. ## Example [#example] ```ts import { CiHubAccessClient, TokenManager } from '@ci-hub/access-sdk' const client = new CiHubAccessClient({ baseUrl: 'https://stage.ci-hub.com/api/v1' }) const tokens = new TokenManager(client) // seeded during authentication const { assets, filters, more } = await tokens.withDamAuth('dropbox', (accessToken, damToken) => client.search({ accessToken, damToken, options: { query: 'logo' } }), ) ``` ```bash curl "https://stage.ci-hub.com/api/v1/assets/search?query=logo" \ -H "Authorization: Bearer $CI_HUB_ACCESS_TOKEN" \ -H "provider-authorization: Bearer $DAM_PROVIDER_TOKEN" ``` ```ts const url = new URL('https://stage.ci-hub.com/api/v1/assets/search') url.searchParams.set('query', 'logo') const response = await fetch(url, { headers: { Authorization: `Bearer ${ciHubAccessToken}`, 'provider-authorization': `Bearer ${damProviderToken}`, }, }) if (!response.ok) { const { error } = await response.json() throw new Error(`${error.code}: ${error.message}`) } const { assets, filters, more } = await response.json() ``` ## Next [#next] # Browse a folder tree (/access/guides/browse-folder) This guide walks a DAM folder tree from the root down, printing each folder and the assets inside it. It assumes you completed the [authentication flow](/access/getting-started): a seeded `TokenManager` on the client-library path, or a CI HUB access token plus a DAM connection token on the HTTP path. ## Read one folder [#read-one-folder] [Folder browse](/access/content/folder) returns the subfolders and assets directly inside a folder. Subfolders arrive on the first page; assets page with the `more` cursor (see [Pagination](/access/concepts/pagination)). Read the folders once, then keep pulling assets until `more` is absent. ```ts import { CiHubAccessClient, TokenManager } from '@ci-hub/access-sdk' const client = new CiHubAccessClient({ baseUrl: 'https://stage.ci-hub.com/api/v1' }) const tokens = new TokenManager(client) // seeded during authentication const getFolderPage = (folderId: string, more?: string) => tokens.withDamAuth('dropbox', (accessToken, damToken) => client.getFolder({ accessToken, damToken, folderId, options: { more } }), ) ``` ```ts const BASE_URL = 'https://stage.ci-hub.com/api/v1' const authHeaders = { Authorization: `Bearer ${ciHubAccessToken}`, 'provider-authorization': `Bearer ${damProviderToken}`, } async function getFolderPage(folderId: string, more?: string) { const url = new URL(`${BASE_URL}/assets/folder/${encodeURIComponent(folderId)}`) if (more !== undefined) url.searchParams.set('more', more) const response = await fetch(url, { headers: authHeaders }) if (!response.ok) { const { error } = await response.json() throw new Error(`${error.code}: ${error.message}`) } return response.json() } ``` The aggregation on top is identical on both paths: ```ts async function readFolder(folderId: string) { const first = await getFolderPage(folderId) const assets = [...first.assets] let more = first.more while (more !== undefined) { const page = await getFolderPage(folderId, more) assets.push(...page.assets) more = page.more } return { folders: first.folders, assets } } ``` ## Walk the tree [#walk-the-tree] Start at `root` and recurse into each subfolder by its `id`. IDs are opaque and can contain slashes; pass them through as received. ```ts async function walkTree(folderId = 'root', depth = 0) { const { folders, assets } = await readFolder(folderId) const indent = ' '.repeat(depth) for (const asset of assets) { console.log(`${indent}- ${asset.name}`) } for (const folder of folders) { console.log(`${indent}+ ${folder.name}/`) await walkTree(folder.id, depth + 1) } } await walkTree() ``` Each asset carries the metadata and the `thumbnailUrl` / `downloadUrl` you need without a second call. See [Asset model](/access/concepts) for the field reference, and [Download an asset](/access/guides/download-asset) to fetch the bytes. ## Next [#next] # Download an asset (/access/guides/download-asset) You do not download an asset by ID. Every asset from [folder browse](/access/content/folder) or [search](/access/content/search) already carries a `downloadUrl`. This guide fetches the file behind that URL. It assumes you completed the [authentication flow](/access/getting-started). ## Fetch the bytes [#fetch-the-bytes] Most results return a CI HUB proxy `downloadUrl` (it contains `/api/v1/assets/download`); provider-direct URL forms carry `$...$` placeholders instead. The client library's `download()` resolves every form and attaches the right credentials by itself. On the HTTP path, keep the `cihubSig` parameter and send both tokens for proxy URLs, and resolve the other forms with the steps in [Asset URLs](/access/concepts/url-placeholders). ```ts import { writeFile } from 'node:fs/promises' import { basename } from 'node:path' async function downloadAsset(asset: { id: string; name: string; downloadUrl: string }) { const response = await tokens.withDamAuth('dropbox', (cihubToken, damToken) => client.download({ url: asset.downloadUrl, damToken, cihubToken }), ) const bytes = Buffer.from(await response.arrayBuffer()) // asset.name is provider-supplied; strip path components so it can't escape the target directory. const fileName = basename(asset.name ?? '').replace(/[/\\]/g, '') || asset.id await writeFile(fileName, bytes) return bytes } ``` ```ts import { writeFile } from 'node:fs/promises' import { basename } from 'node:path' const authHeaders = { Authorization: `Bearer ${ciHubAccessToken}`, 'provider-authorization': `Bearer ${damProviderToken}`, } async function downloadAsset(asset: { id: string; name: string; downloadUrl: string }) { const isProxy = asset.downloadUrl.includes('/api/v1/assets/download') // Proxy URLs keep the cihubSig parameter and carry both tokens. Provider-direct // URLs are resolved per Asset URLs and must never receive the CI HUB tokens; // resolvePlaceholders is the resolution described on that page. const url = isProxy ? asset.downloadUrl : resolvePlaceholders(asset.downloadUrl) const response = await fetch(url, isProxy ? { headers: authHeaders } : undefined) if (!response.ok) { // These media URLs reply with a bare HTTP status, not the error envelope. throw new Error(`download failed: HTTP ${response.status}`) } const bytes = Buffer.from(await response.arrayBuffer()) // asset.name is provider-supplied; strip path components so it can't escape the target directory. const fileName = basename(asset.name ?? '').replace(/[/\\]/g, '') || asset.id await writeFile(fileName, bytes) return bytes } ``` ## Get a ready URL instead of bytes [#get-a-ready-url-instead-of-bytes] To hand a URL to an `` tag or a browser download rather than stream the bytes yourself, append `noRedirect=true` to a CI HUB proxy `downloadUrl` (the `/api/v1/assets/download` form; provider-direct URLs do not support it). The call returns `{ "downloadUrl": "..." }` pointing at the file. This is a raw HTTP call whichever idiom you integrate with; the client library has no wrapper for it. ```ts async function resolveDownloadUrl(asset: { downloadUrl: string }) { const url = new URL(asset.downloadUrl) url.searchParams.set('noRedirect', 'true') const response = await fetch(url, { headers: authHeaders }) if (!response.ok) { // The download endpoint replies with a plain status and short text body, not the JSON envelope. const detail = await response.text() throw new Error(`Download URL resolution failed (${response.status}): ${detail || response.statusText}`) } const { downloadUrl } = await response.json() return downloadUrl } ``` ## Verify the bytes [#verify-the-bytes] Some providers include a content hash on the asset. When present, hash the bytes you received and compare. The field name and algorithm are provider-specific, so read them from the asset's fields rather than assuming; treat the check as optional integrity. See [Asset model](/access/concepts). ```ts import { createHash } from 'node:crypto' function verify(bytes: Buffer, expectedHash: string, algorithm = 'sha256') { const actual = createHash(algorithm).update(bytes).digest('hex') if (actual !== expectedHash) { throw new Error('hash mismatch: the downloaded bytes do not match the asset hash') } } ``` ## Next [#next] # Handle token refresh (/access/guides/handle-token-refresh) `TokenManager` implements this entire guide: proactive refresh of the CI HUB session, reactive retry on expiry, refresh-token rotation, and the DAM try-then-fallback. See the [client library](/access/client-library) page. What follows documents the wire contract underneath, for other languages or custom implementations. A connected session holds two kinds of token, each with its own lifetime: the CI HUB access token from [token exchange](/access/authentication/ci-hub/exchange-token), and the DAM connection token from a [DAM login](/access/authentication/dam/login). Both are renewed through `GET /auth/refreshToken`; the token you send in `provider-authorization` decides which session is renewed. ## Lifetimes [#lifetimes] * **CI HUB access token.** Expires about an hour after exchange. Renew it with the CI HUB refresh token returned alongside it. When the refresh token itself expires, run the [token exchange](/access/authentication/ci-hub/exchange-token) again. * **DAM connection token.** Lifetime and refresh model are provider-dependent. Some providers issue a refresh token; others force a fresh login. See the [refresh support matrix](/access/authentication/dam/refresh). The refresh response returns a new `access_token` and `refresh_token` but no `expires_in`. Track the access token's lifetime from the `expires_in` returned by the original exchange, or from the token's `exp` claim. ## Detect expiry from the error [#detect-expiry-from-the-error] You do not have to track expiry to handle it. An expired token surfaces as a specific [error code](/access/errors), so a reactive path works: catch the code and refresh. | Code | HTTP | What expired | Recover by | | ------------------------------------------------------------------------------- | ---- | -------------------- | ------------------------------------------------------- | | [`cihub-access-token-invalid`](/access/errors#cihub-access-token-invalid) | 401 | CI HUB access token | Refresh the CI HUB session. If that fails, re-exchange. | | [`cihub-refresh-token-invalid`](/access/errors#cihub-refresh-token-invalid) | 401 | CI HUB refresh token | Run the token exchange again. | | [`provider-access-token-invalid`](/access/errors#provider-access-token-invalid) | 403 | DAM connection token | Refresh the DAM token, or start a fresh DAM login. | ## Refresh the CI HUB session [#refresh-the-ci-hub-session] Send the CI HUB refresh token in `provider-authorization`. The call returns a new access token and a new refresh token; store both. You can refresh either way: pre-emptively before the hour is up, or reactively on the first `cihub-access-token-invalid`. The reactive wrapper below refreshes once and retries; pre-emptive refresh follows the same call on a timer keyed off `expires_in`. ```ts const BASE_URL = 'https://stage.ci-hub.com/api/v1' async function withFreshToken(call: (accessToken: string) => Promise) { const response = await call(session.accessToken) if (response.status !== 401) return response // Only an expired access token is refreshable here; pass other 401s through. const { error } = await response.clone().json() if (error?.code !== 'cihub-access-token-invalid') return response const refresh = await fetch(`${BASE_URL}/auth/refreshToken`, { headers: { Authorization: `Bearer ${session.accessToken}`, 'provider-authorization': `Bearer ${session.refreshToken}`, }, }) if (!refresh.ok) { // Refresh token is gone too. Re-run the token exchange. throw new Error('CI HUB session expired; re-exchange required') } const { access_token, refresh_token } = await refresh.json() session.accessToken = access_token session.refreshToken = refresh_token // rotate; the previous refresh token is spent return call(session.accessToken) } ``` ## Refresh the DAM connection [#refresh-the-dam-connection] The DAM connection token expires on the provider's schedule. Renew it through the same endpoint, with the DAM refresh token in `provider-authorization`. Handle every provider with one path: try the refresh, and fall back to a fresh [DAM login](/access/authentication/dam/login) when it fails. A `404` means the provider has no refresh path, so treat it (and any other failure) as the signal to log in again. Some providers return only a new `access_token`; when `refresh_token` is absent, keep the prior one. The [Token refresh](/access/authentication/dam/refresh) reference covers the request and the try-then-fallback pattern. ## Storing tokens [#storing-tokens] The partner platform stores both tokens. CI HUB does not persist DAM connection tokens server-side, so a dropped token means a fresh login, not a server lookup. Refresh rotates the CI HUB refresh token on every call, so always overwrite the stored pair with the values from the latest response. Keep tokens out of logs and analytics. ## Next [#next] # Guides (/access/guides) These guides walk the common operations end to end. Code samples come in two variants: the [client library](/access/client-library) (recommended for TypeScript and JavaScript) and raw HTTP for every other stack. Each guide assumes you have completed the [authentication flow](/access/getting-started); with the client library that means a seeded `TokenManager`, on the HTTP path a CI HUB access token plus a DAM connection token. For the request and response detail behind each call, follow the links into the [content reference](/access/content). # Search assets (/access/guides/search-assets) This guide runs a keyword search against the connected DAM, narrows it with a facet the provider exposes, and pages through the matches. It assumes you completed the [authentication flow](/access/getting-started): a seeded `TokenManager` on the client-library path, or a CI HUB access token plus a DAM connection token on the HTTP path. ```ts import { CiHubAccessClient, TokenManager, type AssetSearchOptions } from '@ci-hub/access-sdk' const client = new CiHubAccessClient({ baseUrl: 'https://stage.ci-hub.com/api/v1' }) const tokens = new TokenManager(client) // seeded during authentication const search = (options: AssetSearchOptions) => tokens.withDamAuth('dropbox', (accessToken, damToken) => client.search({ accessToken, damToken, options }), ) ``` ```ts const BASE_URL = 'https://stage.ci-hub.com/api/v1' const authHeaders = { Authorization: `Bearer ${ciHubAccessToken}`, 'provider-authorization': `Bearer ${damProviderToken}`, } async function search(params: Record) { const url = new URL(`${BASE_URL}/assets/search`) for (const [key, value] of Object.entries(params)) { for (const v of Array.isArray(value) ? value : [value]) { url.searchParams.append(key, v) } } const response = await fetch(url, { headers: authHeaders }) if (!response.ok) { const { error } = await response.json() throw new Error(`${error.code}: ${error.message}`) } return response.json() } ``` Both variants give you the same `search(...)` used below; the client library additionally refreshes expired tokens and throws a typed `AccessSdkError`. ## Search by keyword [#search-by-keyword] [Search](/access/content/search) takes a `query` term and returns a page of assets plus the facets this connection exposes. Which fields a keyword matches is defined by the DAM. ```ts const first = await search({ query: 'logo' }) console.log(`${first.totalAssetsCount} matches`) ``` ## Narrow with a facet [#narrow-with-a-facet] The `filters` array lists the facets the DAM offers. Each filter has an `id` and a set of options, and each option has its own `id`. Collect the option ids you want and send them as the `filters` query parameter; repeat it to combine selections. The same facets appear in [Provider info](/access/authentication/dam/provider-info), so you can render the controls before the first search. ```ts for (const filter of first.filters ?? []) { console.log(filter.name, filter.options.map((o) => o.id)) } // Send the chosen option ids as the filters parameter. const narrowed = await search({ query: 'logo', filters: ['file_type:jpg'] }) ``` ## Page through the results [#page-through-the-results] A response carries `more` while assets remain. Send it back unchanged to get the next page, and repeat the same query and facet parameters on every page. Stop when `more` is absent. See [Pagination](/access/concepts/pagination). ```ts const params = { query: 'logo', filters: ['file_type:jpg'] } const assets = [...narrowed.assets] let more = narrowed.more while (more !== undefined) { const page = await search({ ...params, more: String(more) }) assets.push(...page.assets) more = page.more } ``` Each result asset carries its own `thumbnailUrl` and `downloadUrl`. See [Asset model](/access/concepts) for the fields and [Download an asset](/access/guides/download-asset) to fetch one. ## Next [#next] # Check token (/access/authentication/ci-hub/check-token) ## Example [#example] ```ts import { CiHubAccessClient, TokenManager } from '@ci-hub/access-sdk' const client = new CiHubAccessClient({ baseUrl: 'https://stage.ci-hub.com/api/v1' }) const tokens = new TokenManager(client) // seeded during authentication const profile = await tokens.withCihubAuth((accessToken) => client.checkToken(accessToken)) ``` ```bash curl "https://stage.ci-hub.com/api/v1/auth/checkToken" \ -H "Authorization: Bearer $CI_HUB_ACCESS_TOKEN" ``` ```ts const response = await fetch( 'https://stage.ci-hub.com/api/v1/auth/checkToken', { headers: { Authorization: `Bearer ${ciHubAccessToken}` } } ) if (!response.ok) { const { error } = await response.json() throw new Error(`${error.code}: ${error.message}`) } const profile = await response.json() ``` ## Next [#next] # Exchange token (/access/authentication/ci-hub/exchange-token) ## Rate limit [#rate-limit] The exchange endpoint is rate-limited per partner, keyed by your `iss`. The default is 60 requests per minute; CI HUB can raise it for a partner on request. Other Access SDK endpoints are not rate-limited at this layer; they are bounded by the access token's one-hour lifetime instead. Over the limit, the call returns `429` [`cihub-rate-limited`](/access/errors#cihub-rate-limited). Every response carries the standard `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` headers, and a `429` adds `Retry-After`. Exchange once per session and cache the returned tokens rather than exchanging per request; on a `429`, wait for `Retry-After` before retrying. ## Email outside the JWT [#email-outside-the-jwt] CI HUB needs an email to resolve the user, but it does not have to travel in the JWT. Partners whose tokens carry no `email` claim, including those who anonymize the address, send it in the JSON request body instead: ```ts const session = await client.exchangeToken({ partnerJwt, emailFallback: 'u-8f21c4@anon.your-platform.example.com', }) ``` ```bash curl -X POST "https://stage.ci-hub.com/api/v1/auth/exchangeToken" \ -H "Authorization: Bearer $PARTNER_JWT" \ -H "Content-Type: application/json" \ -d '{"email": "u-8f21c4@anon.your-platform.example.com"}' ``` ```ts const response = await fetch( 'https://stage.ci-hub.com/api/v1/auth/exchangeToken', { method: 'POST', headers: { Authorization: `Bearer ${partnerJwt}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ email: 'u-8f21c4@anon.your-platform.example.com' }), } ) ``` A JWT `email` claim wins over the body, so send one or the other. `Content-Type: application/json` is required: no other content type is parsed for the field. Two constraints apply to a pseudonymous address: * **It must parse as an email.** A bare opaque id is rejected with [`cihub-sdk-email-missing`](/access/errors#cihub-sdk-email-missing). A local part plus a domain you control is enough; the address does not have to receive mail. * **It must be stable for the same person.** The email is how CI HUB resolves a returning user. A value that changes between sessions creates a new CI HUB user each time, and each one takes a seat. Names come from the JWT claims (`given_name`, `family_name`, `name`) only, never from the body. ## Examples [#examples] The [client library](/access/client-library) mints the partner JWT with its own server-only signer; the wire examples use the Node [`jsonwebtoken`](https://github.com/auth0/node-jsonwebtoken) library. Any RS256-capable JWT library works; the contract is the JWT itself, not the language. ```ts import { CiHubAccessClient, TokenManager } from '@ci-hub/access-sdk' import { signPartnerJwt } from '@ci-hub/access-sdk/node' // Server-side: the private key must never reach a browser. const partnerJwt = await signPartnerJwt({ privateKeyPem: process.env.PARTNER_PRIVATE_KEY!, keyId: 'your-kid', claims: { iss: 'https://auth.your-platform.example.com', aud: 'https://api.ci-hub.com', sub: 'user-12345', email: 'jane@customer.example.com', }, }) const client = new CiHubAccessClient({ baseUrl: 'https://stage.ci-hub.com/api/v1' }) const tokens = new TokenManager(client) const session = await client.exchangeToken({ partnerJwt }) await tokens.setCihubSession(session) ``` ```bash PARTNER_JWT=$(node -e " const jwt = require('jsonwebtoken'); const fs = require('fs'); const privateKey = fs.readFileSync('./private.pem'); const now = Math.floor(Date.now() / 1000); console.log(jwt.sign({ iss: 'https://auth.your-platform.example.com', aud: 'https://api.ci-hub.com', sub: 'user-12345', email: 'jane@customer.example.com', iat: now, exp: now + 3600 }, privateKey, { algorithm: 'RS256', keyid: 'your-kid' })); ") curl -X POST "https://stage.ci-hub.com/api/v1/auth/exchangeToken" \ -H "Authorization: Bearer $PARTNER_JWT" \ -H "Content-Type: application/json" \ -d '{}' ``` ```ts import fs from 'node:fs' import jwt from 'jsonwebtoken' const privateKey = fs.readFileSync('./private.pem') const now = Math.floor(Date.now() / 1000) const partnerJwt = jwt.sign( { iss: 'https://auth.your-platform.example.com', aud: 'https://api.ci-hub.com', sub: 'user-12345', email: 'jane@customer.example.com', iat: now, exp: now + 3600, }, privateKey, { algorithm: 'RS256', keyid: 'your-kid' } ) const response = await fetch( 'https://stage.ci-hub.com/api/v1/auth/exchangeToken', { method: 'POST', headers: { Authorization: `Bearer ${partnerJwt}`, 'Content-Type': 'application/json', }, body: '{}', } ) if (!response.ok) { const { error } = await response.json() throw new Error(`${error.code}: ${error.message}`) } const { access_token, refresh_token, expires_in } = await response.json() ``` ## Next [#next] # Logout (/access/authentication/ci-hub/logout) ## Example [#example] ```ts import { CiHubAccessClient, TokenManager } from '@ci-hub/access-sdk' const client = new CiHubAccessClient({ baseUrl: 'https://stage.ci-hub.com/api/v1' }) const tokens = new TokenManager(client) // seeded during authentication // Advisory server logout, then the CI HUB and DAM sessions are dropped from storage. await tokens.logout() ``` ```bash curl "https://stage.ci-hub.com/api/v1/auth/logout" \ -H "Authorization: Bearer $CI_HUB_ACCESS_TOKEN" ``` ```ts await fetch('https://stage.ci-hub.com/api/v1/auth/logout', { headers: { Authorization: `Bearer ${ciHubAccessToken}` }, }) // Drop cached tokens regardless of the response status. clearCachedCiHubTokens() ``` ## Next [#next] # Partner registration (/access/authentication/ci-hub/partner-registration) Partner registration is a one-time manual handshake. Coordinate it with your CI HUB contact. ## Inputs to CI HUB [#inputs-to-ci-hub] | Item | Format | Notes | | ------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------- | | Issuer URL | absolute URL string | Goes into the `iss` claim of every JWT minted by the partner. Identifies the partner platform. | | JWKS URL | absolute URL string | Endpoint CI HUB fetches to retrieve public keys. Reachable from CI HUB infrastructure; HTTPS in production. | | Audience | absolute URL string | Expected `aud` claim. Defaults to `https://api.ci-hub.com`. | | Maximum token age | seconds | Upper bound on `(exp - iat)` for partner JWTs. Default 3600. | | Operational contact | email and name | On-call recipient for production incidents involving the integration. | ## Outputs from CI HUB [#outputs-from-ci-hub] | Item | Purpose | | ----------------------------- | ------------------------------------------------------------------------------------------------- | | Registered issuer (confirmed) | Sanity check against the value used in JWT signing. Must match exactly, including trailing slash. | | Audience (confirmed) | Goes into the `aud` claim. | | `maxTokenAge` (confirmed) | Upper bound enforced by the exchange. | | Stage environment access | Base URL and account configuration for development. | | Production handoff date | Production is enabled after stage verification. | No client secret or partner ID is returned. The pairing of `iss` and the partner's JWKS is the credential. ## JWKS contract [#jwks-contract] CI HUB fetches the partner JWKS and caches the response for 10 minutes. Every partner JWT must reference a `kid` present in the JWKS at exchange time. Minimal JWKS response: ```json { "keys": [ { "kty": "RSA", "kid": "your-kid", "use": "sig", "alg": "RS256", "n": "", "e": "AQAB" } ] } ``` | Field | Required | Notes | | -------- | ----------- | ----------------------------------------------------------------------- | | `kty` | yes | `RSA`. EC keys are not supported. | | `kid` | yes | Stable identifier. The `kid` in the JWT header must equal one of these. | | `use` | recommended | `sig` for signing keys. | | `alg` | recommended | `RS256`. If absent, RS256 is assumed. | | `n`, `e` | yes | RSA public key. RSA-2048 recommended. | ### Key rotation [#key-rotation] Publish the new key alongside the old key in the JWKS. Wait at least 10 minutes (the cache window) before signing with the new `kid`. Remove the old key at the next rotation. ## Signing the partner JWT [#signing-the-partner-jwt] Any RS256-capable JWT library works. The contract is the JWT itself, not the language. The example below uses Node's [`jsonwebtoken`](https://github.com/auth0/node-jsonwebtoken). ```ts import fs from 'node:fs' import jwt from 'jsonwebtoken' const privateKey = fs.readFileSync('./private.pem') const now = Math.floor(Date.now() / 1000) const partnerJwt = jwt.sign( { iss: 'https://auth.your-platform.example.com', aud: 'https://api.ci-hub.com', sub: 'user-12345', email: 'jane@customer.example.com', iat: now, exp: now + 3600, }, privateKey, { algorithm: 'RS256', keyid: 'your-kid', } ) ``` Mint a fresh JWT per token exchange. Do not cache the partner JWT; cache the CI HUB tokens it returns. The full claim specification is on the [exchange token endpoint](/access/authentication/ci-hub/exchange-token) page. ## Stage and production [#stage-and-production] Integrations are built against stage at `https://stage.ci-hub.com/api/v1`. Verification requires a successful token exchange, a DAM login round-trip, and at least one content call against stage. CI HUB replicates the configuration to production at `https://live.ci-hub.com/api/v1` after verification. ## Onboarding checklist [#onboarding-checklist] Partner side: 1. Generate an RSA-2048 keypair. Store the private key in the partner's secret store. Expose the public key via the JWKS endpoint. 2. Send issuer URL, JWKS URL, requested audience, maximum token age, and operational contact to CI HUB. 3. Sign a test JWT and call `POST /auth/exchangeToken` against stage. Verify a CI HUB access token is returned. 4. Call `GET /auth/checkToken` with the access token in the `Authorization: Bearer` header. Verify the response status is 200. 5. After CI HUB promotes the configuration to production, switch the base URL to the live host and repeat steps 3 and 4. Step 4 confirms the CI HUB token is valid, the user is associated with the partner company, and the SDK subscription is active. A full DAM login round-trip and content call become possible once the DAM Auth references are in place. CI HUB side (operational; happens after step 2 above): 1. Register the partner in `sdk.partners` configuration. 2. Provision the partner company and SDK subscription. 3. Notify the partner that stage is ready. ## Next [#next] # Refresh token (/access/authentication/ci-hub/refresh-token) ## When to refresh [#when-to-refresh] The refresh response returns `access_token` and `refresh_token` but no `expires_in`. Track the lifetime from the access token's `exp`, or from the `expires_in` returned by the original [exchange](/access/authentication/ci-hub/exchange-token). This endpoint also renews DAM connection tokens. The token in `provider-authorization` decides which session is renewed; see [DAM token refresh](/access/authentication/dam/refresh) for that flow. ## Example [#example] ```ts import { CiHubAccessClient, TokenManager } from '@ci-hub/access-sdk' const client = new CiHubAccessClient({ baseUrl: 'https://stage.ci-hub.com/api/v1' }) const tokens = new TokenManager(client) // seeded during authentication // TokenManager refreshes for you: withCihubAuth and withDamAuth always run with a fresh token. const accessToken = await tokens.cihubAccessToken() // Or call the endpoint directly, with the pair stored at exchange, and hand // the renewed session back so later calls use it: const renewed = await client.refreshToken({ accessToken: ciHubAccessToken, refreshToken: ciHubRefreshToken, }) await tokens.setCihubSession({ access_token: renewed.access_token, refresh_token: renewed.refresh_token ?? ciHubRefreshToken, }) ``` ```bash curl "https://stage.ci-hub.com/api/v1/auth/refreshToken" \ -H "Authorization: Bearer $CI_HUB_ACCESS_TOKEN" \ -H "provider-authorization: Bearer $CI_HUB_REFRESH_TOKEN" ``` ```ts const response = await fetch( 'https://stage.ci-hub.com/api/v1/auth/refreshToken', { headers: { Authorization: `Bearer ${ciHubAccessToken}`, 'provider-authorization': `Bearer ${ciHubRefreshToken}`, }, } ) if (!response.ok) { const { error } = await response.json() throw new Error(`${error.code}: ${error.message}`) } const { access_token, refresh_token } = await response.json() ``` ## Next [#next] # DAM connection (/access/authentication/dam) A CI HUB access token from [Exchange token](/access/authentication/ci-hub/exchange-token) identifies the user inside CI HUB. It does not grant access to any DAM the user wants to browse. The DAM provider (Bynder, AEM, Frontify, and so on) authenticates the end user separately and returns its own access token. The partner platform sends both tokens on every content call (the two-token pattern, summarized in the [overview](/access/authentication)). ## Flow [#flow] The partner platform starts a login by posting to `/auth/login?provider={id}&polling=true`. CI HUB returns a one-time `redirect_uri` and a `state` token. The partner opens the redirect URI in the end user's browser, where the end user signs in at the DAM. While that runs, the partner polls `/auth/login?state={state}&polling=true`, which returns an empty object until the login completes. The completed poll returns an `access_token` and a `refresh_token`. If the `state` is unknown, expired, or already consumed, or the end user did not finish signing in, the poll still answers 200 but carries an `error` property instead of tokens; treat any body with `error` as final and start a new login. The partner sends the `access_token` as `provider-authorization` on every content call. Full detail is on the [DAM login](/access/authentication/dam/login) reference. ## Token storage [#token-storage] The partner platform stores DAM connection tokens. CI HUB does not persist them server-side. Each DAM has its own lifetime and refresh model, so the partner caches the token and refreshes it before expiry. ## Refresh [#refresh] Refresh behavior varies per provider. Where a provider supports it, the partner renews a connection through `/auth/refreshToken` with the DAM refresh token; where it does not, the partner starts a fresh login. Handle both with one path: try refresh, fall back to login. See [Token refresh](/access/authentication/dam/refresh) for the request and how the try-then-fallback pattern covers providers without a refresh path. ## Next [#next] # DAM login (/access/authentication/dam/login) DAM login is a two-call flow: initiate a login for a chosen provider, then poll until the end user finishes signing in at the DAM. The completed poll returns the tokens the partner sends on every content call. The flow is summarized on the [DAM connection](/access/authentication/dam) concept page. ## Initiate [#initiate] ### Example [#example] ```ts import { CiHubAccessClient, TokenManager } from '@ci-hub/access-sdk' const client = new CiHubAccessClient({ baseUrl: 'https://stage.ci-hub.com/api/v1' }) const tokens = new TokenManager(client) // seeded during authentication const login = await tokens.withCihubAuth((accessToken) => client.beginDamLogin({ provider: 'bynder', accessToken }), ) // Open login.redirect_uri for the end user, then poll with login.state. ``` ```bash curl -X POST "https://stage.ci-hub.com/api/v1/auth/login?provider=bynder&polling=true" \ -H "Authorization: Bearer $CI_HUB_ACCESS_TOKEN" ``` ```ts const response = await fetch( 'https://stage.ci-hub.com/api/v1/auth/login?provider=bynder&polling=true', { method: 'POST', headers: { Authorization: `Bearer ${ciHubAccessToken}` }, redirect: 'manual', } ) // Failures past the token check redirect to a CI HUB URL with an `error` param instead of returning JSON. if (response.status >= 300 && response.status < 400) { const location = response.headers.get('location') ?? '' const error = new URL(location, 'https://stage.ci-hub.com').searchParams.get('error') throw new Error(error ?? 'DAM login initiate failed') } if (!response.ok) { throw new Error(`DAM login initiate failed (${response.status})`) } const { redirect_uri, state } = await response.json() // open redirect_uri for the end user, then poll with state ``` ### Provider parameters [#provider-parameters] Several providers are multi-tenant, so CI HUB asks the end user which instance of the DAM to sign in to on a page of its own before the provider's login. A partner platform that already knows the instance sends it as `serverUrl` on the initiate call, and that page drops out of the flow. `bynder`, `dash`, `fotoware`, `frontify`, `picturepark`, and `purered` read it. It is optional. Omit it and the end user answers the prompt as before. A value the provider rejects falls back to the prompt instead of failing the login, so a stale instance URL degrades rather than breaks. Parameters a provider does not read are ignored. ```ts const login = await tokens.withCihubAuth((accessToken) => client.beginDamLogin({ provider: 'frontify', accessToken, options: { providerParams: { serverUrl: 'https://acme.frontify.com' } }, }), ) ``` ```bash curl -X POST "https://stage.ci-hub.com/api/v1/auth/login?provider=frontify&polling=true&serverUrl=https%3A%2F%2Facme.frontify.com" \ -H "Authorization: Bearer $CI_HUB_ACCESS_TOKEN" ``` ```ts const url = new URL('https://stage.ci-hub.com/api/v1/auth/login') url.searchParams.set('provider', 'frontify') url.searchParams.set('polling', 'true') url.searchParams.set('serverUrl', 'https://acme.frontify.com') const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${ciHubAccessToken}` }, redirect: 'manual', }) ``` ## Poll [#poll] ### Example [#example-1] The poll always answers 200 with a JSON body. A pending login returns `{}`, a completed login returns the tokens, and a dead `state` (unknown, expired, consumed) or a failed sign-in returns `{ error }`. Branch on the body, not on the HTTP status. ```ts // Polls until the login completes, the sign-in fails, or the state expires. const damTokens = await client.waitForDamLogin({ state: login.state }) await tokens.setDamSession('bynder', damTokens) ``` ```bash curl "https://stage.ci-hub.com/api/v1/auth/login?state=$STATE&polling=true" ``` ```ts async function pollLogin(state: string) { const deadline = Date.now() + 5 * 60 * 1000 // state lives 5 minutes while (Date.now() < deadline) { const response = await fetch( `https://stage.ci-hub.com/api/v1/auth/login?state=${encodeURIComponent(state)}&polling=true` ) const body = (await response.json()) as { access_token?: string refresh_token?: string userHash?: string error?: string } // Failed: a dead state or a sign-in the end user did not complete. if (body.error) throw new Error(`DAM login failed: ${body.error}`) // Completed: the DAM connection tokens are present. if (body.access_token) return body // Pending: empty object. Keep polling. await new Promise((r) => setTimeout(r, 1500)) } throw new Error('DAM login timed out') } ``` ## Next [#next] # Provider info (/access/authentication/dam/provider-info) ## Example [#example] ```ts import { CiHubAccessClient, TokenManager } from '@ci-hub/access-sdk' const client = new CiHubAccessClient({ baseUrl: 'https://stage.ci-hub.com/api/v1' }) const tokens = new TokenManager(client) // seeded during authentication const providerInfo = await tokens.withDamAuth('dropbox', (accessToken, damToken) => client.getProviderInfo({ accessToken, damToken }), ) ``` ```bash curl "https://stage.ci-hub.com/api/v1/system/providerInfo" \ -H "Authorization: Bearer $CI_HUB_ACCESS_TOKEN" \ -H "provider-authorization: Bearer $DAM_PROVIDER_TOKEN" ``` ```ts const response = await fetch( 'https://stage.ci-hub.com/api/v1/system/providerInfo', { headers: { Authorization: `Bearer ${ciHubAccessToken}`, 'provider-authorization': `Bearer ${damProviderToken}`, }, } ) if (!response.ok) { const { error } = await response.json() throw new Error(`${error.code}: ${error.message}`) } const providerInfo = await response.json() ``` ## Next [#next] # Providers (/access/authentication/dam/providers) ## Filtering [#filtering] The response lists every provider configured for the host; the partner platform decides which to render. Hide entries where `isDisabledForHost` is `true`. For entries where `isUnsupported` is `true`, show `unsupportedAdapterTitle` / `unsupportedAdapterDescription` and block new logins. ## Example [#example] ```ts import { CiHubAccessClient, TokenManager } from '@ci-hub/access-sdk' const client = new CiHubAccessClient({ baseUrl: 'https://stage.ci-hub.com/api/v1' }) const tokens = new TokenManager(client) // seeded during authentication const providers = await tokens.withCihubAuth((accessToken) => client.listProviders(accessToken)) ``` ```bash curl "https://stage.ci-hub.com/api/v1/auth/providers" \ -H "Authorization: Bearer $CI_HUB_ACCESS_TOKEN" ``` ```ts const response = await fetch( 'https://stage.ci-hub.com/api/v1/auth/providers', { headers: { Authorization: `Bearer ${ciHubAccessToken}` } } ) if (!response.ok) { const { error } = await response.json() throw new Error(`${error.code}: ${error.message}`) } const providers = await response.json() ``` ## Next [#next] # Token refresh (/access/authentication/dam/refresh) A DAM connection token from the [login poll](/access/authentication/dam/login) eventually expires. Some providers renew it without sending the user back through the browser; others require a fresh login instead. The request is the same across providers, so your code is too. ## The pattern [#the-pattern] Handle every provider the same way: try to refresh, and fall back to a fresh [DAM login](/access/authentication/dam/login) when the refresh fails. A `404` means the provider has no refresh path, so treat it (and any other failure) as the signal to log in again. Some providers (Dropbox is one) return only a new `access_token`; when `refresh_token` is absent, keep the prior one and reuse it on the next refresh. One code path covers them all. ## Endpoint [#endpoint] `GET /auth/refreshToken` is shared with the CI HUB session refresh; the token in `provider-authorization` decides which session is renewed. Send the DAM `refresh_token` from the login poll to renew a DAM connection, then send the new `access_token` as `provider-authorization` on subsequent content calls. ## Next [#next]