Skip to content

Organisation licence

The paid features of the reference app are normally unlocked by an in-app purchase on each device. An organisation that runs its own server for its staff can instead receive an organisation licence from the publisher of LVE. The server hands the licence to every client in GET /lve/v1/info, and the clients unlock the plan it names while they are connected to that server.

The licence is a statement signed by the publisher. The server does not verify or interpret it; it only passes it on. Clients verify it with the publisher's public key, which ships with the app.

Plan Unlocks in the reference app
free Up to 100 articles, standard document layout, no sync
pro Unlimited articles, own logo, texts, columns and numbering on documents, network label printers and report exports, iCloud sync between the user's own devices
team Everything in pro, sharing an inventory with a team through iCloud, sync with an own server, contractual penalties on loans

A device's plan is the higher of its purchased plan and its licensed plan. Sync with a server needs team.

base64url(payload) "." base64url(signature)
  • base64url is the URL-safe Base64 alphabet (- and _ instead of + and /) without padding. Readers also accept padding.
  • payload is the exact JSON byte sequence that was signed. Readers MUST verify the signature over these bytes as decoded, not over a re-encoded form.
  • signature is a 64-byte Ed25519 signature (RFC 8032, pure Ed25519) over payload.
{"expires":"2027-12-31","licensee":"Example GmbH","plan":"team","server":"https:\/\/lager.example.com","v":1}
Field Type Meaning
v integer Format version of the payload, 1. The reference app does not read it.
licensee string Name of the organisation, shown in the app
plan string team or pro. An unknown value unlocks nothing.
server string URL of the server the licence is issued for. Only its host counts.
expires string Last day of validity, YYYY-MM-DD, interpreted in UTC

