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

@b1-road/express

v0.1.0-alpha.1

Published

Official Express toolkit for Eduzz Plat — BFF auth (OIDC + session + proxy), permission middleware, typed routes, webhooks, and the resource client.

Readme

@b1-road/express

The official Express toolkit for integrating with Eduzz Plat. It is a BFF (Backend-for-Frontend): one app.use(road()) gives you the OIDC login flow, a server-side session + token store, and a streaming proxy to the Road API — plus the authorization primitives for your own routes. The browser never holds a JWT; it only carries an opaque, encrypted session cookie.

Status: alpha pre-release. BFF-only — there is no Bearer pass-through path. Built on @b1-road/node-core, which @b1-road/nestjs also uses, so PKCE, state, refresh and webhook signing are the same code in both drivers rather than two implementations that agree today.

New to Eduzz Plat?

Plat is Eduzz's platform layer — login, roles and permissions, and the business units your app builds on. This package is one piece of it; start here.

The fastest way in is to let your AI coding agent drive the integration:

claude mcp add road --scope user -- npx -y -p @b1-road/mcp road-mcp

That gives the agent the full integration guide plus tools to register a platform and issue its credentials.

Prefer to click through it? Create a platform in the Dev Portal. Prefer a scaffold in your repo? Run npx @b1-road/integrate.

Upgrading

Reaching production is now one variable, and one guard came with it.

  • ROAD_ENV=production resolves the Road API on its own. ROAD_API_BASE_URL is an override for a local stack or your own gateway — if it currently names a hosted Plat host, delete it.
  • The SDK refuses to boot when the Road API and the Auth Server belong to different Plat environments. Sandbox and production are separate instances with separate credentials, so that pairing could never sign anyone in; it used to fail at the first real user's login instead.
  • A NODE_ENV=production deploy whose URLs coherently name sandbox keeps working. NODE_ENV states a posture, not a target: it still governs cookie security, and the URLs decide which instance you talk to.

Install

npm install @b1-road/express

Peers: express (^4.18 || ^5), Node 20+. For the production token store, also install the optional peer ioredis.

A working example

examples/express-react-starter/ — an Express BFF plus a React SPA, with the dev proxy already set up (the part people miss: the session cookies are same-origin). The server is 60 lines and is the whole integration.

Quick start

// server.ts
import 'dotenv/config';          // must be the FIRST import
import express from 'express';
import { road, requireAuth, auth } from '@b1-road/express';

const app = express();
app.use(road());                 // reads the env vars below

app.get('/me', requireAuth(), (req, res) => {
  res.json(auth().user);
});

app.listen(3000);

Load your .env first — Express does not. road() reads process.env while it is being constructed, so the load has to happen before the module that calls it is imported. Skip it and the app throws at boot naming the exact variables that are sitting in your .env.

# .env
AUTH_SERVER_ISSUER_URL=https://auth.example.com
AUTH_SERVER_CLIENT_ID=your-client-id
AUTH_SERVER_CLIENT_SECRET=your-client-secret
AUTH_SERVER_REDIRECT_URI=https://your-app.com/auth/road/callback
SESSION_SECRET=<32+ byte random string>     # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
REDIS_URL=redis://localhost:6379            # or use a custom store — see "Token store"
ROAD_ENV=sandbox                            # or production — this is the whole address change
# AUTH_SERVER_AUDIENCE=<project-id>         # optional — only if your Auth Server audiences its tokens

road() mounts everything for you:

| Route | What it does | | --- | --- | | GET /auth/road/login | Starts the OIDC PKCE login; redirects to the Auth Server. Honors ?returnTo=. | | GET /auth/road/callback | Exchanges the code, mints the session, sets the cookie, redirects back. Restarts the login once if the login-state window expired mid-flow; on a genuine dead-end it renders a friendly retry page to browsers (JSON to API clients). | | POST /auth/road/logout | Revokes the tokens, destroys the session, clears the cookie, 204. | | GET /auth/road/logout | The same, then RP-initiated logout at the Auth Server so the next login isn't silently re-authenticated. | | ALL /road-api/* | Streaming proxy to the Road API — attaches the user's Bearer server-side. |

