Skip to content
Last updated Give Feedback

License

Every ecommus installation runs against a license JWT signed by the Media Design license server (Ed25519). The model is one-time payment, per-domain, perpetual — see ADR-030.

Customer pays once for a website package (or for an additional plugin/theme add-on). The license is valid forever for the bound domain. Updates are free perpetually within the same major version (1.x.y). Only support is renewable — 30 days are included free, paid plans extend it (3/6/9/12 months).

  • Tierstarter / pro / growth / enterprise (plus internal trial, beta, dev)
  • Kindperpetual (canonical) / trial / saas-monthly (deferred post-customer-#50)
  • Niche packages — which of the four (services, real-estate, travel, ecommerce) the customer can install
  • Themes and plugins — which artifacts are downloadable from npm.ecommus.cloud
  • Feature flagsmulti_tenant, white_label, marketplace_publisher, tenant_provisioning, …
  • Domain binding — single root domain the install runs on (eTLD+1 strict matching, see below)
  • Support windowsupport_until Unix seconds; telemetry only, never blocks the framework
  • Customer info — email, company, CIF (snapshot at issuance, used for transfer + audit)

The token is verified server-side by the API on every request. The customer holds it in ECOMMUS_LICENSE_JWT.

{
"install_id": "<uuid>",
"tier": "pro", // starter | pro | growth | enterprise | trial | beta | dev | community
"kind": "perpetual", // perpetual | trial | saas-monthly
"features": ["multi_tenant", "audit_log"],
"licensed_themes": ["theme-fashion"],
"licensed_plugins": ["niche-ecommerce", "efactura-ro", "shipping-sameday"],
"niche_packages": ["ecommerce"],
"domain_binding": ["acme.ro"], // single root; eTLD+1 strict + localhost whitelist
"issued_at": 1740835200,
"expires_at": null, // null = perpetual; set only for kind:"trial"
"support_until": 1743427200, // Unix seconds; telemetry only
"customer_email": "ana@acme.ro",
"customer_company": "Acme Wellness SRL",
"customer_cif": "RO12345678",
"early_adopter": true, // optional — Phase 2 launch flag
"kid": "k1" // key id (rotation support)
}
TierDomainsNiche packagesmulti_tenantwhite_labelmarketplace_publisherFree support
starter1130 days
pro1190 days
growth32optional6 months
enterpriseunlimitedup to 412 months
trial11 (30 days)n/a
devallall (internal)n/a

Per-niche tier matrix → super-admin/plans panel (see Plans). The framework is complete in Starter for a small RO business — ANAF e-Factura, Stripe/Netopia, Sameday, VAT 21% are all included.

Server-side gates live in apps/api/src/middlewares/auth.ts:

  • requireFeature("multi_tenant")
  • requireLicensedTheme("theme-services")
  • requireLicensedPlugin("niche-booking")
  • requireNichePackage("services")

These run as preHandler on protected routes (theme install, plugin enable, niche-package activation, ANAF SPV submit, marketplace publish, audit-log access).

The license JWT carries domain_binding: ["acme.ro"]. The receiver-side middleware (apps/api/src/middlewares/license-binding.ts) validates the request host against this list using the Public Suffix List via tldts. The match is soft:

✅ Accepts: acme.ro, www.acme.ro, staging.acme.ro, dev.acme.ro, *.acme.ro
✅ Accepts: localhost, *.local, 127.0.0.1, 192.168.x.x, *.localhost
❌ Refuses: acme.de, competitor.ro
❌ Refuses: acme.ro.attacker.com (eTLD+1 = attacker.com — substring attack)

Localhost + private IPs + *.local are always accepted (development convenience). Everything else must share the registrable domain (eTLD+1) with one of the bound roots, AND be the bound root itself or a subdomain of it.

To run on a second separate brand (e.g. acme.de), purchase a second license — see Multi-domain stacking below.

Same brand, two TLDs, same hosting + translation only via redirect (e.g. acme.de is just acme.ro served via redirect with switched locale):

One license covers both. Add the second domain to domain_binding at issuance.

Same brand, two separate sites (different files, independent stacks):

Two licenses, with stacking discount:

DomainDiscount on the website-package price
1stfull price
2nd-30%
3rd-40%
4th+-50%

The discount is automatic at checkout when the customer is logged in to the customer portal and the tracker recognises an existing license under the same customer_email.

When customer A sells the site to customer B, the license transfers with the website. Flow:

  1. Customer A or B contacts MediaDesign.
  2. Operator validates both parties (email confirmation from A + B).
  3. License-server re-issues the JWT with customer_email updated to B (and any other contact fields).
  4. Audit log: license.transfer with before/after snapshot.
  5. Customer A loses access; B receives the new JWT via email.

Transfer is free — there’s no MediaDesign-side fee. Standard process for commerce on a sellable site.

support_until is the ONLY field that times out periodically. The 30-day free window starts at issuance; after that, customer pays for a renewal:

PlanPeriodsIncludes
Free 30dwith any purchasebasic email support
Basic3 / 6 / 9 / 12 monthsemail + ticket, 24-72 h response
Priority3 / 6 / 9 / 12 months+ chat, 4-8 h SLA, dedicated channel
Enterprise12 months+ dedicated success manager, 1 h SLA, monthly review

What changes when support_until is past:

  • Helpdesk no longer responds within the SLA (best-effort only).
  • Cloud-tied features (AI orchestration, ANAF SPV proxy, hosted email) cease.
  • npm.ecommus.cloud premium plugin downloads continue to work (the customer paid for those perpetually).
  • The framework runs unchanged on the customer’s infrastructure.

Renew at any time to flip hasActiveSupport() back to true — no install reset, no JWT replacement (license-server pushes a fresh JWT on the next heartbeat).

The framework does not phone home and disable. The receiver-side never blocks based on support_until. There is no progressive grace state (ok / warning / reduced / locked) — that was the pre-ADR-030 model and has been retired (the helper exists in packages/core/src/license.ts for backward compat but the framework no longer reads it).

Updates within the same major (1.x.y) are free perpetually. When ecommus releases a major (e.g. 2.0.0), customers on v1 pay 50% off for a v2 license. The discount is automatic for accounts with an active v1 license under the same customer_email. Migration tooling between v1 and v2 ships per release.

A trial JWT has:

  • kind: "trial"
  • expires_at: <issued_at + 30 days>
  • Tier inherited from the package the customer is trialing
  • support_until: null (no support during trial)

Storefront serves a “Trial” watermark in the footer until upgrade. On day 25, an automated email prompts conversion at 50% off. On day 30, the storefront returns 503 with an upgrade page (admin remains accessible to migrate data). Customer doesn’t lose data — only access.

For air-gapped deployments, the license server is also distributable. See apps/license-server — Fastify + Postgres + Stripe + Ed25519 signing.

Per ADR-015 §6.7 + ADR-030 §9, layered defences (each insufficient alone):

  1. License JWT signed Ed25519 (forgery-resistant)
  2. Plugin/theme bundle code-signing (tampering-resistant)
  3. Soft eTLD+1 domain binding — refuses to boot on a different root domain
  4. Weekly heartbeat with revocation list (cloud kill switch)
  5. Cloud-tied features (AI, ANAF SPV proxy, hosted email)
  6. EULA penalty clause

Target effective piracy rate: 2–5%. Heartbeat is telemetry only (post-ADR-030); revocation = customer loses cloud features + premium downloads, but the local install keeps running.

SymptomDiagnosis
API boot logs [license] verification failedECOMMUS_LICENSE_JWT missing/malformed/signed by different kid (rotate public key)
API logs domain_not_licensed 403 on every requestHost doesn’t match domain_binding. Re-issue JWT or fix Caddy/Nginx Host header.
Premium plugin install fails 403 license_deniedPlugin not in licensed_plugins. Re-issue JWT with plugin granted.
npm install @ecommus-plugin/<x> returns 403 forbiddenNPM_REGISTRY_TOKEN missing or revoked. Re-fetch from customer portal.
Admin shows “Support window expired” bannersupport_until past. Renew via Stripe; new JWT pushed at next heartbeat.
Heartbeat fails with Connection refused to license serverOutbound to license.ecommus.ro:443 blocked (firewall, air-gap). Allow it or self-host.