npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-api

amazon-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 ./auth

Entry 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 the updatedAt you loaded; a mismatch is a 409 STALE_WRITE carrying currentUpdatedAt so you can re-read and retry.
  • respectLock — refuse the write if anyone holds the edit lock (409 LOCK_HELD). On publishContent this refuses even when you hold it.
  • idempotencyKey (with sessionId) — 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.