@headroom-cms/admin-api
v0.9.0
Published
<!-- The four ```ts check blocks below are TYPE-CHECKED against this package's built dist/*.d.ts by `pnpm test:readme`, which `prepublishOnly` runs. They must stand alone as modules: no `process` (the snippet program has no node types), no undecla
Readme
@headroom-cms/admin-api
The admin client for a Headroom CMS installation: a typed wrapper over the
JWT-authenticated /v1/admin/* API, with full read/write access to sites,
content, media, collections, webhooks, API keys and users.
Which package do I want?
This is the one thing to get right before reading further. Headroom publishes two clients and they are not alternatives — they address different APIs, with different credentials and different data.
| | @headroom-cms/admin-api (this package) | @headroom-cms/api |
|---|---|---|
| API | /v1/admin/* | /v1/{site}/* |
| Credential | Cognito JWT, or an hrt_… admin API token | site API key (X-Headroom-Key) |
| Sees | drafts, unpublished items, trash, every site | published content only, one site |
| Can | create, update, publish, delete | read |
| Built for | the admin UI, the CLI, migration and seeding scripts, agents | your website or app |
If you are rendering a website, you want @headroom-cms/api. Reach for this
package when you are writing something that administers a Headroom install.
Never ship this client to a browser you do not control: an admin credential reaches every site the bound administrator can reach.
Installation
npm install @headroom-cms/admin-apiamazon-cognito-identity-js is an optional peer dependency. Install it only
if you use the ./auth entry point's username/password sign-in; the rest of the
package works without it.
npm install amazon-cognito-identity-js # only for ./authEntry points
| Import | Contains | Runtime |
|---|---|---|
| @headroom-cms/admin-api | HeadroomAdminClient, HeadroomApiError, StaticTokenProvider, block builders, all types | anywhere fetch exists |
| @headroom-cms/admin-api/auth | authenticate() (Cognito SRP), loadSSTConfig() | Node — reads the filesystem, needs the optional peer |
| @headroom-cms/admin-api/node | uploadFile() — upload from a local path | Node only (fs/promises) |
| @headroom-cms/admin-api/seed | idempotent helpers for seeding a site from a script | anywhere |
Authentication
The client never handles credentials itself. It takes a TokenProvider and
calls it before every request:
interface TokenProvider {
getToken(): Promise<string>;
onUnauthorized?(): Promise<string>;
}getToken supplies the bearer. onUnauthorized is optional: when a request
comes back 401 the client calls it once, and retries with whatever token it
returns. Omit it and a 401 surfaces as a HeadroomApiError instead.
An admin API token (recommended for unattended callers)
CI, cron jobs and agents should use a durable hrt_… admin API token. It needs
no pool configuration and no password, and because it does not expire on its own
there is nothing to refresh — StaticTokenProvider is the whole story.
import { HeadroomAdminClient, StaticTokenProvider } from "@headroom-cms/admin-api";
// In practice: process.env.HEADROOM_API_URL / process.env.HEADROOM_ADMIN_TOKEN
declare const apiUrl: string;
declare const adminToken: string;
const client = new HeadroomAdminClient(apiUrl, new StaticTokenProvider(adminToken));
const sites = await client.listSites();
void sites;Mint one with headroom tokens create --label ci. A token carries exactly what
its bound administrator carries, minus the MFA prompt — treat it as that
person's password. The full recipe — and the containment runbook for a leaked
token, which is six steps and not optional — is in the @headroom-cms/cli
package's own README, §§ "Scripted / unattended access" and "If a token leaks:
containment". (Read it there, not from a monorepo path: an earlier revision of
this file cited packages/cli/README.md, which does not resolve for anyone who
installed this package from npm.)
Cognito username and password
For local scripts against a stack you own. Requires the optional peer dependency.
import { HeadroomAdminClient, StaticTokenProvider } from "@headroom-cms/admin-api";
import { authenticate, loadSSTConfig } from "@headroom-cms/admin-api/auth";
const cfg = await loadSSTConfig(); // reads .sst/outputs.json
const jwt = await authenticate({
userPoolId: cfg.userPoolId,
clientId: cfg.clientId,
username: process.env.ADMIN_EMAIL!,
password: process.env.ADMIN_PASSWORD!,
});
const client = new HeadroomAdminClient(cfg.apiUrl, new StaticTokenProvider(jwt));Point the client at cfg.apiUrl (the api output — the uncached Lambda origin),
not cfg.apiCdn, which is the edge-authenticated cache for public reads.
A refreshing provider
Anything holding a short-lived Cognito JWT should implement both methods so an expiry is recovered from rather than thrown:
const provider = {
async getToken() {
return session.accessToken;
},
async onUnauthorized() {
session = await refreshSession();
return session.accessToken;
},
};Scope: the host argument
The admin API is site-partitioned, and nearly every method takes the site's host
as its first argument. An id belonging to a different site reads as not
found, never as forbidden — uniformly, so a 404 never confirms that
something exists elsewhere.
The exceptions are the genuinely global routes: listSites, getAdminVersion,
and the Cognito-administrator methods (listUsers, deleteUser,
disableUserMfa, resolveAdmins, listSuperAdmins, updateSuperAdmins).
Authorization comes in three tiers — site editor, site admin, and
super-admin. Each method's own TSDoc names the tier that applies to it, and
that is the authoritative statement; hover the method in your editor, or read
the HeadroomAdminClient class block for the summary. This README deliberately
does not restate the per-tier route lists: it was maintained as a third copy for
one release and drifted from both others in that time, granting an editor
collection and block-type writes that are in fact admin-only.
One exception is worth stating here because a summary invites the wrong
inference: resolveAdmins is not super-admin gated, despite sitting beside
the Cognito-administrator methods above. It carries no authorization check of
any kind beyond a valid admin JWT or hrt_… token — any admin credential,
including a site-scoped editor on one unrelated site, can resolve Cognito subs
to administrator names and emails, 50 per call. Its own TSDoc says so; factor
that into who you issue admin API tokens to.
Falling short gives 403; no valid credential gives 401.
The content lifecycle
Content moves through three distinct states, and nothing you write is publicly readable until you publish it.
import { HeadroomAdminClient, StaticTokenProvider } from "@headroom-cms/admin-api";
declare const apiUrl: string;
declare const adminToken: string;
const client = new HeadroomAdminClient(apiUrl, new StaticTokenProvider(adminToken));
const host = "example.com";
// 1. Create the row. This creates a DRAFT — never a published item.
const { contentId } = await client.createContent(host, {
collection: "posts",
params: { title: "Hello", slug: "hello" },
});
// 2. Overwrite the working draft. There is exactly ONE per item, so
// successive saves replace each other rather than accumulating.
// `updatedAt` is Unix MILLISECONDS.
const { updatedAt } = await client.saveDraft(host, contentId, {
title: "Hello, world",
body: { intro: "First paragraph." },
});
// 3. Make it live. Only now is it visible to @headroom-cms/api.
await client.publishContent(host, contentId);
// Reading it back: current values live on the DRAFT, not on `content.fields`.
const detail = await client.getContent(host, contentId);
const current = detail.draft?.body;
void updatedAt;
void current;Three things about this flow that surprise people:
Read current values from draft.body, not content.fields.
content.fields is the publish-time snapshot: empty for a never-published
item, stale for the whole duration of an editing session, and even after a
publish it holds only nine short field types — media among them, arriving as a
resolved object, so "short" rather than "scalar" — plus the flattened short
sub-fields of any container, with text / url / email truncated at 200
bytes. getContent's own doc block lists the set. For the full published
body use getPublishedContent.
draft is effectively always present. The server materializes a working
draft on read, so its presence is not evidence that unpublished edits exist.
Compare publishedBlockId against the draft's blockId, or filter
listContent with status: "changed".
required is enforced at publish, not at save. A draft may legitimately be
incomplete; publishContent is where a 400 VALIDATION_FAILED appears.
Concurrency
Guards are opt-in and travel in the request body rather than in HTTP headers:
expectedUpdatedAt— pass theupdatedAtyou loaded; a mismatch is a409 STALE_WRITEcarryingcurrentUpdatedAtso you can re-read and retry.respectLock— refuse the write if anyone holds the edit lock (409 LOCK_HELD). OnpublishContentthis refuses even when you hold it.idempotencyKey(withsessionId) — a repeat within 15 minutes returns the original result instead of writing again.
Version history is not written on an ordinary save. Pass
createVersion: true for a retained snapshot; see listVersions.
Uploading media
Bytes never travel through this client. An upload is three steps, and the library entry does not exist until the third one completes.
import { HeadroomAdminClient, StaticTokenProvider } from "@headroom-cms/admin-api";
declare const file: File;
declare const apiUrl: string;
declare const adminToken: string;
const client = new HeadroomAdminClient(apiUrl, new StaticTokenProvider(adminToken));
const host = "example.com";
// 1. Reserve an id and a presigned location. Nothing is recorded yet.
const { uploadUrl, mediaId } = await client.getUploadUrl(host, {
filename: file.name,
contentType: file.type,
size: file.size, // must be the EXACT byte length — it is signed into the URL
});
// 2. PUT the bytes straight to storage. No Authorization header: the URL
// carries its own signature, and it is valid for 15 minutes.
await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
// 3. Register it. Until this resolves nothing appears in the library.
const item = await client.completeUpload(host, mediaId, {
filename: file.name, // SAME extension as step 1 — the key is derived from it
alt: "A description",
});
void item;An abandoned upload is a permanent cost. If step 3 never runs, the bytes
stay in storage indefinitely — unreferenced, invisible to every API, and
reclaimed by nothing. Prefer re-driving a failed flow over retrying with a fresh
getUploadUrl.
Two shortcuts exist for the cases this flow is awkward for:
client.uploadFromUrl(host, url, …)— the server fetches the bytes itself. All three steps in one call, when you already have a URL.uploadFile(client, host, "./cover.jpg")from@headroom-cms/admin-api/node— the same three steps for a local file path. Node only.
Error handling
Every method throws HeadroomApiError on a non-2xx response. Branch on status
or code — never on message, which is server-authored prose with no stability
guarantee.
import { HeadroomAdminClient, HeadroomApiError, StaticTokenProvider } from "@headroom-cms/admin-api";
declare const apiUrl: string;
declare const adminToken: string;
const client = new HeadroomAdminClient(apiUrl, new StaticTokenProvider(adminToken));
try {
await client.publishContent("example.com", "01J000000000000000000000");
} catch (e) {
if (!(e instanceof HeadroomApiError)) throw e;
switch (e.code) {
case "LOCK_HELD":
// 409 — someone is editing. e.body names the holder.
break;
case "VALIDATION_FAILED":
// 400 — e.body.details names each offending field path.
break;
case "NO_DRAFT":
// 400 — nothing to publish. An unknown id also lands here, not on 404.
break;
default:
throw e;
}
const retryAfter = e.headers?.get("Retry-After"); // 429 puts it in a header
void retryAfter;
}The statuses worth handling:
| Status | Meaning |
|---|---|
| 401 | Expired or missing credential. Handled for you if the token provider implements onUnauthorized. |
| 403 | Authenticated but not permitted — typically an editor on an admin-only route. |
| 404 | Absent, or belonging to another site. The two are deliberately indistinguishable. |
| 409 | Conflict: a name in use, a stale write, a held edit lock. Read code — status alone is not specific enough. |
| 413 | Body too large (~350 KB compressed) on a draft save or create. |
| 429 | Rate limited. The wait is in the Retry-After header, not the body. |
e.body carries whatever context the endpoint added — a lockHolder on a
publish conflict, per-field detail on a rejected write. It is untyped by design.
No route in this API answers 423; a locked resource is a 409.
Escape hatch
Not every route has a typed wrapper — the newsletter endpoints and the trash
routes (GET/DELETE /v1/admin/sites/{host}/trash, and the per-item restore),
for instance, are reachable only this way despite trash appearing in the
capability table above. apiFetch is public for exactly this:
const sends = await client.apiFetch<{ items: unknown[] }>(
`/v1/admin/sites/${host}/newsletters`,
);It handles auth, refresh-on-401 and error mapping identically to the typed methods. Prefer a typed method where one exists.
Types
Every response and parameter shape is exported from the root entry point.
Schema types (Site, Collection, ContentItem, MediaItem, Webhook,
AuditEvent, …) are generated from the Go OpenAPI spec and carry their field
documentation into your editor's hover.
import type { Site, Collection, ContentItem, PaginatedResponse } from "@headroom-cms/admin-api";Also exported: block builders (paragraph, heading, image, …) for
constructing rich-text bodies, and the @headroom-cms/admin-api/seed helpers
(ensureSite, ensureCollections, upsertContent, ensureApiKey, …) for
idempotent seeding scripts.
Related
@headroom-cms/api— the public content SDK for your website.@headroom-cms/cli—headroom, which is built on this package.
