@ticketlayer/backstage
v7.9.0
Published
TypeScript SDK for Backstage API
Readme
@ticketlayer/backstage
TypeScript client for the Backstage API: every operation of the contract,
generated from backstage-api's openapi.public.json, plus the embed subpath
that mounts Backstage widgets in a partner's own page. It lives in
ticketlayer/backstage at sdk/typescript, beside the contract, and is regenerated in
the pull request that changes it.
Install
npm install @ticketlayer/backstageClient
import { BackstageClient } from '@ticketlayer/backstage';
// Staff app (Next.js with Stagedoor OAuth): cookie mode, org from the subdomain
const backstage = new BackstageClient({ baseUrl: 'https://api.staging.t9r.dev', authMode: 'cookie' });
// Server or native app: bearer mode with a staff JWT, an organisation API key
// (tlak_...), an embed session or a device token; the org must be named
const server = new BackstageClient({
baseUrl: 'https://api.staging.t9r.dev',
authMode: 'bearer',
accessToken: () => currentToken, // a resolver is read on every request
organisationSlug: 'rowbot', // sent as X-Ticketlayer-Org
});
const events = await server.events.list();Every request carries TL-Version (the dated API version the client was
generated against) and JSend responses are unwrapped to their data. Errors
are BackstageAPIError with code and statusCode.
Per-call options
Every method takes an optional last argument, RequestOptions: headers
sent on that call only (over the client's own headers; the credential and
X-Ticketlayer-Org the client sets are not replaced), idempotencyKey, sent
as Idempotency-Key, and an abort signal.
// One payment, one settlement: a retry with the same key returns the first.
const key = crypto.randomUUID();
await server.ledger.createSettlement(input, { idempotencyKey: key });
// After path parameters and query options, as the last argument.
await server.ledger.exceptions({ status: 'open' }, { signal: controller.signal });A 401 in bearer mode
Bearer mode has no default refresh endpoint. It used to POST refreshToken
to /v1/auth/refresh, which the API has removed; staff tokens are refreshed
at the identity issuer's /oauth/token, and createAuth(...).createClient()
(below) builds a client that does that by itself. On a 401 the client asks onAuthFailure if you gave one
(return true to retry the request, reading the token again), otherwise
POSTs refreshToken to refreshEndpoint if you set both (and hands the new
pair to onTokenRefresh), and otherwise throws BackstageAPIError with code
SESSION_EXPIRED without a second request. A host that rotates the token
behind an accessToken resolver needs none of these. Cookie mode still
defaults refreshEndpoint to the host app's own /api/oauth/refresh.
Signing in (first-party apps)
Ticketlayer's own apps (the Backstage shell, the packaged box office on the
desktop and on ChromeOS) sign a person in with one flow, in the shape of the
IETF draft "OAuth 2.0 for First-Party Applications": the app starts a sign-in
with its client id and a PKCE challenge, the person gives a password or an
emailed code (and a second factor when they have one), and the last step
answers a one-time authorization code, which the app redeems at the
issuer's /oauth/token. Tokens only ever come from the token endpoint, so
refresh rotation, revocation and DPoP binding work as for every other OAuth
client. The steps refuse any client that is not first party: a third party
uses the ordinary authorization code flow and the issuer's own pages.
The issuer is its own host, separate from the API (https://auth.staging.t9r.dev
beside https://api.staging.t9r.dev), so createAuth takes its own URL.
import { createAuth } from '@ticketlayer/backstage';
const auth = createAuth({
issuerUrl: 'https://auth.staging.t9r.dev',
clientId: 'backstage-boxoffice-desktop', // or per call
});
const pkce = await auth.pkce(); // { verifier, challenge, method: 'S256' }
const dpop = await auth.createDpopKey(); // non-extractable ECDSA P-256 (WebCrypto)
const step = await auth.start({ email, codeChallenge: pkce.challenge, dpop });
// { method: 'password', alternatives: ['email_code'], authSession, expiresIn }
let result = await auth.password({ authSession: step.authSession, password });
// or: await auth.sendEmailCode({ authSession }); then auth.emailCode({ authSession, code })
// -> { status: 'code', code, redirectUri } | { status: 'mfa_required', authSession, factors }
if (result.status === 'mfa_required') {
result = await auth.mfa({ authSession: result.authSession, code: totp });
}
const tokens = await auth.exchange({
code: result.code,
codeVerifier: pkce.verifier,
redirectUri: result.redirectUri,
dpop, // binds the tokens to the key
});
// { accessToken, tokenType: 'DPoP', expiresIn, expiresAt, refreshToken, organisation, ... }startanswers the same whether or not the address has an account. Passorganisation: '<slug>'to pin the first token to one organisation; without it the token is pinned to the person's only organisation, or to none when they have several. A token pinned to none reaches one API operation,GET /me/organisations(session.listOrganisations(), below), which is what a picker shows beforeselectOrganisation; the id_token'sorganisationsclaim (tokens.organisations) carries the same list, less test mode.method: 'sso'with aredirectUrlis in the type for when the issuer does home-realm discovery; it does not answer it yet.- A 401 from a step is the answer (
INVALID_CREDENTIALS,INVALID_SECOND_FACTOR,INVALID_AUTH_SESSIONwhen the ten-minute sign-in has expired), thrown asBackstageAPIErrorwith that code; it is never treated as an expired session. Too many attempts isSIGN_IN_RATE_LIMITED(429). - Token endpoint errors are
BackstageAPIErrorwith the OAuth error ascode(invalid_grant,invalid_client, ...).
A client that refreshes by itself
const session = auth.createSession({
baseUrl: 'https://api.staging.t9r.dev',
tokens, // or just { refreshToken } from the keychain
dpop, // exactly when the tokens are DPoP-bound
onTokens: (t) => keychain.set('refresh', t.refreshToken),
onSessionEnded: () => showSignIn(),
});
const client = session.client; // or auth.createClient({...}) for the client alone
const organisations = await session.listOrganisations();
// [{ id, slug, name, testMode, logoUrl?, ... }]: the person's own, whether or not the session is pinned
await session.selectOrganisation('acme-events'); // RFC 8693 token exchange; the client follows
await client.events.list();
await session.signOut(); // revoke the refresh token (RFC 7009), forget the tokens, onSessionEndedThe client is in bearer mode. Its token provider refreshes through
auth.refresh ahead of expiry (30 seconds by default) and on a 401, one
refresh at a time however many requests are waiting, and hands every new
token set to onTokens: the refresh token rotates on every use, so keep the
newest. With dpop every API call goes as Authorization: DPoP with a
fresh proof, which is how the API accepts a bound token. X-Ticketlayer-Org
is the organisation the tokens are pinned to unless you pass
organisationSlug. When the issuer refuses the refresh token (revoked,
expired or reused), onSessionEnded is called once and requests fail with
SESSION_EXPIRED.
auth.refresh({ refreshToken, dpop }) and
auth.selectOrganisation({ slug, refreshToken, dpop }) are there to call
directly too.
Signing out
session.signOut() revokes the session's refresh token at the issuer's
/oauth/revoke (RFC 7009), forgets the tokens and calls onSessionEnded
once, with a BackstageAPIError whose code is SIGNED_OUT. Revoking a
refresh token ends its whole family: neither it nor any token rotated from
the same sign-in refreshes again. Every sign-in (and every organisation
switch) is a family of its own, so signing out on one till leaves the same
person signed in on another. The access token already issued is a JWT and
lives out its fifteen minutes, but the session no longer sends it. Remove the
refresh token from the keychain in onSessionEnded.
The session ends locally whatever the issuer answers, so a till that is
offline still signs out; if the revocation could not be made, signOut
throws after ending the session, and the refresh token stays live at the
issuer until it expires. auth.revoke({ token, tokenTypeHint?, clientId?,
clientSecret? }) makes the call on its own, for a server holding a refresh
token outside a session (the shell's BFF with its clientSecret).
Signing up
A person with no account creates one in the same shape, ending in a code for
exchange. The answer to signup.start is the same whether or not the
address already has an account (one that does is emailed a note to sign in
instead, and no code can finish its sign-up):
const { verifier, challenge } = await auth.pkce();
const { authSession } = await auth.signup.start({ email, codeChallenge: challenge });
// the person reads the six-digit code from their email
const step = await auth.signup.verify({ authSession, code, password, firstName, lastName });
const tokens = await auth.exchange({ code: step.code, codeVerifier: verifier, redirectUri: step.redirectUri });
// tokens.organisation is null: a registrant, who owns no organisation yet
const session = auth.createSession({ baseUrl, tokens });
const { signUp } = await session.createOrganisation({ organisationName: 'Acme Events', templateName: 'venue' });
// POST /signup made it (in test mode, with a sandbox); the session is now pinned to itThe password must be at least 12 characters (400 PASSWORD_TOO_SHORT, and
the code is not spent). A wrong, spent or expired code is 401
INVALID_CREDENTIALS; five guesses burn a code. templateName is the
person's answer to what kind of organisation this is: ask, never default it.
A DPoP-bound session cannot sign up.
Accepting an invitation
The invitation email links to https://backstage.<domain>/invitations/redeem?code=....
Without a session, auth.invitation.start says who it is for and how to
continue, and the step that finishes accepts it; the code's tokens are pinned
to the organisation:
const started = await auth.invitation.start({ invitation: code, codeChallenge: challenge });
// started.email, started.organisation.name: show them
const step =
started.method === 'password'
? await auth.password({ authSession: started.authSession, password }) // or emailCode / mfa, as for any sign-in
: await auth.invitation.accept({ authSession: started.authSession, password, firstName, lastName });Signed in already, session.acceptInvitation(code) accepts it as that person
(POST /me/invitations/redeem) and pins the session to the organisation. The
invitation must have been sent to the person's own address (403
INVITATION_EMAIL_MISMATCH); it is single use (409
INVITATION_ALREADY_REDEEMED) and expires (410 INVITATION_EXPIRED).
DPoP
createDpopKey() makes an ECDSA P-256 pair whose private key is not
extractable: script can sign with it and never read it, so a token bound to it
is useless to whoever copies the token alone. Keep the CryptoKeyPair itself
(IndexedDB keeps it non-extractable) and pass it back as
createDpopKey({ keyPair }) on the next launch. dpopProof({ key, method, url,
accessToken?, nonce? }) makes one proof (typ: dpop+jwt, alg: ES256, the
public jwk; htm, htu without query or fragment, iat, a fresh jti,
and ath when an access token goes with it). A use_dpop_nonce answer from
the token endpoint is retried once with the nonce.
Where the password goes differs by host: an Electron app passes it over the
preload bridge and signs in from the main process; the isolated web app calls
the issuer itself (its origin is allowed on /v1/auth/*, /oauth/token and /oauth/revoke
only). The shell's sign-in page calls the steps from the browser with
createAuth({ ..., credentials: 'include' }), so the issuer also leaves its
own login session, and redeems the code on its server.
The shell's server: clientSecret
backstage-shell is a confidential client. Its server redeems the code and
refreshes with its secret:
const tokens = await auth.exchange({ code, codeVerifier, redirectUri, clientSecret: process.env.SHELL_CLIENT_SECRET });The secret goes as HTTP Basic (client_secret_basic). It is for server code
only and must never ship to a browser, an Electron renderer or anything else
a person can open: a public client has no secret and proves itself with PKCE
and DPoP. The SDK refuses a clientSecret wherever window and document
exist.
Nothing secret is logged
With debug on, the client prints [redacted] for the body and the answer of
every /v1/auth/* step, masks secret keys (password, authSession, tokens,
client_secret, key, secret) in every other body it logs, and the token
calls log only the grant type and the status. test/auth-redaction.test.js
runs a whole sign-in with every log on and fails if a password, code, handle,
verifier, secret or token appears.
A smoke run against a real issuer
scripts/smoke-auth.mjs drives start, password (and mfa), exchange
with a DPoP-bound code, one API call and a refresh against a running issuer,
printing outcomes and never a secret:
npm run build
TL_ISSUER_URL=https://auth.staging.t9r.dev TL_API_URL=https://api.staging.t9r.dev \
[email protected] TL_PASSWORD=... TL_ORG=your-org \
node scripts/smoke-auth.mjsAgainst a local API (API_SURFACE=all, IDENTITY_ISSUER=http://127.0.0.1:3501,
migrations applied and a person with a password in identity_service.people)
point TL_ISSUER_URL at it and leave TL_API_URL unset. Name TL_ORG when the
person belongs to more than one organisation: the token is otherwise pinned to
none, and reaches only GET /me/organisations.
Develop
npm run sdk:generate # at the repository root: regenerate client.ts and generated/types.ts, write the changeset
npm ci # here
npm run build # tsc -> dist/
npm test # node --test against dist/src/client.ts and src/generated/types.ts are generated by
tools/sdk-tooling and committed; do not edit them by hand. Releases are
driven by changesets and .github/workflows/sdk-release.yml at the
repository root, which versions the package on every merge that changes it
and publishes to npm through trusted publishing: a GitHub OIDC token, no npm
secret. CLAUDE.md is the working agreement.
Embedding Backstage widgets in your app
@ticketlayer/backstage/embed mounts Backstage widgets (the events list, an
event editor, the orders list, an order) in an iframe on your own page. Your
users never log in to Backstage: your server mints an embed session for
one account with your organisation API key, and the widget runs as that
account with the scopes you chose. The subpath is framework-free and is not
loaded by the main entry.
The flow, as Rowbot uses it (one Ticketlayer organisation, one account per club):
Server: mint a session. With the organisation API key, call
POST /embed/sessionsfor the club's account. Keep the API key on the server; only the short-lived session token reaches the browser.import { BackstageClient } from '@ticketlayer/backstage'; const backstage = new BackstageClient({ baseUrl: 'https://api.staging.t9r.dev', accessToken: process.env.TICKETLAYER_ORG_API_KEY, organisationSlug: 'rowbot', }); // e.g. GET /api/ticketing/session on your server const { token, expiresAt } = await backstage.embed.createSession({ accountId: club.ticketlayerAccountId, scopes: ['events.read', 'events.write', 'orders.read'], });Page: mount a widget. Hand the token to the loader; it builds
${baseUrl}/embed/<widget>?session=...&parent_origin=<your origin>, accepts messages only from the Backstage origin, and sizes the frame to its content.import { createBackstageEmbed } from '@ticketlayer/backstage/embed'; const embed = createBackstageEmbed({ baseUrl: 'https://backstage.staging.t9r.dev', session: token, // Called when the widget reports the session expired: mint a new one. sessionProvider: async () => (await fetch('/api/ticketing/session').then((r) => r.json())).token, onNavigate: (widget, params) => history.replaceState(null, '', `/ticketing/${widget}/${params.id ?? ''}`), onEvent: (name, payload) => console.log(name, payload), // event.created, order.refund_requested, ... onError: (err) => console.warn(err.code, err.message), }); embed.mount(document.getElementById('ticketing')!, { widget: 'events' }); // later: embed.navigate('order', { id: 'ord_123' }); embed.unmount();Refresh on expiry. Sessions are short-lived. When the widget gets a 401 (or its token's
exppasses) it poststl:session-expired; the loader calls yoursessionProvider, posts the new token back astl:session, and the widget resumes where it was. Without a provider,onSessionExpiredfires and the widget shows its expired state.
Widgets and params: events ({ action?: 'new' }), event
({ id, tab?: 'details' | 'tickets' | 'occurrences' }), orders, order
({ id }). Actions the session's scopes do not allow (for example editing
with only events.read) are hidden in the widget.
Messages from the widget (all carry version: 1): tl:ready, tl:height
{ height }, tl:navigate { widget, params }, tl:event { name, payload },
tl:session-expired, tl:error { code, message }. The only inbound message
is tl:session { token }. The Backstage deployment must list your origin in
its EMBED_FRAME_ANCESTORS for the frame to load.
React example (the loader owns the iframe; React owns the container):
import { useEffect, useRef } from 'react';
import { createBackstageEmbed, type EmbedWidgetTarget } from '@ticketlayer/backstage/embed';
export function BackstageWidget({ token, target }: { token: string; target: EmbedWidgetTarget }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const embed = createBackstageEmbed({
baseUrl: 'https://backstage.staging.t9r.dev',
session: token,
sessionProvider: () => fetch('/api/ticketing/session').then((r) => r.json()).then((s) => s.token),
});
embed.mount(ref.current, target);
return () => embed.unmount();
}, [token, target]);
return <div ref={ref} />;
}
// <BackstageWidget token={token} target={{ widget: 'order', params: { id: 'ord_123' } }} />