@bettercms-ai/component-output
v0.2.1
Published
Verify BetterCMS component preview sessions and run the preview bridge in your app.
Readme
@bettercms-ai/component-output
Render your own components inside the BetterCMS dashboard, with live content, without giving BetterCMS your code or taking our word for anything.
This package is the provider half of bcms-component-preview/2. You write one route; everything
below the route is here.
Why this exists
The contract asks you to verify a signed session before rendering. In v1 that session was signed with an HMAC keyed by BetterCMS's own auth secret — verifying it would have meant holding the key that mints BetterCMS logins. Nobody could, so nobody did, and no component ever rendered.
v2 signs with Ed25519 and publishes the public key. Verification now needs nothing secret. But it is
still a list of things to get exactly right — four token segments, a JWKS fetch matched on kid, a
signature over specific bytes, then five claim checks — and both ways of getting it wrong are invisible
from outside: refuse every session, or accept a forged one. So the platform that defines the format
ships the check.
Zero dependencies. WebCrypto only. Node 18+, Bun, Deno, edge runtimes, browsers.
Your route
Two halves. The server verifies; the client renders what arrives.
// ── server ──────────────────────────────────────────────────────────────────
import { verifyComponentSession } from "@bettercms-ai/component-output";
const result = await verifyComponentSession({
token: url.searchParams.get("bcmsSession"),
jwksUrl: `${process.env.BCMS_API_ORIGIN}/.well-known/bcms-component-output.json`,
componentId, // from YOUR route, never from the token
expectedOrigin: url.origin,
});
if (!result.ok) {
// Log the reason; do not return it. "Which check failed" is a probing oracle.
console.warn(`[bcms] refused: ${result.reason} componentId=${componentId}`);
// …but DO tell the dashboard you refused, or it reports "your route did not answer". Serve a 404
// page that calls reportComponentRefusal (below) — it sends only a two-value cause, never the reason.
return notFoundPageReporting(componentRefusalCause(result.reason));
}
// ── client ──────────────────────────────────────────────────────────────────
import { createComponentPreviewBridge } from "@bettercms-ai/component-output/bridge";
const bridge = createComponentPreviewBridge({
claims: result.claims,
dashboardOrigin: process.env.BCMS_DASHBOARD_ORIGIN, // config, never document.referrer
onProps: setProps,
onStatus: setStatus,
});
// bridge.dispose() on unmount
return props ? <YourComponent {...props} /> : null;Render nothing until props arrive. Rendering with defaults first puts a plausible-looking approximation on screen, which is the one thing this whole protocol exists to prevent.
Refusing without going silent
A plain 404 makes the dashboard wait five seconds and blame your route. On the page you serve for a refused session:
import { reportComponentRefusal } from "@bettercms-ai/component-output/bridge";
reportComponentRefusal({ dashboardOrigin: process.env.BCMS_DASHBOARD_ORIGIN, cause });cause is keys-unreachable when you could not fetch the JWKS — almost always a wrong
BCMS_API_ORIGIN — and session-refused for everything else. Only those two ever leave your server:
the page body is public, and the specific reason there would help someone forge tokens. See
example/refusal.ts.
The route path
BetterCMS builds the iframe URL from the route you declared for the component, under
/__bettercms/component-preview/. Serve exactly that path.
If your router treats a leading underscore as a pathless layout — TanStack does, and strips one — the
segment will silently vanish from your URL and the frame will 404 on a path that looks correct in your
source. Escape it however your router documents ([_][_]bettercms… for TanStack).
Framing: the failure you will hit first
Your route is rendered inside an iframe on the BetterCMS dashboard. Most server frameworks forbid
that by default — helmet, Rails, Django and Laravel all send X-Frame-Options: SAMEORIGIN — and the
browser then refuses to render your page. It does not look like a framing error: the frame shows the
browser's own "refused to connect" page, the handshake times out, and the dashboard reports that your
route did not answer.
On the preview route, and only there, send:
Content-Security-Policy: frame-ancestors https://<your-bettercms-dashboard>frame-ancestors supersedes X-Frame-Options in every current browser. Remove X-Frame-Options from
this one route if your framework adds it globally, and do not relax it anywhere else.
Which component to render: claims.familyKey
Every session names the component family it is for in result.claims.familyKey. Map that to your own
component:
const components = { "<familyKey>": Navigation };
const Component = components[result.claims.familyKey];
if (!Component) return notFound(); // a family you never agreed to drawThe value is BetterCMS's identifier for the family, and it is per project — a staging project and its production copy have different keys. The dashboard shows the exact value beside the route when you declare it, so copy it from there rather than guessing.
The two claims
result.claims.claim is "preview" or "validated", and it is inside the signature.
validated— an evidence chain pinned to a commit was verified. The claims carry the full tuple:commitSha,evidenceId,evidenceDigest,familyManifestHashand the rest.preview— a route was declared and the props are live CMS values. Nothing has checked the code at that route. The eight evidence fields are absent, not empty, and a preview carrying any of them is rejected by this package rather than interpreted. A blankcommitShais not a commit.
Do not present a preview as verified. Do surface it honestly and without alarm: it is the normal state
of a component nobody has validated yet, which on an imported project is every component.
Configuration
| Variable | What |
|---|---|
| BCMS_API_ORIGIN | Where the JWKS is published. Production is https://api.bettercms.ai. |
| BCMS_DASHBOARD_ORIGIN | The origin allowed to embed you and receive the handshake. |
Give these no defaults. A wrong default does not fail where you set it — it fails at the far end as
JWKS_UNAVAILABLE, then a 404, then a blank frame in somebody else's UI.
Key rotation and revocation
The JWKS is cached: an outage at BetterCMS must not stop your page rendering, which is the entire
argument for signature verification over an introspection endpoint. An unknown kid triggers one
refetch, so rotation needs no redeploy, and cached keys expire after an hour so a revoked key does not
outlive a long-running process.
JWKS_UNAVAILABLE means retry later. KEY_NOT_FOUND means this token will never be accepted. They are
deliberately different.
