Skip to content

SPEC-152-Q5: Self-Hosted Licence-Gating Mechanism

Ref: SPEC-152-Q5 · Capability: SUITABILITY-ENGINE · Status: implemented Repo: suitability-engine · Source: docs/features/002-self-hosted-licence-gating.md

Resolves the technical half of SPEC-152’s Open Question 5 (client-onboarding/docs/features/152-origo-suitability-engine-extraction.md). The commercial half — actual pricing, term length, what happens on non-renewal — remains Dave’s call and is not decided here; this spec fixes the mechanism so that commercial work (client-onboarding#2517) has a real technical shape to price against.

SPEC-152 speculated this might reuse “how sge-public’s distribution model or similar precedent in the WTP portfolio handles gated distribution, if such a precedent exists.” Checked 2026-08-16: no such precedent exists. sge-public is a public GitHub repo (access controlled by GitHub repo visibility, not a runtime licence check) — a fundamentally different distribution model from a partner-operated container that must remain functional after handoff. No other WTP repo implements runtime licence gating. This is genuinely new design, not an adaptation of existing WTP practice.

Mechanism: offline signed licence, verified at container startup

Section titled “Mechanism: offline signed licence, verified at container startup”

Format: a base64-encoded JSON envelope — { payload, signature }. payload is { licenceId, customerId, issuedAt, expiresAt, notBefore? }. signature is an Ed25519 signature (via Node’s node:crypto, no external dependency) over the canonical (key-sorted) JSON encoding of payload.

Key custody:

  • Public key (src/licence/public-key.ts) — committed to this repo, baked into every build. Safe to commit: it can only verify signatures, never produce them.
  • Private key — held by WTP outside this repo (Doppler), used only by scripts/sign-licence.mjs (a WTP-side issuance tool, not shipped in the runtime image — the Dockerfile’s runtime stage only copies dist/).

Enforcement: src/server/index.ts’s enforceLicence() runs before the HTTP server starts. It reads SUITABILITY_ENGINE_LICENCE_PATH (file) or SUITABILITY_ENGINE_LICENCE (inline base64), verifies the signature and notBefore/expiresAt window, and throws — refusing to start the process at all — on any failure. There is no partial/degraded-mode operation; an invalid licence means no server.

DEPLOYMENT_MODE=wtp-hosted skips the check entirely — SPEC-152’s Option A (WTP-hosted SaaS) doesn’t need a licence file, since WTP already controls access to that deployment directly. The licence exists specifically to gate deployments WTP does not operate (Option D).

Security fix (found in PR #7’s review, 2026-08-16): this was originally a runtime env var (SUITABILITY_ENGINE_DEPLOYMENT_MODE), documented in the partner-facing .env.example — meaning any self-hosted operator could set it to wtp-hosted and skip licence verification entirely. DEPLOYMENT_MODE is now a compile-time constant (src/licence/deployment-mode.ts), baked into dist/ by which Docker build stage produced the image (runtime vs. the WTP-internal-only runtime-wtp-hosted target — see the Dockerfile). No runtime input anywhere in the running system can change it. See deployment-mode.ts’s header comment for the full rationale.

Why offline / signature-based, not a phone-home activation server

Section titled “Why offline / signature-based, not a phone-home activation server”

Option D’s stated point is “no WTP infra involvement post-handoff” and no partner client data leaving partner infra. An online activation check on every startup (or periodically) reintroduces a version of the exact problem self-hosting was chosen to solve — a live network dependency back to WTP, and metadata about the partner’s own operational cadence (how often they restart, from what IP) flowing to WTP infra. Offline verification has zero runtime dependency on WTP once the licence file is issued.

Accepted limitation: no remote revocation mid-term

Section titled “Accepted limitation: no remote revocation mid-term”

An offline-verified licence cannot be pulled by WTP once issued short of it reaching its expiresAt. If a contract needs to be terminated early, that’s enforced contractually (the customer is obligated to stop running the container), not technically. This is an explicit tradeoff, not an oversight — a revocable mechanism requires either a phone-home call (which Option D’s design goal rules out) or short-lived licences with frequent reissuance (adds operational overhead for a single-partner pilot). Revisit if Origo’s risk profile, or a future partner’s, warrants it — e.g. shorter expiresAt windows with a renewal process, rather than reintroducing phone-home.

  • Not per-request authorisation. The existing SUITABILITY_ENGINE_SERVER_TOKEN shared-secret bearer token (unchanged) still gates every /v1/tenants/.../suitability/export call. The licence gates whether the process is allowed to run at all; the token gates whether a specific caller can use it once running.
  • Not feature/scope entitlement. The payload schema has no scopes or features field today — a licence is binary (valid deployment / not). Deliberately minimal for a single-partner pilot; extend the schema if a future need (e.g. capping the number of end-clients a partner can configure) arises.
  • Not key rotation-safe. Rotating the embedded public key invalidates every previously issued licence — a hard cutover. Not a concern for a single-key, single-partner pilot; would need multi-key support in verify.ts before a rotation could be done without reissuing every outstanding licence simultaneously.
Terminal window
# WTP issues a licence (private key from Doppler, never on the command line):
doppler run --project suitability-engine --config prod -- \
npm run sign-licence -- --customer origo --days 90 > origo.licence
# Partner deploys with:
SUITABILITY_ENGINE_LICENCE_PATH=/etc/suitability-engine/licence.key
# (or SUITABILITY_ENGINE_LICENCE=<contents> inline)

See .env.example for the full set of relevant env vars.