Licence server API — contract
The contract between a deployed Obscura (the client) and the vendor licence server (a
separate, internal, vendor-side service that is never shipped to a customer).
Two implementations sit either side of this document and they are built by different people, so
the shapes here are the agreement, not a suggestion.
- client —
obscura-server, in the customer's deployment - server — the vendor licence portal (registry, KMS-backed minting, expiry dashboard)
0. The one rule everything else follows
The check is ADVISORY. The signed licence file on disk is the sole authority.
Nothing about entitlement is decided by a network call. moduleEnabled never consults the
server, never blocks on it, and behaves identically whether the server is reachable, wrong, or
does not exist. The check exists to deliver a new signed licence and to report drift.
This is not caution for its own sake. If the check could gate modules, then one outage at the
vendor darkens every customer's paid features simultaneously — including customers who owe
nothing and have done nothing wrong. A licensing control that converts a vendor's bad afternoon
into a fleet-wide product outage is a worse commercial risk than the piracy it prevents.
It also keeps the existing air-gap doctrine intact rather than carving an exception into it:
every control stays offline and biased toward the honest operator, and a site that whitelists the
address gains convenience, not permission.
Consequences, all of which the server MUST assume:
- A deployment may never call, ever. That is a supported mode, not a fault.
- A deployment may call once a month, from a whitelisted egress, and nothing else.
- A response that never arrives, times out, 500s, or returns garbage leaves the deployment
exactly as it was. It is logged at INFO and surfaced in Admin → Licensing as "last checked:
never /
1. Deployment identity
The client identifies itself with the identity that already exists for node-lock: the PostgreSQL
cluster's system_identifier, the same value behind a db: node-lock.
deployment_id = "db:" + <postgres system_identifier>
Chosen because it is already implemented, needs no network to compute (air-gap safe), survives
container and host recreation, and cannot be moved without abandoning the archive it belongs to.
🔴 Do NOT use /etc/machine-id. It does not exist in the distroless runtime image and has
never produced a value in any containerised deployment. Anything built on it fails closed for
every customer.
⚠️ A db: identity binds to the DATABASE CLUSTER. Restoring a backup into a fresh initdb
produces a NEW identity, and the server will see an unknown deployment. That is correct
behaviour, and it belongs on the DR runbook: a restore of that kind needs the vendor to re-point
the registry entry, exactly as it already needs a reissued licence for a db: node-lock.
2. Authentication
Each deployment is issued a deployment_token at handover, alongside its licence. Sent as
Authorization: Bearer <token>.
The token identifies, it does not entitle. Possession grants the ability to ask "is there a
newer licence for this deployment?" and nothing else. A stolen token yields the licence that
deployment was already entitled to — it cannot widen a module set, extend a term, or mint
anything. Keep it that way: the moment the token can influence what is issued, it becomes a
credential worth stealing and the whole offline-authority model is undermined.
3. POST /v1/licence/check
Request
{
"deployment_id": "db:7284659812345678901",
"edition": "cloud", // "cloud" | "enterprise" — from TENANCY_MODE
"build": "v1.0.1", // config.BuildVersion(); "" if unstamped
"licence": {
"fingerprint": "sha256:9f2c…", // SHA-256 of the licence file's exact bytes on disk
"customer": "PT Virtue Digital Indonesia",
"expiry": "2035-12-31T23:59:59Z",
"modules": ["correspondence","office","mcp"]
}
}
🔴 modules MUST be the DECODED LICENCE PAYLOAD, never what the boot log printed and never
the effective set. ModuleAliases expands retired names on the way in — a licence whose
payload literally says mcp boots as ai. Sending the expanded form makes the server believe
the customer holds a licence that does not exist, and a "same but plus one" reissue built from it
silently rewrites the customer's entitlements into different bytes.
🔴 On Cloud, do NOT send the effective per-tenant set. The licence is the envelope;
control.tenant_modules allocates inside it. The server is only ever told about the envelope.
fingerprint is over the file's bytes, not a re-derived canonical form. The server uses it to
answer "does this deployment already have what I would send?" — nothing more.
Send nothing else. No document data, no tenant names, no user identities, no counts. This
call crosses a customer's network boundary, possibly one that required a firewall exception to
permit at all. Every field above is either the vendor's own issued data coming home or a build
identifier. Anything beyond that turns a licence check into telemetry the customer did not agree
to, and the first security review at a government buyer will find it.
Response — 200
{
"status": "current", // current | superseded | unknown
"server_time": "2026-08-19T13:40:12Z",
"notice": "Renewal due in 34 days.", // optional, human-readable, shown verbatim in Admin
"licence": null // or the full signed licence document
}
status |
Meaning | Client does |
|---|---|---|
current |
The file on disk is what the vendor would issue | records the check time; nothing else |
superseded |
A newer licence exists; licence is populated |
verifies, then offers/installs it |
unknown |
The server does not recognise this deployment | records it, shows a neutral note in Admin |
unknown is explicitly NOT an error and must not read like an accusation in the UI. It happens
legitimately after a DR restore into a fresh cluster, and to any deployment that predates the
registry.
Response — errors
Any non-200, malformed body, or timeout is treated identically: the check did not happen. The
client records the attempt and moves on. Servers SHOULD still be well-behaved (429 with
Retry-After for rate limits), but no client behaviour depends on it.
4. Installing a returned licence
When status is superseded, the client verifies before it trusts:
- Verify the Ed25519 signature against this build's own pinned trust anchor — the same code
path a file uploaded through Admin → Licensing takes. A licence arriving over the network gets
no more credit than one arriving on a USB stick. - Reject if the payload's
customerdiffers from the installed licence's, or if the expiry moves
backwards. Both mean something is wrong upstream; neither should be applied silently. - Only then install and hot-reload, exactly as
POST /admin/licensealready does.
🔴 THERE ARE TWO TRUST ANCHORS IN THE FIELD and they do not overlap: release-anchored builds
(dms1, dms2 — their env file sets LICENSE_PUBLIC_KEY) and dev-key builds (x056 demo, local).
A licence signed for one anchor fails verification on the other. The failure is safe here — the
client refuses and keeps its existing licence — but the SERVER must know which anchor a
deployment runs, and sign accordingly, or it will hand out licences nobody can install.
The server therefore records the anchor per deployment in its registry. edition in the request
does NOT imply it: both editions exist under both anchors.
⚠️ Whether install is automatic or requires an operator click is a client-side decision and
deliberately out of scope here. The contract only says a returned licence must be verified before
it is trusted.
5. server_time is a clock witness, not a clock
Enterprise deployments carry a monotonic time witness (migration 00190) so that winding the
host clock back cannot resurrect an expired licence. server_time is a genuinely trustworthy
observation and MAY be fed into that witness.
🔴 It may only ever RAISE the witness, via the existing GREATEST path. A response that would
lower it is discarded, not applied. Otherwise a spoofed or replayed response becomes the very
clock-rollback attack the witness exists to defeat — and it would arrive over a channel the
customer's firewall was told to trust.
6. Cadence
Once every 24h, plus on demand from Admin → Licensing. Jittered, so a fleet that all deployed on
the same afternoon does not arrive in a thundering herd every day at the same second.
Never on the request path of anything a user is waiting for.
7. POST /v1/licence/wake — PROPOSED ADDITION (implemented server-side 2026-08-20)
Status: proposed. The server half is live on the vendor portal; the client half is on
worktree-licence-checknow. Nothing else in this contract changes, and a client that never
calls this endpoint behaves exactly as before.
Why it exists
The vendor wants a "Check now" button on the portal side. The portal cannot have one in the
obvious form, because the portal must never be able to reach into a deployment — that property
is what lets a customer run Obscura behind NAT, inside a corporate network, or fully air-gapped
without handing the vendor an inbound door. It is a design choice, not a missing feature.
So the button does not push. The deployment holds one outbound request open; pressing the button
arms a flag; the held request returns and the deployment runs its own check. Every connection
is still client → portal.
Request
POST /v1/licence/wake
Authorization: Bearer <deployment_token>
Content-Type: application/json
{}
Same token as /check. The body is empty and reserved.
Response — 200
{ "check_now": true }
The entire protocol is that one boolean. It carries no entitlement, no licence, and no command
beyond "run your own check", which the deployment was free to do at any moment anyway. There is
nothing here a hostile server could say that would change what the deployment is licensed for.
Semantics
- The server holds the request open for up to 50 s, then answers
{"check_now": false}.
A client should reconnect immediately; the reconnection is also how the server learns the
deployment is still listening. - A request is delivered exactly once, even if two pollers race. A check that lands from any
cause clears the pending request. - Client timeout must exceed the hold (75 s is used).
401for a missing token; an unrecognised token gets200 {"check_now": false}— the same
non-accusatory posture as/check.
Client obligations
- Treat every failure as nothing: back off (30 s → 15 min) and leave the daily cadence alone.
A404from a portal that does not implement this is a normal, permanent-until-upgraded state. - Derive the wake URL from the check URL (
…/check→…/wake) so one configured address cannot
point the two halves at different servers. - Never let a wake failure touch entitlement. The signed file on disk remains the sole authority,
exactly as in §0.
What it deliberately does not do
- It does not shorten or replace the daily check.
- It does not let the server schedule, throttle or disable a deployment's checks.
- It does not give the portal any knowledge it would not get from the check that follows.
Open items for the server implementation
- Where the signing key lives (KMS/HSM vs sealed file) and whether minting requires human
approval. Both are server-side and neither changes this contract. - Whether
noticeis per-deployment or per-customer. - Retention of check records — they are the customer's operational data, so a stated policy is
needed before the first government buyer asks.