# Licensing (https://docs.vitrinka.ai/security/licensing)



## What a licence is [#what]

A licence is a small JSON file signed with the vendor's Ed25519 key and issued per customer. Every official release carries the vendor's public key compiled in and trusts exactly that key, so the file is verified offline inside the server process — no activation call, no licence server, no phone-home, nothing that breaks in an air-gapped network. It carries the customer name, the tier, a seat count (0 = unlimited), whether air-gap bundles are entitled, and the issue and expiry dates. The seat count is written to the boot log for reconciliation; it is not enforced at runtime and is never reported anywhere.

There is no self-serve channel: licences are signed by the vendor. Contact your vendor to obtain one or to renew — your bundle typically arrives with the licence already in place.

## Installing [#install]

Drop `license.json` into the bundle's `./license/` directory; Compose mounts it read-only at `/run/license` and points `LICENSE_FILE` at it (outside the bundle the default is `DATA_DIR/license.json`). Placement and the backup gotcha (the licence is *not* in `/data`) are covered in [Operations](/operator/operations#licensing).

* **No file** — vitrinka runs in an unrestricted evaluation posture; the licence machinery is a no-op. The hosted service runs this way.
* **A valid file** — the boot log says `license active`, with the customer, tier, seats and expiry.
* **A file the server cannot use** — the boot refuses and the process exits with `license file <path> refused: <cause>. Fix: <fix>`. A broken licence never silently degrades to unlicensed.

| Cause in the refusal                                             | What to do                                                                                                                                                                   |
| ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `its signature does not verify against this build's license key` | The file was edited, damaged in transit or signed by someone other than the vendor: put back the `license.json` your vendor issued, unedited, or ask for it to be re-issued. |
| `not a well-formed license file`                                 | Replace it with the file your vendor sent, byte for byte — a licence is not hand-editable.                                                                                   |
| `issued in the future — the server clock is behind`              | Set the host clock right (NTP) and restart; the licence itself is fine.                                                                                                      |
| `cannot read it`                                                 | Make the file readable by the server process.                                                                                                                                |
| `this build carries no license public key`                       | Run an official vitrinka release; this build cannot verify any licence.                                                                                                      |

Every refusal but the clock one also names the evaluation way out: remove the file to run unrestricted.

Check the installed file offline with the server binary itself — it verifies against the key built into it and prints the payload with `expired` and `inGrace`, or the refusal the boot would give. When the server has refused to boot, `docker compose run --rm --no-deps vitrinka license-inspect /run/license/license.json` runs the same check without it:

```bash
docker compose exec vitrinka /vitrinka license-inspect /run/license/license.json
```

## What it gates [#gates]

Expiry is a soft freeze, never a shutdown. A licensed install sends `X-License-Status` on every response — the status word only, never licensee data:

| State                | Behaviour                                                                                                                                                                                                                                                                                                                                                                                                                  |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Active               | Everything works. `X-License-Status: active`.                                                                                                                                                                                                                                                                                                                                                                              |
| Expired, in grace    | Everything still works for **14 days** past expiry; the app shows a banner asking for renewal and the boot log warns `license expired — grace period running` (`X-License-Status: grace`).                                                                                                                                                                                                                                 |
| Expired beyond grace | Every write — any method but `GET`, `HEAD` and `OPTIONS` — answers `403` saying writes are paused until renewal; **reads always work**, and the banner says so (`X-License-Status: expired`). Sign-in, account recovery, health checks, the browser error tunnel and the licence status endpoint stay open, so an admin can always get in and renew; sign-up, adding a passkey and connecting GitHub wait for the renewal. |

The transition happens live — a long-running process crosses expiry and grace without a restart.

`GET /api/v1/license` always answers `200`, frozen or not, and is never cached (`Cache-Control: private, no-store`). A caller without a signed-in browser session — token callers included — gets only `licensed` and `status`, the same word as the header and all the banner reads. A signed-in browser session also gets `customer`, `tier`, `expiry`, `expired` and `inGrace`. On the host, `license-inspect` reads the full file without a session.

<Callout type="warn">
  Never a hard shutdown, and never data loss — the freeze exists to be renewed out of, not to punish.
</Callout>

## Renewing [#renew]

Replace the file and restart the container; the licence is read at boot, so the freeze lifts as the server starts with the renewed file:

```bash
cp new-license.json ./license/license.json
docker compose restart vitrinka
```
