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

@sidub-inc/licensing-client

v2.2.0

Published

React/TypeScript runtime library for Sidub Licensing - License enforcement and consumption reporting

Readme

@sidub-inc/licensing-client

TypeScript/React runtime library for Sidub Licensing. Provides license enforcement, feature gating, consumption reporting, and cryptographic signature verification.

Installation

npm install @sidub-inc/licensing-client

Requirements:

  • Modern browsers with Web Crypto API support (Chrome, Firefox, Safari, Edge)
  • Node.js 20+ (fetch and crypto.subtle are built in)
  • React 16.8+ (optional, for LicensingProvider / useLicensingContext)

Configuration

Using Encoded Credentials (Recommended)

Encoded credentials bundle all required values into a single portable string:

import { LicensingClient } from '@sidub-inc/licensing-client';

const client = new LicensingClient({
  licenseServiceUri: 'https://api.monaiq.com/licensing',
  encodedCredential: 'SIDUB_LIC_eyJ...',    // From your license portal
  issuerClientId: 'your-account-client-id', // your seller account, from the portal
  productId: 'your-product-id'              // the product this application is
});

The encoded credential contains four values: seatId, serviceKeyId, serviceKeyPublicMember, and the seat's runtimeToken. It grants exactly one seat and nothing else, it authenticates with Authorization: Bearer on the /runtime operations, and it can be revoked from the seat page.

The runtime token is opaque. Never decode it, parse claims out of it, log it or display it — hand it to the SDK as part of the credential string and pass it through unread. It has no expiry of its own: it lives as long as the seat, until you revoke it from the seat page.

The account API key is not this. It is the seller's server-side identity for checkout and the management API; it never ships inside a distributed application, and it has no field on LicensingConfig (see section 4).

Declare your seller and product. issuerClientId and productId are sent with every runtime call; the platform refuses a seat of another seller or product with the denial reason ProductMismatch, and the client refuses an authorization or grant whose signed issuer is not the declared seller. Set both — without them, any credential whose features carry the same keys is accepted.

Manual Configuration

const client = new LicensingClient({
  licenseServiceUri: 'https://api.monaiq.com/licensing',
  runtimeToken: 'eyJ...',                             // opaque; normally carried by encodedCredential
  seatId: 'your-seat-uuid',                           // Optional if passed to getAuthorization()
  serviceKeyId: 'your-service-key-uuid',              // Required for signature validation
  serviceKeyPublicMember: 'base64-encoded-public-key' // Required for signature validation
});

Configuration Reference

| Property | Required | Description | |----------|----------|-------------| | licenseServiceUri | Yes | Base URI for the licensing API | | encodedCredential | No | Encoded credential string (SIDUB_LIC_...) — carries the seat id, the runtime token and the verification key | | runtimeToken | No | The seat's runtime token — normally carried by encodedCredential, rarely set directly. Opaque | | seatId | No | Default seat ID | | serviceKeyId | No | Key ID for signature verification | | serviceKeyPublicMember | No | Base64 EC public key (P-256 SPKI) for signature verification | | consumptionServiceUri | No | Separate URI for consumption reporting | | timeout | No | Request timeout in ms (default: 30000) | | validateSignatures | No | Signature validation posture. Unset = auto (on when a verification key is configured; a one-time warning is emitted when it ends up off). true without a key throws LicensingConfigurationException. false = explicit unsigned mode, no warning. | | cacheEnabled | No | Enable authorization caching (default: true) | | cacheMaxSize | No | Maximum cached authorizations (default: 100) | | cacheFreshTtlMs | No | Upper bound in ms on cache freshness before revalidation (default: 24h cap; 1h for no-expiry authorizations). Revocation latency = min(cacheFreshTtlMs, remaining seat window) — tighten for faster revocation pickup, at the cost of more calls. 0 revalidates every read. | | staleGraceMs | No | Stale-serve grace window in ms: how long an expired-but-signature-validated cached authorization may still be served (flagged isStale) when the service is transiently unreachable (default: 24h, capped 7d, 0 disables). Denials (401/403/404) are never stale-served. | | contextProvider | No | Custom ILicensingContextProvider for credential resolution | | billableResourceId | No | Billable resource ID for consumption reporting | | issuerClientId | No | Your account's client id, sent with every runtime call. A seat of another seller is refused (ProductMismatch). Set it | | productId | No | The product this application is. A seat of another product — your own others included — is refused (ProductMismatch). Set it |

Authentication Channel

Every runtime request sends its token header-only — never in the URL, where it would leak into browser history, Referer headers, and proxy/CDN logs. (Before 2.0 the SDK also appended a ?subscription-key= query string; it is gone.)

