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

@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/backstage

Client

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, ... }
  • start answers the same whether or not the address has an account. Pass organisation: '<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 before selectOrganisation; the id_token's organisations claim (tokens.organisations) carries the same list, less test mode. method: 'sso' with a redirectUrl is 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_SESSION when the ten-minute sign-in has expired), thrown as BackstageAPIError with that code; it is never treated as an expired session. Too many attempts is SIGN_IN_RATE_LIMITED (429).
  • Token endpoint errors are BackstageAPIError with the OAuth error as code (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, onSessionEnded

The 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 it

The 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.mjs

Against 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):

  1. Server: mint a session. With the organisation API key, call POST /embed/sessions for 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'],
    });
  2. 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();
  3. Refresh on expiry. Sessions are short-lived. When the widget gets a 401 (or its token's exp passes) it posts tl:session-expired; the loader calls your sessionProvider, posts the new token back as tl:session, and the widget resumes where it was. Without a provider, onSessionExpired fires 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' } }} />