The signing tool writes the keys in alphabetical order and escapes / as \/ (as Foundation's JSON encoder does); that is irrelevant to readers, which parse the JSON after verifying the bytes.

A client verifies the licence every time it reads info, and when it starts with a stored licence:

  1. Split the token at .. There must be exactly two parts.
  2. Decode both parts from base64url. The signature must be 64 bytes.
  3. Verify the Ed25519 signature over the decoded payload bytes with the publisher's public key. On failure, ignore the licence.
  4. Parse the payload as JSON. licensee, plan, server and expires must be strings.
  5. Compare hosts: the host of server and the host of the base URL the client is connected to, both lower-cased, must be equal. Scheme, port and path are not compared. A licence for https://lager.example.com is valid for https://lager.example.com/inventory and http://LAGER.example.com:8443, and invalid for https://other.example.com.
  6. Check the date: parse expires as midnight UTC at the start of that day. The licence is valid while the current time is before that midnight plus 24 hours, so through the whole expiry day in UTC. There is no further grace period.
  7. If every step passes, the licensed plan is plan. Otherwise the licensed plan is free.

A licence that fails any step is treated as absent. The client does not show an error for an invalid licence; it falls back to its purchases.

The publisher's Ed25519 public key in the reference app (PurchaseManager.License.publisherKey), 32 raw bytes:

Encoding Value
Base64 XUT6JSv2UP7Qs7lic7l/w46dOAV+8jRdwzmoevd5Yqs=
Hex 5d44fa252bf650fed0b3b96273b97fc38e9d38057ef2345dc339a87af77962ab

Third-party clients that want to honour LVE organisation licences use this key. A client of another publisher uses its own key pair; licences are then not interchangeable.

The test token of the reference app's unit tests, from test-vectors/licence.json:

eyJleHBpcmVzIjoiMjA5OS0xMi0zMSIsImxpY2Vuc2VlIjoiVGVzdCBHbWJIIiwicGxhbiI6InRlYW0iLCJzZXJ2ZXIiOiJodHRwczpcL1wvbGFnZXIuZXhhbXBsZS50ZXN0IiwidiI6MX0.SrjXLKu0cKaNjeUVqx_ilKVimb2wBD1j5Whg2SD8FgaGHktoIwllP2i8O0yjeq7UC5_nhtYc3fvJZk9WgrLfDw
Step Result
Payload bytes {"expires":"2099-12-31","licensee":"Test GmbH","plan":"team","server":"https:\/\/lager.example.test","v":1} (107 bytes)
Signature (hex) 4ab8d72cabb470a68d8de515ab1fe294a56299bdb0043d63e56860d920fc1606861e4b682309653f68bc3b4ca37aaed40b9fe786d61cddfbc9664f5682b2df0f
Signature check valid with the public key above
Connected to https://lager.example.test/inventory on 2027-01-01 valid, plan team, licensee "Test GmbH"
Connected to https://other.example.test invalid: host differs
Current time 2100-01-01T00:00:00Z invalid: expired
First character changed (x instead of e) invalid: signature does not match

Verification in Node.js, for reference:

import { createPublicKey, verify } from 'node:crypto';
const key = createPublicKey({
key: { kty: 'OKP', crv: 'Ed25519', x: Buffer.from('XUT6JSv2UP7Qs7lic7l/w46dOAV+8jRdwzmoevd5Yqs=', 'base64').toString('base64url') },
format: 'jwk',
});
function verifyLicence(token, baseUrl, now = new Date()) {
const parts = token.split('.');
if (parts.length !== 2) return null;
const payload = Buffer.from(parts[0], 'base64url');
const signature = Buffer.from(parts[1], 'base64url');
if (signature.length !== 64 || !verify(null, payload, key, signature)) return null;
const licence = JSON.parse(payload.toString('utf8'));
if (new URL(licence.server).hostname.toLowerCase() !== new URL(baseUrl).hostname.toLowerCase()) return null;
if (Date.parse(`${licence.expires}T00:00:00Z`) + 86_400_000 <= now.getTime()) return null;
return licence;
}
Place What
Publisher The private signing key, kept offline. Never in a repository, never in the app, never on a customer server.
Server The token, in the reference server's LVE_LICENSE environment variable. Returned in info.license; left out when empty.
App The public key (compiled in), and the last valid token with the server URL it was verified for, so the plan survives a start without network.

The reference app stores a valid licence after verification and removes it as soon as the server sends no licence or an invalid one, or the user disconnects. On start it verifies the stored licence again (it may have expired while the app was closed).

The publisher uses tools/lve-license.swift, a script that runs with the Swift toolchain on macOS:

Terminal window
# Once: create the key pair. Refuses to overwrite an existing file and writes it with mode 0600.
swift tools/lve-license.swift keygen ~/.lve/license-signing-key
# prints the path and the public key (Base64), which goes into the app
# For each customer: sign a licence.
swift tools/lve-license.swift sign ~/.lve/license-signing-key "Example GmbH" team https://lager.example.com 2027-12-31
# prints the token
sign argument Payload field
licensee licensee
plan plan (team or pro)
server URL server; use the base URL the devices connect to, the host is what counts
expiry date expires, YYYY-MM-DD

The private key file holds the 32-byte Ed25519 private key in Base64. Keep it outside any repository, with a backup in a safe place. Losing it means no further licences can be signed; leaking it means anyone can sign licences, and the only remedy is an app release with a new public key, which invalidates every licence issued so far.

The organisation puts the token into its server configuration (LVE_LICENSE=<token> on the reference server; clear a cached configuration afterwards). Devices pick it up with their next sync round.

A licence cannot be extended; the publisher signs a new token with a later expires and the organisation replaces LVE_LICENSE. Clients read info on every sync round, also while sync is paused, so the new token takes effect within a minute without user action.

When the licence expires, is removed from the server, or the server moves to a host the licence does not name, the licensed plan falls back to free. If the device's own purchases do not cover server sync either:

sequenceDiagram
  autonumber
  participant D as Device
  participant S as Server
  D->>S: GET /lve/v1/info
  S-->>D: license expired or missing
  Note over D: plan no longer covers server sync
  Note over D: status "paused", notice shown once per lapse
  Note over D: no push, no pull, no attachments,<br/>local work continues, outbox grows
  loop every sync trigger
    D->>S: GET /lve/v1/info
    S-->>D: still no valid licence
  end
  D->>S: GET /lve/v1/info
  S-->>D: renewed license
  Note over D: plan covers sync again: status idle,<br/>sync round runs, outbox is pushed
  • Sync pauses: nothing is sent or received. The device keeps working locally; its changes wait in the outbox.
  • The user is told once per lapse, not on every start.
  • The device keeps reading info and resumes by itself when a valid licence returns, or when the user buys the plan.
  • No data is deleted, and the server keeps everything.
  • The free plan's article limit applies to creating new articles; existing articles stay usable.

The same pause applies to iCloud sync when the plan that covers it ends (see iCloud transport).