POST {licenseServiceUri}/runtime/GenerateLicenseAuthorization
Authorization: Bearer <runtimeToken>

POST {licenseServiceUri}/runtime/AcquireAllowanceGrant
POST {licenseServiceUri}/runtime/ReleaseAllowanceGrant
POST {licenseServiceUri}/runtime/ReadAllowance
Authorization: Bearer <runtimeToken>

POST {consumptionServiceUri}/runtime/messages
Authorization: Bearer <runtimeToken>

The credential sends no API-key header of either name: it carries no account authority.

Checkout is not on this path. createCheckoutSession / getCheckoutResult / pollCheckoutResult authenticate as the SELLER with the account key, which you pass explicitly per call (see section 4). A runtime credential cannot drive them.

Revocation latency. Revoking a runtime credential reaches a running app at its next authorization revalidation: bounded by the cache freshness deadline — min(cacheFreshTtlMs, remaining authorization window), at most 24h by default — and a denial is never stale-served. Consumption is refused as soon as the next batch arrives.

Core Operations

1. Get License Authorization

// Using configured seat ID
const authorization = await client.getAuthorization();

// With explicit seat ID
const authorization = await client.getAuthorization('seat-uuid');

// Authorization includes:
// - seatId, licenseId, classification, features[], issuedAt, expiresAt
// - signature (if server-signed)
// - signatureValidated (true if cryptographic verification passed)

2. Assert License Conditions

import { ServiceAccessAssertion, ServiceAccessLevel } from '@sidub-inc/licensing-client';

const assertion = ServiceAccessAssertion.create('premium-feature', ServiceAccessLevel.Allowed);
const hasAccess = client.assertLicense(assertion, authorization);

3. Report Consumption

await client.performOperation({
  seatId: 'seat-uuid',
  feature: { featureKey: 'api-calls' },
  operationType: 'increment',
  quantity: 1,
  timestamp: new Date()
});

performOperation counts the use before it reports it — against the feature's allowance or its rate-limit window — so enforcement holds whether or not consumptionServiceUri is set; the URI only decides whether the use is also reported. An allowance use the period does not cover throws AllowanceExhaustedError (see Allowances).

4. Checkout from your backend (API key — server-side only)

createCheckoutSession / getCheckoutResult / pollCheckoutResult on LicensingClient are the key-authenticated checkout/embedded path. The API key is a seller secret, so this path runs on your backend only — a Node service that takes the order, knows the customer's email, and holds the key in its own configuration. It is never called from a browser bundle, a desktop shell or a mobile app: anything that ships to the buyer's device purchases with the buyer's own identity instead (section 5).

The key is a required explicit argument on each of the three calls. It has no field on LicensingConfig, so it cannot be baked into a client a device might one day receive.

// Backend (Node) — the API key stays in server configuration.
const sellerApiKey = process.env.MONAIQ_API_KEY!;

const client = new LicensingClient({
  licenseServiceUri: 'https://api.monaiq.com/licensing'
});

const session = await client.createCheckoutSession({
  offeringId: 'offering-uuid',
  issuerClientId: 'issuer-uuid',
  correlationId: order.id,
  customerEmail: order.customerEmail,
  successUrl: 'https://example.com/success',
  cancelUrl: 'https://example.com/cancel'
}, sellerApiKey);

// Paid offerings: hand session.sessionUrl to your front end to redirect the
// customer.
//
// Free offerings bought against a TYPED email address: no URL, and nothing is
// provisioned yet — the buyer has not been proven. The response carries
// session.claimUrl: send the buyer there NOW, in the same browser. They confirm,
// prove the address with a one-time code, and come back to your returnUrl with
// the session id — then one getCheckoutResult read is enough. Monaiq emails the
// same link only as a fallback for a buyer who closed the tab; never make them
// wait for it. Without a returnUrl, keep polling: the result stays 'pending'
// until the buyer confirms, then flips to 'completed' with the credential.
if (session.claimUrl) redirectTo(session.claimUrl);

// Poll for the outcome with exponential backoff (1s → 15s cap, 60 attempts).
const result = await client.pollCheckoutResult(session.sessionId, {
  apiKey: sellerApiKey,
  signal: abortController.signal  // optional: cancel polling
});
// result.status: 'completed' | 'failed'
// result.encodedCredential: SIDUB_LIC_... (on completion)
// Throws LicensingError if max attempts are exceeded or the signal aborts

