Skip to content
Last updated Give Feedback

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.

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:

  1. 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.
  2. expires_at is null for kind: "perpetual" (the canonical case). Set only for trial (30 days) or saas-monthly (deferred).
  3. support_until is set from support_period (or 30 days from issue if no paid support).
  4. Inserts a row in licenses with status active + the rendered JWT.
  5. Creates / updates the Stripe customer if support is paid via subscription.
  6. Emails the JWT to customer_email with installation instructions.
  7. 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.

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.

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:

  1. Re-issues the JWT with customer_email / customer_company / customer_cif updated to B
  2. Same domain_binding, same tier, same niche/plugin/theme grants, same support_until
  3. Audit log: license.transfer with before/after snapshot
  4. Customer A is emailed a “license transferred” notification (informational, no action)
  5. 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).

POST /api/super-admin/licenses/:id/revoke:

  • Inserts the license id into the revoked_licenses set with a reason (operator-typed) + revoked_at
  • License-server /heartbeat returns revoked: true for 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.

POST /api/super-admin/licenses/:id/refund (only available within Stripe’s refund window, currently 14 days from issue):

  1. Calls Stripe /v1/refunds with the original charge id
  2. On Stripe success, sets license status to refunded + revoked_at = now
  3. Same effect as revoke for the install — heartbeat refused going forward
  4. Audit log: license.refund with 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
  • kid of the active key (if the customer’s install is on an old kid, the row is yellow-flagged for “rotate before retire”)
  • install_ids seen — should be exactly one per license unless multi-install was explicitly granted
  • support_until countdown — when paid support expires (renewal flow available)

The license-server raises an anomaly when:

  • More than one install_id heartbeats 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:

  1. Review the install_id list. If two installs are running on the same JWT, contact the customer.
  2. If misuse is confirmed (e.g. the customer shared the JWT online), revoke + audit-log with the reason.
  3. If legitimate (customer is migrating to a new VPS and forgot to disable the old one), grant a one-shot multi_install_window for 7 days from the licence detail page.

The license-server holds an active signing key + a list of accepted public keys (kids). When rotating:

  1. Generate a new keypair: openssl genpkey -algorithm Ed25519 -out new-private.pem
  2. Add the new public key to the customer copies’ accepted_kids (deploy with the next release)
  3. Wait for >95% of customer installs to confirm they have the new public key (heartbeat reports accepted_kids)
  4. Switch the active signing key to the new one in license-server config
  5. New license issuances use the new kid. Existing JWTs stay valid until they expire — they were signed with the old kid, which the customer copies still accept.
  6. When all old-kid licenses have expired or been re-issued, drop the old public key from accepted_kids in 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).

Top-of-panel filters:

  • Statusactive / revoked / refunded / transferred
  • Tierstarter / pro / growth / enterprise / trial / beta / dev
  • Kindperpetual / trial / saas-monthly (deferred)
  • Niche — any of the four
  • Support windowactive / expired (telemetry — for prioritising renewal outreach)
  • Heartbeat freshness<24h / <7d / >30d (connectivity only)
  • Search — customer email / company / CIF / tenant slug

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.