ab-ecosystem-node
v0.2.0
Published
Node SDK for the Partner Ecosystem: JWKS-based auth middleware and server-to-server actions.
Maintainers
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/nodeexpress 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 onaccountId === 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