'pending' covers async settlement. The server deliberately reports its internal PaymentPending state (delayed-notification payment methods such as bank debits) as 'pending' on the wire. A session paid by bank debit can stay 'pending' for days and still complete — the result remains pollable for up to 14 days. Treat a prolonged 'pending' as "money may still be moving", not as failure. This applies to both checkout paths.

What you receive is a license code. result.encodedCredential carries a runtime token that authorises that one seat and nothing else, and it can be revoked from the seat page. Install it with new LicensingClient({ licenseServiceUri, encodedCredential }).

Store the credential out of JavaScript's reach. encodedCredential is base64-obfuscated, NOT encrypted — anyone holding it can read the secret inside. Hand it to your backend for storage; do not persist it in localStorage or anywhere page JavaScript (and therefore any XSS) can read it. The SDK itself never writes the credential or its secret to localStorage — it persists only authorization payloads and rate-limit counters, and the secret appears in storage key names only as a non-reversible hash.

5. In-app purchase (buyer bearer — PurchaseClient)

PurchaseClient is how a seller's own distributed application sells the seller's offerings to the person using it. The rule it implements:

The buyer is the credential. The app signs the buyer in with their Monaiq identity and purchases with the buyer's bearer token. No seller secret ships — there is no API key on this client, and no customer email in the request: the server resolves the buyer from the token and mints the license for them.

import { PurchaseClient } from '@sidub-inc/licensing-client';

const purchase = new PurchaseClient({
  purchaseServiceUri: 'https://api.monaiq.com/purchase',   // parallel to …/licensing
  getAccessToken: () => acquireBuyerToken(),               // from MSAL / native auth (below)
  timeout: 30000                                           // optional, ms
});

// 1. Read the seller's catalog LIVE — anonymous, no token, no baked-in ids.
const catalog = await purchase.listOfferings(issuerClientId);
const offering = catalog.offeringsByProduct[productId][0];
const review = await purchase.getOffering(issuerClientId, offering.id);
//    review.resolvedFeatures: what the license will grant

// 2. Buy as the signed-in buyer. Keep the requestId: a RETRY MUST SEND THE SAME ONE.
const requestId = crypto.randomUUID();
const session = await purchase.createCheckout({
  requestId,
  offeringId: offering.id,
  issuerClientId,
  correlationId: 'user-42',          // optional, ≤ 128 chars, echoed on the result
  originContext: 'mobile_app'        // from a mobile shell; 'web' or omitted elsewhere
});

// 3. Paid: open the hosted Stripe Checkout URL in the SYSTEM browser.
//    Free: sessionUrl is null — the license is already minted.
if (session.sessionUrl) await openInSystemBrowser(session.sessionUrl);

// 4. Poll the result as the buyer — never rely on the redirect landing in your app.
const result = await purchase.pollResult(session.sessionId, { signal });
// result.status: 'completed' | 'failed'
// result.encodedCredential: the BUYER's SIDUB_LIC_... credential (on completion)

| Method | Auth | What it does | |--------|------|--------------| | listOfferings(issuerClientId) | none | GET /purchase/{issuerClientId}/offerings — the storefront as monaiq.com/marketplace/{issuerClientId} shows it | | getOffering(issuerClientId, offeringId) | none | GET /purchase/{issuerClientId}/offerings/{offeringId} — one offering's checkout review with resolved features | | createCheckout(request) | buyer bearer | POST /purchase/checkout — mints a free license immediately, or returns the Stripe Checkout URL for a paid one | | getResult(sessionId) | buyer bearer | GET /purchase/checkout/{sessionId} — the outcome, visible only to the buyer who made it | | pollResult(sessionId, { signal?, maxAttempts? }) | buyer bearer | getResult with backoff (1s → 15s cap, 60 attempts), honouring Retry-After, abortable |

getAccessToken is called on every request, so a provider that refreshes silently is always current. It is required by createCheckout, getResult and pollResult — the client throws LicensingConfigurationException naming it before any request when it is absent — and never called by the reads.

Idempotency. requestId defaults to a fresh crypto.randomUUID(). When you retry a failed createCheckout (network drop, expired token) send the same requestId: the server returns the checkout that already exists instead of minting a second one, and refuses (409 RequestIdInUse) an id that belongs to a different buyer or seller. Generate it once per purchase attempt and keep it until the result is final.

Local validation mirrors the server and throws LicensingConfigurationException before any request: the three ids must be GUIDs, originContext must be 'web' or 'mobile_app', successUrl / cancelUrl must be absolute http(s) URLs, correlationId ≤ 128 characters.

