KEYSAT_INTEGRATION.md: section 0a "How enforcement actually works"

Captures the offline-vs-online enforcement framing that every
operator hits when they realize they want to revoke / downgrade /
lapse a license. Previously this answer was scattered across
sections; consolidating into a dedicated section 0a so both LLMs
and humans following the integration doc see it before they make
the SDK call-pattern decision.

Covers:
- What the buyer's app can enforce offline (baked-in expiry,
  entitlement set, trial flag, fingerprint binding)
- What the operator can change ONLY online (revocation, tier
  changes, sub lapses, seat enforcement)
- The two design dials operators pick (baked-expiry length,
  whether the app calls validate())
- The two patterns: A = "true perpetual, offline-only"; B =
  "perpetual price, online-enforced entitlements"
- Side-by-side TS code samples for each pattern
- Operator-side implications for each product type (perpetual,
  recurring, trial-converting)
- Cross-reference to section 11a (tier upgrades only have teeth
  with Pattern B) so the LLM following that section's flow back
  to here gets the right framing
- Note that Keysat itself dogfoods Pattern B (with reference
  to the new license_self::refresh_self_tier_from_db helper)

The framing is the same one that came out of Grant's testing
session — the integration doc is now the canonical place to
point any future operator who asks "wait, why doesn't downgrading
take effect?"
This commit is contained in:
Grant
2026-05-09 14:03:32 -05:00
parent 927ac2be53
commit 89d505b9de
+143
View File
@@ -129,6 +129,149 @@ ranges above and confirm before coding.
---
## 0a. How enforcement actually works (online vs offline)
This is the most-asked question every operator hits when they
realize they want to revoke a license, downgrade a buyer, or have
a recurring sub lapse. Read this section before designing your
gating logic; the choice you make here is sticky.
### What the buyer's app can enforce **offline**
These are baked into the **signed license key** at issuance time.
Once issued, they're cryptographically immutable for the life of
that key. The buyer can install your app on an air-gapped box and
these checks still work, forever:
- **Hard expiry.** If the operator issued the license with
`duration_seconds: 31536000` (1 year), the offline verifier
rejects it on day 366. No network needed.
- **Entitlement set.** Whatever entitlements were on the policy
when the license was signed are what the offline check sees
forever. Operator edits to the policy after issuance don't
reach this license.
- **Trial flag.** TRIAL bit in the signed payload, offline
detectable.
- **Fingerprint binding.** If the key was issued bound to
machine X's fingerprint, machine Y fails offline verification.
These are tamper-proof because Ed25519 signatures can't be forged
without the operator's private key.
### What the operator can change **only via online enforcement**
These mutations live in the operator's licensing-service DB and
**never reach the buyer's app** unless the app actively calls
`/v1/validate`:
- **Revocation.** DB row flips `revoked_at`; signed key still
verifies offline.
- **Tier downgrade / upgrade.** New entitlements live in the DB;
signed key still has the old ones.
- **Recurring subscription lapse.** Sub goes `past_due` →
`lapsed` server-side. Signed key (which is just `expires_at =
now + 30 days` for monthly subs) keeps verifying offline until
its baked expiry.
- **Seat enforcement** beyond per-key fingerprint binding.
### The two design dials the operator picks
For each product they sell:
1. **How short is the baked expiry?** Short (e.g. 35 days for a
monthly sub) = buyer must come online frequently to refresh;
operator retains tight control. Long / perpetual = buyer can
stay offline indefinitely; operator gives up most post-sale
enforcement.
2. **Does the buyer's app actually call `validate()`?** This is
YOUR call as the SDK consumer. If the app only does
`verifier.verify(key)` (offline signature check) and never
calls `client.validate(...)`, **no operator-side change can
ever reach a buyer who's already activated.** If the app calls
`validate()` on launch + daily with a sensible cache fallback,
operators have near-real-time control.
### The two patterns
**Pattern A — true perpetual, no take-backs.** App does
`verifier.verify(key)` at launch and trusts whatever the signed
payload says. Buyer pays once, gets entitlements forever, even
if the operator regrets it. Honest sale, like buying a Photoshop
CS6 disk in 2012. Works for: tools the operator is confident they
want to lifetime-license; markets where buyers explicitly value
"buy once, own forever"; software that may need to function
on air-gapped boxes.
**Pattern B — perpetual *price*, online-enforced entitlements.**
App calls `client.validate(...)` periodically (on launch + daily)
and treats the SERVER's entitlement set as authoritative. The
license is "perpetual" in that there's no expiry-driven re-payment,
but enforcement is live. Operator retains downgrade / revoke /
sub-lapse control. Buyer's offline experience is normal as long
as they come online once per cache window. This is what most
"SaaS replacement" products want.
```ts
// Pattern A — offline-only
import { Verifier } from '@keysat/licensing-client'
const v = new Verifier(OPERATOR_PUBKEY_PEM)
const ok = v.verify(licenseKey)
if (!ok.valid || !ok.entitlements.includes('core')) refuseToStart()
// Pattern B — online-aware with offline fallback
import { Client } from '@keysat/licensing-client'
const client = new Client(OPERATOR_KEYSAT_URL)
const result = await client.validate(licenseKey, { productSlug, fingerprint })
if (!result.ok) refuseToStart()
// result.entitlements is the LIVE set from the server
// On network failure, fall back to verifier.verify() with a
// cache TTL appropriate to your business (e.g. 7 days).
```
### Operator-side implication
Your pricing/enforcement model has to match the offline-vs-online
tradeoff:
- **Perpetual licenses** with Pattern A: you give up post-sale
control. Honest sale. Refund-if-buyer-asks model.
- **Perpetual licenses** with Pattern B: full operator control,
but the app has to be online periodically to bite. Buyers who
go fully offline forever can't be touched.
- **Recurring subs**: NEED short baked-in expiries (1-2 cycles'
worth) plus working `/v1/validate` integration. Otherwise
lapsing is unenforceable.
- **Free trial converting to paid**: bake `expires_at = trial_end`
so the trial expires offline, then renewal flow extends it on
payment.
### What this means for the tier-upgrade feature (section 11a)
The whole tier-upgrade flow only has teeth if buyers' apps are
calling `validate()`. For a buyer using Pattern A who paid for
Patron and the operator later downgrades them: nothing happens
until they come online. **Same constraint going the other way:**
a Pattern A buyer's app wouldn't see new entitlements after an
upgrade until next online call.
This isn't a Keysat-specific limitation — it's a property of any
license model that doesn't require always-on phone-home. **Keysat
deliberately doesn't.** That's a feature, not a bug; but you, the
SDK consumer, need to decide which pattern your app implements
based on the operator's business model.
### Keysat dogfoods Pattern B
The Keysat daemon itself uses Pattern B for its own self-license:
verifies the on-disk LIC1 key at boot (Pattern A signature check),
THEN refreshes entitlements from the local DB hourly + on-demand
via `POST /v1/admin/self-license/refresh` (Pattern B online
component). This is the same pattern you'd implement in any
"perpetual price, live entitlements" app. See
`license_self::refresh_self_tier_from_db` for reference.
---
## 1. What Keysat does, in one paragraph
Keysat lets independent software creators sell their work on their own