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.
Precedent check (before designing this)
Section titled “Precedent check (before designing this)”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 copiesdist/).
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.
What this does NOT do
Section titled “What this does NOT do”- Not per-request authorisation. The existing
SUITABILITY_ENGINE_SERVER_TOKENshared-secret bearer token (unchanged) still gates every/v1/tenants/.../suitability/exportcall. 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
scopesorfeaturesfield 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.tsbefore a rotation could be done without reissuing every outstanding licence simultaneously.
# 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.