Errors are LicensingError with the server's machine code and correlationId (see Error Handling): BuyerTokenMissing / BuyerTokenInvalid (401 — sign the buyer in again), CheckoutResultForbidden (403 — another buyer's checkout), NotFound (404 — seller or offering not on sale), RequestIdInUse (409), ValidationError (400).

Who can hold a buyer bearer — and the SPA rule

PurchaseClient is for shells that can sign the buyer in with Monaiq's public client and obtain a bearer for the licensing audience (checkout.purchase):

  • Electron / desktop — MSAL with system-browser PKCE against the Entra External ID authority and a loopback redirect.
  • React Native / mobile — Entra External ID native authentication.
  • Node backends acting for a signed-in buyer — the buyer's token, not a seller key.

A plain browser single-page app cannot. Its callback URL cannot be registered on Monaiq's public client, so it has no way to obtain a buyer bearer. In phase one a SPA does not purchase in-app at all: it links the buyer to the hosted storefront, https://monaiq.com/marketplace/<issuerClientId>, and the hosted page takes the purchase. The catalog reads (listOfferings, getOffering) still work from a SPA, so you can render your own offering list and link each item to the storefront.

// Electron / desktop shell — MSAL Node, system-browser PKCE, loopback redirect.
// Authority, public client id and the checkout.purchase scope for your
// environment come from the Monaiq docs; none of them is a secret.
const SCOPES = ['api://<monaiq-licensing-app-id>/checkout.purchase'];

async function acquireBuyerToken(): Promise<string> {
  const account = (await pca.getTokenCache().getAllAccounts())[0];
  const result = account
    ? await pca.acquireTokenSilent({ account, scopes: SCOPES })
    : await pca.acquireTokenInteractive({ scopes: SCOPES, openBrowser: (url) => shell.openExternal(url) });
  return result.accessToken;
}

6. Report Access Check (Telemetry)

Telemetry-only: the event is always sent non-billable with a zero amount. Billable metering flows exclusively through performOperation().

// All parameters optional — defaults come from the resolved context/config.
await client.reportAccessCheck();                        // configured seat + feature context
await client.reportAccessCheck('seat-uuid', 'api-calls'); // explicit seat + feature key

Authorization Caching

Authorization responses are cached (in memory, persisted to localStorage in browsers — the raw runtime token is never stored, and appears in storage key names only as a non-reversible hash) with TTL-based freshness. Caching is enabled by default.

const client = new LicensingClient({
  licenseServiceUri: '...',
  encodedCredential: '...',
  cacheEnabled: true,       // default
  cacheMaxSize: 100,        // default
  cacheFreshTtlMs: 3600000, // optional: tighten revalidation cadence (default cap: 24h)
  staleGraceMs: 86400000    // optional: stale-serve window on outages (default: 24h)
});

// Manual cache management
client.clearCache();                    // Remove all cached authorizations
client.invalidateCache('seat-uuid');    // Remove entries for a specific seat

When caching is enabled, repeated calls to getAuthorization() with the same seat ID return the cached result until its freshness deadline — min(seat expiry, cacheFreshTtlMs, 24h; 1h when the authorization has no expiry). Revocation trade-off: a revoked or downgraded seat keeps authorizing from cache until that deadline; tighten cacheFreshTtlMs to shorten the window. During transient outages an expired-but-validated entry may additionally be served flagged isStale for up to staleGraceMs — but a server denial (401/403/404) is never masked.

An entry restored from localStorage is verified with the same validator as a fetched one on its first use, within the authorization's own signed expiry, before it is served; an entry that fails, or that an earlier client stored without the signed text, is removed and fetched again. With validateSignatures: false nothing is verified.

Context Providers

Context providers resolve licensing credentials dynamically, supporting multi-tenant scenarios.

ConfigurationContextProvider (Default)

Builds context from LicensingConfig fields. Used automatically when no custom provider is configured:

import { ConfigurationContextProvider } from '@sidub-inc/licensing-client';

const provider = new ConfigurationContextProvider(config);
const context = await provider.resolveContext();
// context: { seatId, serviceKeyId, serviceKeyPublicMember, runtimeToken, billableResourceId? }

Custom Context Provider

Implement ILicensingContextProvider for custom resolution strategies (e.g., per-tenant lookup):

import { ILicensingContextProvider, LicensingContextType } from '@sidub-inc/licensing-client';

class TenantContextProvider implements ILicensingContextProvider {
  async resolveContext(): Promise<LicensingContextType | null> {
    const tenantCreds = await fetchTenantCredentials();
    if (!tenantCreds) return null;
    return {
      seatId: tenantCreds.seatId,
      serviceKeyId: tenantCreds.serviceKeyId,
      serviceKeyPublicMember: tenantCreds.publicKey,
      runtimeToken: tenantCreds.runtimeToken   // opaque — never decode or log it
    };
  }
}

