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
| Concern | File |
|---|---|
| Instance identity, licensing server URL(s) | config/licensing.js |
| Cached license state (one document per instance) | models/license.js |
| Reading/writing the cached state | services/database/license.js |
| Talking to the licensing service | services/licensing/client.js |
| Business logic (validate, activate, trial, "is this active?") | services/licensing/gate.js |
| Offline token verification | services/licensing/offline.js |
| Periodic revalidation | services/licensing/scheduler.js |
| Request-time enforcement | middleware/license.js, middleware/accessGate.js |
| UI/API surface | controllers/licenseController.js, routes/licensePages.js, routes/api/license.js |
| Client-side forms | public/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():
- Ensures an
instanceIdexists (creating the singleton document on first-ever call). - 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.
- 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. - Otherwise, calls
client.validateKey(key, instanceId)and handles the result:- Valid → overwrite the cached
status/type/expiresAt, setlastValidatedAtandlastValidationReachable: 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: falseand leave everything else —status,expiresAt— untouched.
- Valid → overwrite the cached
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:
falseif there's no document, no key, orstatus !== 'active'.- For
type === 'trial', additionally checksexpiresAtagainst 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 callsclient.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 viaoffline.verifyOfflineLicense(see below); otherwiseclient.validateKeyis called and the result cached on success. Unlikerevalidate(), a failure here throws, so the activation UI can show an actual error instead of silently treating it as "unreachable." - Checkout:
licenseController.js#checkoutredirects to a hosted Stripe checkout URL obtained fromclient.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 theLICENSE_OFFLINE_PUBLIC_KEY_JWKenvironment variable usingcrypto.verify, then checked thatpayload.appmatches this app's identity (config/licensing.js'sappName) and thatpayload.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.