Skip to main content

Licensing System

Loadout Waypoints is source-available under the Elastic License 2.0 (see LICENSE and COMMERCIAL.md in the repo) — a self-hosted install still needs a license key to unlock the app, enforced by a gate that runs before every request. This page explains exactly how that gate, key validation, and offline licenses work.

Where it lives

ConcernFile
Instance identity, licensing server URL(s)config/licensing.js
Cached license state (one document per instance)models/license.js
Reading/writing the cached stateservices/database/license.js
Talking to the licensing serviceservices/licensing/client.js
Business logic (validate, activate, trial, "is this active?")services/licensing/gate.js
Offline token verificationservices/licensing/offline.js
Periodic revalidationservices/licensing/scheduler.js
Request-time enforcementmiddleware/license.js, middleware/accessGate.js
UI/API surfacecontrollers/licenseController.js, routes/licensePages.js, routes/api/license.js
Client-side formspublic/js/license.js, public/js/adminLicense.js

The cached license document

There's exactly one License document per instance (_id: 'singleton', see Data Model), holding an instanceId (a UUID generated on first access, identifying this install to the licensing service), the key, its type (trial/lifetime/admin), status (active/expired/revoked/unknown), email, expiresAt, and revalidation bookkeeping. Every request that isn't for /license or /api/license reads this cached document — the licensing service is never called synchronously on the request path.

Revalidation loop

services/licensing/scheduler.js runs gate.revalidate() once immediately at boot, then daily at 1:30am via node-cron. revalidate():

  1. Ensures an instanceId exists (creating the singleton document on first-ever call).
  2. If the cached key is an offline token (see below), does nothing — offline licenses are verified once, locally, at activation time, and never checked in with the server again.
  3. If there's no key at all, does nothing — the instance stays gated at /license, and does not auto-start a trial on its own.
  4. Otherwise, calls client.validateKey(key, instanceId) and handles the result:
    • Valid → overwrite the cached status/type/expiresAt, set lastValidatedAt and lastValidationReachable: true.
    • Definitively invalid (the service responded, just not with a valid key — a 404/403) → mark status: 'revoked'.
    • Unreachable (network error, timeout, non-definitive failure) → set lastValidationReachable: false and leave everything else — status, expiresAt — untouched.

That last branch is the entire grace-period mechanism: there's no separate grace-period timer or countdown. services/licensing/client.js posts to each URL in config/licensing.js's serverUrls list in turn (10-second timeout each, serverUrls supports comma-separated mirrors of the same service, not alternate services), treating a 404/403 as a definitive answer returned immediately and anything else that fails as retriable.

Is the license active right now?

gate.isLicenseActive(doc, now) is a small pure function, checked on every gated request:

  • false if there's no document, no key, or status !== 'active'.
  • For type === 'trial', additionally checks expiresAt against the current time locally — a trial always expires on schedule even if the licensing service can't be reached to confirm it.
  • Deliberately ignores lastValidationReachable/lastValidatedAt. A self-hosted install that loses network access to the licensing service keeps running on its last good verdict indefinitely — there's no forced lockout from a licensing-service outage or a firewalled/air-gapped host, as long as the last successful check said "active." The only way to lock out an already-active license is an explicit revoke/expiry the service actually confirms, or (for trials only) the locally-tracked expiry date passing.

Trial, activation, and checkout

  • Starting a trial (gate.startTrial(email)): validates the email format, then calls client.requestTrial(instanceId, email). The licensing service does not return a key in the response — it emails one to the address. Nothing is cached client-side at this point; the user has to paste the emailed key into the activation form themselves.
  • Activating a key (gate.activate(key)): if the key is an offline token, verified entirely locally via offline.verifyOfflineLicense (see below); otherwise client.validateKey is called and the result cached on success. Unlike revalidate(), a failure here throws, so the activation UI can show an actual error instead of silently treating it as "unreachable."
  • Checkout: licenseController.js#checkout redirects to a hosted Stripe checkout URL obtained from client.startCheckout().

Offline licenses

An offline license is a self-contained signed token, prefixed AWPOFFLINE., checked via services/licensing/offline.js:

  • isOfflineToken(key) — checks the prefix.
  • verifyOfflineLicense(token, expectedApp) — the token is three dot-separated base64url parts (AWPOFFLINE.<payload>.<signature>). The payload is verified against a public key loaded from the LICENSE_OFFLINE_PUBLIC_KEY_JWK environment variable using crypto.verify, then checked that payload.app matches this app's identity (config/licensing.js's appName) and that payload.expiresAt (if present) hasn't passed.

Offline licenses never make a network call, ever — not at activation, not on the daily revalidation loop (step 2 above short-circuits before any network attempt). Only their locally-embedded expiry, if any, is re-checked on every request via isLicenseActive. This is the path for air-gapped or network-restricted self-hosted deployments.

No shared secret

An earlier version of this app sent a shared x-license-secret header on every licensing call. It was removed on purpose: that secret was identical across every customer's install, baked into software distributed to run on hardware the app authors don't control, so any single leaked self-hosted install would have compromised the licensing service for everyone. The licensing service's /api/trial and /api/validate endpoints are public today, protected instead by email-gating and rate limiting on the service's own side.

Enforcement

middleware/accessGate.js is a one-line re-export of requireLicense from middleware/license.js, mounted in server.js before every router — including the login page and the license page's own static assets, though /license and /api/license themselves are explicitly allowlisted so the activation flow works while gated. requireLicense loads the cached license doc and calls isLicenseActive; if inactive, it returns a JSON 402 for /api/* paths or redirects to /license otherwise.

This file is deliberately the single swap point between this open-source repository (which enforces the gate unconditionally, with no environment-variable bypass) and a privately-hosted cloud fork of the app, which would replace this one file with a check against a separate subscription/account system instead. Self-hosted users only ever see the unconditional variant.