Portable Encoded Credentials

import { licensingContextFromEncodedString, licensingContextToEncodedString } from '@sidub-inc/licensing-client';

// Decode an encoded credential string into a context
const context = licensingContextFromEncodedString('SIDUB_LIC_eyJ...', billableResourceId);

// Encode a context back to a portable string (billable fields are not included)
const encoded = licensingContextToEncodedString(context);

Changing the credential at runtime

await client.refresh(newConfig) rebuilds the client's credential from a new encodedCredential (the other fields merge; explicit fields on newConfig win), clears the cached state, and returns the allowance units held under the old credential to the platform. Call it from an effect keyed on the credential string instead of remounting the provider.

Feature State (Rate Limiting)

Track local rate-limit consumption within time windows:

import { RateLimitFeatureState } from '@sidub-inc/licensing-client';

// Track consumption within a 60-second window
const state = new RateLimitFeatureState(60);
state.consumeRate(1);
state.consumeRate(1);
const current = state.getConsumption(); // 2 (entries older than 60s are pruned)

RateLimitAssertion consults local feature state before server-reported consumption:

// Feature state is stored per seat + feature key
client.setFeatureState('seat-uuid', 'api-calls', state);
const stored = client.getFeatureState('seat-uuid', 'api-calls');

Assertions

Assertions provide declarative license condition checking.

FeatureExistsAssertion

import { FeatureExistsAssertion } from '@sidub-inc/licensing-client';

const assertion = FeatureExistsAssertion.create('analytics');
const hasFeature = client.assertLicense(assertion, authorization);

ServiceAccessAssertion

import { ServiceAccessAssertion, ServiceAccessLevel } from '@sidub-inc/licensing-client';

const assertion = ServiceAccessAssertion.create('api-access', ServiceAccessLevel.Allowed);
const hasAccess = client.assertLicense(assertion, authorization);

RateLimitAssertion

Rate-limit assertions do NOT return false when the limit is exceeded — they throw RateLimitError (and emit an unconditional console.warn), matching the .NET SDK's RateLimitException contract. Handle the violation with try/catch:

import { RateLimitAssertion, RateLimitError } from '@sidub-inc/licensing-client';

const assertion = RateLimitAssertion.create({
  featureKey: 'api-calls',
  rateLimit: 1000,
  currentConsumption: 500
});

try {
  client.assertLicense(assertion, authorization); // true when within the limit
  performTheRateLimitedWork();
} catch (error) {
  if (error instanceof RateLimitError) {
    showUpgradePrompt(); // limit exhausted — the throw IS the enforcement signal
  } else {
    throw error;
  }
}

The limit is inclusive: consumption equal to rateLimit still passes; the throw fires once consumption exceeds it.

Allowances (QuotaAssertion)

An allowance is a number of uses per billing period, counted across every device and server the seat runs on. Record each use with performOperation. A use the allowance does not cover throws AllowanceExhaustedError — nothing is counted or reported — and so does assertLicense(QuotaAssertion.create(key), authorization) until a use is covered again. The error carries featureKey, allowance and periodEndUtc (when the period resets, or null), and its message says whether the period is spent or the uses asked for do not fit. Every refusal is also logged with console.warn. A feature the license does not carry returns false.

To check before the work, call assertAllowance(featureKey, amount). amount is the number of uses the work will record — the quantity it passes to performOperation — and is 1 when omitted. The check counts nothing:

import { AllowanceExhaustedError } from '@sidub-inc/licensing-client';

try {
  if (!(await client.assertAllowance('exports'))) return; // false: not on the license
  doExport();
  await client.performOperation({ feature: { featureKey: 'exports' }, operationType: 'increment', quantity: 1, timestamp: new Date() });
} catch (error) {
  if (error instanceof AllowanceExhaustedError) {
    showResetDate(error.periodEndUtc);
  } else {
    throw error; // LicensingError: the platform could not be reached
  }
}

assertLicense reads what the client knows and asks the platform nothing, so assert after the use.

For the next minute after a refusal (or until the period ends, if sooner) the client answers from what it knows and asks the platform nothing. A spent period refuses every use. A period with fewer uses left than were asked for refuses any use or check larger than what is left; smaller uses go through.

Allowance figures

getState() returns one allowances[] entry per allowance:

| Figure | Meaning | |---|---| | allowance | Uses per billing period; 0 means uncapped | | used | Uses the platform has recorded in the period, plus the uses this client has counted since | | reserved | Units the seat's other runtimes hold and have not yet recorded as used | | remaining | What is left for this client to use | | periodEndUtc | When the period resets | | asOfUtc | When the platform gave the figures |

