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

@lessly/users-client

v0.7.0

Published

Browser client for the Lessly Users toolkit — flow engine, PKCE code handoff and session state

Readme

@lessly/users-client

The browser half of the Lessly Users toolkit: a typed flow client, the PKCE code handoff, and session state. Framework-agnostic and dependency-free. React bindings live in @lessly/users-react.

It speaks only the public wire contract with a publishable key (upk_…). It holds no secrets, verifies no tokens, and cannot reach the management plane — anything this library can do, a curl with the same key can do.

Install

npm install @lessly/users-client

Quickstart — the browser path

import { createUsersClient, isCodeHandoff, isFailure } from '@lessly/users-client';

const users = createUsersClient({
  productId: 'prod_123',
  publishableKey: 'upk_live_…',
  // baseUrl defaults to the production public edge; staging/local pass it explicitly.
});

// One engine: `start` signs in OR signs up, and tells you which only at the end.
const attempt = await users.start({
  identifier: email,
  redirectUri: 'https://app.example.com/auth/callback', // exactly as allowlisted
});

const result = await attempt.attempt({ strategy: 'password', password });

if (isFailure(result)) {
  showError(result.error.code); // 'invalid_credentials' | 'attempt_expired' | …
} else if (isCodeHandoff(result)) {
  // The browser receives a ONE-TIME CODE, never tokens. Post it to your own
  // backend together with the PKCE verifier; your backend calls
  // `exchangeCode` from @lessly/users, sets first-party cookies, and is done.
  await fetch('/auth/complete', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ code: result.code, verifier: attempt.pkceVerifier }),
  });
}

Why the detour through your backend: a cookie on our host is invisible to your domain (third-party cookies are dead), and a token handed to a page is a token an XSS can steal. The code is bound at issuance to the flow's client, the exact redirect_uri and the PKCE challenge, lives ≤60s, and is single-use.

Who mints the PKCE pair

Two ownership models, and the one above is the browser-owned half: no codeChallenge, so this client mints the pair and hands the verifier back on attempt.pkceVerifier for you to post alongside the code.

The other half is BACKEND-owned, and it is what the prebuilt <SignIn/> uses: your backend mints the pair, keeps the verifier and passes only the challenge in.

const attempt = await users.signIn.create({
  identifier: email,
  redirectUri: 'https://app.example.com/auth/callback',
  codeChallenge, // S256, minted by YOUR backend, which kept the verifier
});

attempt.pkceVerifier; // undefined — this browser never held one, and stores none

Use it whenever the code's redeemer is not this page. A verifier travelling to the same address as the code proves nothing that the code did not already prove.

Session state

Useful when your backend hands the bundle back to the page, or on the trusted (non-browser) path where a completion carries tokens directly.

users.session.setTokens(bundle); // from your backend's exchangeCode
users.session.getState(); // { status, user, session, expiresAt }
await users.session.getToken(); // refreshes first if near expiry
await users.session.refresh(); // concurrent calls are coalesced into one
await users.session.signOut('local'); // 'local' | 'others' | 'global'
const stop = users.session.onSessionChange((state) => render(state));

Tokens live in memory only. Nothing writes them to localStorage, and the library never tries: the durable half of a session belongs in the __Host- cookie your backend sets.

The email strategies (Phase 3)

A product with a verified mail sender also offers email_code and email_link, and says so in handle.strategies. Both renderings are ONE token, and both are verified through the same attempt:

const attempt = await users.start({ identifier, redirectUri });
await attempt.prepare({ strategy: 'email_code' }); // puts an email in flight
await attempt.resend(); // the same strategy again
const result = await attempt.attempt({ strategy: 'email_code', code });

prepare answers identically for a known and an unknown address, and its refusals come back as ordinary flow failures — resend_too_soon inside the cooldown, email_undeliverable when nothing could be handed to mail.

The magic link's interstitial is two calls, and the split is the security story:

const info = await users.magicLink.info(token); // describes, consumes NOTHING
await users.magicLink.consume(token, { csrfToken: info.csrfToken, attemptId: info.attemptId });

On the originating device the client attaches the attempt's stored secret and the link completes. On another device it holds none, so no session is minted there — the answer is the same token's code rendering to type back into the client that started the sign-in.

Recovery, invite and email change (Phase 3)

await users.recovery.requestReset({ identifier }); // always { status: 'sent' }
const info = await users.recovery.info(token);
await users.recovery.completeReset(token, { csrfToken: info.csrfToken, password });

await users.invite.accept(token, { csrfToken, password });

await users.emailChange.start({ newEmail }); // authorized by the live session
await users.emailChange.confirm(token, { csrfToken }); // the NEW address
await users.emailChange.approve(token, { csrfToken }); // the CURRENT primary
await users.emailChange.revert(token, { csrfToken }); // the old address's undo

Consuming a reset or an invite answers { status: 'complete' } and NOTHING else: no token, no session, no user id. The link authorizes exactly one password set, and the client signs in afterwards through the normal engine — which is what keeps inbox access alone from bypassing a second factor.

There is no verification call: an address is verified as a side effect of completing an email strategy.

Signing in with a provider (Phase 4)

Two calls, one round trip through Google or GitHub in between.

// The button. It navigates away — everything after this line runs on the
// landing page, in a fresh document.
await users.oauth.start({ provider: 'google', redirectTo: CALLBACK_URL });

