KEYSAT_INTEGRATION.md: fix bugs + refresh against current SDKs

Critical bug fixes — code an LLM would copy verbatim:

- Wire format §4: clarify FLAG_FINGERPRINT_BOUND = bit 0 (mask 0x01),
  FLAG_TRIAL = bit 1 (mask 0x02). The doc previously claimed
  FLAG_TRIAL=1, which is wrong — that's the fingerprint-bound bit.
- Trial detection across §7a / §7b / §7c / §14: stop doing
  `(flags & 1)` manually. TS/Python/Go SDKs pre-parse isTrial /
  is_trial / IsTrial() on the payload. Rust requires manual math
  but with the FLAG_TRIAL constant from the crate, not bit 0.
- Field name sweep: TS payload field is `licenseUuid` (or top-
  level `licenseId` on the result root), not `payload.licenseId`.
  Rust payload's `license_id` is `[u8; 16]` raw bytes — render to
  hex for display. Updated examples to match each SDK's actual API.
- §9a cross-product safety: rewritten end-to-end. The payload
  carries product UUID (not slug). The old doc told the LLM to
  assert `payload.product_slug !== MY_SLUG`, which silently passes
  because the field doesn't exist. New doc covers both correct
  paths: online via `validate(slug, …)` (daemon resolves
  slug→UUID), or offline by embedding the operator's product UUID.

Stale references / improvements:

- §0 Q4: cross-reference for hard-gate flavors corrected to §7d
  (was pointing at §8 which is entitlement-naming). Added a
  "soft-gate is the safe default" nudge.
- §0 new Q8: ask whether the operator already has an entitlements
  catalog before drafting the config card.
- §7a GitHub fallback: trimmed. SDK repos are public and have
  `prepare` scripts, so the ssh-vs-https troubleshooting saga
  isn't needed anymore.
- §7d "Mode::Enforce" reference removed — that build-time flag
  was deprecated. Keysat itself dogfoods soft-gate (always boots,
  tier caps enforce at create-time).

New content for one-shot integration success:

- §8 / §11a: hidden_entitlements (v0.2.0:24) explained — buy page
  filters them out; SDK consumers should too.
- §11a "Rendering tier cards": multi-currency formatter
  (priceCurrency + priceValue), marketing_bullets +
  marketing_bullets_position, featured_discount auto-apply via
  the `code` option on startPurchase.
- §11a Common mistakes: assuming all prices are in sats; skipping
  the featured-discount surfacing.
- New §15a "Verify your integration with curl": four-command
  health-check the LLM can run before writing app code. Catches
  slug typos, missing policies, unreachable daemon early.
- §15 Common mistakes: added the product UUID gotcha and the
  flag bit-math gotcha as explicit entries.