allowance is used plus reserved plus remaining. Reserved units come back to remaining when the runtime that holds them returns them.

getState() reads the figures from the platform when the client holds none or took them more than a minute ago. The read takes no units. When it fails, getState() still answers with the figures the client holds, and asks again after 30 seconds. The figures are unknown while periodEndUtc is null: the platform could not be reached and the client held none.

Counting across runtimes

The client reserves units from the platform as it goes, so the allowance is counted across every runtime of the seat; you never handle this. The next grant is taken behind the caller before the units held run out, so a use waits on the platform only when the client holds none; a client may hold two grants of the seat's allowance at once. An access check (reportAccessCheck, assertLicense) is never counted as a use. When a client stops, the units it did not spend go back to the platform for the seat's other runtimes. The client returns them by itself:

  • Browser: when the page is hidden (pagehide).
  • React: when LicensingProvider unmounts.
  • Node: when the process ends on its own, with nothing left to do. That includes a process whose signal handler closes its servers and lets the event loop drain. The process waits at most 5 seconds for the return.

process.exit() ends a Node process at once, so code that calls it awaits client.disposeAsync() first:

process.on('SIGTERM', async () => {
  await client.disposeAsync(); // resolves once the units are returned; never rejects
  process.exit(0);
});

client.dispose() does the same teardown without waiting. It returns nothing, so a React effect cleanup can return it: useEffect(() => () => client.dispose(), [client]).

A process that is killed or crashes returns nothing. Units that are not returned are unavailable to the seat's other runtimes for about an hour.

Composite Assertions

import { CompositeAssertion, FeatureExistsAssertion } from '@sidub-inc/licensing-client';

// AND: all must be satisfied
const both = CompositeAssertion.and(
  FeatureExistsAssertion.create('feature1'),
  FeatureExistsAssertion.create('feature2')
);

// OR: any must be satisfied
const either = CompositeAssertion.or(
  FeatureExistsAssertion.create('feature1'),
  FeatureExistsAssertion.create('feature2')
);

// NOT: invert result
import { NotAssertion } from '@sidub-inc/licensing-client';
const notBlocked = NotAssertion.create(FeatureExistsAssertion.create('blocked'));

React Integration

Provider Setup

import { LicensingProvider } from '@sidub-inc/licensing-client';

function App() {
  return (
    <LicensingProvider 
      config={{ 
        licenseServiceUri: 'https://api.monaiq.com/licensing',
        encodedCredential: process.env.REACT_APP_LICENSE_CREDENTIAL
      }}
    >
      <YourApp />
    </LicensingProvider>
  );
}

Using the Client from Context

The React surface is deliberately small: LicensingProvider owns one LicensingClient for the tree, and useLicensingContext() hands it to components. Everything else — authorization, assertions, consumption, checkout — is the same imperative client API documented above.

import { useLicensingContext, FeatureExistsAssertion } from '@sidub-inc/licensing-client';
import { useEffect, useState } from 'react';

function FeatureGate({ children }: { children: React.ReactNode }) {
  const client = useLicensingContext();
  const [hasPremium, setHasPremium] = useState<boolean | null>(null);

  useEffect(() => {
    let cancelled = false;
    client.getAuthorization()
      .then(authorization => {
        if (cancelled) return;
        const assertion = FeatureExistsAssertion.create('premium');
        setHasPremium(client.assertLicense(assertion, authorization));
      })
      .catch(() => !cancelled && setHasPremium(false));
    return () => { cancelled = true; };
  }, [client]);

  if (hasPremium === null) return <div>Loading…</div>;
  return hasPremium ? <>{children}</> : <UpgradePrompt />;
}

In-app purchase from a component (PurchaseClient)

A purchase button in a React tree uses PurchaseClient with the buyer's bearer — never LicensingClient with an API key (a key in a component is a seller secret shipped to every device). This component belongs to a shell that has already signed the buyer in and can hand out their access token: an Electron/desktop app (MSAL, system-browser PKCE, loopback redirect) or a React Native app (Entra External ID native authentication). See section 5 for the client and the sign-in snippet.