// The landing page the 302 came back to (`?attempt=…&status=…`).
const completion = await users.oauth.completeFromCallback();
if (completion?.kind === 'sign_in' && isCodeHandoff(completion.result)) {
  const bundle = await myBackend.exchange(completion.result.code, completion.pkceVerifier!);
  users.session.setTokens(bundle);
}

redirectTo must be one of the product's allowlisted callbacks, byte for byte. start takes an identifier if a form asked for one and does not need it otherwise: the account is resolved from the provider's profile, and the placeholder the client sends instead never becomes anybody's identifier.

The redirect leaves the page, so an OAuth-capable client needs storage that survives a navigation:

createUsersClient({ …, storage: localStorageAdapter() });

Without it the landing page holds no client_secret, and completeFromCallback throws rather than inventing an outcome.

The state is a cookie. prepare answers with a __Host- cookie, so that call is made with credentials: 'include' — which means the edge in front of the API has to allow credentialed cross-origin requests. See docs/oauth-social-login.md §Platform notes.

Settings screens manage what the account can sign in with:

const { identities, unlinkable } = await users.oauth.identities.list();
await users.oauth.identities.link({ provider: 'github', returnUrl: CALLBACK_URL });
await users.oauth.identities.unlink({ identityId });

unlinkable is the server's own answer to "what may go?" — render it rather than recomputing "can't unlink the last way in", and expect oauth_unlink_last_way_in as a returned failure if you try anyway. Both mutating calls need a fresh access token (minted within 15 minutes) and answer 401 otherwise.

The second factor, step-up and devices (Phase 5)

Once a user enrolls an authenticator, every way into their account stops one step short and asks for it:

const attempt = await users.start({ identifier, redirectUri });
let result = await attempt.attempt({ strategy: 'password', password });

if (result.status === 'needs_second_factor') {
  // Nothing was minted. The attempt is still open — ask for the code.
  result = await attempt.submitSecondFactor(code); // TOTP *or* a backup code
}

The completed session claims aal2, and amr names both factors.

Managing the factor is the account plane:

const { secret, otpauthUri } = await users.mfa.enroll();  // shown ONCE
await users.mfa.confirm(codeFromTheApp);
const { codes } = await users.mfa.backupCodes.generate(); // shown ONCE, replaces the old batch
await users.mfa.list();
await users.mfa.disable('totp');

Step-up, without assembling a header

Every sensitive call takes the code the user typed. The client turns it into the service's one-time grant, spends it, and forgets it:

await users.account.changePassword({ currentPassword, newPassword, code });
await users.sessions.revokeOthers({ code });

Ask first, so a form knows whether to show the box at all:

const { secondFactorRequired } = await users.stepUp.start();

For a screen that makes several sensitive calls, verify once and let the client spend what it holds — grants are kept per operation and never spent on another:

await users.stepUp.verify({ operation: 'mfa_enroll', code });
await users.mfa.enroll();               // spends it
await users.mfa.backupCodes.generate(); // needs its own

With nothing to present the call goes out bare, and the refusal is typed: StepUpRequiredError (recoverable — prompt and retry), StaleAuthenticationError (not — send the user back through sign-in), StepUpUnavailableError (an outage, with retryAfter).

The device list

const { sessions } = await users.sessions.list();   // current / aal / amr / impersonated
await users.sessions.revoke(id, { code });          // somebody else's device
await users.sessions.revoke(id, { current: true }); // this one — never gated
await users.sessions.revokeOthers({ code });

Revoked and expired rows are included on purpose: "you were signed out on that laptop an hour ago" is what somebody checking this screen is looking for.

Storage adapters

Only the in-flight attempt handle and its client_secret are stored, so a reload mid sign-in can users.resume(attemptId).

import { localStorageAdapter } from '@lessly/users-client';
createUsersClient({ …, storage: localStorageAdapter() });

The default is memory. localStorageAdapter is opt-in and the tradeoff is explicit: anything with XSS on the page can read it. What is exposed is one short-lived, single-completion attempt credential — never a session.

Errors

Flow outcomes are returned, not thrown: a wrong password is { status: 'failed', error: { code } } with HTTP 200, because a uniform body is the enumeration defence. Only transport conditions throw — AuthenticationError (401, with a factual environment hint), NotFoundError (404), RateLimitedError (429, retryAfter), StepUpRequiredError (403), StaleAuthenticationError (401), StepUpUnavailableError (503), NetworkError, UsersApiError.

Unknown, additively-introduced statuses are handed back unchanged instead of throwing, so an old client degrades gracefully when the server grows.

The publishable key header

The key travels in x-publishable-key. It is deliberately not sent in x-api-key: that header is the code-exchange leg's credential, and the service treats its presence as "a backend is calling" — sending a page-scrapable key there would switch off the per-product origin allowlist that protects your product's browser flows.

Upgrading

0.6.0 is a BREAKING release (pre-1.0, so a minor). It renames the appearance layer (Hosted*Auth*) and removes hostedThemeCss. See CHANGELOG.md for the full table and what to replace hostedThemeCss with.

Related

  • @lessly/users — the server SDK your backend uses (exchangeCode, verifyToken, middleware)
  • @lessly/users-react — hooks and prebuilt components over this core