TPTruPDF

@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 of exp, then daily until expiry. Paid tier only — trial gets the hard overlay instead.
  • LicenseExpired — fires when exp passes mid-session. Stops the heartbeat and unbinds the gate.
  • LicenseRevoked — fires when the heartbeat server reports revoked: 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(); // boolean

gate.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).