These route paths are a compatibility contract, not a convention. The redirect URI you register with the Auth Server names the callback literally, and they are identical to the ones @b1-road/nestjs mounts — so the same registered redirect URI works with either driver.

Auth Server setup: register an OIDC app with the redirect URI https://your-app.com/auth/road/callback and the openid profile email offline_access scopes, then drop its client id/secret into the env. Run npx road doctor to verify the wiring.

Where to mount it

app.use(road());            // at the root
app.use('/api', road());    // under a prefix — nothing else to configure

Under a prefix, the routes become /api/auth/road/* and /api/road-api/*. Point the React SDK's apiBaseUrl at /api/road-api and register the redirect URI with the prefix. Express hands the middleware a mount-relative URL, so the SDK works this out itself.

Mount it before your body parser if you can. The proxy forwards an unread request stream straight through, which is what keeps uploads and streaming request bodies working; once express.json() has consumed the body the proxy re-serialises it, which is correct but no longer streaming. Everything else works in either order.

Serving a cookie-mode SPA? Keep it same-origin. Serve the built SPA from this app (express.static) or put both behind one origin. The session and CSRF cookies are origin-bound, so a static SPA on a separate host can't use cookie mode — a local dev proxy hides this, and it breaks on deploy.

The React companion

@b1-road/react in its cookie-mode default needs two props — no JWT, no authMode:

<RoadProvider
  apiBaseUrl="/road-api"
  onUnauthenticated={() => window.location.assign('/auth/road/login')}
>
  <App />
</RoadProvider>

The React fetcher sends credentials: 'include', omits Authorization, and handles CSRF automatically: the BFF issues a readable XSRF-TOKEN cookie on login and on every proxied response, and the React fetcher echoes it as X-XSRF-TOKEN on mutations. Reads and writes work out of the box.

Protecting routes

auth(), can() and road are the whole handler-author surface — the same three you'd write in @b1-road/nestjs, so a handler's body doesn't change when an app changes framework.

import { requirePermission, auth, can, Read, Member } from '@b1-road/express';

app.get(
  '/business-units/:buId/members',
  requirePermission(Read, Member, { in: 'buId' }),
  async (req, res) => {
    res.json(await auth().road.businessUnits(req.params.buId).members.all());
  },
);

requirePermission implies requireAuth — one middleware, not two.

const a = auth();
a.userId            // string (Auth Server user id)
a.user              // RoadUser (from the id_token claims)
a.token             // the current access token (refreshed transparently)
a.road              // user-bound RoadClient
a.assert(can(Read, Member).in(buId));

a.road.businessUnits(buId).members        // for await iterable
a.road.as.service().iam.authorize(...)    // outbound service-mode override

auth() takes no argument — it reads the async context requireAuth() established, and keeps working across await. If you ever lose that context (a setTimeout, a queue hand-off), getAuth(req) reads it off the request instead.

Calling the API

auth().road is a RoadClient already bound to the caller. Three things are worth knowing, because they remove most of the code you would otherwise write.

A resource you fetched knows how to act on itself. No threading the same id through every call:

const bu = await auth().road.businessUnits.get(buId);

bu.name                       // it's the BusinessUnitDetail too
await bu.members.suspend(memberId);
await bu.roles.create({ name: 'Auditor', permissions: ['read:Member'] });

Pagination is a for await. No cursor loop, no hasMore bookkeeping:

for await (const member of auth().road.businessUnits(buId).members) {
  console.log(member.email);
}

// Bounded forms, when you want one page or a ceiling:
const first = await auth().road.businessUnits(buId).members.firstPage({ limit: 20 });
const some  = await auth().road.businessUnits(buId).members.all({ maxItems: 100 });

Expand replaces N+1. Ask for the children with the parent:

const bu = await auth().road.businessUnits.get(buId, { include: ['members', 'roles'] });
//                                                              ^ autocompleted,
//                                                                and a typo is a
//                                                                compile error

Per-call options live on the call, not in global config:

await auth().road.businessUnits.get(buId, { timeoutMs: 2000, signal: ac.signal });
await auth().road.businessUnits.create(input, { idempotencyKey: myKey });

roadRouter() — the scope name, checked by the compiler

requirePermission(..., { in: 'buId' }) is resolved at runtime, because app.get() doesn't tell the middleware what path it was registered on. Declare the route through roadRouter() and the path becomes part of the type:

import { roadRouter, auth, Read, Member } from '@b1-road/express';

const router = roadRouter();

router.get(
  '/business-units/:buId/members',
  { permission: [Read, Member], in: 'buId' },   // ← 'orgId' here is a compile error
  async (req, res) => {
    res.json(await auth().road.businessUnits(req.params.buId).members.all());
  },
);

app.use(router);

Two scopes: the business unit, and your platform's subscription

{ in: 'buId' } authorizes against the business unit's scope. That is right for Road's own subjects (Member, Role, Invitation, BUSettings) and wrong for yours.

The permissions you declared for your platform live on the subscription scope — a child scope Road creates when a business unit subscribes to your platform. Grants are collected by walking up the scope tree, never down, so a check resolved at the business unit cannot see a role granted on the subscription:

// Road's subject — business-unit scope.
requirePermission(Read, Member, { in: 'buId' });

// YOUR subject — the subscription scope where your roles actually live.
requirePermission(Read, 'Invoice', { in: { from: 'platform', buParam: 'buId' } });

buParam names the route param carrying the business unit id. The platform defaults to the platformId you pass to road() (or ROAD_PLATFORM_ID), so an app that is a platform never names itself; add platformId: 'plat_…' to the check to address a different one.

Test this with a non-owner. A business-unit owner holds a wildcard role that cascades down into the subscription scope, so they pass either form. The wrong one denies everyone else, which is why this surfaces in production rather than in development.

Routes on a roadRouter() are authenticated by default — a route you forget to protect is the failure mode that matters, so opening one is explicit:

router.get('/health', { public: true }, handler);        // no auth at all
router.get('/profile', { authenticate: true }, handler); // authn, no permission check

It returns a real express.Router, so router.use(...), mounting under a prefix, and every other middleware work unchanged.

Two things to know. The path must be a string literal for the checking to work — that is where the param names come from, so a path held in a variable falls back to no checking. And roadRouter() sets mergeParams: true (Express defaults it off), so a router mounted at /business-units/:buId still sees :buId in req.params, which is where a scope resolver looks for it.

Errors

Road's middleware answers a failed authentication or authorization itself, rather than calling next(err). That is deliberate: Express's default error handler turns a thrown 401 into an HTML 500, so a signed-out fetch would see a server error instead of "you are signed out" — and @b1-road/react, which calls onUnauthenticated on a 401, would never fire it.

Two escape hatches, both explicit:

app.use(road({ onAuthError: (err, req, res, next) => { ... } }));  // globally
app.get('/x', requireAuth({ next: true }), handler);               // per route

Mount roadErrorHandler() last to render a RoadApiError thrown from your handler (auth().assert(...), a road.* call that 404s) in the same shape:

app.use(roadErrorHandler());
import { RoadAuthzError } from '@b1-road/express';

try {
  await road.iam.authorize({ ... });
} catch (err) {
  if (err instanceof RoadAuthzError) {
    err.code         // 'permission_denied'
    err.decision     // structured DecisionTrace
    err.requestId    // correlates to Road API logs — quote this in support
  }
}

In non-prod, X-Road-Debug: 1 (or ?debug=road) appends the DecisionTrace to 403 bodies.

Two things worth knowing

road() registers no route patterns. It is a plain middleware that reads the path and decides, because '/road-api/*' is valid on Express 4 and a path-to-regexp error on Express 5 — so registering patterns would mean picking a major. The cost: /auth/road/* and /road-api/* are invisible to route-dumping tools like express-list-endpoints, and a route of your own on those paths registered before road() will win while one registered after never runs.

roadRouter() sets mergeParams: true. Express defaults it off, which means a router mounted at /business-units/:buId would not see :buId in req.params — and the resulting "scope param not present on the route" is a confusing way to learn it.

How it works

Browser ── session cookie ──▶ Express ── Bearer (Auth Server JWT) ──▶ Road API
                              │
                              │ token store (Redis / custom / memory)
                              ▼
                              Auth Server (OIDC discovery + token endpoint)

The browser holds only an iron-session-sealed cookie carrying an opaque session id. The token store, keyed by that id, holds the TokenSet (access + refresh + expiry + cached id_token claims). The proxy and requireAuth() fetch the access token from the store, refresh it transparently when it's within 60s of expiry, and attach Authorization: Bearer … server-side. Concurrent requests that all hit the refresh window share one refresh, so a rotating refresh token is never spent twice.

Configuration

road() with no arguments reads everything from env. Override inline as needed:

app.use(road({
  authServer: {
    issuerUrl:    process.env.AUTH_SERVER_ISSUER_URL,
    clientId:     process.env.AUTH_SERVER_CLIENT_ID,
    clientSecret: process.env.AUTH_SERVER_CLIENT_SECRET,
    redirectUri:  process.env.AUTH_SERVER_REDIRECT_URI,
    scopes:       ['openid', 'profile', 'email', 'offline_access'],
    // loginStateTtlSeconds: 900,   // login-state window (default 15 min)
  },
  store:   { driver: 'redis', url: process.env.REDIS_URL },
  session: { name: 'road_session', secret: process.env.SESSION_SECRET, maxAge: 60 * 60 * 24 * 7 },
  proxy:   { prefix: 'road-api', allow: ['organization/*', 'me/*', 'iam/identity/*', 'iam/authorization/*'] },
  api:     { baseUrl: process.env.ROAD_API_BASE_URL, version: 'alpha' },
  logger:  console,   // default: silent
}));

Production safety — road() throws at boot when:

  • OIDC client credentials are missing,
  • the Road API and the Auth Server belong to different Eduzz Plat environments (sandbox credentials in production posture, or the reverse — the two are separate instances and that pairing cannot sign anyone in),
  • store.driver is memory and NODE_ENV === 'production',
  • session.secret is shorter than 32 bytes,
  • api.baseUrl is unparseable or not http(s) (it is optional now: ROAD_ENV resolves a hosted one),
  • proxy.prefix is empty (it would claim every route in your app),
  • authServer.loginStateTtlSeconds is not positive.

Session cookie and sameSite

The session cookie defaults to SameSite=Lax, which is what you want: the browser withholds it on cross-site POSTs, so a forged form cannot act as your user.

Setting session.sameSite: 'none' gives that up, and a cross-origin SPA is the usual reason to reach for it. Before you do, note what it costs: the /auth/road/logout routes are deliberately not CSRF-protected (they are reached by a top-level navigation), and Road's logout ends all of the user's Road sessions — so under none, a forged request can sign someone out of every Road-integrated app. That is a nuisance, not an escalation, but it is a nuisance you should choose knowingly.

The better answer is almost always to keep the SPA same-origin — serve it from this app, or put both behind one domain — which is what the proxy exists to make easy.

Token store

| Driver | When | Notes | | --- | --- | --- | | memory | dev / tests | Map with TTL eviction (refused in production) | | redis | production | road:session:{id}; needs the optional ioredis peer | | custom | your own | store: { driver: 'custom', store: myStore } implementing RoadTokenStore |

You do not need Redis. Session state has to live outside the process because you run more than one container — not because of any framework. If you already run Postgres, DynamoDB or anything else with a key-value read, a custom store is four methods (put / get / delete / list) and costs you nothing extra to operate:

const store: RoadTokenStore = {
  async put(id, tokens) { await db.upsert('road_sessions', { id, tokens }); },
  async get(id)         { return (await db.find('road_sessions', id))?.tokens ?? null; },
  async delete(id)      { await db.remove('road_sessions', id); },
  async list()          { return db.ids('road_sessions'); },
};

app.use(road({ store: { driver: 'custom', store } }));

Call roadApp.close() from your SIGTERM handler so the store releases what it holds — otherwise a rolling deploy waits for the socket to time out:

const roadApp = road();
app.use(roadApp);
process.on('SIGTERM', async () => { await roadApp.close(); server.close(); });

Proxy & CSRF

The proxy forwards only allowlisted path prefixes (default organization/*, me/*, iam/identity/*, iam/authorization/*); anything else 404s before any token lookup, and a path containing . or .. segments is rejected outright so it cannot normalise out of the namespace.

Non-GET requests require a signed double-submit CSRF token (cookie XSRF-TOKEN, header X-XSRF-TOKEN). The token is an HMAC of the session under your session secret, and the BFF verifies it is the one it issued for this session — so an attacker who can write a cookie on your domain still cannot forge it. Both names are configurable under proxy.csrf.

Webhooks

import { roadWebhooks } from '@b1-road/express';

const hooks = roadWebhooks({ secret: process.env.ROAD_WEBHOOK_SECRET })
  .on('organization.member.joined', async (event) => {
    await db.members.upsert(event.data);
  })
  .on(['organization.member.removed', 'organization.member.suspended'], async (event) => {
    await db.members.deactivate(event.data.memberId);
  });

app.post('/road/webhooks', hooks);

Deliveries are verified by HMAC-SHA256 over the raw bytes, with a 5-minute replay window. It is fail-closed: with no secret (and no ROAD_WEBHOOK_SECRET) every delivery is refused with a 503, so you find out on the first one rather than accepting anonymous POSTs.

The handler reads the raw body itself, so it works whether or not express.json() is registered, and in either order. The one case it cannot rescue is a body parser that consumed the stream and kept nothing — there it refuses with a 503 naming the fix, rather than "verifying" against a re-serialised body (key order and whitespace differ, so every genuine delivery would fail). If you parse globally, keep the bytes:

app.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; } }));

An event type you have no handler for is acknowledged with 200 and logged — otherwise every new event Road ships would show up as a delivery failure.

The wire format

| Header | Meaning | | --- | --- | | X-Road-Signature | sha256=<hex> — HMAC-SHA256 over `${timestamp}.${rawBody}` | | X-Road-Timestamp | Unix seconds (or ms). Must be within 5 minutes. |

The response tells you whose problem it is:

| Status | Meaning | | --- | --- | | 200 | verified and handled (or acknowledged as an unknown event type) | | 400 | the signature verified, but the body is not a Road delivery envelope | | 401 | the delivery did not verify — bad signature, stale timestamp, missing headers | | 408 | the body did not finish arriving inside readTimeoutMs (default 10s) | | 413 | the body exceeded maxBodyBytes (default 1 MiB) | | 503 | you are not configured — no secret, or the raw body was unavailable |

Replays are bounded, not prevented. A delivery captured and re-sent inside the 5-minute window verifies again, because nothing here remembers which deliveries it has seen — that needs storage, and the storage is yours to choose. Every delivery carries a unique id; if your handler is not idempotent, record it and drop repeats. Most handlers (upserts, state assignments) are naturally idempotent and need nothing.

A verify callback replaces the HMAC default entirely — for an Auth Server whose signing protocol differs, or to authenticate on something else (mTLS, an IP allowlist). It wins over secret, and it receives rawBody: string | undefined; treat undefined as "cannot verify" rather than falling back to the parsed body.

Events

ROAD_WEBHOOK_EVENT_TYPES is the catalog, exported for exhaustiveness checks:

import { ROAD_WEBHOOK_EVENT_TYPES, type RoadEventType } from '@b1-road/express';

| Event | Fires when | | --- | --- | | organization.invitation.created | an invitation is sent | | organization.invitation.accepted | an invitation is accepted | | organization.invitation.rejected | an invitation is declined | | organization.invitation.cancelled | an invitation is revoked | | organization.member.joined | a member joins a business unit | | organization.member.suspended | a member is suspended | | organization.member.reinstated | a suspended member is restored | | organization.member.removed | a member is removed | | organization.member.role-changed | a member's roles change | | bridge.grant.created | a Platform Bridge grant is issued | | bridge.grant.revoked | a Platform Bridge grant is revoked | | extension.install.created | an extension is installed on a business unit | | extension.install.uninstalled | an extension is uninstalled |

RoadEventType is the union of exactly the entries in ROAD_WEBHOOK_EVENT_TYPES, so .on('typo.event') is a compile error rather than a handler that never fires — and the table above is generated from the same list, so it cannot quietly fall behind it.

If you cache authorization answers, subscribe to the bridge.grant.* and extension.install.* events. They are what lets a revoked grant stop being honoured on the event rather than at the next cache expiry.

Health

import { roadHealth } from '@b1-road/express';

app.get('/healthz', roadHealth());

200 when the Road API, the Auth Server and the token store all answer; 503 with a per-dependency reason otherwise. Every probe is bounded (5s default). For a liveness probe, narrow it — roadHealth({ checks: ['token-store'] }) — so a brief Road outage doesn't restart a container that is fine.

Test mode — no Auth Server, no Redis, no signed JWTs

import express from 'express';
import request from 'supertest';
import { roadScenario, roadTest } from '@b1-road/express/testing';

const scenario = roadScenario()
  .withUser('u_owner', { name: 'Eduardo' })
  .withBusinessUnit('bu_1', { name: 'B1' })
  .withRole('bu_1', 'Owner', { permissions: ['*'] })
  .withMember('bu_1', 'u_owner', { roles: ['Owner'] })
  .withSession('u_owner', { sessionId: 'sess_1' });

const app = express();
app.use(await roadTest(scenario));     // ← the only line that differs from production
app.use(myRoutes);

await request(app)
  .get('/road-api/organization/business-units/bu_1/members')
  .set('Cookie', await scenario.sessionCookieFor('sess_1'))
  .expect(200);

roadTest() swaps the Auth Server, the token store and the Road API for in-memory stand-ins seeded from the scenario — but exercises the SDK's real code paths: the same session sealing, the same proxy chain, the same permission checks. The OIDC round-trip itself isn't simulated; declare sessions instead.

The scenario object comes from @b1-road/node-core, so a suite written against the Nest driver ports across without touching its fixtures.

Service mode (workers, cron, queues)

Outbound calls made outside a request use a service JWT:

// `process.env` values are `string | undefined`, and these three are
// required — so read them somewhere that fails loudly rather than widening
// the type at the call site.
function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
}

const roadApp = road({
  service: {
    kind: 'private_key_jwt',
    clientId: required('ROAD_SERVICE_CLIENT_ID'),
    keyId: required('ROAD_SERVICE_KEY_ID'),
    privateKey: required('ROAD_SERVICE_PRIVATE_KEY'),
  },
});
app.use(roadApp);

// inside a worker (no request in flight)
await roadApp.client.iam.authorize({ ... });

The SDK obtains an Auth Server token via client_credentials or private_key_jwt, caches it until exp − 60s, and re-acquires on 401.

Platform Bridge

import { requireBridgeGrant } from '@b1-road/express';

// The mount carries :tenantId, because resolveTenant reads it from the route.
app.use('/partner-api/:tenantId', requireBridgeGrant({
  client: roadApp.client,
  permission: 'read:Order',
  resolveTenant: (req) => (req as { params: Record<string, string> }).params.tenantId,
}));

Provider-side enforcement for brokered tokens, including the tenant and acting-user bindings Road cannot check for you — the two checks Road cannot make, because only you know whose data a request touches.

resolveTenant is not optional in practice: strictTenancy defaults to true, so without it every Extensions-minted token is refused. If you cache authorization answers, also subscribe to bridge.grant.* and extension.install.* (below) — they are what lets a revoked grant stop being honoured on the event rather than at the next cache expiry.

CLI

npx road doctor                 # Auth Server + token store + Road API health
npx road session list           # active sessions (requires REDIS_URL)
npx road session revoke <id>    # server-side logout for one session
npx road cache clear            # bust the OIDC discovery cache

Relationship to the other drivers

| Package | Use it when | | --- | --- | | @b1-road/express | your server is Express | | @b1-road/nestjs | your server is NestJS | | @b1-road/node-core | you are writing a driver for another framework |

All three share one implementation of PKCE, state, refresh, the proxy chain, the CSRF double-submit and the webhook HMAC. A fix lands once and every driver inherits it.