@ticketlayer/backstage-elements
v0.2.2
Published
Ticketlayer Backstage Elements - embeddable organiser-facing widgets for partner admin surfaces, built with Stencil
Readme
@ticketlayer/backstage-elements
Backstage Elements are the organiser-facing component layer: the orders list
with refund actions, an event's tickets and ticket types, the station's device
selector, the seat map designer. They are the admin-side counterpart of
@ticketlayer/elements
(the buyer-facing components).
They serve two kinds of host, from one source:
- Partner admin surfaces - Rowbot Manage, the Webflow Designer Extension -
where a partner's own users manage ticketing without ever logging in to
Backstage. These authenticate with an embed session minted by the
partner's server with its organisation API key (
POST /embed/sessions, see@ticketlayer/backstage). - Ticketlayer's own staff apps - Manage and the box office, in this monorepo - which authenticate with the staff session they already hold and must not have to mint an embed session for themselves.
That second audience is the design, not an afterthought: building our own apps out of these elements is what makes them a first-class thing other people can build with. See Credentials for the one interface that serves both.
Built with Stencil, published as Web Components (tlb-*) and React wrappers.
On npm since 0.2.0. It lives in the ticketlayer/backstage-app monorepo at
packages/elements, and releases from the root .github/workflows/release.yml
through changesets.
How this relates to the embed widgets
Today the organiser widgets exist as pages in Manage
(src/app/embed/*: events, event, orders, order) that a partner
mounts in an iframe through the @ticketlayer/backstage/embed loader. The
iframe is the transitional mechanism:
tlb-orderstoday is a thin wrapper: it takes asessionProvider, mounts theorderswidget throughcreateBackstageEmbed, and relays the iframe contract (tl:navigate,tl:event,tl:ready,tl:session-expired,tl:error; protocol version 1) as DOM events (tlbOrderSelected,tlbRefundRequested,tlbReady,tlbSessionExpired,tlbError,tlbEvent).- Once the SDK exposes the underlying operations (orders list/get, refund,
ticket types, layouts) behind embed-session credentials, each element
renders natively in the host page behind the same props and events.
Hosts written against
tlb-orderstoday do not change when that happens.
The loader lives in ticketlayer/backstage at sdk/typescript/src/embed; the
widget side of the contract in this monorepo's
apps/manage/src/lib/embed-contract.ts.
Elements
| Tag | Status | Credentials | What it is |
|-----|--------|-------------|------------|
| tlb-orders | available | embed | The account's orders list, order detail and its actions (refund, resend confirmation), mounted from the embed orders widget |
| tlb-device-selector | available | staff, embed | The station's location, receipt/ticket printer and card terminal, and the terminal charge. Renders natively |
| tlb-event-tickets | planned | embed | An event's ticket types and issued tickets (renders a "coming soon" panel) |
| tlb-seat-designer | planned | embed | The seat map designer for an event layout (renders a "coming soon" panel) |
tlb-orders
<script type="module" src="https://cdn.ticketlayer.com/backstage-elements/v1/ticketlayer-backstage-elements.esm.js"></script>
<tlb-orders id="orders" base-url="https://backstage.ticketlayer.com" account-id="acc_123"></tlb-orders>
<script>
const orders = document.getElementById('orders');
// Your server mints the embed session with the organisation API key.
orders.sessionProvider = async ({ accountId, reason }) =>
(await fetch(`/api/ticketing/session?account=${accountId}`).then((r) => r.json())).token;
orders.addEventListener('tlbOrderSelected', (e) => console.log(e.detail.orderId));
orders.addEventListener('tlbRefundRequested', (e) => console.log(e.detail));
</script>React:
import { TlbOrders } from '@ticketlayer/backstage-elements/react';
<TlbOrders
baseUrl="https://backstage.ticketlayer.com"
accountId={club.ticketlayerAccountId}
sessionProvider={({ accountId }) => mintSession(accountId)}
onTlbOrderSelected={(e) => router.push(`/manage/orders/${e.detail.orderId}`)}
onTlbRefundRequested={(e) => toast(`Refund requested for ${e.detail.orderId}`)}
/>Props: baseUrl (the Backstage app origin), sessionProvider
(({ accountId, reason: 'mount' | 'expired' }) => Promise<string> | string;
called on mount and again when the widget reports its session expired),
accountId (passed to the provider and echoed on every event), filters
({ status, search, from, to } or a JSON string; reserved - appended to the
widget URL as filter.* parameters that the widget ignores until
Manage reads them; the native render applies them directly),
initialHeight, loader (replace createBackstageEmbed; used by tests).
Methods: showList(), openOrder(id), setSession(token).
The embed session needs the orders.read scope; refunds need
payments.refund once that scope exists (G3 in the platform plan). The
Backstage deployment must list the host origin in EMBED_FRAME_ANCESTORS.
tlb-device-selector
The station's active location, receipt/ticket printer and card terminal, and the card charge that goes with them.
It never touches local hardware. Everything is the devices API: GET /locations,
GET /devices/printers, and GET /devices whose relay devices carry the
payment_terminal peripherals the terminal list is flattened from.
chargeOnTerminal queues a payment.charge command on the relay device and
polls GET /devices/commands/{id} until the relay reports a result - the
stagehand relay drives the terminal and posts the result back, so there is no
127.0.0.1, no Local Control Token in the browser and no LAN reachability.
React:
import { TlbDeviceSelector } from '@ticketlayer/backstage-elements/react';
import { chargeOnTerminal } from '@ticketlayer/backstage-elements';
<TlbDeviceSelector
credential={credential}
onTlbDeviceSelectionChange={(e) => setTerminal(e.detail.terminal)}
/>;
const result = await chargeOnTerminal(credential, {
terminal,
amount: 1250,
currency: 'GBP',
reference: order.id,
onStage: (stage) => setStage(stage),
});Props: credential (the first-party path), sessionProvider + apiBaseUrl +
accountId (the partner path), label.
Events: tlbDeviceSelectionChange, tlbReady, tlbError.
Methods: refresh(), getSelection(), select({ locationId, printerId, terminalId }),
chargeOnTerminal(request).
Reading the selection elsewhere in the page. The selector drives a small store keyed by the credential's tenant, and a host subscribes to it - that is how a payment panel somewhere else knows which terminal the header selected, without the host having to be React:
import { deviceStoreFor } from '@ticketlayer/backstage-elements';
const store = deviceStoreFor(credential);
const unsubscribe = store.subscribe((state) => render(state.terminal));
await store.load(credential); // idempotent: concurrent loads share one callThe selection persists in localStorage under
backstage-device-<scopeKey>-location|printer|terminal, so it never leaks
across organisations.
The devices routes are x-tl-surface: staff and are reachable with a staff
session today. See Known limitation below for the embed path.
Known limitation: the embed path cannot reach the devices API yet
The element supports both credentials, but the API (ticketlayer/backstage) does not yet let an
embed session through to /v1/devices/*:
SCOPE_CATALOGUEinsrc/shared/auth/scope-policy.tsinticketlayer/backstagehas nodevices.read, and an embed session's scopes must be a subset of its API key's, which are catalogue scopes. So the read permissions (devices.list,locations.list) cannot be granted to a delegated token.devices.dispatchclassifies as neither read nor write, andpermissionAllowedByScopesdenies everything else for delegated tokens, so the terminal charge would stay denied even with a read scope.
Adding devices.read (and a dispatch decision) to the scope catalogue is the
the API change that opens it. Until then the partner path is exercised
in tests against a fake API, not against a live one, and a partner surface
should use tlb-orders and the other embed elements.
Credentials
Every element consumes one credential interface and the host picks the implementation. The element never holds a token, never mints one, and has no organisation or account attribute: the tenant comes from the credential's own signed claims. That is the platform's rule - scope comes from the credential, never from a prop - and it is what stops a prompt, a query string or a partner page widening what an element can see.
interface TlbCredential<TApi> {
readonly kind: 'embed' | 'staff';
/** The credential's tenant, from its token claims. Namespaces per-station
preferences; never sent to the API. */
readonly scopeKey: string;
/** The authed API surface for one batch of calls, resolved per use so a
token can rotate underneath. */
resolve(): Promise<TApi>;
}TApi is the slice of the Backstage SDK client an element calls, named one
operation at a time (TlbDeviceApi for the device elements). A real
BackstageClient satisfies it structurally, and so does a fake in a test.
The partner path: an embed session
Unchanged. Set sessionProvider (and apiBaseUrl, the the API origin)
and the element builds the credential itself:
<tlb-device-selector id="devices" api-base-url="https://api.ticketlayer.com" account-id="acc_123"></tlb-device-selector>
<script>
document.getElementById('devices').sessionProvider = async ({ accountId }) =>
(await fetch(`/api/ticketing/session?account=${accountId}`).then((r) => r.json())).token;
</script>The token is checked to be an embed session (token_use: 'embed', audience
backstage-embed) before it is used, so the partner path cannot become a way
to present a staff token. the API would reject one anyway; failing in
the element means it never leaves the page.
The first-party path: the staff session the app already holds
A Backstage app hands the element the authed SDK client it is already using. No embed session is minted for a staff user, and no token is copied anywhere.
// Components from /react; the framework-free helpers from the package root.
import { TlbDeviceSelector } from '@ticketlayer/backstage-elements/react';
import { createStaffCredential } from '@ticketlayer/backstage-elements';
const credential = createStaffCredential(client, {
// The tenant is read from this token's signed org_slug claim. scopeKey is
// the documented fallback for cookie-mode hosts, whose token the client
// reads itself; it must come from the signed-in session.
accessToken: () => token,
scopeKey: orgSlug,
});
<TlbDeviceSelector credential={credential} />createStaffCredential refuses an embed session token, the mirror of the embed
path refusing a staff one, so neither path is a route into the other.
Where elements-core fits
TKT-68 is building
packages/elements-core in the live monorepo: the shared core shape, manifest
type and catalogue emitter. Nothing here invents that abstraction. When it
lands, TlbCredential is the seam: it is the one thing every element takes
from its host, so it is what elements-core should own (with the embed and
staff factories as the two reference implementations), and
src/credentials/ moves wholesale. The device store and the typed devices
calls stay here - they are this element family's domain, not core.
Theming
Every element reads --tlb-* custom properties and falls back to the
buyer-facing --tl-* set, then to a built-in default:
color: var(--tlb-foreground, var(--tl-foreground, #111827));So a page that already carries a channel theme (injected by
@ticketlayer/live) gets matching admin widgets for free, and a host that
wants the admin widgets in its own palette (Rowbot Manage deliberately does
not wear club colours) sets --tlb-* on a container. No --tlb-* default is
defined globally, on purpose: it would shadow the --tl-* fallback.
The catalogue
dist/elements.catalogue.json (built by pnpm run build, shipped in the package)
describes every element for docs, the React wrappers and an llms.txt: one
entry per tag with
tag,name,category,descriptionstatus:available/beta/planned/deprecatedrenders:embed-iframetoday,nativelater,placeholderfor plannedruntimes:web-component,reactsdkMethods: the SDK operations the element uses (or will use)scopesthe embed session must carry, andoptionalScopesfor actionsprops,events,methods,cssPropertiesfrom the source (Stencil's docs-json)
The hand-maintained half is src/catalogue.manifest.json (same shape as
the @ticketlayer/elements catalogue); scripts/build-catalogue.mjs merges it with
docs/components.json and fails the build when a component has no manifest
entry or vice versa. src/catalogue.spec.ts checks the same in pnpm test.
Development
What ships, and the ./react subpath
package.json declares exports for ., ./react and ./loader, and every
one of them resolves to built output. Nothing published is TypeScript source.
- The React wrapper sources live in
react/src: the hand-writtenindex.ts, pluscomponents.tsandreact-component-lib/which the Stencil React output target generates (both gitignored). pnpm run buildcompiles them withreact/tsconfig.build.jsontodist/react, which is what./reactpoints at.react/tsconfig.jsonstaysnoEmitand is whatpnpm run typecheckuses.- Source and output sit at the same depth below the package root on purpose,
so the relative specifiers in the sources (
../../loader,../../dist/types/index) resolve to the same files either way. Emitting anywhere else would mean rewriting every specifier aftertsc. scripts/patch-react.mjsrewrites the generatedreact/src/components.tsto import../../dist/types/indexand../../loaderrelatively. Stencil emits those imports by the root package name, which nothing can resolve from inside the package (the nearestpackage.jsonto that file isreact/package.json, so Node's self-reference does not apply).react/package.jsonis a private, unpublished manifest for a future standalone@ticketlayer/backstage-elements-react. It is not in thefilesarray and does not ship.
Because ./react is compiled JavaScript, a consumer no longer needs this
package in Next's transpilePackages.
Working on it
From the repository root (pnpm workspace, Turborepo):
pnpm install
pnpm --filter @ticketlayer/backstage-elements build # stencil -> dist/, loader/, react/src/components.ts, docs; the catalogue; tsc -> dist/react
pnpm --filter @ticketlayer/backstage-elements test # stencil test --spec (jest)
pnpm --filter @ticketlayer/backstage-elements typecheck # the React wrappers (after a build)
pnpm --filter @ticketlayer/backstage-elements dev # dev server with src/index.html
pnpm changeset # describe a change for the changelogAn app in this monorepo consumes it as "@ticketlayer/backstage-elements":
"workspace:*"; turbo builds it before the app, and the app resolves its
dist/ and loader/ like any installed package.
@ticketlayer/backstage is a peer dependency (>=0.3.0 <5.0.0), and a
devDependency at the version the apps here use so that pnpm installs one copy
for both. It is a peer rather than a dependency because this library holds
credentials: two copies of the SDK in one page means two token stores and two
refresh cycles, and the symptom is intermittent auth failure. Every host
already depends on it directly.
Mirrors @ticketlayer/elements (ticketlayer/live, packages/elements): same Stencil, React output target, jest and
TypeScript versions, shadow DOM, per-component readme.md from
docs-readme, React wrapper sources in react/.
Roadmap
- Transitional (now):
tlb-ordersover the embedorderswidget;tlb-device-selectorrendering natively under either credential; placeholders fortlb-event-ticketsandtlb-seat-designer. - Widgets as elements:
tlb-events,tlb-event(details, tickets, occurrences) andtlb-orderwrapping the remaining embed widgets, so a partner page composes the whole admin surface from elements. - Native renders: as the SDK gains embed-session operations (orders list/get, refund, ticket types, occurrences, layouts), each element drops its iframe and renders in the host page with the same API. The seat map designer is the largest of these and stays an iframe longest.
- CDN build alongside the live Elements bundle: still to come.
