@viingx/content-hub-sdk
v0.1.0-preview.11
Published
Official SDK for the viingx Content Hub
Readme
@viingx/content-hub-sdk
Official TypeScript SDK for the viingx Content Hub. Fully typed, dependency-free, and browser- and Node-friendly (uses the global fetch).
The @viingx family: this package (talk to the Hub from TypeScript/JavaScript) · @viingx/content-hub-codegen (generate a fully-typed client from your tenant's types — start there for the best DX) · @viingx/extension-sdk (build UI extensions for the web client) · @viingx/create-extension (scaffold an extension project).
Preview release (
0.1.0-preview.x). The API may change before1.0.AI agents: this package ships an
AGENTS.mdwith the non-guessable Hub rules — point your agent at it. Projects using the codegen get an auto-maintained working-tree version.
Install
npm install @viingx/content-hub-sdkQuick start
import { createContentHub, apiKeyConnection } from "@viingx/content-hub-sdk";
// A connection bundles origin + credential + transport. The hub is a stateless typed view over it.
const hub = createContentHub(
apiKeyConnection("https://hub.example.com", process.env.HUB_API_KEY!), // origin — the SDK appends `/api/v1.0` (a full `/api/v1.0` base is normalized too)
{ defaultLocale: "en" }, // source locale used when creating entities
);Build a connection with apiKeyConnection (sends X-API-Key), apiKeySessionConnection (exchanges the key for a session once — see below), tokenConnection (a session token → Authorization: Bearer), or customConnection(baseUrl, auth) with your own ContentHubAuth. Each takes connection options: fetch (custom transport for tests/runtimes), timeoutMs (default 30000), maxRetries (default 2 — 429/503 retry any method honoring Retry-After; the ambiguous 502/504 and network errors/timeouts retry only idempotent requests, so a create isn't blindly replayed — see Write latency & bulk imports).
What's covered
Everything the Hub's REST API offers, one section each below:
- Content — typed entity collections: CRUD, queries, localizations, history, files & derivatives, entity previews (thumbnails), editor presence (advisory locking), temp uploads.
- Editorial — workflow (transitions, comments, task list, state/assignee pickers) and quality gates (pre-save checks with violations).
- Account & access — profiles (own + other users'), authorization data, client-side permission hints.
- Platform — types, callbacks, webhooks, actions (+ handler client), pages, share links, settings & branding, extensions management, audit log, media (DAM), layouts (InDesign), zip bundling, GraphQL passthrough.
- Utilities —
signUrl(local time-limited URLs),typeInfo/localize(labels, titles, localized strings). - Callback handler types — for endpoints the Hub calls out to (workflow restrictions, quality-gate rules, text-module sources): type the request/response of the handler you host.
Long-lived API-key clients: use a session
Plain X-API-Key auth makes the Hub validate the key and build a throwaway session on every request (on the login rate-limit path). For services and other long-lived clients, exchange the key for a session once:
import { createContentHub, apiKeySessionConnection } from "@viingx/content-hub-sdk";
const hub = createContentHub(apiKeySessionConnection("https://hub.example.com", process.env.HUB_API_KEY!), {
defaultLocale: "en",
});Same one-liner shape as apiKeyConnection; the exchange is lazy and single-flighted, requests ride the session token, and an idle-expired session re-exchanges automatically (one retry). Stick with plain apiKeyConnection for scripts and serverless handlers — there, one persisted session per short-lived process is worse than stateless per-request auth.
On graceful shutdown, optionally end the session immediately with the connection's logout() (otherwise it simply expires on idle — but frequent restarts each leave one lingering until then):
const connection = apiKeySessionConnection(baseUrl, apiKey);
const hub = createContentHub(connection, { defaultLocale: "en" });
process.on("SIGTERM", () => void connection.logout());Sessions & re-authentication
API keys don't expire. For session-based auth, a viingx session extends automatically on activity — every SDK call resets its sliding expiry — so an active client stays signed in; keeping an idle session alive (a periodic ping) is the host's job. The SDK never refreshes credentials itself: a 401 means the session has ended (idle timeout, or the IdP closed it) and only the host can re-establish it.
Pass a token provider plus an onUnauthorized hook so the SDK can recover from a 401 once:
import { createContentHub, tokenConnection } from "@viingx/content-hub-sdk";
const hub = createContentHub(
tokenConnection(
"https://hub.example.com",
() => host.currentAccessToken, // provider — returns the current token (changes only across re-logins)
{ onUnauthorized: () => host.reLogin() }, // on 401: re-authenticate, then the SDK retries once
),
{ defaultLocale: "en" },
);On a 401 the SDK calls onUnauthorized (concurrent 401s trigger it once), then retries the request a single time with the refreshed credential. If recovery fails or it 401s again, the error is thrown.
Logging in
Usually the host hands you a session token, so you just pass it to tokenConnection. When you need to obtain one yourself, the session functions hit /auth/* directly — no hub needed, and (mostly) no credential. login returns the session and a ready connection bound to its token:
import { login, createContentHub } from "@viingx/content-hub-sdk";
const result = await login("https://hub.example.com", { login: "alice", password: "…" });
if (result.status === "mfaRequired") {
// re-call with the user's TOTP code
await login("https://hub.example.com", {
login: "alice",
password: "…",
additionalCredentials: [{ type: "verifyTOTP", code }],
});
}
if (result.status === "authenticated") {
const hub = createContentHub(result.connection, { defaultLocale: "en" }); // ready to use — no token wiring
console.log(result.session.userId);
}For a long-lived, auto-refreshing session, use the provider form instead (the login-returned connection is token-bound and doesn't self-refresh):
let token = "";
const first = await login(baseUrl, { login, password });
if (first.status === "authenticated") token = first.session.token;
const hub = createContentHub(
tokenConnection(baseUrl, () => token, {
onUnauthorized: async () => {
const r = await login(baseUrl, { login, password });
if (r.status === "authenticated") token = r.session.token;
},
}),
{ defaultLocale: "en" },
);Also standalone: loginSharedKey(baseUrl, { key }) (pre-shared/API key or one-time token), check(baseUrl, token) / logout(baseUrl, token), and the password-reset pair requestPasswordReset(baseUrl, { email }) → resetPassword(baseUrl, { token, newPassword }) (the token comes from the emailed link). SSO login isn't covered yet.
Typed collections
Describe your payload and get a fully-typed collection — item.payload is typed, queries autocomplete, and tsc catches mistakes:
type Article = {
title: string;
body?: string;
views?: number;
};
const articles = hub.entities.of<Article>("article");Declare the payload as a type alias, not an interface — TypeScript infers the implicit index signature the SDK's Payload constraint needs only for type aliases; an interface fails with "Index signature for type 'string' is missing". If you hit that error, change interface to type (don't add [key: string]: any — it destroys the payload typing).
CRUD
const id = await articles.create({ title: "Hello" });
const idDe = await articles.create({ title: "Hallo" }, { locale: "de" }); // explicit source locale
const article = await articles.get(id);
console.log(article.payload.title, article.version);Writes use optimistic concurrency: pass the current version (read it from a prior get). A stale version throws a ConcurrentModification error — refetch and retry.
// Read-modify-write
await articles.update(article.id, {
version: article.version,
payload: { ...article.payload, title: "Hello, world" },
});
// Partial update — set a field to `null` to clear it
await articles.patch(article.id, { payload: { body: null } });
await articles.delete(article.id, article.version);Write latency & bulk imports
A write resolves only after the change is committed to the search index — so a subsequent read or query always reflects it (read-after-write consistency). The index refreshes about once a second, so a single write typically takes roughly half a second (up to ~1s). That's per-write latency, not a throughput ceiling.
To import many entities quickly, issue writes concurrently rather than serially — but keep the concurrency bounded. The Hub enforces a modest per-tenant request limit that is shared across every user and client of that tenant, so unbounded parallelism gets throttled (HTTP 429). The SDK automatically retries throttled requests with backoff (honoring Retry-After), but a small worker pool is faster and a better tenant citizen than firing hundreds at once.
// Bounded-concurrency import — only a handful of writes in flight at a time.
async function importAll(items: Article[], concurrency = 8) {
const queue = [...items];
await Promise.all(
Array.from({ length: concurrency }, async () => {
for (let next = queue.shift(); next; next = queue.shift()) {
await articles.create(next);
}
}),
);
}Tune concurrency down if you see throttling — the budget is shared with everything else hitting the tenant.
Retrying a failed create safely. The SDK auto-retries throttling (429/503) for any method, but an ambiguous failure on a create — a gateway 502/504, a timeout, or a mid-flight network error — is not replayed: the Hub may have committed the entity before the response was lost, so a blind retry could create a duplicate. The thrown error says so ("the Hub may still have processed this request…"). Because ids are server-assigned, recover the same way you'd import idempotently — store a correlating key (e.g. an external id) as a searchable payload field and, on such a failure, query for it before deciding to retry or reuse:
try {
await articles.create({ externalId, title });
} catch (e) {
if (e instanceof ContentHubError || /may still have processed/.test(String(e))) {
const [existing] = (
await articles.query(
{ where: q.eq("content.payload.externalId", externalId) },
{ paging: { type: "offset", offset: 0, limit: 1 } },
)
).items;
if (!existing) await articles.create({ externalId, title }); // safe: it wasn't created
} else throw e;
}Query
Build where/sort with the q helpers (or write the expression objects directly):
import { q } from "@viingx/content-hub-sdk";
const page = await articles.query(
{
where: q.and(q.eq("content.payload.title", "Hello"), q.gt("content.payload.views", 100)),
sort: [q.desc("content.payload.views")],
},
{ paging: { type: "offset", offset: 0, limit: 20 } },
);
console.log(page.total, page.items.length); // offset paging → `total`
// Iterate every match; cursor paging is handled for you
for await (const a of articles.queryAll({ where: q.eq("content.payload.title", "Hello") }, { pageSize: 50 })) {
console.log(a.payload.title);
}The result type follows the paging mode — offset paging yields { total }, cursor paging yields { nextCursor }, so no narrowing is needed when you pick a mode.
Page limit/pageSize is capped at 200 (the server rejects larger values; the SDK throws a clear RangeError before the request). For large result sets, page through them or use queryAll.
Query property paths use the wire form
content.payload.<name>(andcontent.metadata.*,id, …) — notpayload.<name>. On the plain SDK they're uncheckedstrings; the generated, per-type client narrows them to the type's indexed paths so a misspelled or non-queryable path is a compile error.
Errors
Every non-2xx response throws a ContentHubError with the HTTP status and a machine-readable type:
import { ContentHubError } from "@viingx/content-hub-sdk";
try {
await articles.update(id, { version: stale, payload });
} catch (e) {
if (e instanceof ContentHubError && e.type === "ConcurrentModification") {
// someone else changed it — refetch and retry
}
}Localizations, history, files, types
Files, two ways — a URL for markup, a stream for code. Which you use follows one question: who loads the bytes?
- The browser / markup (
<img src>,<a href>, a share link, a third party) → a…Urlbuilder (files.getUrl,previewUrl,files.getDerivativeUrl,media.cropUrl,pages.fileUrl, …). If the loader can't carry your auth — a cross-site or headless app on its own domain, a sandboxed extension (opaque origin), a third party — wrap the URL insignUrlto authorize once (scoped, expiring) and deliver credential-free. If you render same-site with the Hub, the session cookie authenticates the load — use the plain URL, no signing (details). - Your own code (a Node stream, browser processing, zipping, re-upload) → the
download…twin of the same builder (files.download,downloadPreview,files.downloadDerivative, …), which returns aDownload:stream()(in Node, pipe viaReadable.fromWeb),blob(), orarrayBuffer(). Auth rides the connection — no signing.
This separates authorization from delivery: builders + signUrl mint shareable, cacheable,
credential-free URLs for anything that renders a file; the download… twins keep the bytes on
your authenticated connection for anything that processes one.
// Localizations (translations of an entity)
await articles.locales.create(id, "de", { title: "Hallo" });
const de = await articles.locales.get(id, "de");
// History
const { events } = await articles.history.list(id);
// File upload — accepts File, Blob, ArrayBuffer, typed array (e.g. a Node Buffer), ReadableStream,
// or a stream factory (see note below)
const file = await articles.files.upload(someFile); // File: name and mime come from the file itself
const fromBytes = await articles.files.upload(buffer, { name: "clip.mp4", mime: "video/mp4" }); // others: both required
// Node: upload from disk via a stream FACTORY — a fresh stream per attempt keeps the transport's retries
// (Readable from "node:stream", createReadStream from "node:fs")
const fromDisk = await articles.files.upload(() => Readable.toWeb(createReadStream(path)), {
name: "clip.mp4",
mime: "video/mp4",
});
await articles.patch(id, { payload: { hero: file } });
// File download
const url = articles.files.getUrl(id, file); // returns an authenticated URL (see note below)
const blob = await (await articles.files.download(id, file)).blob(); // returns a Blob
const arrayBuffer = await (await articles.files.download(id, file)).arrayBuffer(); // returns an array buffer
const stream = (await articles.files.download(id, file)).stream(); // returns a ReadableStream
const oldVersion = (await articles.files.download(id, file, { version: 3 })).stream(); // return as ReadableStream for a specific version
// Temp uploads: preview a file (and its derivatives) BEFORE assigning it to an entity
const pending = await articles.files.upload(someFile);
const pendingMeta = await hub.uploads.getMetadata(pending);
const pendingUrl = hub.uploads.getUrl(pending); // same URL/derivative surface as entity files
const pendingBytes = await (await hub.uploads.download(pending)).blob(); // read (or cancel()) every Download
// File metadata & derivatives (renditions)
const meta = await articles.files.getMetadata(id, file); // { id, hash, length, mime, created, createdBy }
const derivs = await articles.files.listDerivatives(id, file); // name-keyed slots incl. width/height payload
const thumb = derivs.thumbnail?.content; // present once generated; its `id` enables cacheable URLs
const thumbUrl = articles.files.getDerivativeUrl(id, file, "thumbnail", { derivativeId: thumb?.id });
// with derivativeId → stable & long-cacheable (a stale id 302s to the current one); without → always current, uncached
const thumbDownload = await articles.files.downloadDerivative(id, file, "thumbnail");
// Types (content type definitions)
const { items } = await hub.types.list();Entity previews (thumbnails)
The classic client task — show an entity's preview image. The Hub maintains previews per entity, served by their own builder (with a downloadPreview twin for the bytes):
const thumbUrl = articles.previewUrl(id); // current thumbnail ("systemPreview"); uncached
// Stable, long-cacheable URL: pass the current previewId from the preview root —
const item = await articles.get(id, { includePreview: true });
const p = item.preview?.payload.systemPreview; // thumbnail; systemHighPreview = large, systemPdfPreview = PDF
if (p) {
const cacheable = articles.previewUrl(id, "systemPreview", { previewId: p.previewId });
// authenticated URL — for a bare <img src>, mint a credential-free one:
const imgSrc = await signUrl(cacheable, credential, { expires, method: "GET" });
}previewWidth/previewHeight/previewMimeType ride along for layout. A stale previewId normally 302-redirects to the current preview — but a signed stale-id URL fails (the redirect target isn't covered by the signature), so sign only a current id (or the id-less URL) and re-sign after previews regenerate.
files.getUrl(...)returns an authenticated endpoint, not a public/signed URL — the request needs the client's auth, so it won't load in a bare<img src>unless the browser already carries that auth.Every stored-file URL builder (
files.getUrl,files.getDerivativeUrl, the collection'spreviewUrl,pages.fileUrl,settings.system.fileUrl,pictureUrl, thehub.uploadstwins) has adownload…twin with the same parameters that fetches the bytes over the SDK's connection as aDownload(blob()/arrayBuffer()/stream()+mime/length/filename). Use the URL builders where a URL is the product — embedding, sharing, andsignUrlfor anything that can't send auth headers; use thedownload…twins to consume the bytes in your own code.All uploads (
files.upload,media.*FromUpload,extensions.upload,profile.uploadPicture) accept aFile,Blob,ArrayBuffer, any typed array (a NodeBufferfromfs.readFileis aUint8Array),ReadableStream, or a stream factory (() => stream, sync or async). The Hub stores the upload's MIME type verbatim and derives previews, renditions, metadata extraction, and Media AI from it — aFile/Blobcarries its own type; every other source requiresoptions.mime(a genericapplication/octet-streamwould silently disable all of these). Watch aBlobfromfetch(): if the response'sContent-Typewasapplication/octet-stream(or missing),blob.typecarries that through the runtimemimeguard and stores it verbatim — pass an explicitoptions.mime(e.g. from the file extension) rather than trusting a fetched blob's type. A bare stream is sent single-attempt (it can't be re-sent, so transient/401 retries are disabled); a factory is called once per attempt and keeps the retries — prefer it whenever you can re-open the source (e.g. a file on disk). Streaming request bodies need Node (≥ 18) or a Chromium-based browser; use aBlob/ArrayBuffer/typed array where Firefox/Safari matter (memory-backed sources work everywhere and keep the retries).Uploads are exempt from the connection's
timeoutMs— an upload takes as long as its payload needs. In Node, undici's built-in stall timeouts (~5 min per phase) still catch dead connections; in a browser, abort a stuck upload yourself if you need a bound.
Editor presence (advisory locking)
Building an editing UI? entities.of(type).editors is the Hub's advisory (cooperative) locking: register your editor so other clients — including the viingx web client — warn their users ("… is currently editing this content"), and list the registrations to warn yours. It never blocks anything: writes stay guarded by optimistic versioning alone.
// On open: register this editor instance (one UUID per window/session, short expiry).
const clientId = crypto.randomUUID();
const regId = await articles.editors.register(id, {
clientId,
clientName: "Product editor",
expiry: new Date(Date.now() + 60_000),
});
// While editing: heartbeat the registration and watch for company.
setInterval(async () => {
await articles.editors.refresh(id, regId, new Date(Date.now() + 60_000));
const { editors, metadata } = await articles.editors.list(id);
const others = editors.filter((e) => e.clientId !== clientId);
// warn on `others`; `metadata.version` also reveals concurrent saves — one call covers both.
}, 30_000);
// On close:
await articles.editors.unregister(id, regId);Registrations expire on their own (a crashed client's warning disappears without cleanup), and options.locale scopes all four calls to a localization instead of the source content.
Filtering by
clientIdis right for a standalone editor (each of your windows is its own instance). Inside a viingx web-client extension, the host has already registered the current user under its ownclientId, so aclientIdfilter would warn the user about themselves — filter by the user instead (e.createdBy !== (await hub.profile.get()).id), and consider not registering at all since the host already did.
Workflow
For types whose definition declares a workflow, entities.of(type).workflow transitions the state; hub.workflow holds the cross-type queries.
// Read the current workflow state (the workflow root rides on the entity)
const post = await articles.get(id, { includeWorkflow: true });
console.log(post.workflow?.payload.state, post.workflow?.payload.assignee);
// Transition it. `version` is the WORKFLOW document's version (workflow.metadata.version) —
// not the entity version. The server enforces the type's transition/assignee rules.
await articles.workflow.update(id, {
version: post.workflow!.metadata.version,
state: "review",
assignee: userId, // required by states with `assigneeRequired`
deadline: "2026-07-15T12:00:00Z",
comment: "Ready for review", // optional, shows up in listComments
});
// Comments recorded with state changes (newest first, offset/limit paged)
const { items } = await articles.workflow.listComments(id);
// Pickers: allowed target states, and who may be assigned for one (restrictions resolved server-side)
const next = await articles.workflow.suggestStates([id]);
const people = await articles.workflow.suggestAssignees({ entityIds: [id], state: next[0], query: "ali" });
// The caller's task list: entities across ALL workflow types assigned to me.
// Sortable only by content.metadata.type, workflow.payload.state (progress), workflow.payload.deadline.
const mine = await hub.workflow.myAssignedEntities({ sort: [q.asc("workflow.payload.deadline")] });The type generic on entities.of<P, LP, S>(…) narrows update.state to the type's state names — the generated client from @viingx/content-hub-codegen wires that automatically.
Quality gates (pre-save checks)
Persisted gate results ride on reads (get(id, { includeQualityGates: true }) → pass/fail booleans). To know why a gate fails — and before saving — compute the gates for a prospective write; nothing is persisted:
const check = await articles.qualityGates.computeForCreate({ title: "…" }, { uiLocale: "en" });
for (const [gate, r] of Object.entries(check.qualityGates)) {
if (!r.satisfied) r.violations?.forEach((v) => console.log(`${gate}: ${v.entityPointer} — ${v.message}`));
}
await articles.qualityGates.computeForUpdate(id, { version, payload }); // same input as update()
await articles.qualityGates.computeForLocale(id, "de", payload); // new localization; pass {version, localizationVersion} for an existing oneviolations carry property pointers and human-readable messages (localized via uiLocale) — ideal for form validation in integrations.
Profile & account management
hub.profile is the authenticated user's own account. hub.profiles is about other users: the reads (query/get/pictureUrl) are for every authenticated user — e.g. showing names and avatars in workflow assignee pickers — scoped to the profiles visible to the caller (public profiles, their own, or all of them for user admins; invisible profiles read as not found). Only the credential operations (getCredentialStatus, setPassword, setApiKey, resetMfa, …) need elevated permission on user entities. Both ride the hub's connection.
// Your own profile
const me = await hub.profile.get();
await hub.profile.setTimeZone("Europe/Berlin");
await hub.profile.changePassword(currentPassword, newPassword);
await hub.profile.uploadPicture(imageBlob); // re-get() for the new picture id; ArrayBuffer/stream need { mime }
// Second factor (TOTP)
const setup = await hub.profile.createTotp(); // render setup.url as a QR code
await hub.profile.confirmTotp({ id: setup.id, label: "phone", code });
// Other users (any authenticated caller; visibility-scoped) — e.g. an assignee picker
const users = await hub.profiles.query("alice");
const avatarUrl = users[0].picture && hub.profiles.pictureUrl(users[0].id, users[0].picture.id);
const avatar = users[0].picture && (await hub.profiles.downloadPicture(users[0].id, users[0].picture.id));
// Admin-only: manage another user's credentials
await hub.profiles.resetMfa(userId);
await hub.profiles.setApiKey(userId, key);profile.pictureUrl(id) / profiles.pictureUrl(userId, id) build authenticated picture URLs (same caveat as files.getUrl); downloadPicture is the Download twin of each.
hub.authorization exposes read-only authorization data: userGroups() (groups and the rules they grant) and descriptors() (the catalog of grantable objects and verbs, e.g. to build a permissions UI).
const groups = await hub.authorization.userGroups();
const catalog = await hub.authorization.descriptors();Callbacks
hub.callbacks manages outbound callback (webhook) definitions — CRUD with optimistic concurrency, like hub.types. Reads omit secrets; create/update carry them.
await hub.callbacks.create({
name: "ship",
label: { en: "Ship" },
url: "https://example.com/hook",
auth: "header",
header: "x-api-key",
secret: "…", // write-only; never returned by reads
});
const { items } = await hub.callbacks.list();
await hub.callbacks.update(input, version); // full replace — `input` must include the secret again
await hub.callbacks.delete("ship", version);Secrets are write-only. Reads (
get/list) never returnsecret/clientSecret, andupdateis a full replace — so you can't read-modify-write a callback; you must re-supply the secret on everyupdate. (tscenforces this: a readCallbackhas nosecret, so it won't satisfyupdate'sCallbackInput.)To change only the label without the secret, use
setLabel(name, label)— the SDK's name for the backend's label-onlyPATCH(it preserves every other field server-side, no version check). It's deliberately not calledpatch, since it isn't a general partial update.
Implementing a callback handler
A registered callback is a URL the Hub POSTs to when a workflow restriction or quality-gate rule references it (by name), or when a story editor resolves text modules (a URL set directly in the type). The SDK ships types for the handler you host — the request the Hub sends and the response it expects — so your endpoint is typed end-to-end (no runtime, just types, like WebhookNotification):
| Handler | Request | Response |
| ------------------------------------- | --------------------------------- | ---------------------------------------------------------------- |
| Workflow state-transition restriction | WorkflowStateRestrictionRequest | WorkflowStateRestrictionResponse ({ states }) |
| Workflow assignees | WorkflowAssigneesRequest | WorkflowAssigneesResponse ({ users }) |
| Quality-gate rule | QualityGateHandlerRequest | QualityGateHandlerResult ({ status, message?, fieldHints? }) |
| Text-module source | TextModuleSourceRequest | TextModuleSource |
import type { QualityGateHandlerRequest, QualityGateHandlerResult } from "@viingx/content-hub-sdk";
app.post("/gates/price-positive", (req, res) => {
const body = req.body as QualityGateHandlerRequest;
const price = body.entity.content.payload.price; // the raw wire entity, not the projected EntityItem
const result: QualityGateHandlerResult =
typeof price === "number" && price > 0
? { status: true }
: {
status: false,
fieldHints: [
{ propertyId: "price", entityPointer: "/content/payload/price", message: "must be positive" },
],
};
res.json(result); // reply 200 + application/json, or the Hub treats the gate as failed
});The quality-gate request carries the entity as the wire wrapper (entity.content.payload, entity.content.metadata) plus the full TypeDefinition — not the projected EntityItem. Register the endpoint with hub.callbacks and reference its name from the workflow/quality-gate configuration.
Derivative definitions
hub.derivativeDefinitions manages the tenant's templates (max 10) for auto-generating file derivatives (renditions). CRUD with optimistic concurrency, like hub.callbacks. The record is a discriminated union — image (dimensions + output format) or pdf (quality):
await hub.derivativeDefinitions.create({
name: "thumb",
type: "image",
maxWidth: 200,
maxHeight: 200,
mime: "image/webp",
});
await hub.derivativeDefinitions.create({ name: "print", type: "pdf", pdfQuality: 0.9 });
const { items } = await hub.derivativeDefinitions.list();
await hub.derivativeDefinitions.update(
{ name: "thumb", type: "image", maxWidth: 300, maxHeight: 300, mime: "image/webp" },
version,
);
await hub.derivativeDefinitions.delete("thumb", version);When a file is uploaded, the server generates one derivative per definition. (tsc enforces the per-type fields — maxWidth/maxHeight/mime for images, pdfQuality for pdf.)
Webhooks
hub.webhooks manages event subscriptions — the Hub POSTs a notification to your url when an entity of entityType changes. (The route is /subscriptions; the SDK surface is webhooks.)
const id = await hub.webhooks.create({
subscriptionType: "entity",
entityType: "article",
url: "https://you.example/hook",
});
const { items } = await hub.webhooks.list();
await hub.webhooks.update(
id,
{ subscriptionType: "entity", entityType: "article", url: "https://you.example/hook2" },
version,
);
await hub.webhooks.delete(id, version);Type your receiver with the delivered payload:
import type { WebhookNotification } from "@viingx/content-hub-sdk";
function handle(body: WebhookNotification) {
for (const e of body.events) {
// e.kind: "entity" | "localization" | "auxiliary"; e.action: "created" | "updated" | "deleted"
}
}Locales
hub.locales manages the tenant's active locales — the options offered when choosing an entity source locale or a localization. The backend models this as a big catalog of records you toggle with a full-wrapper PUT; the SDK projects that to simple reads and toggles.
const active = await hub.locales.listEnabled(); // [{ code: "en", enabled: true, label: {…} }, …]
await hub.locales.enable("de");
await hub.locales.disable("fr");
const { items, total } = await hub.locales.list(); // full catalog (paginated)enable/disable/setEnabled read the current record and write the flag back with its version, so a concurrent change surfaces as a ConcurrentModification error.
Actions
hub.actions runs server-side automations. Manage the definition catalog (CRUD with optimistic concurrency, like hub.types), then invoke an action by name:
await hub.actions.definitions.create({
type: "entity",
name: "publish",
handler: "https://you.example/actions/publish",
entity: { types: ["article"], selections: ["one", "multiple"] },
});
const { items } = await hub.actions.definitions.list();
// Run it. The returned invocation is "finished" for a sync handler, "executing" for an async one.
const { invocation } = await hub.actions.invoke("publish", { entities: { article: ["e1"] } });
const mine = await hub.actions.invocations.listSelf();
await hub.actions.invocations.delete(invocation.id);Building the handler the Hub calls? There are two styles. A synchronous handler does the work inline and returns an ActionFinalization as its HTTP response:
import { type ActionFinalization, type ActionHandlerRequest } from "@viingx/content-hub-sdk";
app.post("/actions/publish", (req, res) => {
const request = req.body as ActionHandlerRequest;
const ids = request.invocation.parameters.entities?.article ?? []; // entity action → selected ids
const result: ActionFinalization =
ids.length > 0
? { status: "success", message: `Published ${ids.length}.` }
: { status: "failure", message: "Nothing selected." };
res.json(result);
});An asynchronous handler acks immediately, then reports progress and finalizes out-of-band via actionHandler — pass the incoming request's invocation straight in (it carries the hostUrl, id, and per-invocation token):
import { actionHandler, type ActionHandlerRequest } from "@viingx/content-hub-sdk";
app.post("/actions/export", async (req, res) => {
const request = req.body as ActionHandlerRequest;
res.sendStatus(204); // ack now; finish asynchronously
const handler = actionHandler(request.invocation);
await handler.reportProgress({ message: "Exporting…", percent: 0.5 });
await handler.succeed({ download: "https://you.example/out.zip" }); // or handler.fail("…")
});To act as the invoking user instead of with your own credentials: when the action definition enables transferSession, the request additionally carries a userSessionToken — pass it to tokenConnection and the handler's Hub calls run with that user's permissions.
Pages
hub.pages manages standalone, versioned page/dashboard documents (built in the page builder). CRUD with optimistic concurrency; create returns a server-assigned id.
const id = await hub.pages.create({
name: "dashboard",
label: { en: "Dashboard" },
icon: "fluent-ui:Home",
form: { elements: [{ type: "text", text: { en: "Welcome" } }] },
});
const page = await hub.pages.get(id);
const { items } = await hub.pages.list();
const v2 = await hub.pages.get(id, { version: 2 }); // a historical version
await hub.pages.update(id, { ...page.page, label: { en: "Home" } }, page.version);
await hub.pages.delete(id, page.version);The form layout tree is editor-owned (the page builder's schema), so the SDK keeps elements open — type is the stable discriminator and the rest round-trips as-is. That makes reading, deleting, and exporting/importing pages straightforward; hand-authoring a rich layout means filling in element fields the SDK doesn't statically check. A referenced file's bytes live at an authenticated URL, with a Download twin:
// Page file download
const url = hub.pages.fileUrl(id, fileId); // returns an authenticated URL — not public/signed
const download = await hub.pages.downloadFile(id, fileId);Share links
hub.shareLinks creates public, token-addressed file-download links — the link id is the access token, so the returned link URL needs no credential to open. Links are immutable: there's no update, and delete takes no version (recreate to change one).
const link = await hub.shareLinks.create({
type: "download",
label: "Q3 brochure",
fileName: "brochure.zip",
entries: [{ path: "brochure.png", type: "article", entityId, version, fileId }],
});
console.log(link.link); // public URL to hand out — no auth needed to download
const { items } = await hub.shareLinks.list();
await hub.shareLinks.delete(link.id); // revokeA dynamicDownload link resolves files from a query-driven hierarchy at download time (reusing the same QueryFilter expression as entities.query):
await hub.shareLinks.create({
type: "dynamicDownload",
label: "Featured assets",
fileName: "featured.zip",
fileNameStrategy: "original",
hierarchy: [
{
type: "entity",
entityType: "article",
query: q.eq("content.payload.featured", true),
properties: [{ type: "file", propertyId: "image" }],
},
],
});Settings
hub.settings has three areas: tenant system config, the caller's own user preferences, and branding. (These are separate from hub.profile, which owns your account — UI locale, time zone, password, picture, 2FA.)
// System settings — tenant-wide, admin-gated, optimistic concurrency.
const { setting, version } = await hub.settings.system.get();
await hub.settings.system.update({ ...setting, notifications: { workflow: true } }, version);
// Settings file download (e.g. a logo referenced from the config)
const fileUrl = hub.settings.system.fileUrl(fileId); // returns an authenticated URL
const fileDownload = await hub.settings.system.downloadFile(fileId);
// User settings — your own preferences. Each setter returns the updated settings.
const me = await hub.settings.user.get();
await hub.settings.user.setWorkflowNotifications(false);
await hub.settings.user.setDefaultContentSourceLocale("de"); // call with no arg to clearThree "locale" notions, don't conflate them:
hub.profile.setLocaleis your UI language;settings.user.setDefaultContentSourceLocaleis your default source locale for authoring; andcreateContentHub({ defaultLocale })is a client-side create fallback that stores nothing server-side.
Branding is public — fetch it without a credential for login-screen theming, or over the connection:
import { branding } from "@viingx/content-hub-sdk";
const brand = await branding("https://hub.example.com"); // pre-auth, no credential
const sameOverHub = await hub.settings.branding(); // authenticated
// brand.logoUrl is ready for <img src> — a public absolute URL or a data: URI, server-resolved.
// Use it as-is; there is deliberately no logo URL builder.The system config tree is mirrored faithfully (so it round-trips for config transport); saved-search sorting/filters are kept open (editor-owned UI state), so round-tripping them preserves fields the SDK doesn't statically check.
Bulk download (zip)
hub.zip(entries) bundles multiple API responses into a single zip. Each entry names a relative /api/v1.0/... source to fetch (with your credential) and the target path it gets inside the archive — synchronous, no job to poll. (For a persistent, shareable bundle, use hub.shareLinks instead.)
It returns a Download: the response metadata (mime, length?, filename?, headers) plus the body, read exactly once via blob() / arrayBuffer() (buffered) or stream() (a web ReadableStream, for large archives) — or released unread via cancel() if you only wanted the metadata. It resolves once the response headers arrive — the transport's timeout, transient retries, and re-auth all apply up to that point; a failure mid-body errors the stream / rejects the buffered accessors (not retried).
const download = await hub.zip([
{ source: `/api/v1.0/types/article/entities/${id}/files/${fileId}`, target: "images/cover.png" },
{ source: `/api/v1.0/types/article/entities/${id}`, target: "article.json" },
]);
// browser: const url = URL.createObjectURL(await download.blob());
// Node: await writeFile("bundle.zip", Buffer.from(await download.arrayBuffer()));// Node: pipe a large archive to a file without buffering
import { Readable } from "node:stream";
import { createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import type { ReadableStream as NodeWebStream } from "node:stream/web";
const download = await hub.zip(entries);
// The cast bridges the DOM and Node declarations of ReadableStream (recent @types/node rejects
// the DOM one) — at runtime they are the same object.
await pipeline(Readable.fromWeb(download.stream() as NodeWebStream<Uint8Array>), createWriteStream("bundle.zip"));source must start with /api/v1.0/; the Hub fetches each one server-side, so the same permission checks apply per entry.
Extensions
hub.extensions provisions web-client UI plugins, each registered as a zip bundle (an extension.json descriptor + assets) and keyed by the descriptor's name. upload is an upsert — it creates a new extension or replaces the one with that name. Useful for deployment and config transport (move a bundle p→q→prod).
const ext = await hub.extensions.upload(zipBlob); // zip with extension.json at its root
const { items } = await hub.extensions.list();
const bundle = await hub.extensions.export(ext.name); // the zip bundle, for backup/transport
await hub.extensions.delete(ext.name);This is the Hub's extension management API. Extensions are run inside the web client, which serves and instantiates their files itself — the SDK doesn't expose that (nor is it the @viingx/extension-sdk toolkit for building extensions).
Audit log
hub.audit.query(...) reads the append-only, tenant-global audit log. It's read-only (events are written by the system), and results are filtered to the event types you have permission to read. Filtering reuses the same QueryFilter/q as entity queries — there's no dedicated date param, so filter on created:
import { q } from "@viingx/content-hub-sdk";
const { events, total } = await hub.audit.query({
where: q.and(q.eq("type", "download"), q.gt("created", "2026-01-01T00:00:00Z")),
sort: [q.desc("created")],
paging: { offset: 0, limit: 100 },
});
for (const e of events) {
if (e.type === "download") console.log(e.createdBy, "downloaded", e.file); // narrows by `type`
}Event records are discriminated by type (download / login / logout / setCredentials / tenant), each with common id / created / createdBy / authorizedBy.
Media (DAM)
hub.media adds the upload-driven operations of the systemMedia digital-asset type — beyond the generic CRUD you still do via hub.entities.of("systemMedia").
// Create an asset from a file (browser File or a Blob), with optional AI processing.
// The upload's MIME type becomes the asset's stored type and drives previews, metadata
// extraction, and Media AI — a File/Blob carries it; any other source requires options.mime:
const id = await hub.media.createFromUpload(file, { name: "cover.png", aiTags: true, aiDescription: true });
const id2 = await hub.media.createFromUpload(await fs.readFile("cover.png"), { name: "cover.png", mime: "image/png" });
await hub.media.updateFromUpload(id, newFile); // replace its file
// Preview the AI-suggested metadata WITHOUT creating an entity:
const suggestion = await hub.media.suggestFromUpload(file, { aiTags: true });
// suggestion is generic — pass your generated systemMedia type for full typing:
// await hub.media.suggestFromUpload<SystemMedia>(file)
// Crop download
const url = hub.media.cropUrl(id, "crop16to9"); // returns an authenticated URL
const crop = await hub.media.downloadCrop(id, "crop16to9", { width: 800, mime: "image/webp" });The suggested-metadata payload is the vendor-fixed systemMedia shape (~80 system* fields). The SDK leaves it generic — supply your generated systemMedia type as the type parameter for full typing; otherwise it's an open record.
Layouts (Adobe InDesign)
hub.layouts exports systemLayouts entities for the Adobe InDesign integration — a layout plus the media/text it links to. (Generic reads/writes stay on hub.entities.of("systemLayouts").)
const manifest = await hub.layouts.exportInfo(layoutId); // { layout, content: [...] } — what the zip would contain
// Layout export download (the layout + linked files as a zip)
const download = await hub.layouts.export(layoutId);
const old = await hub.layouts.export(layoutId, { version: 3 }); // a historical versionGraphQL
hub.graphql is a thin passthrough to the Hub's read-only GraphQL endpoint — handy for fetching nested/related data in one round-trip. You write the query and supply the result type; it returns data and throws a GraphQLError (carrying errors) if the response has any.
const data = await hub.graphql<{ entities: { article: { query: { total: number } } } }>(
`query Featured($q: String) {
entities { article { query(query: $q) { total result { id } } } }
}`,
{ q: '{"op":"=","property":"content.payload.featured","value":true}' },
);The schema is read-only and generated per tenant from your type definitions (each content type becomes a GraphQL type), so it differs per tenant — explore it via introspection or the GraphiQL UI at the same /graphql path. Writes go through the REST APIs above. Because the schema is per-tenant, the SDK doesn't ship static GraphQL types; typed GraphQL is a job for the code generator — snapshot the schema with @viingx/content-hub-codegen's schema pull, then point @graphql-codegen / gql.tada at the SDL.
Signed URLs
signUrl mints a time-limited, credential-free URL for any Hub endpoint — computed locally (no round-trip; same signature as POST /auth/signURLs). Share the result with third parties, or use it where you can't send auth headers (<img src>, <a href>, webhook targets). Compose it with any URL builder (files.getUrl, previewUrl, media.cropUrl, pages.fileUrl, settings.system.fileUrl, pictureUrl, …) or a hand-built API URL — the URL must be absolute (build the connection from an absolute origin).
import { signUrl } from "@viingx/content-hub-sdk";
// The API-key credential needs the key owner's user id — fetch it ONCE per connection and cache
// it (it's stable for the key's lifetime). Signing itself is local; keep it that way — don't
// re-fetch the profile per signed URL.
const userId = (await hub.profile.get()).id;
const fileUrl = hub.entities.of("article").files.getUrl(id, post.payload.attachment);
const shareable = await signUrl(
fileUrl,
{ userId, apiKey }, // or { sessionId, sessionKey } / { token } — see below
{ expires: new Date(Date.now() + 60 * 60 * 1000), method: "GET" },
);
// → https://…?…&authMethod=GET&authUser=…&authCreated=…&authExpires=…&authSignature=VII-SHA256:…Credentials (UrlSigningCredential):
{ userId, apiKey }— signs as the API-key user; the URL works untilexpires(or key rotation). SSO users have no API key. TheuserIdis the key owner's account id — obtain it viahub.profile.get()once and reuse it. (A session credential below needs no user id at all — the trade-off is that its URLs die with the session, while key-signed URLs live untilexpires; prefer key-signing for long-cacheable embeds.){ sessionId, sessionKey }— both are on theSessionreturned bylogin. The URL dies with the session (logout/idle timeout), even beforeexpires.{ token }— a session token (e.g. handed over by a host application); decoded locally. Same session-bound lifetime. JWTs cannot sign URLs.
Omit method to allow any method the signer may use; restrict it (recommended) to reduce misuse. HEAD is implicitly allowed where GET is.
Displaying images in a web app (galleries, <img src>)
The classic task-app case: a browser UI needs to show product pictures, but <img src> resource loads cannot carry an Authorization header — that's a browser-platform limit, not a Hub one. Pick by where the app is hosted (a web-client extension is always the signed-URL case — its sandboxed iframe has an opaque origin, so the session cookie never attaches; sign with the handed-over session { token } from the host's getAuthToken()):
Same site as the Hub (served from the Hub origin, or any host under the same registrable domain): use cookie auth — the simplest and best option. Login sets a session cookie (SameSite=Strict, HttpOnly), and the Hub accepts it on safe methods (GET/HEAD — writes still require the token header, which is the CSRF guard). After login, a plain <img src> with a getUrl/getDerivativeUrl URL just works: long-cacheable, per-user permissions on every request, and it stops at logout. One subtlety for same-site-but-cross-origin apps (app.example.com → hub.example.com): fetch defaults to credentials: "same-origin", so give the connection a fetch that opts in, or the login response's cookie is ignored:
const withCookies: typeof fetch = (input, init) => fetch(input, { ...init, credentials: "include" });
const hub = createContentHub(
tokenConnection(baseUrl, () => token, { fetch: withCookies }),
{ defaultLocale: "en" },
);Cross-site (the app on its own domain): the browser will never attach the Strict cookie to cross-site image loads (and SameSite=None dies to third-party-cookie blocking in Safari/Firefox), so the user's credentials must travel in the URL — and doing that safely is exactly what signUrl is: a scoped, expiring, method-restricted signature derived from the credential (never the raw token — it must not leak into URLs, logs, or referrers).
When the app's users sign in with their viingx credentials (the browser holds a session), the browser signs its own image URLs — locally, with the user's token, no backend and no service account involved:
// In the browser — the user logged in with their viingx credentials:
const imageUrl = await signUrl(
hub.products.files.getDerivativeUrl(id, image, "jpegPreview", { derivativeId }),
{ token }, // the user's own session token (from login / the host app)
{ window: 3600, method: "GET" }, // cache-stable across re-renders
);This inherits the right security properties automatically: the Hub resolves the session on every image request, so the user's own permissions gate each file — a user can only mint working URLs for content they may read — and all their URLs stop working when the session ends (logout/idle). CORS is open, so this works from any app origin.
When end users are not Hub users, have your app's backend sign with the API key instead — same call, server-side, still zero Hub round-trips:
// In the task app's backend (holds the Hub API key — never ship it to the browser):
const products = await hub.products.query({...});
const posters = products.items.map(async (p) => ({
id: p.id,
title: info.title(p),
imageUrl: await signUrl(
hub.products.files.getDerivativeUrl(p.id, p.payload.image, "jpegPreview", {
derivativeId: derivatives[p.id]?.jpegPreview?.content?.id, // stable + long-cacheable
}),
{ userId, apiKey },
{ window: 3600, method: "GET" }, // cache-stable: same URL for the whole window
),
}));window: 3600 pins the signature to hour boundaries: re-rendering re-signs to the identical URL (so the browser cache works), validity is always 1–2 windows, and URLs roll over naturally — no expiry bookkeeping. If the browser user is a Hub user, the same works client-side with the session { token } credential instead. The signature covers the exact URL — sign the URL precisely as it will be requested (same origin, path, and query). One quirk: collection root paths (…/api/v1.0/types, …/pages, …) must be signed with a trailing slash (…/types/) — the Hub reconstructs router roots that way during verification. Specific resources and file URLs are unaffected.
Labels & titles (display helpers)
Render entities the way the web client does — localized labels for types, properties, option values, workflow states, and gates, plus entity-title resolution. Labels are tenant configuration (admins edit them at runtime), so fetch the current definition via info() rather than baking labels in:
import { localize } from "@viingx/content-hub-sdk";
const info = await articles.info(); // TypeInfo over the CURRENT type definition
info.label("de"); // "Artikel" — type display name
info.title(item); // entity title (ui-configured label property; → id)
info.property("viingxMedia")?.label("de"); // "Medien" — property names autocomplete from the payload type
info.property("size")?.optionLabel("s", "de"); // listString/listColor option label
info.propertyById(violation.propertyId)?.label("de"); // quality-gate violation → human label
info.stateLabel("inReview", "de"); // pairs with workflow.suggestStates
localize(page.label, "de"); // the raw primitive for any Localized<T> valueFallback order everywhere: exact locale → sublocale stripping (de_CH → de) → en → any → the stated fallback (property/state/gate name, raw value, entity id). The offline twin typeInfo(definition) builds the same view from a committed definition.
Permission hints
evaluatePermission is a client-side check over a session's authorization rules — useful for optimistic UI (show/hide an action) without a round-trip. It is not a security boundary: the Hub always enforces permissions, and there is no server permission-eval endpoint to defer to.
import { evaluatePermission, login } from "@viingx/content-hub-sdk";
const res = await login(url, creds);
if (res.status === "authenticated") {
const decision = evaluatePermission(res.session.rules, "update", "entity/article");
// "allowed" → a rule grants it outright
// "denied" → no rule grants it
// "conditional" → a matching rule is data-gated; only the server can decide — show optimistically
if (decision !== "denied") enableEditButton();
}object is the wire object string — e.g. "entity/article", "entity/*", "type/article", "page/<id>", or "*"; verb is "read" / "update" / "create" / "delete". Because it can't evaluate data-dependent conditions (those need the target entity, which only the server has), it returns "conditional" rather than guess — treat that as "probably, let the server confirm".
Server info
info(baseUrl) is a public version/capability probe — no credential needed (handy for a pre-auth version/compatibility check). hub.info() hits the same endpoint over your connection and additionally returns this session's features and integrations.
import { info } from "@viingx/content-hub-sdk";
const { productVersion, apiVersion } = await info("https://hub.example.com"); // pre-auth
const full = await hub.info(); // authenticated → also `features` / `integrations`License
Apache-2.0 — © viingx AG
