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

@zanii-id/sdk

v0.7.0

Published

Zanii ID TypeScript SDK — OAuth 2.1 / OIDC client for Node.js servers

Readme

@zanii-id/sdk

Node.js SDK for Zanii ID — OAuth 2.1 / OpenID Connect sign-in for products in the Zanii ecosystem.

Authorization Code flow with mandatory PKCE, offline id_token verification via jose, and drop-in integrations for Express and the Next.js App Router.

npm install @zanii-id/sdk

Requires Node 20+.

Use it

Configuration is read from ZANII_ISSUER, ZANII_CLIENT_ID, ZANII_CLIENT_SECRET and ZANII_REDIRECT_URI, validated with zod, and fails with the exact missing variable names.

import { ZaniiClient } from "@zanii-id/sdk";

const zanii = new ZaniiClient();
const { url, state, nonce, codeVerifier } = await zanii.getAuthorizationUrl();
// redirect to `url`, then in your callback:
const tokens = await zanii.exchangeCode({ code, state }, expectedState, codeVerifier);
const claims = await zanii.verifyIdToken(tokens.id_token!, nonce);

Express

import { buildAuthRouter, requireAuth } from "@zanii-id/sdk/express";

app.use("/auth", buildAuthRouter(zanii, { sessionSecret, onUser }));
app.get("/dashboard", requireAuth(zanii, { sessionSecret }), (req, res) =>
  res.json({ zanii_user_id: req.zaniiUser!.id }),
);

Next.js (App Router)

// app/auth/[...zanii]/route.ts
import { createAuthHandlers } from "@zanii-id/sdk/next";
export const { GET } = createAuthHandlers(zanii, { sessionSecret });

getSession() reads the signed session cookie inside server components.

Agent activity

import { subjectTag, fetchVerifiedActivity } from "@zanii-id/sdk/activity";
// requires the optional peer: npm i @zanii/subject

const tag = await subjectTag(user.did, CLIENT_ID);
const entries = await fetchVerifiedActivity(tag);

Every receipt is verified offline — signature, delegation chain, scope and tag match. Entries that fail keep their place in the list with a flagReason instead of vanishing.

Lifecycle webhooks

Register a lifecycle_webhook_url on your client (console or /orgs/clients) and Zanii ID POSTs account events to it, signed over the raw body with the same webhook_secret you already hold: X-Zanii-Signature: sha256=<hmac> plus X-Zanii-Event. verifyLifecycleEvent checks it in constant time and returns the parsed event. Give it the raw bytes, before any JSON middleware touches them.

import { LifecycleSignatureError, verifyLifecycleEvent } from "@zanii-id/sdk";

app.post("/webhooks/zanii-id", express.raw({ type: "application/json" }), async (req, res) => {
  let ev;
  try {
    ev = verifyLifecycleEvent(req.body, req.header("x-zanii-signature"), WEBHOOK_SECRET);
  } catch (err) {
    if (err instanceof LifecycleSignatureError) return res.sendStatus(401);
    throw err;
  }
  if (ev.event === "user.deleted") await users.erase(ev.sub);
  res.sendStatus(204);
});

| event | what to do | |---|---| | user.deleted | erase or anonymise your copy of that sub | | consent.revoked | drop the local session; the next login shows the consent screen again | | user.password_changed | refresh tokens are already dead; drop the local session | | user.email_changed | update the display email; data.email, data.email_verified |

Deliveries retry with backoff for about an hour (8 attempts). Answer 2xx quickly and do the work afterwards. sub is the identifier you receive at login, so key on it as usual.

Two-step verification and amr

Every id_token carries acr (urn:zanii:mfa when the session has a second factor, urn:zanii:pwd otherwise) and amr (["pwd"], ["pwd","otp"] or ["webauthn"]). To require a second factor, start the flow with it:

import { ACR_MFA } from "@zanii-id/sdk";
const { url } = await client.getAuthorizationUrl({ acrValues: ACR_MFA }); // or prompt: "login", maxAge: 0

With the Express router or the Next.js handlers, /auth/login?acr=mfa does the same. Enrolled users are challenged in place; users with no second factor come back with error=unmet_authentication_requirements - send them to ${issuer}/ui/account to enrol.

Next.js sessions and refresh

getSession() returns the session with accessTokenExpired: true once the 10-minute access token has lapsed; redirect to /auth/refresh?next=/current/path and the route handler rotates the tokens and rewrites the cookie. Add age to the scopes for age_over + age_method + age_provider (a verified check) or age_declared (the sign-up gate only). The default scope includes offline_access, which is what a refresh token requires. JWKS fetches are memoised per issuer for the process.

Machines, agents and logout (0.3.0)

const cc = await client.clientCredentials({ scope: "agent", audience: "cli_other" });
const ex = await client.exchangeToken(userAccessToken, { actorToken: cc.access_token, audience: "cli_other" });
// AuthorizationError code "invalid_grant" = the audience's organization refuses this actor (receipted either way).

const device = await client.deviceAuthorize({ scope: "openid" });   // show device.user_code / verification_uri_complete
const tokens = await client.pollDeviceToken(device);                // interval, slow_down, expires_in, Retry-After

import { generateDpopKey } from "@zanii-id/sdk";
const bound = new ZaniiClient({ dpopKey: await generateDpopKey() }); // DPoP proofs on token, userinfo, revoke
const keyed = new ZaniiClient({ clientAssertionKey: { privateKey, kid: "k1" } }); // private_key_jwt, no secret
const { url } = await client.getAuthorizationUrl({ par: true, acrValues: ACR_MFA }); // PAR: only client_id + request_uri

client.logoutUrl(idToken, "https://app.test/bye", "s1");
const lt = await client.verifyLogoutToken(logoutToken);     // back-channel: end sessions for lt.sid
const sid = client.parseFrontchannelLogout(url.searchParams); // front-channel iframe ?iss=&sid=

buildAuthRouter (Express) and createAuthHandlers (Next, now { GET, POST }) mount POST .../backchannel-logout and GET .../frontchannel-logout when you pass onBackchannelLogout: (sid) => ...; the hook ends every local session tied to that IdP session id.

Server-to-server: postReceiptEvent(issuer, event, { platformClientId, webhookSecret }) relays a ledger receipt event to the user's activity page; new OrgClient(issuer, apiKey) wraps /orgs/* (settings and the agent deny list, clients, lifecycle URLs, domain and workload binding, screenAgent).

Claims: ZaniiClaims (from verifyIdToken) and UserInfo type act, cnf and the age scope.

Cheap for the issuer: discovery and JWKS cached, timeoutMs (default 10 s) on every fetch, reads retried once only when the server answered 429/502/503/504 (honouring Retry-After), token grants never retried, device polling never faster than told.

Prove it (0.4.0)

const info = await client.introspect(accessToken); // RFC 7662 + Zanii: act, cnf, cst, kya, consent
if (info.active && info.consent) console.log(info.consent.commitment, info.consent.scope);

The end-to-end verifier is the Python command zanii-id verify <did> (pip install 'zanii-id[verify]').

Notes

  • Confidential clients authenticate with HTTP Basic; without a secret the client runs in public mode and sends client_id in the body.
  • Token requests are never retried.
  • Session cookies are signed (HMAC-SHA256), HttpOnly, SameSite=Lax. They are tamper-evident, not encrypted — keep sensitive values out of them.

Licence

Apache-2.0. See LICENSE.