LLM-consumer impact: the previous version had three subtle bugs
that survived offline signature verification — wrong trial
detection on every fingerprint-bound license, missing product
isolation across multi-product Keysats, and a wrong PRO-tier
default selection. All three failure modes are now flagged or
fixed in the doc; an LLM that follows the new doc literally
produces correct integration code.
This commit is contained in:
Grant
2026-05-11 20:55:51 -05:00
parent 9e772fdd4c
commit 6201a30353
+226 -117
View File
@@ -43,23 +43,25 @@ hangs on these:
4. **How should unlicensed users experience the app?** Three legitimate 4. **How should unlicensed users experience the app?** Three legitimate
patterns; pick whichever fits the operator's business model. **None patterns; pick whichever fits the operator's business model. **None
is "wrong."** is "wrong."**
- **Soft gate** (safest default if the operator is unsure) — 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.
- **Hard gate** — the app downloads freely from the Start9 registry, - **Hard gate** — the app downloads freely from the Start9 registry,
but won't function without a paid license. The binary is essentially but won't function without a paid license. The binary is essentially
a locked installer until the buyer activates. Common for closed-source a locked installer until the buyer activates. Common for closed-source
paid apps and for open-source apps that the operator chooses to paid apps and for open-source apps that the operator chooses to
monetize through the registry distribution. See section 8 for the monetize through the registry distribution. See section **7d** for the
two flavors of hard gating (refuse-to-start vs. activate-screen-only). 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 - **Nag mode** — no enforcement; just a "support development" banner
when unlicensed. Pure honor system. Useful when the app is when unlicensed. Pure honor system. Useful when the app is
fundamentally free-to-use but the operator wants a tip-jar. fundamentally free-to-use but the operator wants a tip-jar.
Nudge the operator if their answer doesn't match their business Nudge the operator if their answer doesn't match their business
reality. Closed-source-paid + nag-mode is incoherent; freemium + reality. Closed-source-paid + nag-mode is incoherent; freemium +
hard-gate alienates the existing user base. hard-gate alienates the existing user base. **When in doubt, propose
soft-gate** — it preserves the user's ability to evaluate the app
before paying and gives the operator the simplest implementation.
5. **What are the entitlement strings, and what does each unlock?** 5. **What are the entitlement strings, and what does each unlock?**
The operator decides; ask them. Common patterns: The operator decides; ask them. Common patterns:
- `["self_host"]` for a free tier — "you can run the app, no premium features" - `["self_host"]` for a free tier — "you can run the app, no premium features"
@@ -88,7 +90,17 @@ hangs on these:
If the creator doesn't know yet, propose sensible defaults from the If the creator doesn't know yet, propose sensible defaults from the
ranges above and confirm before coding. ranges above and confirm before coding.
8. **Compile a config card before writing code.** After answering 17, 8. **Has the operator declared an entitlements catalog on the product
yet?** (Admin → Products → Edit → "Entitlements catalog".) If yes,
the operator already has a typed list of feature slugs + display
names + descriptions; ask them to paste it so your gating logic
uses the canonical slugs and avoids drift. If no, propose a catalog
in your config card (next step) so the operator can paste it into
admin in one step — this prevents the operator from inventing a
slightly different slug spelling later. See section 8 ("Picking
entitlement names") for the catalog mechanics.
9. **Compile a config card before writing code.** After answering 18,
produce a short summary the operator can paste into the Keysat admin produce a short summary the operator can paste into the Keysat admin
without re-deriving anything. This is the single highest-leverage without re-deriving anything. This is the single highest-leverage
step for avoiding "wait, what entitlements did we agree on?" churn step for avoiding "wait, what entitlements did we agree on?" churn
@@ -355,15 +367,18 @@ the payload. The SDK parses and verifies in one call. You should never
need to handle the encoding manually. need to handle the encoding manually.
The signed payload contains: The signed payload contains:
- `product_id` (UUID) — for matching against your product slug - `product_id` (UUID; **not a slug** — see §9a for cross-product checks)
- `license_id` (UUID) — useful for logging - `license_id` (UUID — useful for logging; never log the full key)
- `issued_at` (Unix seconds) - `issued_at` (Unix seconds)
- `expires_at` (Unix seconds; 0 means perpetual) - `expires_at` (Unix seconds; 0 means perpetual)
- `flags` (bitfield; `FLAG_TRIAL=1`) - `flags` (bitfield: bit 0 = `FLAG_FINGERPRINT_BOUND` (mask `0x01`); bit 1 = `FLAG_TRIAL` (mask `0x02`))
- `entitlements: string[]` — **this is the array you gate features on** - `entitlements: string[]` — **this is the array you gate features on**
- `fingerprint_hash` (32 bytes; for online machine-binding) - `fingerprint_hash` (32 bytes; for online machine-binding)
Your software reads `entitlements` and decides what to unlock. Your software reads `entitlements` and decides what to unlock. **Do not
do flag bit arithmetic yourself** — every SDK pre-parses the flags into
boolean fields on the payload (`isTrial` / `is_trial`, `isFingerprintBound`
/ `is_fingerprint_bound`). Use those.
--- ---
@@ -474,50 +489,21 @@ direct callers but the timer keeps humming along.
npm install @keysat/licensing-client npm install @keysat/licensing-client
``` ```
**GitHub fallback** (if the npm package isn't published yet). Several **GitHub fallback** (the npm package is pending publication; the GitHub
prerequisites must be met for this path to work end-to-end: repo is public and installable directly):
1. The `keysat-xyz/keysat-client-ts` repo must be **public** on GitHub. ```jsonc
Private repos require credentials, which fails inside hermetic build // package.json
environments (Docker, CI, fresh dev machines without an SSH key). If "dependencies": {
the repo flips public temporarily for one build, every future build "@keysat/licensing-client": "git+https://github.com/keysat-xyz/keysat-client-ts.git"
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
``` ```
Use the explicit `git+https://` form (not the `github:user/repo` shorthand),
which avoids the ssh-vs-https resolution drift that bites hermetic build
environments. The SDK's `prepare` script builds `dist/` automatically on
git install, so no extra steps are needed.
**Embed the public key.** The simplest way is to commit the PEM file **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: to your repo at `assets/issuer.pub` and import it as a raw string:
@@ -565,18 +551,20 @@ export function checkLicense(): LicenseState {
if (!raw) return { state: 'unlicensed', entitlements: new Set() } if (!raw) return { state: 'unlicensed', entitlements: new Set() }
try { try {
const ok = verifier.verify(raw) const ok = verifier.verify(raw)
// (optional) reject keys for the wrong product slug // For cross-product safety, you need to assert the payload's
if (ok.payload.productSlug && ok.payload.productSlug !== PRODUCT_SLUG) { // product matches what your app expects. The payload carries a
return { state: 'invalid', reason: 'product_mismatch', entitlements: new Set() } // PRODUCT UUID, not a slug — see §9a for the correct check
} // (online via `client.validate(key, slug, fp)`, or offline by
// comparing `payload.productUuid` against the operator-provided
// product UUID constant).
return { return {
state: 'licensed', state: 'licensed',
licenseId: ok.payload.licenseId, licenseId: ok.licenseId, // top-level shortcut on the VerifyOk result
entitlements: new Set(ok.payload.entitlements || []), entitlements: new Set(ok.payload.entitlements || []),
expiresAt: ok.payload.expiresAt expiresAt: ok.payload.expiresAt
? new Date(ok.payload.expiresAt * 1000) ? new Date(ok.payload.expiresAt * 1000)
: undefined, : undefined,
isTrial: !!(ok.payload.flags & 1), isTrial: ok.payload.isTrial, // pre-parsed by the SDK — don't bit-math
} }
} catch (e: any) { } catch (e: any) {
return { state: 'invalid', reason: e.message, entitlements: new Set() } return { state: 'invalid', reason: e.message, entitlements: new Set() }
@@ -675,11 +663,11 @@ def check_license() -> LicenseState:
ok = _verifier.verify(raw) ok = _verifier.verify(raw)
return LicenseState( return LicenseState(
state='licensed', state='licensed',
license_id=str(ok.payload.license_id), license_id=str(ok.license_id), # top-level shortcut on VerifyOk
entitlements=set(ok.payload.entitlements or []), entitlements=set(ok.entitlements or []),
expires_at=datetime.fromtimestamp(ok.payload.expires_at) expires_at=datetime.fromtimestamp(ok.expires_at)
if ok.payload.expires_at else None, if ok.expires_at else None,
is_trial=bool(ok.payload.flags & 1), is_trial=ok.is_trial, # pre-parsed by the SDK; don't bit-math
) )
except Exception as e: except Exception as e:
return LicenseState(state='invalid', reason=str(e)) return LicenseState(state='invalid', reason=str(e))
@@ -733,7 +721,7 @@ const ISSUER_PEM: &str = include_str!("../assets/issuer.pub");
```rust ```rust
// src/license.rs // src/license.rs
use keysat_licensing_client::{Verifier, PublicKeyPem}; use keysat_licensing_client::{Verifier, PublicKeyPem, FLAG_TRIAL};
use std::collections::HashSet; use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
@@ -792,14 +780,17 @@ pub fn check_license() -> LicenseState {
match verifier.verify(&raw) { match verifier.verify(&raw) {
Ok(ok) => LicenseState { Ok(ok) => LicenseState {
state: "licensed", state: "licensed",
license_id: Some(ok.payload.license_id.to_string()), // license_id is a [u8; 16] in the Rust SDK — render to hex.
license_id: Some(hex::encode(ok.payload.license_id)),
entitlements: ok.payload.entitlements.into_iter().collect(), entitlements: ok.payload.entitlements.into_iter().collect(),
expires_at: if ok.payload.expires_at == 0 { expires_at: if ok.payload.expires_at == 0 {
None None
} else { } else {
chrono::DateTime::from_timestamp(ok.payload.expires_at, 0) chrono::DateTime::from_timestamp(ok.payload.expires_at, 0)
}, },
is_trial: (ok.payload.flags & 1) != 0, // Use the FLAG_TRIAL constant — it's bit 1 (mask 0x02), NOT 0x01.
// The Rust SDK leaves flag parsing to the caller.
is_trial: (ok.payload.flags & FLAG_TRIAL) != 0,
..Default::default() ..Default::default()
}, },
Err(e) => LicenseState { Err(e) => LicenseState {
@@ -965,17 +956,16 @@ def activate():
// ACTIVATION_PATHS or `state.license.read().await.state == "licensed"`. // ACTIVATION_PATHS or `state.license.read().await.state == "licensed"`.
``` ```
**How would Keysat itself do this?** Keysat already has the `Mode::Enforce` **How does Keysat itself handle this?** Keysat dogfoods the soft-gate
build-time flag in [`license_self.rs`](./licensing-service-startos/licensing-service/src/license_self.rs): pattern: missing or invalid licenses log a warning and the daemon
when built with `KEYSAT_LICENSE_ENFORCE=1`, missing or invalid licenses starts in `Tier::Unlicensed` (the Creator-tier caps apply). The admin
cause the daemon to refuse to start (Flavor 1). Default Permissive UI renders as Creator-tier with an upgrade CTA; product / policy / code
builds run unlicensed at Creator-tier caps. To switch Keysat to Flavor 2 creation endpoints return 402 once the tier caps are hit (see
("run but block until activated") would mean: keep the existing boot-time [`api/tier.rs`](./licensing-service-startos/licensing-service/src/api/tier.rs)).
license check non-fatal, expose `/admin/login`-style activation endpoints There's no `KEYSAT_LICENSE_ENFORCE` build flag — that was deprecated in
under a hardcoded allowlist, and have an axum middleware return 402 on favor of always-permissive boot + tier-cap enforcement at create-time.
every other admin/business endpoint until `state.self_tier` flips from The pattern is a good reference for soft-gate or hard-gate-Flavor-2 in
`Unlicensed` to `Licensed`. The pieces are all there — it's a few hundred your own app: never block boot; gate work on entitlements.
lines of axum middleware + an SPA "Activate" splash screen.
### 7e. Packaging gotchas — Docker, s9pk, hermetic builds ### 7e. Packaging gotchas — Docker, s9pk, hermetic builds
@@ -1300,59 +1290,74 @@ in your app, a license issued for Recap would parse + signature-verify
successfully inside Notewise — same public key, valid signature. That would successfully inside Notewise — same public key, valid signature. That would
be a real bug, not a theoretical one. be a real bug, not a theoretical one.
**The protection exists, but it's your job to use it.** The LIC1 payload **Important: the signed payload carries a product UUID, not the product
includes a signed `product_slug` field. Recap's licenses literally carry slug.** Every SDK exposes it as `productUuid` (TS), `product_id` (Python /
`"product_slug": "recap"` inside the signed bytes; Notewise's carry Rust as a `uuid.UUID` / `[u8; 16]`), or `ProductID` (Go as `[16]byte`).
`"product_slug": "notewise"`. The signature covers those bytes, so the There is no `product_slug` field on the payload. Treat the two paths
buyer can't tamper with them — but the SDK won't reject a wrong-product differently:
license unless you tell it which product you are.
### Rule ### Online path (preferred) — daemon resolves slug → UUID for you
- **Online validation:** always pass `product_slug` to `client.validate(...)`. Always pass the product slug to `client.validate(...)`. The daemon
The daemon enforces it and returns `reason: 'product_mismatch'` on mismatch. looks up the product by slug, fetches its UUID, and compares against
- **Offline verify:** always assert `payload.product_slug === MY_PRODUCT_SLUG` the UUID baked into the license. If they don't match, you get
after `parseAndVerify(...)`. The SDK does not do this for you. `reason: 'product_mismatch'`. This is the simplest correct check.
### Concrete pattern (TypeScript)
```ts ```ts
const MY_PRODUCT_SLUG = 'recap' // hard-code; matches what the operator picked const MY_PRODUCT_SLUG = 'recap' // hard-code; matches what the operator picked
// Online — daemon enforces product_slug for you
const r = await client.validate(licenseKey, MY_PRODUCT_SLUG, machineFingerprint) const r = await client.validate(licenseKey, MY_PRODUCT_SLUG, machineFingerprint)
if (!r.ok) { if (!r.ok) {
// r.reason === 'product_mismatch' if a Notewise license was presented // r.reason === 'product_mismatch' if a Notewise license was presented
reject(r.reason) reject(r.reason)
return return
} }
```
// Offline — you must check yourself ### Offline path — ask the operator for the product UUID and compare it
const payload = parseAndVerify(licenseKey, EMBEDDED_PUBKEY_PEM)
if (payload.product_slug !== MY_PRODUCT_SLUG) { Because the payload only has a UUID, you need the operator's product UUID
(not just the slug) baked into your app to do an offline cross-product
check. Get the UUID from the operator's admin UI (Products → Edit → the
URL or the admin API: `GET /v1/admin/products` returns each product's `id`).
Embed it as a constant alongside the slug.
```ts
const MY_PRODUCT_SLUG = 'recap'
const MY_PRODUCT_UUID = '11111111-2222-3333-4444-555555555555' // ask the operator
const ok = verifier.verify(licenseKey)
if (ok.payload.productUuid !== MY_PRODUCT_UUID) {
reject('product_mismatch') reject('product_mismatch')
return return
} }
``` ```
Same shape in Python / Rust / Go: pass `product_slug` to `validate`, For Python / Rust / Go, compare `payload.product_id` / `payload.ProductID`
check `payload.product_slug` after `parse_and_verify`. Every SDK exposes against the same UUID constant (parsed as `uuid.UUID` in Python, raw bytes
the field on the parsed payload object. in Rust / Go).
### Why the SDK doesn't auto-reject offline ### Why the SDK doesn't auto-reject offline
`ParseAndVerify` is intentionally low-level — it returns the verified `Verifier.verify` is intentionally low-level — it returns the verified
payload and lets the caller decide what to enforce. A multi-product app payload and lets the caller decide what to enforce. A multi-product app
(unusual but possible) might legitimately accept any product the operator might legitimately accept any product the operator signed for; a
signed for; a per-product app must reject mismatches. Making this opt-in per-product app must reject mismatches. Making this opt-in keeps the
keeps the SDK honest about what it's checking on your behalf. SDK honest about what it's checking on your behalf.
### Forgetting to check is a silent failure ### Forgetting to check is a silent failure
If you call `parseAndVerify` without asserting the product, a license If you call `verifier.verify` without asserting the product UUID (or
from any of the operator's products will signature-verify and you'll using the online `validate` path with the slug), a license from any
treat it as valid. There is no warning. **Make the check a constant of the operator's products will signature-verify and you'll treat it
in your app and assert it on every code path that loads a license.** as valid. There is no warning. **Make the check a constant in your app
and assert it on every code path that loads a license.**
### Single-product instances don't need this
If the operator runs one Keysat per product (most indie setups), you
don't need the offline cross-product check at all — the daemon only
mints licenses for the one product. The online `validate(slug, …)` call
still catches typos in the slug, so it's worth doing either way.
--- ---
@@ -1547,6 +1552,49 @@ session, _ := client.StartPurchase(ctx, PRODUCT_SLUG, keysat.StartPurchaseOption
// open session.CheckoutURL; poll on settle // open session.CheckoutURL; poll on settle
``` ```
### Rendering tier cards: what to surface
The `listPublicPolicies` response gives you everything the operator's
buy page renders. To match the buy page experience inside your app:
- **`policy.entitlements`** is the granted-and-visible set. The daemon
already filtered out any entries the operator marked
`metadata.hidden_entitlements` for that tier (a feature shipped in
v0.2.0:24 — operators use it for "Everything in Basic, plus:"
marketing where they don't want to repeat already-implied items on
higher tiers). You don't need to filter further; just render.
- **`policy.marketingBullets`** (camelCase in TS, `marketing_bullets`
in Python / Rust / Go) is operator-authored copy — short ✓ items
rendered alongside (or instead of) the entitlements list on the buy
page. Render them too; they're how the operator describes the tier
in plain language.
- **`policy.marketingBulletsPosition`** is `"above"` (default) or
`"below"` — operator decides whether the marketing bullets appear
before or after the entitlement chips on the card.
- **`policy.featuredDiscount`** (nullable) — when non-null, the operator
has flagged this tier with an active launch-special discount.
Surface it on the tier card: a struck-through original price, the
discounted price, and a "LAUNCH SPECIAL" / "N% OFF" ribbon. Auto-apply
the discount in `startPurchase` by passing the featured code's
`code` string in the `code` option — otherwise the buyer pays the
un-discounted price even though the card showed otherwise.
- **`product.priceCurrency`** controls how to format prices. Possible
values: `"SAT"` (use `priceSats` as-is), `"USD"` / `"EUR"` (use
`priceValue` in cents and format as decimal dollars / euros). A
product's policies inherit the product's currency; render
accordingly.
Reference formatter (TypeScript):
```ts
function formatPrice(p: PublicPolicy, product: Product): string {
const cur = product.priceCurrency ?? 'SAT'
if (cur === 'SAT') return `${p.priceSats.toLocaleString()} sats`
const main = (p.priceValue ?? 0) / 100
return `${cur === 'USD' ? '$' : '€'}${main.toFixed(2)}`
}
```
### Common mistakes ### Common mistakes
- **Hardcoding policy slugs in the client.** The whole point of - **Hardcoding policy slugs in the client.** The whole point of
@@ -1563,17 +1611,25 @@ session, _ := client.StartPurchase(ctx, PRODUCT_SLUG, keysat.StartPurchaseOption
promos and referral discounts. It can't change which tier the buyer promos and referral discounts. It can't change which tier the buyer
ends up on. Use `policySlug`. ends up on. Use `policySlug`.
- **Forgetting `policySlug` and assuming the right tier.** With - **Forgetting `policySlug` and assuming the right tier.** With
`policySlug` omitted, the daemon picks the policy slugged "default" `policySlug` omitted, the daemon picks the highlighted ("most
(if any), else the first active one. On a Core/Pro setup where popular") policy if any, else the cheapest. On a Core/Pro setup
Core happens to be alphabetically first or named "default", every where Pro is highlighted, every buyer who hits your in-app upgrade
buyer who hits your in-app upgrade flow without a `policySlug` ends flow without a `policySlug` ends up on Pro regardless of what they
up on Core regardless of what they clicked. Always pass the slug clicked. Always pass the slug the buyer chose.
the buyer chose.
- **Copying the price from your hardcoded UI rather than the API.** - **Copying the price from your hardcoded UI rather than the API.**
Operators legitimately edit tier pricing in admin without warning; Operators legitimately edit tier pricing in admin without warning;
if you cache a price, you'll under- or over-charge buyers vs. what if you cache a price, you'll under- or over-charge buyers vs. what
they actually pay. Render `policy.priceSats` directly from the they actually pay. Render `policy.priceSats` directly from the
current `listPublicPolicies` response. current `listPublicPolicies` response.
- **Assuming all prices are in sats.** A multi-currency product
(`priceCurrency === "USD"` or `"EUR"`) has its price in cents under
`priceValue` and a stale-or-zero `priceSats`. Format on `priceCurrency`
+ `priceValue`, not `priceSats` alone.
- **Skipping the featured-discount surfacing.** The buy page auto-
applies featured codes; your in-app picker doesn't unless you
forward `featuredDiscount.code` into `startPurchase`. Without this,
buyers see "LAUNCH SPECIAL" on your tier card but get charged the
un-discounted price at checkout — a real bug.
### Architecture diagram ### Architecture diagram
@@ -1695,12 +1751,12 @@ function checkLicense() {
const ok = verifier.verify(raw) const ok = verifier.verify(raw)
return { return {
state: 'licensed', state: 'licensed',
licenseId: ok.payload.licenseId, licenseId: ok.licenseId, // top-level shortcut on the VerifyOk result
entitlements: new Set(ok.payload.entitlements || []), entitlements: new Set(ok.payload.entitlements || []),
expiresAt: ok.payload.expiresAt expiresAt: ok.payload.expiresAt
? new Date(ok.payload.expiresAt * 1000) ? new Date(ok.payload.expiresAt * 1000)
: null, : null,
isTrial: !!(ok.payload.flags & 1), isTrial: ok.payload.isTrial, // SDK pre-parses the flags; don't bit-math
} }
} catch (e) { } catch (e) {
return { state: 'invalid', reason: e.message, entitlements: new Set() } return { state: 'invalid', reason: e.message, entitlements: new Set() }
@@ -1785,12 +1841,20 @@ ship it.
against slug `bar`. Typos in the slug constant cause "license valid against slug `bar`. Typos in the slug constant cause "license valid
but my code rejects it" head-scratchers. Read the slug from a but my code rejects it" head-scratchers. Read the slug from a
single constant. single constant.
- **Not asserting `product_slug` after offline verify.** `ParseAndVerify` - **Not asserting the product after offline verify.** `verifier.verify`
checks the signature, not the product. If the operator sells multiple checks the signature, not the product. If the operator sells multiple
products from the same Keysat, every product's licenses share the products from the same Keysat, every product's licenses share the
signing key — a license for Product A will signature-verify inside signing key — a license for Product A will signature-verify inside
Product B's app. Always assert `payload.product_slug === MY_PRODUCT_SLUG` Product B's app. The payload carries a **product UUID, not a slug**;
after the parse. See §9a for the full pattern. the right check is either (a) call `client.validate(key, slug, …)`
so the daemon resolves slug → UUID server-side, or (b) embed the
operator's product UUID and assert `payload.productUuid === MY_PRODUCT_UUID`
offline. See §9a for both patterns.
- **Doing flag bit-arithmetic for `isTrial`.** `(payload.flags & 1)`
is the `FINGERPRINT_BOUND` bit, NOT `TRIAL`. The TS / Python / Go
SDKs pre-parse the flags into `isTrial` / `is_trial` / `IsTrial()` —
use those. The Rust SDK requires manual math; if you need it, use
the exported `FLAG_TRIAL` constant (mask `0x02`).
- **Logging the full license key.** It's a bearer credential — log - **Logging the full license key.** It's a bearer credential — log
the `license_id` instead. the `license_id` instead.
- **Refusing to start without a license.** Boot in unlicensed mode and - **Refusing to start without a license.** Boot in unlicensed mode and
@@ -1822,6 +1886,51 @@ ship it.
--- ---
## 15a. Verify your integration with curl before writing app code
Before wiring anything into the app, confirm the operator's Keysat is
reachable and configured correctly. These four commands take ~30
seconds and catch most "we agreed on the wrong slug" failures.
```bash
# Replace with your operator's values.
export KEYSAT_BASE_URL='https://licensing.example.com'
export PRODUCT_SLUG='your-product-slug'
# 1. Daemon is reachable.
curl -fsSL "$KEYSAT_BASE_URL/healthz" && echo " healthz OK"
# 2. Issuer public key endpoint responds (so embed-time fetch works).
curl -fsSL "$KEYSAT_BASE_URL/v1/issuer/public-key" | head -1
# 3. The product slug exists and has at least one policy. If the JSON
# response is { "error": "not_found" }, you have a slug typo or the
# operator hasn't created the product yet.
curl -fsSL "$KEYSAT_BASE_URL/v1/products/$PRODUCT_SLUG/policies" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print("policies:", [p["slug"] for p in d.get("policies", [])])'
# 4. Validate a real license key (ask the operator for one — a free-
# license discount-code redemption is the cheapest path).
LICENSE_KEY='LIC1-...'
curl -fsSL -X POST "$KEYSAT_BASE_URL/v1/validate" \
-H 'content-type: application/json' \
-d '{"key":"'"$LICENSE_KEY"'","product":"'"$PRODUCT_SLUG"'"}' \
| python3 -m json.tool
```
If all four commands succeed, the daemon is wired correctly and you
have a valid license key to test against. Now write the integration.
If step 3 returns an empty `policies` array, the operator hasn't
created any policies for this product yet — surface that to them
before continuing (no policies means no buyer-purchasable tiers).
If step 4 returns `{ "ok": false, "reason": "..." }`, the license
isn't valid for this product. The most common cause is a slug typo
in the operator's product setup vs. what they told you.
---
## 16. Testing the integration ## 16. Testing the integration
1. Get a real license to test against. Easiest: ask the operator to 1. Get a real license to test against. Easiest: ask the operator to