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.
Token format
Section titled “Token format”base64url(payload) "." base64url(signature)base64urlis the URL-safe Base64 alphabet (-and_instead of+and/) without padding. Readers also accept padding.payloadis the exact JSON byte sequence that was signed. Readers MUST verify the signature over these bytes as decoded, not over a re-encoded form.signatureis a 64-byte Ed25519 signature (RFC 8032, pure Ed25519) overpayload.
Payload
Section titled “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.
Verification
Section titled “Verification”A client verifies the licence every time it reads info, and when it starts with a stored licence:
- Split the token at
.. There must be exactly two parts. - Decode both parts from base64url. The signature must be 64 bytes.
- Verify the Ed25519 signature over the decoded payload bytes with the publisher's public key. On failure, ignore the licence.
- Parse the payload as JSON.
licensee,plan,serverandexpiresmust be strings. - Compare hosts: the host of
serverand 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 forhttps://lager.example.comis valid forhttps://lager.example.com/inventoryandhttp://LAGER.example.com:8443, and invalid forhttps://other.example.com. - Check the date: parse
expiresas 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. - If every step passes, the licensed plan is
plan. Otherwise the licensed plan isfree.
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.
Public key
Section titled “Public key”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.
Worked example
Section titled “Worked example”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;}Where the licence lives
Section titled “Where the licence lives”| 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).
Issuing licences
Section titled “Issuing licences”The publisher uses tools/lve-license.swift, a script that runs with the Swift toolchain on macOS:
# 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 tokensign 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.
Renewal
Section titled “Renewal”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 lapses
Section titled “When the licence lapses”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
infoand 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
freeplan'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).