@sidub-inc/licensing-client
v2.2.0
Published
React/TypeScript runtime library for Sidub Licensing - License enforcement and consumption reporting
Maintainers
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-clientRequirements:
- Modern browsers with Web Crypto API support (Chrome, Firefox, Safari, Edge)
- Node.js 20+ (
fetchandcrypto.subtleare 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 keyAuthorization 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 seatWhen 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
LicensingProviderunmounts. - 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:
- Server signs
LicenseAuthorization— and everyAllowanceGrantandAllowanceReading— with its private EC key (P-256), over the entity's name and its fields - Client receives the answer with its
signaturefield - Client verifies the signature using the configured public key
- 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
encodedCredentialsetup carries them): validation is on automatically. - No keys and
validateSignaturesunset (e.g. a bareruntimeTokenconfig): validation is OFF and the client emits a one-time console warning — the authorization body is accepted without cryptographic verification. validateSignatures: truewith no keys:getAuthorization()throwsLicensingConfigurationException— 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 itThe 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.
