Licenses
Licenses
Section titled “Licenses”The Licenses panel (/licenses in super-admin) is where the operator issues new customer licenses, transfers them between owners, revokes leaked ones, processes refunds, and audits heartbeat telemetry. Every license is a JWT signed by apps/license-server with the platform’s Ed25519 private key.
The license model is one-time per-domain perpetual (per ADR-030). The only thing that times out is the support window — see License for the customer-facing semantics. Heartbeat from customer installs is telemetry only and never enforces a lock on the framework runtime.
Issue a license
Section titled “Issue a license”POST /api/super-admin/licenses (button: Emite licență nouă):
{ "customer_email": "ana@acme.ro", "customer_company": "Acme Wellness SRL", "customer_cif": "RO12345678", "tier": "pro", // starter | pro | growth | enterprise | trial | beta | dev "kind": "perpetual", // perpetual (default) | trial | saas-monthly (deferred) "niche_packages": ["services"], "licensed_plugins": ["niche-booking", "efactura-ro", "notifications-sms"], "licensed_themes": ["theme-services"], "features": ["multi_tenant", "audit_log"], "domain_binding": ["acme-wellness.ro"], // mandatory; eTLD+1 strict + localhost "support_period": "12mo", // optional — paid support window beyond the 30d free "early_adopter": true, // optional — Phase 2 launch flag for loyalty discount "send_email": true}Server-side:
- License-server signs the JWT with the active
kid(key id) — the customer’s install accepts the token if the matching public key is in their copy. expires_atis null forkind: "perpetual"(the canonical case). Set only fortrial(30 days) orsaas-monthly(deferred).support_untilis set fromsupport_period(or 30 days from issue if no paid support).- Inserts a row in
licenseswith statusactive+ the rendered JWT. - Creates / updates the Stripe customer if support is paid via subscription.
- Emails the JWT to
customer_emailwith installation instructions. - Audit log:
license.issue(records every claim of the JWT — kid, tier, kind, niches, plugins, themes, features, customer info).
The JWT is rendered once in the response and email. It’s not retrievable later without re-issuing — the database stores a hash + claim snapshot, not the raw signed token. If the customer loses the JWT, re-send (next section) re-renders + re-signs with a fresh issued_at, same expires_at and support_until.
Re-send a license
Section titled “Re-send a license”Clicking the email icon on any active license row emails the customer the JWT again. The JWT is re-signed at the moment of the click (different iat, same exp, same claims). This makes the action audit-loggable and prevents stale tokens from being reused if the email was leaked.
Transfer (sale of website)
Section titled “Transfer (sale of website)”When customer A sells the website to customer B, the license transfers with the website (ADR-030 §4). POST /api/super-admin/licenses/:id/transfer:
{ "new_customer_email": "noul-proprietar@acme.ro", "new_customer_company": "Acme New Owner SRL", "new_customer_cif": "RO87654321", "confirmation_from_old": true, // operator confirms email approval from A "confirmation_from_new": true // operator confirms email approval from B}Server-side:
- Re-issues the JWT with
customer_email/customer_company/customer_cifupdated to B - Same
domain_binding, same tier, same niche/plugin/theme grants, samesupport_until - Audit log:
license.transferwith before/after snapshot - Customer A is emailed a “license transferred” notification (informational, no action)
- Customer B receives the new JWT via email + install instructions
Transfer is free — no MediaDesign-side fee. Standard process for selling a commerce site. The new owner’s install picks up the new JWT at the next heartbeat (no install reset required).
Revoke
Section titled “Revoke”POST /api/super-admin/licenses/:id/revoke:
- Inserts the license id into the
revoked_licensesset with areason(operator-typed) +revoked_at - License-server
/heartbeatreturnsrevoked: truefor the install going forward - The customer’s install transitions cloud-tied features to “ceased” (AI / ANAF SPV proxy / hosted email) and stops being able to download premium plugins from
npm.ecommus.cloud - The framework runtime continues to operate locally — we don’t phone-home-and-disable
- Audit log:
license.revoke
Revoke when:
- The customer reports their JWT was leaked (e.g. shared on a forum)
- A heartbeat anomaly is confirmed misuse (multiple
install_ids for one license — see Anomaly response below) - The customer cancelled and the refund window expired (see Refund)
Revocation differs from suspension of a tenant. Tenant suspension blocks the customer’s storefront; license revocation blocks future cloud features + premium plugin downloads, but the local install keeps serving its customers.
Refund
Section titled “Refund”POST /api/super-admin/licenses/:id/refund (only available within Stripe’s refund window, currently 14 days from issue):
- Calls Stripe
/v1/refundswith the original charge id - On Stripe success, sets license status to
refunded+revoked_at = now - Same effect as revoke for the install — heartbeat refused going forward
- Audit log:
license.refundwith the Stripe refund id
If the refund window has expired, the button is disabled — operator must use revoke + manual refund via Stripe dashboard, then update the license status to refunded separately.
Heartbeat status (telemetry only — no enforcement)
Section titled “Heartbeat status (telemetry only — no enforcement)”Each license row shows the last heartbeat as a connectivity indicator only. Green dot = customer’s install pinged in the last 24 h. Yellow / red = haven’t heard from this install in 7+ / 30+ days. The framework runtime never depends on heartbeat — a customer install with no internet still works perfectly. The heartbeat is for our visibility (uptime, version distribution, anomaly detection).
The support_until field is the only time-based lock, and it gates only SLA + cloud-tied features (AI / ANAF SPV proxy / hosted email). Customer install runtime is unaffected.
Click into a license to see:
- The full heartbeat history (last 100 hits) — connectivity snapshot only
kidof the active key (if the customer’s install is on an oldkid, the row is yellow-flagged for “rotate before retire”)install_ids seen — should be exactly one per license unless multi-install was explicitly grantedsupport_untilcountdown — when paid support expires (renewal flow available)
Anomaly response
Section titled “Anomaly response”The license-server raises an anomaly when:
- More than one
install_idheartbeats with the same JWT in the same 24-h window (license sharing) - A heartbeat arrives from an IP geographically far from the registered customer (informational only, not blocking)
- A heartbeat arrives with a JWT that was previously revoked (proves the customer ran an old token; not actionable on its own)
Anomalies appear at the top of the panel as warning banners. Operator workflow:
- Review the install_id list. If two installs are running on the same JWT, contact the customer.
- If misuse is confirmed (e.g. the customer shared the JWT online), revoke + audit-log with the reason.
- If legitimate (customer is migrating to a new VPS and forgot to disable the old one), grant a one-shot
multi_install_windowfor 7 days from the licence detail page.
Key rotation (kid)
Section titled “Key rotation (kid)”The license-server holds an active signing key + a list of accepted public keys (kids). When rotating:
- Generate a new keypair:
openssl genpkey -algorithm Ed25519 -out new-private.pem - Add the new public key to the customer copies’
accepted_kids(deploy with the next release) - Wait for >95% of customer installs to confirm they have the new public key (heartbeat reports
accepted_kids) - Switch the active signing key to the new one in license-server config
- New license issuances use the new
kid. Existing JWTs stay valid until they expire — they were signed with the oldkid, which the customer copies still accept. - When all old-kid licenses have expired or been re-issued, drop the old public key from
accepted_kidsin the next release.
This procedure has zero downtime if the rollout to customer copies (step 2) reaches 100% before step 6. ADR-026 covers the full rotation cadence (we target a yearly rotation).
Search + filter
Section titled “Search + filter”Top-of-panel filters:
- Status —
active/revoked/refunded/transferred - Tier —
starter/pro/growth/enterprise/trial/beta/dev - Kind —
perpetual/trial/saas-monthly(deferred) - Niche — any of the four
- Support window —
active/expired(telemetry — for prioritising renewal outreach) - Heartbeat freshness —
<24h/<7d/>30d(connectivity only) - Search — customer email / company / CIF / tenant slug
Data retention
Section titled “Data retention”Revoked + refunded licenses stay in the licenses table indefinitely (small footprint, useful for audit + Stripe reconciliation). Heartbeat detail rows older than 90 days are aggregated into a per-day summary by a nightly job to keep the table small.
See also
Section titled “See also”- License (customer-facing) — JWT shape, tier matrix, grace state
- Tenants — tenant lifecycle (often paired with license issuance)
- Plans — what each plan includes
- Audit Log — full event trail (panel:
/audit-log) - ADR-026 — license key rotation procedure
apps/license-server— server source