Single-page apps: a plain browser SPA cannot obtain a buyer bearer (its callback URL cannot be registered on Monaiq's public client), so it does not render this button. It links the buyer to the hosted storefront, https://monaiq.com/marketplace/<issuerClientId>, and the hosted page takes the purchase. The anonymous catalog reads still work from a SPA.

import { PurchaseClient, LicensingError } from '@sidub-inc/licensing-client';
import type { CheckoutSessionResult } from '@sidub-inc/licensing-client';
import { useEffect, useMemo, useRef, useState } from 'react';

// Supplied by the shell: the signed-in buyer's token (MSAL acquireTokenSilent /
// native auth) and a way to open a URL in the SYSTEM browser
// (Electron shell.openExternal, React Native Linking.openURL).
declare function acquireBuyerToken(): Promise<string>;
declare function openInSystemBrowser(url: string): Promise<void>;

const ISSUER_CLIENT_ID = 'your-seller-client-id';

function PurchaseButton({ offeringId }: { offeringId: string }) {
  const purchase = useMemo(() => new PurchaseClient({
    purchaseServiceUri: 'https://api.monaiq.com/purchase',
    getAccessToken: acquireBuyerToken
  }), []);
  const [status, setStatus] = useState<'idle' | 'processing' | 'completed' | 'failed'>('idle');
  const [result, setResult] = useState<CheckoutSessionResult | null>(null);
  const requestIdRef = useRef<string | null>(null);   // one per purchase attempt; a retry reuses it
  const controllerRef = useRef<AbortController | null>(null);

  const handlePurchase = async () => {
    setStatus('processing');
    requestIdRef.current = requestIdRef.current ?? crypto.randomUUID();
    try {
      const session = await purchase.createCheckout({
        requestId: requestIdRef.current,
        offeringId,
        issuerClientId: ISSUER_CLIENT_ID,
        originContext: 'mobile_app'   // from a mobile shell; omit (or 'web') on desktop
      });

      // Paid: the hosted Stripe Checkout page opens in the system browser.
      // Free: no URL — the license is already minted.
      if (session.sessionUrl) await openInSystemBrowser(session.sessionUrl);

      // Either way, poll the result here — the redirect never lands in this app.
      controllerRef.current = new AbortController();
      const checkoutResult = await purchase.pollResult(session.sessionId, {
        signal: controllerRef.current.signal
      });
      requestIdRef.current = null;                       // final: the next purchase gets a new id
      setResult(checkoutResult);
      setStatus(checkoutResult.status === 'completed' ? 'completed' : 'failed');
    } catch (error) {
      if (error instanceof LicensingError && error.code === 'BuyerTokenInvalid') {
        // The buyer's session lapsed: sign in again, then Retry (same requestId).
      }
      setStatus('failed');
    }
  };

  useEffect(() => () => controllerRef.current?.abort(), []);

  if (status === 'completed') return <div>Purchase complete! License: {result?.licenseId}</div>;
  if (status === 'failed') return <div>Purchase did not complete <button onClick={handlePurchase}>Retry</button></div>;

  return <button onClick={handlePurchase} disabled={status === 'processing'}>Purchase</button>;
}

pollResult() uses exponential backoff (1s initial, doubling to a 15s cap, 60 attempts by default — roughly 14 minutes) and never polls sooner than the server's Retry-After asks. Pass an AbortSignal to cancel. Throws LicensingError on timeout, cancellation, or a denial (code set).

Without the Provider

Outside React (Node services, plain scripts) or when a component tree does not need a shared context, construct LicensingClient directly — the Provider is a convenience, not a requirement:

import { LicensingClient } from '@sidub-inc/licensing-client';

const client = new LicensingClient({
  licenseServiceUri: 'https://api.monaiq.com/licensing',
  encodedCredential: 'SIDUB_LIC_...'
});

Call client.dispose() when you stop using a client you constructed. Code that calls process.exit() awaits client.disposeAsync() first; see Counting across runtimes.

Signature Validation

When serviceKeyId and serviceKeyPublicMember are configured (directly or via encoded credential), the library validates authorization signatures using ECDSA with P-256 (secp256r1) and SHA-256.

Validation flow:

  1. Server signs LicenseAuthorization — and every AllowanceGrant and AllowanceReading — with its private EC key (P-256), over the entity's name and its fields
  2. Client receives the answer with its signature field
  3. Client verifies the signature using the configured public key
  4. Invalid signatures throw CryptoError

Every runtime request states the signature layout this client reads (SignatureLayout: 1); the answer names the layout it was signed in, and the client verifies in it. An answer in a layout this client does not read fails with CryptoError (VERIFICATION_FAILED) naming the update it needs; an answer that names none verifies as before.

The posture is always explicit (2.0):

  • Keys configured (the recommended encodedCredential setup carries them): validation is on automatically.
  • No keys and validateSignatures unset (e.g. a bare runtimeToken config): validation is OFF and the client emits a one-time console warning — the authorization body is accepted without cryptographic verification.
  • validateSignatures: true with no keys: getAuthorization() throws LicensingConfigurationException — you demanded validation that cannot happen.
  • validateSignatures: false: explicit unsigned mode; no warning.

Check the effective posture at runtime via client.isSignatureValidationEnabled (also surfaced by the useLicensingContextValue() hook).

Disable validation explicitly (not recommended):

const client = new LicensingClient({
  licenseServiceUri: '...',
  encodedCredential: '...',
  validateSignatures: false
});

Credential Encoding

import { encodeCredential, decodeCredential } from '@sidub-inc/licensing-client';

const encoded = encodeCredential({
  seatId: 'seat-uuid',
  serviceKeyId: 'service-key-uuid',
  serviceKeyPublicMember: 'base64-key',
  runtimeToken                                 // opaque — never decode it
});                                            // "SIDUB_LIC_eyJ..." (version 2)

const decoded = decodeCredential(encoded);     // LicensingCredential
decoded.seatId;
decoded.runtimeToken;                          // opaque — never decode, log or display it

The parser is strict, and it is the only one: the payload must carry a RuntimeToken and a SeatId, and any version other than the one this SDK writes (2) is rejected — a version 1 payload named the seat LicenseId and is refused, not reinterpreted. Every decode in the SDK — construction, refresh(), the context provider — goes through it.

Error Handling

| Error Type | Description | |-----------|-------------| | LicensingError | Base error for API, network, and timeout errors — carries the server's error contract (see below) | | LicensingConfigurationException | Missing required configuration fields (extends LicensingError) | | CryptoError | Signature validation failures (KEY_IMPORT_FAILED, VERIFICATION_FAILED, INVALID_SIGNATURE) | | RateLimitError | Thrown by assertLicense when a RateLimitAssertion limit is exceeded — catch it; it is the enforcement signal, not a failure | | AllowanceExhaustedError | Thrown by performOperation, assertAllowance and assertLicense when an allowance does not cover a use — carries featureKey, allowance and periodEndUtc; it is the enforcement signal, not a failure |

The server error contract

On every HTTP failure the licensing service returns a structured ApiError body — {Code, Message, CorrelationId, Details[]} — plus an X-Correlation-Id header. LicensingError parses it (2.0):

| Field | Description | |-------|-------------| | code | Machine-readable server code ('ApiKeyMissing', 'ApiKeyInvalid', 'AuthorizationDenied', 'NotFound', 'ValidationError', 'ServerError', ...; on the purchase path also 'BuyerTokenMissing', 'BuyerTokenInvalid', 'CheckoutResultForbidden', 'RequestIdInUse') — branch on this, not on message text | | correlationId | The request correlation id (body or X-Correlation-Id header) — quote it in support requests | | details | Optional per-field entries {code, message, field, targetId} | | message | Prefers the server's human-readable Message; falls back to the HTTP status text | | statusCode | HTTP status code | | response | The raw response body text (always preserved) | | retryAfterMs | Parsed Retry-After header, when the server supplied one |

code and correlationId are undefined only when the failure never reached the server (network error, timeout) or the server did not supply them.

import { LicensingError, LicensingConfigurationException, CryptoError } from '@sidub-inc/licensing-client';

try {
  const authorization = await client.getAuthorization();
} catch (error) {
  if (error instanceof CryptoError) {
    // Signature validation failed
    console.error('Signature invalid:', error.code);
  } else if (error instanceof LicensingConfigurationException) {
    // Missing configuration
    console.error('Config error:', error.message);
  } else if (error instanceof LicensingError) {
    // API or network error — branch on the machine code
    if (error.code === 'AuthorizationDenied') {
      showUpgradePrompt();
    } else {
      console.error(
        `Licensing error ${error.code ?? error.statusCode}: ${error.message}`,
        `correlationId=${error.correlationId ?? 'n/a'}`
      );
    }
  }
}

TypeScript Types

import type {
  LicensingConfig,
  LicenseAuthorization,
  LicensingCredential,
  ILicenseFeature,
  LicenseClassificationType,
  ILicenseAssertion,
  // Purchase client (buyer bearer)
  PurchaseClientConfig,
  PurchaseCheckoutRequest,
  PurchaseOfferingList,
  PurchaseOfferingDetail,
  PurchaseOffering,
  PurchaseProduct,
  PurchaseResolvedFeature,
  CheckoutSession,
  CheckoutSessionResult
} from '@sidub-inc/licensing-client';

License

Proprietary licenses available at https://sidub.ca/ © Sidub Inc.