The license is a signed instance.v1.License file (license.bin): raw
LicenseClaims protobuf bytes + an Ed25519 signature over those exact bytes.
Claims carry: customer, domains[] (the licensed hosts), issued_at/expires_at,
capability flags (public_signup, billing, drill_catalog), max_users
(0 = unlimited), and a license_id (UUIDv7, for ops correlation).
Minting is offline and human-only. The Ed25519 private key is custodied by
T.REX (James) and never enters CI. Only the public key ships in libs/core, and
only the signed, publiclicense.bin flows through the pipeline. Clients download
the license bytes via the unauthenticated GetInstanceInfo handshake, so the file is
not a secret — it is signed, not confidential.
The server is validate-and-warn: a missing/expired/invalid license never stops
the process. But clients hard-block on a server that serves no valid license
(“Not a verified Range Day Pro server”), so onboarding is not complete until a valid
license is delivered.
Environments are separated only by the domains claim (and capabilities). One
private key signs staging, production, and every white-label customer.
Canonical hosts
Env
API host (--domain)
PWA web_origin (WEB_ORIGIN)
Production
server.range-day.app
https://my.range-day.app
Staging
staging-server.range-day.app
https://staging-my.range-day.app
White-label
customer’s API host
customer’s PWA host (must be a licensed domain)
Staging’s API host is staging-server.range-day.app — not the marketing host
staging.range-day.app. Licensing the wrong one makes the client’s connect-host fall
outside the signed domains → DomainMismatch → every client blocks.
web_origin is unsigned config; the client validates its host against the
signed domains and falls back to https://my.range-day.app if it isn’t licensed.
So every host you put in WEB_ORIGIN must also be one of the --domain values.
1 — Mint the license (offline, on James’s machine)
The private key is a PKCS#8 PEM (-----BEGIN PRIVATE KEY-----) or a 32-byte hex seed;
the helper auto-detects. Build the CLI once (cargo build -p rangeday) and:
Licensed hosts. The connecting API host must match one.
--expires YYYY-MM-DD
Invalid from 00:00:00 UTC on this date.
--public-signup
Omit for invite-only (hides Register).
--billing / --drill-catalog
Enable those capabilities (omit = off).
--max-users N
Seat cap; 0/omit = unlimited.
--license-id <uuid>
Pin an id for deterministic re-issuance; omit = fresh UUIDv7.
Record the license_id it prints against the customer/domains/expiry — it’s your
ops handle for renewal and (future) revocation. Also record the customer’s renewal
date (typically expires_at minus a lead time) wherever customer accounts are
tracked: a white-label customer’s server is theirs (often air-gapped), so renewal is a
minting-side process, not something our monitoring can reach. (T.REX’s own deployments
are minted far-future, so the in-cluster <30-day Grafana alert is the net for our envs;
see license-expiry-monitoring.)
Verify before delivering — full offline check (signature against the embedded
public key, domain, expiry) of the exact file you’re about to ship. The --domain
is the env’s API host (the table above), because that’s the host clients verify
against; this is precisely the check that catches a wrong-host mis-mint:
Exit 0 = valid; 1 = verification failed (wrong domain, expired, bad signature,
empty); 2 = the check itself broke (unreadable file). rangeday license inspect <file> prints the decoded claims + status when you need to see what’s inside.
The deploy pipelines run this same check automatically — pre-apply on the secret
and post-deploy against the served claims — so a mis-mint fails CI before rollout.
T.REX’s own unlimited deployment is expressed as --max-users 0, never a
hardcoded customer-name bypass in code.
2 — Deliver to a managed (k8s) environment
The license rides through the existing deploy pipeline as a per-environment GitHub
secret; CI base64-decodes it into the server-license k8s secret, which the
Deployment mounts at /etc/rangeday/license.bin (LICENSE_FILE).
Base64-encode the file (single line, no wrapping):
In GitHub → Environment (staging / production) → Secrets — scope these
per-environment, never repo-level. A repo-level LICENSE_B64 / WEB_ORIGIN feeds the
same value to both staging and production, so one env serves the other’s signed
domains / web_origin → DomainMismatch → every client hard-blocks.
LICENSE_B64 = the base64 string from step 1.
WEB_ORIGIN = the PWA origin for that env (see table). Required for every environment
except production. If unset, the server falls back to the pinned production default
(https://my.range-day.app), which is wrong for staging and any white-label host, so
the PWA’s origin check fails.
Trigger the deploy:
Staging: push to master (paths under deploy/k8s/**, .github/actions/**,
apps/server/** … trigger it) or run Deploy Server via workflow_dispatch.
Production: push a server-v* tag (rangeday release server <bump>) or run
Release Server via workflow_dispatch.
CI runs apply-secrets (writes WEB_ORIGIN into server-env) and apply-license
(writes server-license) before the kustomize apply, so the rolled pod mounts the
new license. Both steps skip cleanly if their input is empty — a deploy without
LICENSE_B64 still succeeds and the server serves an empty license (clients block).
Once LICENSE_B64 is set, delete the local *.bin — the GitHub secret is now the
source of record, and the exact license is reproducible by re-running license issue
with the same --license-id. (Licenses aren’t committed, so there’s no ignore rule to
rely on; just don’t leave artifacts lying around.)
Editing only.github/workflows/** does not trip the Deploy Server path filter.
The k8s/action changes here do — or use workflow_dispatch.
3 — Deliver to a white-label (docker-compose) deployment
The customer (or T.REX, on their behalf) drops license.bin next to the compose file
and bind-mounts it. Capabilities come from the license, notconfig.yaml.
services: server: volumes: - ./license.bin:/etc/rangeday/license.bin:ro environment: - LICENSE_FILE=/etc/rangeday/license.bin - WEB_ORIGIN=https://<their-pwa-host> # must be a licensed --domain
docker compose up -d (or restart the server) to pick up the file. See
White-Label Deployment for the full compose/config layout.
4 — Renew / change capabilities
Expiry is a hard outage, not a soft warning. The client gate rejects an expired
license exactly like an invalid one (VerifyOutcome::Rejected → Blocked, gate.rs), so
every client — including existing users at the login screen — blocks the moment
expires_at passes. There is no server-side grace period that keeps things working
past expiry. Renew well before expires_at (the license-expiry-monitoring item
tracks the <30-day alert that should back this up). To renew or change a seat cap /
capability:
Re-run rangeday license issue with the new --expires (and/or new flags). Reuse
--license-id <uuid> if you want continuity of the ops id.
Replace the delivery artifact:
k8s: update the LICENSE_B64 secret, then re-run the deploy (push/tag or
workflow_dispatch). The deploy stamps the new license’s sha256 as a pod-template
annotation, so the rollout self-triggers even when the image is unchanged, and
the post-deploy verify step confirms the new license is actually served. (Only if
you edit the server-license secret out-of-band — bypassing the pipeline — must you
kubectl rollout restart deployment/server -n <ns> yourself, because the server
reads the license only at boot.)
white-label: swap license.bin and restart the server. No rebuild.
Verify (below).
5 — Verify
After deploy/restart, confirm the server actually serves a valid license:
Exit 0 ⇒ signature/domain/expiry all good. Empty license (exit 1) ⇒ the
secret/file didn’t land — recheck step 2/3. Swap verify for inspect to see
the decoded claims (customer, domains, expiry, capabilities, license_id) and
confirm the right webOrigin in the raw JSON.
Client signs in: the PWA at the env’s web_origin reaches the login screen
(instead of the “Not a verified Range Day Pro server” block) and shows the right
branding / Register visibility.
Guardrails recap
Private key never in CI; mint offline. The license is public (signed, not secret).
The server-license secret mount is optional: true — do not remove it, or any env
without a license fails to schedule.
Don’t deliver the license via a kustomize secretGenerator (this repo hit the
generator empty-data bug); keep the explicit apply-license create-or-apply.
WEB_ORIGIN host must be one of the signed --domains, or the client ignores it and
falls back to the default.