# @leumas/licensing

Leumas licensing — Ed25519-signed OFFLINE entitlement tokens (LEV1.<payload>.<sig>), license-key mint/hash, the platform issuer service + routers, and the appliance client. The 'buy on leumas.tech →...


Ed25519-signed **offline entitlement tokens** — the licensing seam between the Leumas-hosted
platform and self-hosted Studio appliances (the EXE). Lands the unit deferred in
`ops/migration/imperium-hosting.json` ("offline signed entitlement tokens").

## Built (M2 groundwork, verified)

- `src/token.js` — `signEntitlementToken` / `verifyEntitlementToken` / `needsRefresh`.
  Format `LEV1.<b64url payload>.<b64url sig>`; node:crypto only. Payload carries
  `entitlement_policies`-shaped docs + a tenant `features` map + `iat/nbf/exp/refreshAfter`.
- `src/keygen.js` — `LEUMAS-XXXX-XXXX-XXXX-XXXX` mint (Crockford base32), peppered sha256 storage
  hash (apikeys discipline), `keyLast4`.
- `scripts/generate-keypair.mjs <kid>` — public key → `keys/public-keys.json` (committed, ships in
  the EXE); private key → `LEUMAS_LICENSE_SIGNING_KEY` on the platform ONLY.

## To build (M2 remainder — see ops/migration/studio-desktop-licensing.json)

- `src/service.js` — platform issuer: issue-on-membership (subscribe to auth's membership events),
  `activate` (key → activation row + signed token), `refresh` (heartbeat + revocation check),
  `deactivate`, seat/activation limits. Tables: `licenses` + `license_activations` in
  `@leumas/schemas` (real Drizzle tables — money-adjacent).
- `src/client.js` — appliance: persists `license.json` under `@leumas/runtime-paths` dataRoot,
  daily refresh with offline grace (token stays valid until `exp`), degrades to a banner —
  NEVER bricks local data; `lastSeenWallClock` clock-tamper basic; `onChange` →
  `entitlements.refresh()`.
- `routers/platform.router.js` (`/licensing/*`, platform role only) +
  `routers/appliance.router.js` (replaces the `/api/license` stub in
  `products/leumas-api/src/routes/activation.js` behind the SAME route shapes).

## Desktop sign-in — the key-free door

`POST /licensing/desktop/signin` (platform) is what lets the Studio EXE's first run ask for a
**leumas.tech email and password** instead of a licence key.

```
EXE first run ──-> POST /api/license/signin (appliance router)
 └-> client.signIn()
 └-> POST /licensing/desktop/signin (platform router)
                                ├ verifyCredentials()          ← injected usersService.login
                                └ service.signInDesktop()       → {token, license, licenseKey}
 <-─ appliance: provisionFederatedUser() + startSession() → session cookie
```

Three rules hold it together:

- **The password is checked by the platform, and never travels further.** `@leumas/licensing` has
  no `@leumas/auth`; the host injects `verifyCredentials`. Every failure — wrong password, unknown
  address, deactivated account — comes back as one indistinguishable 401, so the endpoint is not an
  account-enumeration oracle. It sits behind the same 10-per-15-min limiter as key activation.
- **One licence per DEVICE, key rotated on every sign-in.** The appliance needs a plaintext key to
  run its heartbeat and the platform stores only a hash, so a device signing in again is handed a
  NEW key rather than one nobody can recover. Rotation touches only that device's row; a shared row
  with a rotating key would cut off every other install the customer owns.
- **After the save, the state on disk is identical to a key activation.** Same `licenseKey`, same
  `instanceId`, same token — so `refresh`, `deactivate`, revocation, offline grace and the relay have
  exactly one code path, not two.

Sign-in is offered only when the appliance has a central server (`LEUMAS_LICENSE_SERVER`). Without
one there is no account to check, so `/api/license/signin` answers 501 and `GET /api/license/status`
reports `signIn: false` — which is what the first-run wizard reads to choose its flow. It never
guesses.

## The trust split (do not change casually)

The **policy ceiling is signed by Leumas** (the appliance's entitlements engine loads policies
from the token, never from its own DB); **membership assignment stays local** (the customer's
admin grants plans to their team via the existing offline `grant` path in @leumas/auth).

Desktop sign-in does not bend that split. It proves WHO centrally and mirrors that identity into a
local row (`provisionFederatedUser`, admin only if it is the first account); what that person may do
on the box is still local RBAC under a Leumas-signed ceiling.


---
Source: shared/packages/licensing/README.md
Canonical: https://docs.leumas.tech/p/packages/licensing
