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

ab-ecosystem-node

v0.2.0

Published

Node SDK for the Partner Ecosystem: JWKS-based auth middleware and server-to-server actions.

Readme

@partner-ecosystem/node

Node SDK for the Partner Ecosystem. Initialise it once at application start-up, then use its middlewares (e.g. JWKS-based auth) and actions (server-to-server API calls).

Install

npm install @partner-ecosystem/node

express is a peer dependency (only needed if you use the middlewares).

Initialise

import { init } from '@partner-ecosystem/node';

export const partner = init({
  apiUrl: process.env.PARTNER_API_URL!,        // e.g. https://api.example.com
  projectId: process.env.PARTNER_PROJECT_ID!,
  projectSecret: process.env.PARTNER_PROJECT_SECRET!,
});

| Option | Required | Default | Description | | --------------- | -------- | --------------------------- | -------------------------------------------------------- | | apiUrl | yes | — | Base URL of the Partner Ecosystem API. | | projectId | yes | — | Public project identifier (also default token aud). | | projectSecret | yes | — | Secret used to authenticate action calls. | | jwksPath | no | /.well-known/jwks.json | JWKS endpoint relative to apiUrl. | | issuer | no | (unchecked) | Expected iss claim of incoming tokens. | | audience | no | projectId | Expected aud claim of incoming tokens. | | jwks | no | — | cacheMaxAge / cooldownDuration / timeoutDuration. | | roles | no | — | path / cacheMax / staleOnError — see Team members. | | logger | no | console | Sink for SDK warnings (stale role sets, warm failures). |

Middlewares

auth() — JWKS bearer-token authentication

Verifies the Authorization: Bearer <jwt> header. The token's kid selects the public key from <apiUrl>/.well-known/jwks.json; on success the decoded payload is attached as req.user and the request continues to the next handler.

import express from 'express';
import { partner } from './partner';

const app = express();

// Global
app.use(partner.middlewares.auth());

// Per-route
app.get('/me', partner.middlewares.auth(), (req, res) => {
  res.json({ user: req.user });
});

// Optional auth (sets req.user when a valid token is present, never 401s)
app.get('/feed', partner.middlewares.auth({ optional: true }), handler);

On a missing or invalid token the middleware responds 401 with { error, message } (unless optional: true).

It also attaches the normalised identity — owner vs. team member, actor id, role — as req.auth. See Team members & permissions.

req.user is typed via AuthUser. Add your own claims with declaration merging:

declare module '@partner-ecosystem/node' {
  interface AuthUser {
    orgId: string;
    roles: string[];
  }
}

Team members & permissions

SSO tokens can now act as a team member of an account instead of its owner. Three new claims carry that:

{ "psuid": "...", "accountId": "...", "act": "member",
  "roleId": "66f...c1", "roleVersion": 7 }

auth() normalises these onto req.auth, so you never touch the raw claims:

| req.auth | Meaning | | -------------- | ---------------------------------------------------------------------- | | actorId | psuid (falls back to sub) — the human to key audit logs on. | | accountId | The account the request is scoped to; shared by the owner and members. | | act | 'member' or 'owner'. | | isMember / isOwner | Pre-computed branch. | | roleId / roleVersion | Present for members only. | | claims | The raw verified payload (also still on req.user). |

Always branch on act, never on accountId === psuid. An owner carries no role claims at all; reading that as "a member with no permissions" locks the owner out of their own account.

requirePermission() — the usual case

const auth = partner.middlewares.auth();

// Owners always pass. Members need the exact permission string.
app.get('/billing', auth, partner.middlewares.requirePermission('BILLING.VIEW'), handler);

// Every listed permission is required…
app.post('/campaigns', auth, partner.middlewares.requirePermission(['CAMPAIGN.SEND', 'BILLING.VIEW']), handler);

// …or any one of them.
app.get('/billing', auth, partner.middlewares.requireAnyPermission(['BILLING.VIEW', 'BILLING.EDIT']), handler);

