1379 lines
50 KiB
Markdown
1379 lines
50 KiB
Markdown
# Integrating Keysat licensing into your software
|
||
|
||
This document is the complete instruction set for adding Keysat-based
|
||
licensing to any application. It covers Node/TypeScript, Python, and Rust.
|
||
Hand it to an LLM (or a developer) along with your codebase and ask them
|
||
to wire it up — they should have everything they need.
|
||
|
||
## How to use this document
|
||
|
||
Drop `KEYSAT_INTEGRATION.md` into a fresh session against the target
|
||
codebase, then prompt:
|
||
|
||
> Read this `KEYSAT_INTEGRATION.md`. Then add Keysat licensing to this
|
||
> codebase. Ask the software creator how they would like to license,
|
||
> monetize and/or gate their software, including how unlicensed software
|
||
> should operate. Use the creator's Keysat public key which can be found
|
||
> in the Keysat admin UI. After wiring, summarize what you changed.
|
||
|
||
> **Reading this as an LLM?** Your job is to add license-key reading,
|
||
> signature verification, and entitlement-based feature gating to the
|
||
> target application. Before you write any code, ask the software creator
|
||
> the questions in section 0. Then read sections 1–6, then jump to the
|
||
> language section (7a/7b/7c) that matches your target codebase. The
|
||
> "Worked example" in section 14 is the canonical pattern to mimic.
|
||
|
||
---
|
||
|
||
## 0. Questions to ask the software creator before writing code
|
||
|
||
Don't write any code until you have answers. The whole licensing model
|
||
hangs on these:
|
||
|
||
1. **What's the operator's Keysat instance URL?**
|
||
(e.g. `https://licensing.example.com`. Used for online validation
|
||
and the in-app purchase flow.)
|
||
2. **What's the operator's product slug?**
|
||
(Short string the operator chose when creating the product in their
|
||
Keysat admin. License keys are scoped to this slug.)
|
||
3. **What's the operator's signing public key?**
|
||
(PEM-formatted Ed25519 public key. Get it from the Keysat admin
|
||
Overview tab → "Embed your public key" → Copy. The operator pastes
|
||
it; you embed it.)
|
||
4. **How should unlicensed users experience the app?** Three legitimate
|
||
patterns; pick whichever fits the operator's business model. **None
|
||
is "wrong."**
|
||
- **Hard gate** — the app downloads freely from the Start9 registry,
|
||
but won't function without a paid license. The binary is essentially
|
||
a locked installer until the buyer activates. Common for closed-source
|
||
paid apps and for open-source apps that the operator chooses to
|
||
monetize through the registry distribution. See section 8 for the
|
||
two flavors of hard gating (refuse-to-start vs. activate-screen-only).
|
||
- **Soft gate** — the app runs and provides basic functionality
|
||
unlicensed; specific paid features return 402 with an "Upgrade to
|
||
unlock" message. Recommended for free → paid migrations and for
|
||
freemium products.
|
||
- **Nag mode** — no enforcement; just a "support development" banner
|
||
when unlicensed. Pure honor system. Useful when the app is
|
||
fundamentally free-to-use but the operator wants a tip-jar.
|
||
|
||
Nudge the operator if their answer doesn't match their business
|
||
reality. Closed-source-paid + nag-mode is incoherent; freemium +
|
||
hard-gate alienates the existing user base.
|
||
5. **What are the entitlement strings, and what does each unlock?**
|
||
The operator decides; ask them. Common patterns:
|
||
- `["self_host"]` for a free tier — "you can run the app, no premium features"
|
||
- `["self_host", "export", "ai_features", "team_seats"]` for a paid tier
|
||
- `["patron"]` extra for a vanity supporter tier
|
||
Document the mapping (entitlement string → feature unlocked) in your
|
||
integration so the operator can ship the right policies.
|
||
6. **Where should the license key live on disk at runtime?**
|
||
Default: `/data/license.txt` for server / containerized apps, or
|
||
`~/.config/<your-app>/license.key` for desktop apps. Operator may
|
||
override.
|
||
7. **Which pricing tiers exist** and roughly what they cost? (Optional
|
||
for the integration itself, but useful for shaping the "Upgrade"
|
||
message that shows when an unlicensed user hits a paid feature.)
|
||
|
||
If the creator doesn't know yet, propose sensible defaults from the
|
||
ranges above and confirm before coding.
|
||
|
||
8. **Compile a config card before writing code.** After answering 1–7,
|
||
produce a short summary the operator can paste into the Keysat admin
|
||
without re-deriving anything. This is the single highest-leverage
|
||
step for avoiding "wait, what entitlements did we agree on?" churn
|
||
later. The card has three parts:
|
||
|
||
- **Product**: the slug from question 2.
|
||
- **Policies**: each policy's name and the entitlement set it issues.
|
||
Treat this as the operator's pricing menu — one policy per tier.
|
||
- **Behavior matrix**: caller state → what happens. Lets the operator
|
||
sanity-check the gating model (question 4) against the policy set.
|
||
|
||
Show the card to the operator, get explicit confirmation, *then*
|
||
write code. Example for a two-tier hard-gate-flavor-2 freemium app:
|
||
|
||
```
|
||
Product slug: youtube-summarizer
|
||
|
||
Policies to create in Keysat admin:
|
||
• Core → entitlements: ["core"]
|
||
• Pro → entitlements: ["core", "subscriptions", "history", "library"]
|
||
|
||
Entitlement → unlocks:
|
||
core — past the activation screen; basic summarize
|
||
subscriptions — channel subscriptions, auto-queue
|
||
history — saved summary library
|
||
library — bulk import/export
|
||
|
||
Behavior matrix:
|
||
no license → 402 license_required everywhere
|
||
Core license → summarize works; subs/history/library = 402 feature_not_in_tier
|
||
Pro license → all features available
|
||
```
|
||
|
||
Without this card, mid-implementation drift is near-certain — the LLM
|
||
gates on `library_io`, the operator creates a policy with `library`,
|
||
and the buyer sees a "feature not in tier" error on a feature they
|
||
thought they paid for.
|
||
|
||
---
|
||
|
||
## 1. What Keysat does, in one paragraph
|
||
|
||
Keysat lets independent software creators sell their work on their own
|
||
terms. The operator (the creator) runs a Keysat instance — typically on
|
||
a Start9 box — and Keysat handles the buy page, the Bitcoin payment via
|
||
BTCPay, and issuing each buyer a signed license key in `LIC1-…-…` form.
|
||
**Your software's job** is to read that key from somewhere on disk
|
||
(a file, an env var, a config setting) and verify its signature against
|
||
the operator's public key. What happens after verification is up to the
|
||
creator: maybe the app refuses to function without a license (one-time
|
||
purchase model), maybe specific features unlock (free + paid tiers),
|
||
maybe nothing changes and the verified license is just used to show a
|
||
"thanks for supporting development" badge. You never talk to a Keysat
|
||
server at runtime unless you want to — verification is offline, fast
|
||
(~1ms), and doesn't depend on the network.
|
||
|
||
---
|
||
|
||
## 2. The whole integration in 30 seconds
|
||
|
||
```
|
||
1. Install the Keysat SDK in your language.
|
||
2. Embed the operator's PUBLIC key into your app at build time.
|
||
3. On startup, read the license key from disk; verify it; populate an
|
||
`entitlements` set.
|
||
4. Throughout your code, gate paid features with `if entitlements.has("X")`.
|
||
5. (Optional) On a timer, also call /v1/validate to catch revocations.
|
||
```
|
||
|
||
Everything else is polish.
|
||
|
||
---
|
||
|
||
## 3. Prerequisites — three things you need from the operator
|
||
|
||
1. **A Keysat instance reachable on the public internet.** Typically
|
||
something like `https://licensing.example.com`. The operator already
|
||
has this; you don't need to install one.
|
||
2. **A product slug** the operator created in their Keysat. This is a
|
||
short string (`acme-paint-pro`, `myapp`, etc.). Licenses issued for
|
||
one slug won't validate against another — this is intentional and
|
||
stops a customer from buying a cheap product and using its key to
|
||
unlock an expensive one.
|
||
3. **The operator's signing public key in PEM form.** This is what you
|
||
embed in source. Get it from:
|
||
- The admin Overview tab → "Embed your public key" tip card → Copy
|
||
- Or `curl https://licensing.example.com/v1/issuer/public-key | jq -r .public_key_pem`
|
||
|
||
The PEM is non-secret — **anyone with the public key can verify
|
||
licenses but not mint them.** It's safe to commit to source control
|
||
and ship in your binary.
|
||
|
||
4. **The public buy URL for your product.** Each product on a Keysat
|
||
instance has a buyer-facing page at
|
||
`<keysat-base-url>/buy/<product-slug>`. Use this for "Buy a key" /
|
||
"Upgrade to Pro" links in your app's activation screen, settings, and
|
||
per-feature upsell tiles. Compute it from the same constants you've
|
||
already embedded — don't hard-code a separate URL that can drift:
|
||
|
||
```ts
|
||
const buyUrl = `${KEYSAT_BASE_URL.replace(/\/$/, "")}/buy/${PRODUCT_SLUG}`
|
||
```
|
||
|
||
The simpler "link to a buy page" path (this URL) is fine for most
|
||
apps. If you want a more integrated checkout, see section 11 for
|
||
`client.startPurchase()`.
|
||
|
||
---
|
||
|
||
## 4. The wire format you'll be reading
|
||
|
||
License keys look like:
|
||
|
||
```
|
||
LIC1-AIAMCWOS5JVHSQE2UMP6PNKXODHSIPHM5O3XQQ2J6CE4XV6WVNMA3BIAAAAA…
|
||
```
|
||
|
||
A `LIC1-` prefix, then two base32 segments separated by `-`. The first
|
||
segment is a binary payload; the second is an Ed25519 signature over
|
||
the payload. The SDK parses and verifies in one call. You should never
|
||
need to handle the encoding manually.
|
||
|
||
The signed payload contains:
|
||
- `product_id` (UUID) — for matching against your product slug
|
||
- `license_id` (UUID) — useful for logging
|
||
- `issued_at` (Unix seconds)
|
||
- `expires_at` (Unix seconds; 0 means perpetual)
|
||
- `flags` (bitfield; `FLAG_TRIAL=1`)
|
||
- `entitlements: string[]` — **this is the array you gate features on**
|
||
- `fingerprint_hash` (32 bytes; for online machine-binding)
|
||
|
||
Your software reads `entitlements` and decides what to unlock.
|
||
|
||
---
|
||
|
||
## 5. Where to read the license from
|
||
|
||
There's no one-size-fits-all answer; pick one based on how your users
|
||
interact with your app. **Recommended order**:
|
||
|
||
1. **A file in the user's data directory.** On Linux this is typically
|
||
`~/.config/<your-app>/license.key`, or `/data/license.txt` for
|
||
server software running in a container. The file contains exactly
|
||
one line: the raw `LIC1-…` string. This is the most common pattern.
|
||
|
||
2. **An environment variable** like `MYAPP_LICENSE_KEY`. Useful for
|
||
server-side software, CLIs, Docker Compose, and systemd. Easy to
|
||
set, but users forget they set it and lose track.
|
||
|
||
3. **A "paste your license key" UI** in your app's settings, with the
|
||
value persisted to localStorage / OS keychain / your own config.
|
||
Most familiar to users coming from commercial software.
|
||
|
||
4. **Multiple of the above.** A common pattern is: "env var first,
|
||
then file, then UI prompt." All three give you a license string
|
||
either way; the SDK doesn't care where it came from.
|
||
|
||
For Start9 packages: there's a [activate-license-template](./activate-license-template/)
|
||
that wires this up for you using StartOS Actions and the package store.
|
||
Copy that template, replace the slug, and you've got Pattern 1 + a
|
||
StartOS Actions UI for buyers to paste keys into.
|
||
|
||
---
|
||
|
||
## 6. The canonical integration pattern
|
||
|
||
Every integration follows the same shape regardless of language and
|
||
regardless of which enforcement model from question 4 the operator picked.
|
||
The verify-once-at-startup primitive is the same; what you do with the
|
||
result is what changes.
|
||
|
||
```
|
||
on startup:
|
||
raw_key = read_license_string() # file, env, or UI value
|
||
license_state = {state: 'unlicensed', entitlements: []}
|
||
if raw_key is not None:
|
||
result = verify(raw_key, ISSUER_PEM) # SDK call
|
||
if result.is_valid:
|
||
license_state = {
|
||
state: 'licensed',
|
||
entitlements: result.entitlements,
|
||
license_id: result.license_id,
|
||
expires_at: result.expires_at,
|
||
}
|
||
else:
|
||
log("license rejected: " + result.reason)
|
||
|
||
# Then — depending on the operator's chosen model:
|
||
#
|
||
# HARD GATE : if not licensed, exit (Flavor 1) or block all
|
||
# business endpoints (Flavor 2). See section 7d.
|
||
#
|
||
# SOFT GATE : run normally; specific feature handlers consult
|
||
# license_state.entitlements before unlocking.
|
||
# See section 7a/7b/7c.
|
||
#
|
||
# NAG MODE : run normally; show a "support development" banner
|
||
# in the UI when license_state.state != 'licensed'.
|
||
```
|
||
|
||
The verify-and-populate-state step is identical for all three models.
|
||
The doc is structured the same way: section 7 covers the verify
|
||
primitive in each language; section 7d covers the hard-gate enforcement
|
||
flavors; the worked examples in section 14 show soft-gate; the
|
||
patterns are mix-and-match.
|
||
|
||
**One universal rule across all three models:** never hard-fail on
|
||
*network* errors during the optional online `validate()` call (section 9).
|
||
That's separate from refusing to start when no license is present — which
|
||
is fine for hard-gate Flavor 1. The thing to avoid is making your app's
|
||
uptime depend on the operator's licensing server being reachable.
|
||
|
||
**Don't forget background workers.** HTTP middleware gates only catch
|
||
incoming requests. If you have in-process timers, schedulers, queue
|
||
consumers, or other background jobs that exercise gated features, add
|
||
an explicit early-return at the top of each one:
|
||
|
||
```js
|
||
async function checkSubscriptionsBackground() {
|
||
if (!LIC.entitlements.has("subscriptions")) return // skip silently
|
||
// … existing work
|
||
}
|
||
```
|
||
|
||
Otherwise an unlicensed (or insufficient-tier) instance will keep doing
|
||
work the buyer didn't pay for — wasting bandwidth, API quota, and
|
||
server CPU, and producing stale state in the UI when entitlements are
|
||
later restored. This bites people because the server returns 402 to
|
||
direct callers but the timer keeps humming along.
|
||
|
||
---
|
||
|
||
## 7. Language-specific implementations
|
||
|
||
### 7a. TypeScript / Node
|
||
|
||
**Install (preferred, once published):**
|
||
|
||
```bash
|
||
npm install @keysat/licensing-client
|
||
```
|
||
|
||
**GitHub fallback** (if the npm package isn't published yet). Several
|
||
prerequisites must be met for this path to work end-to-end:
|
||
|
||
1. The `keysat-xyz/keysat-client-ts` repo must be **public** on GitHub.
|
||
Private repos require credentials, which fails inside hermetic build
|
||
environments (Docker, CI, fresh dev machines without an SSH key). If
|
||
the repo flips public temporarily for one build, every future build
|
||
re-hits this wall — prefer publishing to npm if at all possible.
|
||
2. The repo must include a `prepare` script in `package.json` that
|
||
builds `dist/` on git-install. This is fixed as of this doc; if you
|
||
see `Cannot find module '...dist/index.cjs'` after install, the SDK
|
||
you're pulling pre-dates the fix and you need a newer commit.
|
||
3. **Use the explicit `git+https://` URL form**, not the `github:`
|
||
shorthand:
|
||
|
||
```jsonc
|
||
// package.json
|
||
"@keysat/licensing-client": "git+https://github.com/keysat-xyz/keysat-client-ts.git"
|
||
```
|
||
|
||
The `github:user/repo` shorthand often resolves to `git+ssh://...`
|
||
on machines with an existing GitHub SSH key, which then breaks for
|
||
any subsequent integrator without a key (CI, Docker, a fresh laptop).
|
||
|
||
4. **If you switched from `github:` to `git+https://`, also delete the
|
||
stale lock-file entry.** `npm install` will keep the previous
|
||
`resolved: "git+ssh://..."` line in `package-lock.json` even after
|
||
you change the spec in `package.json`. The fastest fix is:
|
||
|
||
```bash
|
||
rm package-lock.json node_modules
|
||
npm cache clean --force
|
||
npm install
|
||
```
|
||
|
||
Or hand-edit the `resolved:` field of the offending entry to swap
|
||
`git+ssh://` → `git+https://`, leaving the commit hash unchanged.
|
||
|
||
When all four are satisfied:
|
||
|
||
```bash
|
||
npm install github:keysat-xyz/keysat-client-ts
|
||
```
|
||
|
||
**Embed the public key.** The simplest way is to commit the PEM file
|
||
to your repo at `assets/issuer.pub` and import it as a raw string:
|
||
|
||
```ts
|
||
// in your bundler config (Vite shown)
|
||
import issuerPem from './assets/issuer.pub?raw'
|
||
```
|
||
|
||
Or in plain Node:
|
||
|
||
```ts
|
||
import { readFileSync } from 'node:fs'
|
||
import * as path from 'node:path'
|
||
const issuerPem = readFileSync(path.join(__dirname, 'assets/issuer.pub'), 'utf8')
|
||
```
|
||
|
||
**Verify on startup:**
|
||
|
||
```ts
|
||
import { Verifier, PublicKey } from '@keysat/licensing-client'
|
||
import { readFileSync } from 'node:fs'
|
||
|
||
const PRODUCT_SLUG = '<your-product-slug>'
|
||
const LICENSE_PATH = process.env.MYAPP_LICENSE_KEY_PATH || '/data/license.txt'
|
||
|
||
function readLicenseKey(): string | null {
|
||
if (process.env.MYAPP_LICENSE_KEY) return process.env.MYAPP_LICENSE_KEY.trim()
|
||
try { return readFileSync(LICENSE_PATH, 'utf8').trim() }
|
||
catch { return null }
|
||
}
|
||
|
||
const verifier = new Verifier(PublicKey.fromPem(issuerPem))
|
||
|
||
export interface LicenseState {
|
||
state: 'licensed' | 'unlicensed' | 'invalid'
|
||
reason?: string
|
||
licenseId?: string
|
||
entitlements: Set<string>
|
||
expiresAt?: Date
|
||
isTrial?: boolean
|
||
}
|
||
|
||
export function checkLicense(): LicenseState {
|
||
const raw = readLicenseKey()
|
||
if (!raw) return { state: 'unlicensed', entitlements: new Set() }
|
||
try {
|
||
const ok = verifier.verify(raw)
|
||
// (optional) reject keys for the wrong product slug
|
||
if (ok.payload.productSlug && ok.payload.productSlug !== PRODUCT_SLUG) {
|
||
return { state: 'invalid', reason: 'product_mismatch', entitlements: new Set() }
|
||
}
|
||
return {
|
||
state: 'licensed',
|
||
licenseId: ok.payload.licenseId,
|
||
entitlements: new Set(ok.payload.entitlements || []),
|
||
expiresAt: ok.payload.expiresAt
|
||
? new Date(ok.payload.expiresAt * 1000)
|
||
: undefined,
|
||
isTrial: !!(ok.payload.flags & 1),
|
||
}
|
||
} catch (e: any) {
|
||
return { state: 'invalid', reason: e.message, entitlements: new Set() }
|
||
}
|
||
}
|
||
```
|
||
|
||
**Use the state object** wherever a feature is gated:
|
||
|
||
```ts
|
||
const lic = checkLicense()
|
||
console.log(`[license] state=${lic.state} entitlements=[${[...lic.entitlements].join(',')}]`)
|
||
|
||
// In an Express route:
|
||
app.post('/api/export', (req, res) => {
|
||
if (!lic.entitlements.has('export')) {
|
||
return res.status(402).json({
|
||
error: 'feature_not_in_tier',
|
||
message: 'Export requires a paid license. See <upgrade_url>.',
|
||
})
|
||
}
|
||
// ... existing export logic
|
||
})
|
||
```
|
||
|
||
### 7b. Python
|
||
|
||
**Install (preferred, once published):**
|
||
|
||
```bash
|
||
pip install keysat-licensing-client
|
||
```
|
||
|
||
**GitHub fallback** (if the PyPI package isn't published yet). The
|
||
`keysat-xyz/keysat-client-python` repo must be **public** on GitHub
|
||
for this to work in clean environments:
|
||
|
||
```bash
|
||
pip install git+https://github.com/keysat-xyz/keysat-client-python.git
|
||
```
|
||
|
||
(Python's pip-from-git path is simpler than npm's — no separate build
|
||
step is required since pure-Python packages are installable from source.)
|
||
|
||
**Embed the public key** at a path your code can read:
|
||
|
||
```python
|
||
# myapp/license.py
|
||
from pathlib import Path
|
||
ISSUER_PEM = (Path(__file__).parent / 'assets' / 'issuer.pub').read_text()
|
||
```
|
||
|
||
**Verify on startup:**
|
||
|
||
```python
|
||
# myapp/license.py
|
||
import os
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from keysat_licensing_client import Verifier
|
||
|
||
PRODUCT_SLUG = '<your-product-slug>'
|
||
LICENSE_PATH = os.environ.get('MYAPP_LICENSE_KEY_PATH', '/data/license.txt')
|
||
|
||
ISSUER_PEM = (Path(__file__).parent / 'assets' / 'issuer.pub').read_text()
|
||
_verifier = Verifier.from_pem(ISSUER_PEM)
|
||
|
||
|
||
@dataclass
|
||
class LicenseState:
|
||
state: str # 'licensed' | 'unlicensed' | 'invalid'
|
||
reason: Optional[str] = None
|
||
license_id: Optional[str] = None
|
||
entitlements: set = field(default_factory=set)
|
||
expires_at: Optional[datetime] = None
|
||
is_trial: bool = False
|
||
|
||
|
||
def _read_license_key() -> Optional[str]:
|
||
if env := os.environ.get('MYAPP_LICENSE_KEY'):
|
||
return env.strip()
|
||
try:
|
||
return Path(LICENSE_PATH).read_text().strip()
|
||
except (FileNotFoundError, PermissionError):
|
||
return None
|
||
|
||
|
||
def check_license() -> LicenseState:
|
||
raw = _read_license_key()
|
||
if not raw:
|
||
return LicenseState(state='unlicensed')
|
||
try:
|
||
ok = _verifier.verify(raw)
|
||
return LicenseState(
|
||
state='licensed',
|
||
license_id=str(ok.payload.license_id),
|
||
entitlements=set(ok.payload.entitlements or []),
|
||
expires_at=datetime.fromtimestamp(ok.payload.expires_at)
|
||
if ok.payload.expires_at else None,
|
||
is_trial=bool(ok.payload.flags & 1),
|
||
)
|
||
except Exception as e:
|
||
return LicenseState(state='invalid', reason=str(e))
|
||
```
|
||
|
||
**Use it:**
|
||
|
||
```python
|
||
# myapp/server.py
|
||
from .license import check_license
|
||
|
||
LIC = check_license()
|
||
print(f'[license] state={LIC.state} entitlements={LIC.entitlements}')
|
||
|
||
@app.post('/api/export')
|
||
def export_endpoint():
|
||
if 'export' not in LIC.entitlements:
|
||
abort(402, description={
|
||
'error': 'feature_not_in_tier',
|
||
'message': 'Export requires a paid license.',
|
||
})
|
||
# ... do the thing
|
||
```
|
||
|
||
### 7c. Rust
|
||
|
||
**Install (preferred, once published):**
|
||
|
||
```toml
|
||
# Cargo.toml
|
||
[dependencies]
|
||
keysat-licensing-client = "0.1"
|
||
```
|
||
|
||
**Git fallback** (if not on crates.io yet). The
|
||
`keysat-xyz/keysat-client-rust` repo must be **public** on GitHub:
|
||
|
||
```toml
|
||
keysat-licensing-client = { git = "https://github.com/keysat-xyz/keysat-client-rust.git" }
|
||
```
|
||
|
||
Cargo builds from source, so no separate build step is required.
|
||
|
||
**Embed the public key:**
|
||
|
||
```rust
|
||
const ISSUER_PEM: &str = include_str!("../assets/issuer.pub");
|
||
```
|
||
|
||
**Verify on startup:**
|
||
|
||
```rust
|
||
// src/license.rs
|
||
use keysat_licensing_client::{Verifier, PublicKeyPem};
|
||
use std::collections::HashSet;
|
||
use std::path::PathBuf;
|
||
|
||
pub const PRODUCT_SLUG: &str = "<your-product-slug>";
|
||
pub const ISSUER_PEM: &str = include_str!("../assets/issuer.pub");
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct LicenseState {
|
||
pub state: &'static str, // "licensed" | "unlicensed" | "invalid"
|
||
pub reason: Option<String>,
|
||
pub license_id: Option<String>,
|
||
pub entitlements: HashSet<String>,
|
||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||
pub is_trial: bool,
|
||
}
|
||
|
||
impl Default for LicenseState {
|
||
fn default() -> Self {
|
||
Self {
|
||
state: "unlicensed",
|
||
reason: None,
|
||
license_id: None,
|
||
entitlements: HashSet::new(),
|
||
expires_at: None,
|
||
is_trial: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn read_license_key() -> Option<String> {
|
||
if let Ok(s) = std::env::var("MYAPP_LICENSE_KEY") {
|
||
let s = s.trim().to_string();
|
||
if !s.is_empty() { return Some(s) }
|
||
}
|
||
let path = std::env::var("MYAPP_LICENSE_KEY_PATH")
|
||
.unwrap_or_else(|_| "/data/license.txt".to_string());
|
||
std::fs::read_to_string(PathBuf::from(path))
|
||
.ok()
|
||
.map(|s| s.trim().to_string())
|
||
.filter(|s| !s.is_empty())
|
||
}
|
||
|
||
pub fn check_license() -> LicenseState {
|
||
let raw = match read_license_key() {
|
||
Some(s) => s,
|
||
None => return LicenseState::default(),
|
||
};
|
||
let pubkey = match PublicKeyPem::from_str(ISSUER_PEM) {
|
||
Ok(k) => k,
|
||
Err(e) => return LicenseState {
|
||
state: "invalid", reason: Some(format!("bad pubkey embedded: {e}")),
|
||
..Default::default()
|
||
},
|
||
};
|
||
let verifier = Verifier::new(pubkey);
|
||
match verifier.verify(&raw) {
|
||
Ok(ok) => LicenseState {
|
||
state: "licensed",
|
||
license_id: Some(ok.payload.license_id.to_string()),
|
||
entitlements: ok.payload.entitlements.into_iter().collect(),
|
||
expires_at: if ok.payload.expires_at == 0 {
|
||
None
|
||
} else {
|
||
chrono::DateTime::from_timestamp(ok.payload.expires_at, 0)
|
||
},
|
||
is_trial: (ok.payload.flags & 1) != 0,
|
||
..Default::default()
|
||
},
|
||
Err(e) => LicenseState {
|
||
state: "invalid", reason: Some(e.to_string()),
|
||
..Default::default()
|
||
},
|
||
}
|
||
}
|
||
```
|
||
|
||
**Use it:**
|
||
|
||
```rust
|
||
let lic = license::check_license();
|
||
tracing::info!(state = lic.state, entitlements = ?lic.entitlements, "license loaded");
|
||
|
||
// At a feature gate:
|
||
if !lic.entitlements.contains("export") {
|
||
return Err(MyError::PaymentRequired(
|
||
"Export requires a paid license.".into()
|
||
));
|
||
}
|
||
```
|
||
|
||
### 7d. Hard-gate patterns — "the app doesn't function without a license"
|
||
|
||
If the operator chose **hard gate** in the section-0 questions (binary
|
||
freely downloadable, but locked until activated), use one of these two
|
||
flavors instead of the entitlements-as-feature-flags pattern above. The
|
||
verifier helpers from 7a / 7b / 7c are still the right primitive — the
|
||
difference is what you do with the result.
|
||
|
||
**Flavor 1: Refuse to start.** The daemon exits at boot with a clear
|
||
log line if there's no valid license. StartOS will show the service as
|
||
crashing — the operator's README needs to tell buyers "install the
|
||
license first via Actions → Set license, then start the service."
|
||
|
||
```ts
|
||
// TypeScript / Node
|
||
const lic = checkLicense()
|
||
if (lic.state !== 'licensed') {
|
||
console.error(`[license] not licensed (${lic.state}): ${lic.reason || ''}`)
|
||
console.error(`[license] paste a license key into ${LICENSE_PATH} via the StartOS "Set license" action, then restart.`)
|
||
process.exit(1)
|
||
}
|
||
```
|
||
|
||
```python
|
||
# Python
|
||
lic = check_license()
|
||
if lic.state != 'licensed':
|
||
log.error(f'[license] not licensed ({lic.state}): {lic.reason or ""}')
|
||
log.error(f'[license] paste a license key, then restart.')
|
||
raise SystemExit(1)
|
||
```
|
||
|
||
```rust
|
||
// Rust
|
||
let lic = license::check_license();
|
||
if lic.state != "licensed" {
|
||
eprintln!("[license] not licensed ({}): {}", lic.state, lic.reason.unwrap_or_default());
|
||
eprintln!("[license] paste a license key, then restart.");
|
||
std::process::exit(1);
|
||
}
|
||
```
|
||
|
||
This is the most aggressive option. Use when (a) the app is closed-source
|
||
and there's no "free version" of the binary anyone could compile, and
|
||
(b) the operator is OK with StartOS surfacing the service as
|
||
unhealthy until activated.
|
||
|
||
**Flavor 2: Run, but block all real work behind an "Activate" screen.**
|
||
The daemon starts normally, but every business endpoint returns 402
|
||
until a license is activated. Only the activation endpoint(s) and a
|
||
status endpoint are open. Buyers see a clean "paste your license to
|
||
get started" UI on first run; StartOS shows the service as healthy.
|
||
Generally a better buyer experience than Flavor 1.
|
||
|
||
```ts
|
||
// TypeScript / Express — middleware that gates everything except
|
||
// the activation paths.
|
||
const ACTIVATION_PATHS = new Set([
|
||
'/api/license-status', // for the frontend to render activation UI
|
||
'/api/activate', // accepts a pasted license key, writes to file, refreshes state
|
||
'/healthz', // for StartOS / orchestration
|
||
])
|
||
|
||
let LIC = checkLicense() // mutable; refresh after activation
|
||
|
||
app.use((req, res, next) => {
|
||
if (ACTIVATION_PATHS.has(req.path)) return next()
|
||
if (LIC.state !== 'licensed') {
|
||
return res.status(402).json({
|
||
error: 'license_required',
|
||
message: 'This service requires a Keysat license to function.',
|
||
activate_url: '/activate', // your frontend's activation page
|
||
state: LIC.state,
|
||
reason: LIC.reason,
|
||
})
|
||
}
|
||
next()
|
||
})
|
||
|
||
// Activation endpoint — accepts a pasted key, writes it, re-checks.
|
||
app.post('/api/activate', express.json(), (req, res) => {
|
||
const key = (req.body.license_key || '').trim()
|
||
if (!key.startsWith('LIC1-')) {
|
||
return res.status(400).json({ error: 'bad_format', message: 'Expected a LIC1-… key.' })
|
||
}
|
||
fs.writeFileSync(LICENSE_PATH, key + '\n')
|
||
LIC = checkLicense()
|
||
if (LIC.state === 'licensed') {
|
||
return res.json({ ok: true, state: 'licensed', entitlements: [...LIC.entitlements] })
|
||
}
|
||
return res.status(400).json({ error: 'invalid', state: LIC.state, reason: LIC.reason })
|
||
})
|
||
```
|
||
|
||
```python
|
||
# Python / Flask — same idea
|
||
ACTIVATION_PATHS = {'/api/license-status', '/api/activate', '/healthz'}
|
||
LIC = check_license() # module-level; reload after activation
|
||
|
||
@app.before_request
|
||
def license_gate():
|
||
if request.path in ACTIVATION_PATHS:
|
||
return None
|
||
if LIC.state != 'licensed':
|
||
return jsonify({
|
||
'error': 'license_required',
|
||
'message': 'This service requires a Keysat license to function.',
|
||
'state': LIC.state,
|
||
'reason': LIC.reason,
|
||
}), 402
|
||
|
||
@app.post('/api/activate')
|
||
def activate():
|
||
global LIC
|
||
key = (request.json or {}).get('license_key', '').strip()
|
||
if not key.startswith('LIC1-'):
|
||
return {'error': 'bad_format'}, 400
|
||
Path(LICENSE_PATH).write_text(key + '\n')
|
||
LIC = check_license()
|
||
if LIC.state == 'licensed':
|
||
return {'ok': True, 'entitlements': sorted(LIC.entitlements)}
|
||
return {'error': 'invalid', 'state': LIC.state, 'reason': LIC.reason}, 400
|
||
```
|
||
|
||
```rust
|
||
// Rust / axum — same idea: a middleware layer that guards all admin
|
||
// routes, plus an /api/activate endpoint that accepts a key and updates
|
||
// the in-memory state.
|
||
//
|
||
// Sketch (full impl follows the existing axum middleware pattern):
|
||
// Router::new()
|
||
// .route("/api/license-status", get(license_status))
|
||
// .route("/api/activate", post(activate))
|
||
// .route("/healthz", get(healthz))
|
||
// .nest("/api", api_routes())
|
||
// .layer(axum::middleware::from_fn_with_state(state.clone(), license_gate))
|
||
//
|
||
// Inside `license_gate`, return 402 unless the request path is in
|
||
// ACTIVATION_PATHS or `state.license.read().await.state == "licensed"`.
|
||
```
|
||
|
||
**How would Keysat itself do this?** Keysat already has the `Mode::Enforce`
|
||
build-time flag in [`license_self.rs`](./licensing-service-startos/licensing-service/src/license_self.rs):
|
||
when built with `KEYSAT_LICENSE_ENFORCE=1`, missing or invalid licenses
|
||
cause the daemon to refuse to start (Flavor 1). Default Permissive
|
||
builds run unlicensed at Creator-tier caps. To switch Keysat to Flavor 2
|
||
("run but block until activated") would mean: keep the existing boot-time
|
||
license check non-fatal, expose `/admin/login`-style activation endpoints
|
||
under a hardcoded allowlist, and have an axum middleware return 402 on
|
||
every other admin/business endpoint until `state.self_tier` flips from
|
||
`Unlicensed` to `Licensed`. The pieces are all there — it's a few hundred
|
||
lines of axum middleware + an SPA "Activate" splash screen.
|
||
|
||
### 7e. Packaging gotchas — Docker, s9pk, hermetic builds
|
||
|
||
Most non-trivial integrations end up packaged in Docker (Start9 s9pk,
|
||
generic container deploys, CI-built images). The following gotchas
|
||
together account for ~80% of the "it works locally but the build
|
||
fails" failure mode:
|
||
|
||
**1. Slim base images don't ship `git`, `ssh`, or `ca-certificates`.**
|
||
`node:20-slim`, `python:3.11-slim`, etc. are intentionally minimal.
|
||
If you have a git-URL dependency (e.g. the GitHub fallback above),
|
||
you'll need at least these in the *builder* stage:
|
||
|
||
```dockerfile
|
||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||
git ca-certificates \
|
||
&& rm -rf /var/lib/apt/lists/*
|
||
```
|
||
|
||
Without `git`: npm errors with `spawn git ENOENT` when resolving the
|
||
dependency. Without `ca-certificates`: HTTPS clones fail with
|
||
`SSL certificate problem: unable to get local issuer certificate`.
|
||
|
||
**2. npm's git resolver tries `ssh://` for github.com URLs first.**
|
||
Even if your `package.json` spec and `package-lock.json` `resolved`
|
||
both say `git+https://`, npm internally tries SSH first when the host
|
||
is github.com. In a container with no SSH client or key, this fails.
|
||
Force git to silently rewrite SSH URLs to HTTPS:
|
||
|
||
```dockerfile
|
||
RUN git config --global --add url."https://github.com/".insteadOf "ssh://git@github.com/" \
|
||
&& git config --global --add url."https://github.com/".insteadOf "git@github.com:" \
|
||
&& git config --global --add url."https://github.com/".insteadOf "git://github.com/"
|
||
```
|
||
|
||
The `--add` flag matters — without it, each subsequent invocation
|
||
overwrites the previous one (they share a key) and only the last
|
||
rewrite is active.
|
||
|
||
**3. Don't forget to `COPY` your new license module.** If your
|
||
Dockerfile lists individual server files explicitly:
|
||
|
||
```dockerfile
|
||
COPY server/package.json ./server/
|
||
COPY server/index.js ./server/
|
||
COPY public/ ./public/
|
||
COPY assets/ ./assets/
|
||
```
|
||
|
||
…the build will succeed, the image will start, and then crash at
|
||
runtime with `Cannot find module './license.js'`. Add a line for the
|
||
license module:
|
||
|
||
```dockerfile
|
||
COPY server/license.js ./server/ # ← easy to miss
|
||
```
|
||
|
||
This is the single most common "package builds, container won't boot"
|
||
failure when retro-fitting licensing into an existing app.
|
||
|
||
**4. Make's incremental rebuild can mask uncommitted changes.** s9pk
|
||
build chains often look like `make x86 → start-cli s9pk pack → docker
|
||
build`. Make may decide nothing's newer than the existing `.s9pk`
|
||
because its dependencies typically include `.git/index` (which only
|
||
updates on `git add`). Symptom: you change a source file, rebuild,
|
||
get an instant "✅ Build Complete!" with the same package as before.
|
||
|
||
Either stage your changes (`git add -A`) so `.git/index` updates, or
|
||
delete the existing `.s9pk` to force a rebuild:
|
||
|
||
```bash
|
||
rm myapp_x86_64.s9pk && make x86
|
||
```
|
||
|
||
**5. The `--ignore-scripts` flag will skip the SDK's `prepare` build.**
|
||
If your Dockerfile uses `npm ci --ignore-scripts` (a common security
|
||
hardening), the SDK won't build its `dist/` and you'll hit the
|
||
"Cannot find module" runtime error from §7a. Either drop
|
||
`--ignore-scripts` for the builder stage, or pre-build the SDK
|
||
elsewhere and vendor `dist/` in.
|
||
|
||
### 7f. Frontend integration for hard-gate Flavor 2
|
||
|
||
If you picked hard-gate Flavor 2 (server starts, business endpoints
|
||
return 402 until activated), **the frontend is half the work** —
|
||
otherwise unlicensed users see a sea of fetch errors instead of a
|
||
clean activation screen. The pattern below is framework-agnostic and
|
||
works in vanilla JS, React, Vue, etc.
|
||
|
||
**Step 1: Fetch license-status before any other API call.** It's the
|
||
prerequisite for deciding what to render.
|
||
|
||
```js
|
||
async function loadLicenseStatus() {
|
||
const r = await fetch("/api/license-status")
|
||
return r.json() // { state, entitlements, productSlug, keysatBaseUrl, … }
|
||
}
|
||
```
|
||
|
||
**Step 2: Render the activation screen as a top-level guard.** If
|
||
`state !== "licensed"` (or the `core` entitlement is missing), replace
|
||
the entire app body with the activation card. Don't render the normal
|
||
UI underneath — every API call would 402 anyway, producing visible
|
||
broken state.
|
||
|
||
```jsx
|
||
if (lic.state !== "licensed" || !lic.entitlements.includes("core")) {
|
||
return <ActivationScreen lic={lic} onActivate={key => activate(key)} />
|
||
}
|
||
return <App />
|
||
```
|
||
|
||
**Step 3: The activation card needs four things:**
|
||
- A `<textarea>` for pasting the LIC1-... key (use a textarea, not an
|
||
input — keys are 100+ chars and users will copy-paste with
|
||
whitespace)
|
||
- An "Activate" button that POSTs to `/api/license/activate` with
|
||
`{license_key: <pasted>}` and refreshes state on success
|
||
- Distinct error messages for each `reason` code (see §12), not a
|
||
generic "activation failed"
|
||
- A "Buy a key" link to `${keysatBaseUrl}/buy/${productSlug}` (see §3)
|
||
|
||
**Step 4: Gate Pro features in the UI, not just the server.** The
|
||
server returns 402 for missing entitlements, but unless the frontend
|
||
also checks, users see ghost UI for features they can't use:
|
||
|
||
```jsx
|
||
{lic.entitlements.includes("subscriptions")
|
||
? <SubscriptionsPanel />
|
||
: <ProUpsell feature="subscriptions" buyUrl={buyUrl} />}
|
||
```
|
||
|
||
Each `ProUpsell` should explain what they'd unlock, not just "Pro
|
||
feature." The server's 402 response includes a `message` field with a
|
||
sentence-long description — surface it.
|
||
|
||
**Step 5: Add a license block to settings.** Buyers want to see what
|
||
tier they're on, when it expires, and have a way to remove the key.
|
||
Hit `/api/license-status` for state, render a colored badge per tier,
|
||
and expose a "Deactivate" button that POSTs to
|
||
`/api/license/deactivate`.
|
||
|
||
**Step 6: Respond to entitlement changes without a reload.** After
|
||
activation, re-fetch any data your app skipped at boot (history,
|
||
subscriptions, etc.) — the user just unlocked them. After
|
||
deactivation, clear it from in-memory state so the previous tier's
|
||
data doesn't leak through the activation screen.
|
||
|
||
**Reference shape your `/api/license-status` should return** so the
|
||
frontend has everything it needs without extra round-trips:
|
||
|
||
```json
|
||
{
|
||
"state": "licensed",
|
||
"reason": null,
|
||
"licenseId": "abc123…",
|
||
"entitlements": ["core", "subscriptions", "history", "library"],
|
||
"expiresAt": "2027-05-01T00:00:00Z",
|
||
"isTrial": false,
|
||
"productSlug": "youtube-summarizer",
|
||
"keysatBaseUrl": "https://licensing.example.com"
|
||
}
|
||
```
|
||
|
||
`productSlug` and `keysatBaseUrl` aren't strictly part of license
|
||
state — they're there so the frontend can construct the `/buy/<slug>`
|
||
URL without hard-coding it. Ship them in the response.
|
||
|
||
---
|
||
|
||
## 8. Picking entitlement names
|
||
|
||
Entitlement strings are arbitrary; they're whatever the operator put on
|
||
the policy when issuing the license. Common conventions:
|
||
|
||
- **Feature flags**: `export`, `ai_summaries`, `team_seats`, `recurring_billing`, `card_payments`
|
||
- **Capability tiers**: `unlimited_products`, `unlimited_seats`, `priority_support`
|
||
- **Branded markers**: `patron` (no real feature, just a badge)
|
||
|
||
Pick names that are stable, lowercase, snake-case, descriptive. Document
|
||
your chosen entitlement names in your README so operators / customers
|
||
know what they're buying. Treat them like API contract — once you ship
|
||
a feature gated on `"export"`, you can't rename to `"file_export"` without
|
||
breaking existing licenses.
|
||
|
||
The operator can use whatever set they want when creating policies; your
|
||
app only needs to know the names of features it gates on. Operators
|
||
selling tiered plans typically have:
|
||
- A free / Creator tier with one entitlement (`self_host` or similar)
|
||
- A pro / paid tier with several (`unlimited_*`, premium features)
|
||
- Optional Patron / supporter tier with all of Pro plus a `patron` badge
|
||
|
||
---
|
||
|
||
## 9. Online validation (optional, recommended)
|
||
|
||
Offline verify proves the key was signed by the right operator. **Online
|
||
validation also catches revocations** (operator disabled a key) and
|
||
**enforces fingerprint binding** (one license = one machine). Use both:
|
||
offline at boot, online on a timer.
|
||
|
||
```ts
|
||
// TypeScript
|
||
import { Client } from '@keysat/licensing-client'
|
||
const client = new Client('https://licensing.example.com')
|
||
|
||
async function onlineCheck(licenseKey: string, machineFingerprint: string) {
|
||
try {
|
||
const r = await client.validate(licenseKey, PRODUCT_SLUG, machineFingerprint)
|
||
if (!r.ok) {
|
||
// r.reason is one of: 'revoked' | 'fingerprint_mismatch' |
|
||
// 'not_found' | 'bad_signature' | 'product_mismatch'
|
||
console.warn('license rejected:', r.reason)
|
||
// → React in your UI; don't hard-crash on this.
|
||
}
|
||
} catch {
|
||
// Network errors → "status unknown". Don't block the user.
|
||
}
|
||
}
|
||
```
|
||
|
||
```python
|
||
# Python
|
||
from keysat_licensing_client import Client
|
||
client = Client('https://licensing.example.com')
|
||
try:
|
||
r = client.validate(license_key, PRODUCT_SLUG, machine_fingerprint)
|
||
if not r.ok:
|
||
log.warning(f'license rejected: {r.reason}')
|
||
except Exception:
|
||
pass # network error → status unknown
|
||
```
|
||
|
||
```rust
|
||
// Rust (with `online` feature)
|
||
let client = keysat_licensing_client::online::Client::new("https://licensing.example.com")?;
|
||
match client.validate(&key, Some(PRODUCT_SLUG), Some(&fp)).await {
|
||
Ok(r) if r.ok => { /* fine */ }
|
||
Ok(r) => log::warn!("rejected: {:?}", r.reason),
|
||
Err(_) => { /* network error, don't punish */ }
|
||
}
|
||
```
|
||
|
||
**Cadence**: once at startup after the offline check succeeds, then on
|
||
a timer (hourly is plenty). Once-per-feature-call is too aggressive
|
||
and beats up the operator's server.
|
||
|
||
**Critical**: never refuse to start if `validate()` throws. Network
|
||
errors must degrade to "I can't tell, assume the user is fine" — not
|
||
"app refuses to launch." Otherwise your app's uptime depends on the
|
||
operator's licensing server being up.
|
||
|
||
---
|
||
|
||
## 10. Fingerprint binding (for `validate()`)
|
||
|
||
When you call `client.validate(...)`, the third argument is a machine
|
||
fingerprint. The operator's Keysat binds the first fingerprint it sees
|
||
to the license; subsequent calls with a different fingerprint return
|
||
`reason: 'fingerprint_mismatch'`. This is the anti-piracy mechanism.
|
||
|
||
**Compute the fingerprint** from something stable across reboots but
|
||
unique per machine:
|
||
|
||
| Platform | Source |
|
||
|---|---|
|
||
| Linux | `/etc/machine-id` |
|
||
| macOS | `ioreg -d2 -c IOPlatformExpertDevice` → IOPlatformUUID |
|
||
| Windows | Registry: `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` |
|
||
| Fallback | A UUID written into your app's config dir on first launch |
|
||
|
||
Mix in a per-product salt so fingerprints collected by your app can't be
|
||
reused against a different operator's licensing service:
|
||
|
||
```ts
|
||
const fingerprint = `${PRODUCT_SLUG}|${machineId}`
|
||
```
|
||
|
||
The SDK hashes this before sending, so the operator's Keysat never sees
|
||
the raw input.
|
||
|
||
If a customer legitimately moves devices and hits `fingerprint_mismatch`,
|
||
they should contact the operator. The operator can reset the binding
|
||
from their admin dashboard. Don't try to help users bypass this in
|
||
your app — it's the protection working as intended.
|
||
|
||
---
|
||
|
||
## 11. Driving the purchase flow from inside your app (optional)
|
||
|
||
If your app can open URLs (desktop GUI, CLI that can `xdg-open`), you
|
||
can drive the entire purchase flow from inside without forcing the user
|
||
into a separate browser tab.
|
||
|
||
```ts
|
||
import { Client } from '@keysat/licensing-client'
|
||
import open from 'open'
|
||
|
||
const client = new Client('https://licensing.example.com')
|
||
|
||
async function buyLicense(buyerEmail?: string): Promise<string> {
|
||
const session = await client.startPurchase(PRODUCT_SLUG, { buyerEmail })
|
||
await open(session.checkoutUrl) // BTCPay invoice page
|
||
const key = await client.waitForLicense(session.invoiceId, { timeoutMs: 30 * 60_000 })
|
||
return key
|
||
}
|
||
```
|
||
|
||
`waitForLicense` polls `/v1/purchase/<id>` until the BTCPay invoice
|
||
settles and the license is signed. Save the returned key to disk
|
||
(`/data/license.txt` or wherever your app reads from), then re-run
|
||
`checkLicense()`.
|
||
|
||
The simpler alternative: just link to the operator's buy page and let
|
||
them complete the purchase on the web, then paste the resulting key
|
||
into your app's settings. Less integrated, less friction to implement.
|
||
|
||
---
|
||
|
||
## 12. UX patterns for revocation & errors
|
||
|
||
When `validate()` returns `ok: false`, the `reason` field tells you why:
|
||
|
||
| reason | What to show the user |
|
||
|---|---|
|
||
| `revoked` | "This license has been revoked by the seller. Contact support." |
|
||
| `fingerprint_mismatch` | "This license is already active on another computer." |
|
||
| `not_found` | "License key not recognized. Did you copy it correctly?" |
|
||
| `bad_signature` | "This license appears tampered. Contact support." |
|
||
| `product_mismatch` | "This license is for a different product." |
|
||
| `expired` | "Your license expired on <date>. Renew at <url>." |
|
||
|
||
Customize the copy for your tone, but show **distinct** messages — they
|
||
mean very different things to the user.
|
||
|
||
---
|
||
|
||
## 13. Migrating from a non-Bitcoin licensing scheme (Gumroad, Stripe, etc.)
|
||
|
||
If you already sell licenses through a non-Bitcoin system, you don't
|
||
have to do a flag-day migration. Two-phase plan:
|
||
|
||
**Phase 1: dual-stack.** Your app accepts BOTH old-format keys and
|
||
`LIC1-…` keys. They look different, so detection is trivial:
|
||
|
||
```ts
|
||
const isKeysat = raw.startsWith('LIC1-')
|
||
```
|
||
|
||
Honor old keys via your old verification path, new keys via the Keysat
|
||
SDK. Both unlock the same features. Existing customers see no change.
|
||
|
||
**Phase 2: cutover.** When you're ready to retire the old system,
|
||
issue fresh `LIC1-` keys for existing customers via the operator's
|
||
admin "Issue license manually" action and email them with a one-line
|
||
"here's your new key" note. Mark the old format deprecated; don't
|
||
break it for some grace period.
|
||
|
||
For free → paid migrations, use **free-license discount codes**: the
|
||
operator creates a code with `kind: free_license, max_uses: <your existing
|
||
user count>`, you put a "Redeem your existing-user code" button in your
|
||
app's first-launch screen, and existing users redeem once and never see
|
||
the prompt again.
|
||
|
||
---
|
||
|
||
## 14. Worked example: minimal Express server
|
||
|
||
A complete pattern for an Express + JS app. Copy-paste, replace
|
||
`MYAPP`, the slug, and the issuer PEM, and you have a working integration.
|
||
|
||
```js
|
||
// server/license.js
|
||
const fs = require('node:fs')
|
||
const path = require('node:path')
|
||
const { Verifier, PublicKey } = require('@keysat/licensing-client')
|
||
|
||
const PRODUCT_SLUG = 'myapp'
|
||
const LICENSE_PATH = process.env.MYAPP_LICENSE_KEY_PATH || '/data/license.txt'
|
||
const ISSUER_PEM = fs.readFileSync(
|
||
path.join(__dirname, '..', 'assets', 'issuer.pub'),
|
||
'utf8'
|
||
)
|
||
const verifier = new Verifier(PublicKey.fromPem(ISSUER_PEM))
|
||
|
||
function readKey() {
|
||
if (process.env.MYAPP_LICENSE_KEY) return process.env.MYAPP_LICENSE_KEY.trim()
|
||
try { return fs.readFileSync(LICENSE_PATH, 'utf8').trim() } catch { return null }
|
||
}
|
||
|
||
function checkLicense() {
|
||
const raw = readKey()
|
||
if (!raw) return { state: 'unlicensed', entitlements: new Set() }
|
||
try {
|
||
const ok = verifier.verify(raw)
|
||
return {
|
||
state: 'licensed',
|
||
licenseId: ok.payload.licenseId,
|
||
entitlements: new Set(ok.payload.entitlements || []),
|
||
expiresAt: ok.payload.expiresAt
|
||
? new Date(ok.payload.expiresAt * 1000)
|
||
: null,
|
||
isTrial: !!(ok.payload.flags & 1),
|
||
}
|
||
} catch (e) {
|
||
return { state: 'invalid', reason: e.message, entitlements: new Set() }
|
||
}
|
||
}
|
||
|
||
module.exports = { checkLicense, LICENSE_PATH }
|
||
```
|
||
|
||
```js
|
||
// server/index.js
|
||
const express = require('express')
|
||
const { checkLicense, LICENSE_PATH } = require('./license')
|
||
|
||
const app = express()
|
||
const LIC = checkLicense()
|
||
console.log(`[license] state=${LIC.state} entitlements=[${[...LIC.entitlements].join(',')}]`)
|
||
|
||
if (LIC.state === 'invalid') {
|
||
console.warn(`[license] invalid: ${LIC.reason} — running unlicensed`)
|
||
}
|
||
|
||
// Free for everyone
|
||
app.get('/api/healthz', (_, res) => res.json({ ok: true }))
|
||
|
||
// Free tier: limited basic feature
|
||
app.post('/api/basic', (req, res) => {
|
||
res.json({ ok: true, result: 'basic feature' })
|
||
})
|
||
|
||
// Paid feature — gated on the `export` entitlement
|
||
app.post('/api/export', (req, res) => {
|
||
if (!LIC.entitlements.has('export')) {
|
||
return res.status(402).json({
|
||
error: 'feature_not_in_tier',
|
||
message: 'Export requires a paid license. See <upgrade_url>.',
|
||
license_path: LICENSE_PATH,
|
||
})
|
||
}
|
||
res.json({ ok: true, result: 'paid export' })
|
||
})
|
||
|
||
// Buyer-facing license status (so the frontend can show "licensed" badge
|
||
// and construct the buy URL without hard-coding it).
|
||
const KEYSAT_BASE_URL = 'https://licensing.example.com' // operator's instance
|
||
|
||
app.get('/api/license-status', (_, res) => {
|
||
res.json({
|
||
state: LIC.state,
|
||
reason: LIC.reason || null,
|
||
licenseId: LIC.licenseId || null,
|
||
entitlements: [...LIC.entitlements],
|
||
expiresAt: LIC.expiresAt || null,
|
||
isTrial: !!LIC.isTrial,
|
||
productSlug: PRODUCT_SLUG,
|
||
keysatBaseUrl: KEYSAT_BASE_URL,
|
||
})
|
||
})
|
||
|
||
app.listen(process.env.PORT || 8080)
|
||
```
|
||
|
||
**That's a complete integration.** ~75 lines. Replace the slug, the
|
||
PEM file, and the entitlement names with what your operator chose, and
|
||
ship it.
|
||
|
||
---
|
||
|
||
## 15. Common mistakes
|
||
|
||
- **Embedding the wrong key.** The PEM you embed is the **public** key
|
||
(from `GET /v1/issuer/public-key`). The private key never leaves the
|
||
operator's Keysat. If you accidentally ship a private key, every
|
||
attacker can mint licenses.
|
||
- **Hard-failing on `validate()` errors.** If your app refuses to boot
|
||
when validation throws, you've gated on the operator's server uptime.
|
||
Always treat network errors as "status unknown" and fall back to the
|
||
offline check.
|
||
- **Calling `validate()` in a hot loop.** Once at startup + once per
|
||
hour is plenty.
|
||
- **Slug mismatch.** A license issued for slug `foo` won't validate
|
||
against slug `bar`. Typos in the slug constant cause "license valid
|
||
but my code rejects it" head-scratchers. Read the slug from a
|
||
single constant.
|
||
- **Logging the full license key.** It's a bearer credential — log
|
||
the `license_id` instead.
|
||
- **Refusing to start without a license.** Boot in unlicensed mode and
|
||
let the user keep using whatever's free-tier. Much better UX than
|
||
exit-on-startup.
|
||
- **Forgetting to `COPY` the new license module into the container.**
|
||
If your Dockerfile lists individual server files explicitly, adding
|
||
`server/license.js` requires its own `COPY` line. Build succeeds,
|
||
container starts, then crashes at startup with `Cannot find module
|
||
'./license.js'`. See §7e for the full Docker checklist.
|
||
- **Letting the SDK ship without a built `dist/`.** Git installs of
|
||
the Keysat client *only* work if the package has a `prepare` script
|
||
that builds on install (or commits its `dist/` directory). Without
|
||
that, the install succeeds but the package is empty. If you publish
|
||
to npm, this isn't a problem — `prepublishOnly` builds `dist/` for
|
||
you. If you only host on GitHub, ensure `prepare` is wired.
|
||
- **Using `github:user/repo` shorthand instead of `git+https://...`.**
|
||
The shorthand often resolves to SSH on machines with a GitHub key,
|
||
which then breaks every hermetic build downstream. Always use the
|
||
explicit `git+https://github.com/...` form, and double-check the
|
||
`resolved:` field in your `package-lock.json` after switching — npm
|
||
caches the previous resolution and may keep an SSH URL in the lock
|
||
even after you change the spec.
|
||
- **Skipping the frontend half of hard-gate Flavor 2.** A server-only
|
||
integration boots happily but every request 402s, which the
|
||
unlicensed user experiences as a broken app rather than a clear
|
||
"activate to continue" screen. See §7f for the framework-agnostic
|
||
pattern.
|
||
|
||
---
|
||
|
||
## 16. Testing the integration
|
||
|
||
1. Get a real license to test against. Easiest: ask the operator to
|
||
issue you one manually from their admin UI's "Manually issue a
|
||
license" form (Licenses tab). Or, if they've created a `free_license`
|
||
discount code, redeem it: `curl -X POST https://licensing.example.com/v1/redeem -H 'content-type: application/json' -d '{"product":"<slug>","code":"<code>"}'` — the response includes a `license_key`.
|
||
2. Save the key to `/data/license.txt` (or wherever you read from).
|
||
3. Restart your app.
|
||
4. Look for a startup log line: `[license] state=licensed entitlements=[…]`.
|
||
5. Hit a paid endpoint — should succeed.
|
||
6. Hit a paid endpoint after deleting the license file — should return
|
||
402.
|
||
7. Tamper with one character of the key — should log `state=invalid`.
|
||
8. (Online) Have the operator revoke the license; on next online check,
|
||
reason should be `revoked`.
|
||
|
||
---
|
||
|
||
## 17. Reference: Keysat dogfoods this same pattern
|
||
|
||
The Keysat daemon itself uses this exact integration to license itself.
|
||
[`license_self.rs`](./licensing-service-startos/licensing-service/src/license_self.rs)
|
||
in the Keysat repo:
|
||
|
||
- Embeds the master public key as `TRUST_ROOT_PUBKEY_PEM`.
|
||
- Reads the license from `/data/keysat-license.txt` at boot.
|
||
- Verifies via the same `parse_key + verify_payload` machinery the SDK
|
||
uses.
|
||
- Exposes `state.self_tier.entitlements` to the rest of the daemon.
|
||
- Other handlers gate features on entitlements (e.g., `unlimited_products`,
|
||
`recurring_billing`) — see [`tier.rs`](./licensing-service-startos/licensing-service/src/api/tier.rs)
|
||
for the canonical gate-helper pattern.
|
||
|
||
If you want a working precedent to copy, that's the cleanest one in the
|
||
codebase. The pattern is identical to what your app should do.
|