@trupdf/licensing
Public API for the licensing subsystem. The package is included in
the rolled-up @trupdf/viewer bundle — the host-facing surface
(LicenseManager, gate, WatermarkOverlay, TrialExpiryGuard,
LicenseError and their types) is re-exported by
import { ... } from '@trupdf/viewer'. Lower-level helpers
(verifyLicense, matchesDomain, RevocationCache, FeatureGate)
import from @trupdf/licensing directly.
LicenseManager
The orchestrator. Hosts construct one per session.
class LicenseManager {
constructor(opts?: LicenseManagerOptions);
status(): LicenseStatus;
current(): Entitlement | null;
watermark(): WatermarkOverlay | null;
trialExpiryGuard(): TrialExpiryGuard | null;
async init(licenseString: string, opts?: InitOptions): Promise<Entitlement>;
destroy(): void;
on<T>(type: T, handler: LicenseEventHandler<T>): () => void;
off<T>(type: T, handler: LicenseEventHandler<T>): void;
recordFeatureUse(feature: Feature): void;
}init(token)
Validate the signed token, bind the gate, start the heartbeat, arm
expiry / stale timers, attach the trial overlay (trial tier only).
Returns the frozen Entitlement on success; throws LicenseError
on any failure.
Idempotent on identical input — calling init(sameToken) twice
returns the same entitlement and doesn't re-run any I/O. Calling
with a different token throws; the caller must destroy() first.
destroy()
Stops heartbeats, clears timers, unbinds the gate, detaches the
trial overlay, resets state to uninitialized. Safe to call any
number of times.
status()
Returns the current lifecycle state:
type LicenseStatus =
| 'uninitialized'
| 'validating'
| 'ready'
| 'expired'
| 'revoked'
| 'invalid'
| 'wrong-domain';Reflects post-mortem failures too — after init() throws, status()
returns the matching state so polling code doesn't need a try/catch.
current()
Returns the bound Entitlement while status() === 'ready', else
null. The entitlement is frozen — never mutate it.
interface Entitlement {
readonly jti: string;
readonly customerId: string;
readonly customerEmail: string;
readonly tier: 'trial' | 'pro' | 'enterprise';
readonly domains: readonly string[];
readonly features: ReadonlySet<Feature>;
readonly issuedAt: number;
readonly expiresAt: number;
readonly offline: boolean;
readonly heartbeat: 'trial' | 'paid' | 'none';
}Events
manager.on(type, handler) returns an unsubscribe function.
interface LicenseEvents {
LicenseReady: { entitlement: Entitlement };
LicenseExpired: { entitlement: Entitlement };
LicenseRevoked: { jti: string };
LicenseExpiringSoon: { entitlement: Entitlement; daysLeft: number };
HeartbeatStale: { lastSuccessAt: number; ageMs: number };
}LicenseReady— fires once on successful init.LicenseExpiringSoon— fires once on init if within 30 days ofexp, then daily until expiry. Paid tier only — trial gets the hard overlay instead.LicenseExpired— fires whenexppasses mid-session. Stops the heartbeat and unbinds the gate.LicenseRevoked— fires when the heartbeat server reportsrevoked: true. Stops the heartbeat and unbinds the gate.HeartbeatStale— fires when no successful heartbeat in 14 days. Useful to surface "your TruPDF deployment can't reach our servers" in a customer-facing UI.
Options
interface LicenseManagerOptions {
// Override the singleton FeatureGate (testing).
gate?: FeatureGate;
// Inject a pre-built RevocationCache (custom storage / fetcher).
cache?: RevocationCache;
// Hostname for domain matching. Default location.hostname.
hostnameProvider?: () => string;
// Override the fetch impl (testing, custom transport).
fetcher?: typeof fetch;
// License-server base URL. Default: https://license.trupdf.truenotech.com.
urlBase?: string;
// Send in heartbeat payloads.
sdkVersion?: string;
// Coarse browser fingerprint provider.
fingerprintProvider?: () => string;
// Override the verifier — test seam for pubkey injection.
verify?: (token: string, opts?: VerifyOptions) => Promise<Entitlement>;
// Verify-time pubkey override. Forwarded to the default verifier.
publicKeyB64?: string;
// Custom Heartbeat factory for deterministic testing.
heartbeatFactory?: (opts) => Heartbeat;
// Skip revocation refresh during init. Default true.
refreshRevocationsOnInit?: boolean;
// Override the 30-day "expiring soon" window. Tests use tiny values.
expiringSoonWindowMs?: number;
expiringSoonRepeatMs?: number;
heartbeatStaleThresholdMs?: number;
heartbeatStaleCheckIntervalMs?: number;
// Trial-tier surfaces. Pass null to disable, custom to override.
trialExpiryGuard?: TrialExpiryGuard | null;
watermarkOverlay?: WatermarkOverlay | null;
}In production hosts only ever set urlBase (rarely) and
sdkVersion — every other option defaults to the right thing.
FeatureGate
Module-level singleton plus instantiable class. Premium engines call
gate.require('<feature>') in their constructors.
import { gate } from '@trupdf/viewer';
// Hide a button when the user's license doesn't grant the feature:
if (gate.has('redaction')) showRedactionButton();
// Read the bound tier:
const tier = gate.tier(); // 'trial' | 'pro' | 'enterprise' | null
// Read the bound jti (for telemetry):
const jti = gate.jti(); // string | null
// Is the gate live and not expired?
gate.isLicensed(); // booleangate.require(feature) throws LicenseError synchronously:
NOT_BOUND— no license bound.FEATURE_NOT_LICENSED— bound, but missing the feature.
You shouldn't normally call gate.require directly — it runs inside
every premium engine constructor. Use gate.has(...) to gate UI.
WatermarkOverlay
Built and attached automatically by LicenseManager on trial tier.
Public if you want to tweak styling.
class WatermarkOverlay {
readonly text: string;
readonly opacity: number; // 0.05–0.9, clamped
readonly rotationDeg: number;
readonly fontSize: number;
readonly color: { r: number; g: number; b: number };
readonly spacing: number;
constructor(opts?: WatermarkOptions);
paintCanvas(ctx: CanvasRenderingContext2D, width: number, height: number): void;
computeLayoutPdf(widthPoints: number, heightPoints: number): WatermarkInstance[];
}paintCanvas paints diagonal repeating text onto a 2D canvas. Saves
- restores context state with a try/finally so partial mutations never leak.
computeLayoutPdf emits placement instances in PDF user space.
SaveEngine consumes the result and writes the text directly into the
PDF — the burn is a content-stream overlay, not an /Annot, so
the watermark survives in Acrobat, Chrome, Firefox, Edge, and Apple
Preview without any /AP fallback dance. XFDF round-trip stays clean
too.
The locked default text is "TruPDF Trial — Not for Production" —
no per-customer attribution.
TrialExpiryGuard
Renders the non-dismissible full-viewport overlay on
LicenseExpired / LicenseRevoked. Attached automatically by
LicenseManager on trial tier.
class TrialExpiryGuard {
constructor(opts?: TrialExpiryGuardOptions);
attach(manager: LicenseManager): void;
detach(): void;
isShowing(): boolean;
show(): void;
hide(): void;
}role="alertdialog", aria-modal="true", max 32-bit z-index, opaque
inline styles. Customisable host (defaults to document.body),
message, and support email.
verifyLicense(token)
Pure function — no I/O, no state. Used internally by LicenseManager
but also exposed so hosts can validate a token before storing it.
async function verifyLicense(token: string, opts?: VerifyOptions): Promise<Entitlement>;Throws LicenseError on any verification failure. The verifier does
not check domain or revocation — those are policy checks the
LicenseManager runs around it.
matchesDomain(hostname, patterns)
Synchronous glob matcher used by LicenseManager for the
domain-check step. Same rules as in
Licensing → Domain matching:
matchesDomain('app.acme.com', ['*.acme.com']); // true
matchesDomain('acme.com', ['*.acme.com']); // true (apex)
matchesDomain('acme.com.evil', ['*.acme.com']); // false
matchesDomain('app.acme.com', ['*']); // false (bare * forbidden)
matchesDomain('localhost', []); // true (always)RevocationCache
Storage-abstracted set of revoked jtis. The default uses
localStorage; in workers or storage-blocked browsers it falls back
to an in-memory store. Hosts almost never construct one directly —
LicenseManager owns the lifecycle.
class RevocationCache {
constructor(opts?: RevocationCacheOptions);
load(): void;
isRevoked(jti: string): boolean;
applyRevocations(jtis: Iterable<string>, updatedAt?: number): void;
refresh(opts?: { signal?: AbortSignal }): Promise<boolean>;
isStale(maxAgeMs?: number, now?: number): boolean;
snapshot(): { jtis: ReadonlySet<string>; updatedAt: number };
}LicenseError
class LicenseError extends Error {
readonly code: LicenseErrorCode;
}
type LicenseErrorCode =
| 'LICENSE_MISSING'
| 'PAYLOAD_MALFORMED'
| 'SIGNATURE_INVALID'
| 'UNSUPPORTED_VERSION'
| 'EXPIRED'
| 'NOT_YET_VALID'
| 'WRONG_DOMAIN'
| 'REVOKED'
| 'FEATURE_NOT_LICENSED'
| 'NOT_BOUND'
| 'TAMPERED';Token format
The wire format is not standard JWT. We use a fixed-algorithm Ed25519-signed-JSON token:
trupdf.<base64url(JSON payload)>.<base64url(64-byte sig)>
The signature covers the exact UTF-8 bytes of the payload segment as
it appears on the wire — never the decoded JSON — so no
canonicalisation ambiguity. Algorithm is hardcoded; there's no alg
field in the payload, which eliminates the alg-confusion attack
class.
Payload (v=1):
{
"v": 1,
"jti": "uuid-here",
"sub": "acme-corp",
"email": "[email protected]",
"tier": "pro",
"domains": ["*.acme.com", "localhost", "127.0.0.1"],
"features": ["redaction", "sign", "convert", "compare", "ocr"],
"iat": 1737303200,
"exp": 1768839200,
"offline": false,
"hb": "paid"
}hb is the heartbeat cadence: 'trial' (6h), 'paid' (7d),
'none' (skipped — enterprise offline).