| Status | Body | When | | ------ | ----------------------------------------------------------- | ------------------------------------------------------------- | | 401 | { error: 'missing_token' } | No verified identity (auth() did not run, or optional). | | 403 | { error: 'forbidden', message: 'Access Denied', required, missing } | Member lacks the permission. | | 503 | { error: 'role_unavailable' } | Role could not be resolved at all — see Outages. |

Enforcing by hand

import { can } from 'ab-ecosystem-node';

const perms = await partner.permissionsFor(req.auth);
// null        → account owner, unrestricted
// empty Set   → member with no role
// Set<string> → the member's exact permissions

if (!can(perms, 'BILLING.VIEW')) return res.status(403).json({ error: 'forbidden' });

// or, in one call
if (!(await partner.hasPermission(req.auth, 'CAMPAIGN.SEND'))) { /* ... */ }

can() is an exact match — never startsWith, never wildcards. That is what keeps an unvalidated permission string inert rather than an escalation path. canAll, canAny and missingPermission are exported for multi-permission checks.

Caching

The permission set comes from GET <apiUrl>/api/v1/projects/:projectId/account/roles/:roleId — the same /api/v1/projects/:projectId/... base and the same project HTTP Basic credentials as every other call.

The cache key is roleId:roleVersion, so entries are immutable by construction — no TTL, no invalidation signal. When an owner edits a role, core bumps the version and the next minted token simply misses the cache. In steady state it is one fetch per distinct role version across your entire member base, and concurrent requests for the same version share one in-flight fetch.

// Optional: warm at start-up. Lazy resolution works fine without this.
await partner.warmRoles([{ roleId: '66f...c1', roleVersion: 7 }]);

// Bypass the cache (e.g. to display a role in an admin screen).
const role = await partner.getRole('66f...c1');  // { roleId, version, title, permission, accountId, raw }

Options: roles.cacheMax bounds the LRU (default 500 distinct role versions), roles.path overrides the endpoint path ({projectId} / {roleId} are substituted), and roles.staleOnError controls the outage behaviour below.

roles.path defaults to /api/v1/projects/{projectId}/account/roles/{roleId} — only override it if your deployment serves roles off a different base (e.g. an SSO host without the /api prefix: roles: { path: '/v1/projects/{projectId}/account/roles/{roleId}' }).

Outages

If a role fetch fails and that roleId was fetched successfully at some point before, the last known permission set is served and a warning is logged. Serving a slightly stale set is bounded risk; denying is a total outage for every member of a working product. If nothing is known for the role, the call throws (and requirePermission answers 503). Set roles: { staleOnError: false } to fail closed instead.

Freshness contract

A role edit or a membership revocation reaches the product when the member's token is next minted — up to 1h, since the SSO surface has no refresh flow. Core's own endpoints are immediate. If that window matters, shorten JWT_EXPIRES_IN and add a refresh endpoint; that fixes revocation and permissions together, whereas changing the permission transport fixes neither.

Audit logs — action required

A product that has only ever seen one identity per account almost certainly keys its logs on the account. Every member action will then be silently attributed to the owner, and that is retroactively uncorrectable. Audit every log write and use req.auth.actorId (the psuid) as the actor, keeping accountId as the scope:

log.info('campaign.sent', {
  actorId: req.auth.actorId,     // who did it      (psuid)
  accountId: req.auth.accountId, // whose data      (account)
  act: req.auth.act,             // 'member' | 'owner'
});

Also note: if you proxy /subscribe or the credit-wallet routes, expect 403 / Access Denied for members until roles carry those permission strings.

Actions

Server-to-server calls authenticated with the project credentials. Results are delivered through a node-style callback (error, result).

partner.actions.ping((err, result) => {
  if (err) return console.error('partner API unreachable', err);
  console.log('partner API ok:', result?.ok);
});

Development

npm install
npm run build       # compile to dist/ (+ .d.ts)
npm run typecheck   # type-check without emitting