Correct SDK-integration docs and add license-gating walkthrough

Fixes surfaced by the onboarding test harness, each verified against the
published SDKs and the daemon:

- integrate.html: real v0.3 verify() shape (throws/Err, returns
  VerifyOk{payload,...}, no `valid` bool; LicensingError `.code` in TS,
  `.kind` in Python; Rust Error::BadSignature/BadFormat). Offline-expiry
  and server-side key-transport notes; corrected the admin-API table
  (licenses list needs product_id; added the /search row).
- agent.html: merchant-onboard role row; product/policy-create workflows;
  buyer_note -> note; find-by-email -> /search; the worked-example
  walkthrough; code blocks restyled to the pre.code design contract.
- wire-format.html: corrected the GET /v1/issuer/public-key response shape.
This commit is contained in:
Keysat
2026-06-16 22:47:59 -05:00
parent 3f1fbe0f3b
commit 47facc8909
3 changed files with 186 additions and 77 deletions
+59 -51
View File
@@ -109,7 +109,7 @@ MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
</div>
<h2 id="verify">Step 2: Verify a license at startup</h2>
<p>Read the user&rsquo;s license key from wherever you store it (a file in their data directory, the OS keychain, an env var) and verify it on application start.</p>
<p>Read the user&rsquo;s license key from wherever you store it (a file in their data directory, the OS keychain, an env var) and verify it on application start. In a server-side app the key arrives per request instead: read it from a header you define (for example <code>X-License-Key</code>) or the session, then verify it the same way.</p>
<pre class="code lang-pane" data-lang="ts"><span class="k">import</span> { <span class="f">Verifier</span>, <span class="f">PublicKey</span> } <span class="k">from</span> <span class="s">'@keysat/licensing-client'</span>;
@@ -117,83 +117,90 @@ MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
<span class="f">PublicKey</span>.<span class="f">fromPem</span>(ISSUER_PEM)
);
<span class="k">const</span> result = verifier.<span class="f">verify</span>(licenseKeyFromUser);
<span class="c">// verify() returns the verified license, or THROWS if the key is missing,
// malformed, forged, or signed by someone else. Catch it (see step 3).</span>
<span class="k">const</span> license = verifier.<span class="f">verify</span>(licenseKeyFromUser);
<span class="c">// Now decide what to do with the result, based on YOUR business model.
// One-time purchase to use the app at all? Refuse to start unless valid.
// Free + paid features? Check entitlements per feature.
// Supporter badge only? Just render differently when valid.</span>
<span class="k">if</span> (result.valid) {
app.licensed = <span class="k">true</span>;
app.entitlements = result.entitlements;
}</pre>
<span class="c">// What you do next is up to YOUR business model. The verified payload
// carries the entitlements baked in at issue time.</span>
app.licensed = <span class="k">true</span>;
app.entitlements = license.payload.entitlements; <span class="c">// string[]</span></pre>
<pre class="code lang-pane" data-lang="rs" style="display:none"><span class="k">use</span> licensing_client::{<span class="f">Verifier</span>, <span class="f">PublicKeyPem</span>};
<span class="k">let</span> pk = <span class="f">PublicKeyPem</span>::from_str(ISSUER_PEM)<span class="p">?</span>;
<span class="k">let</span> verifier = <span class="f">Verifier</span>::new(pk);
<span class="k">let</span> result = verifier.verify(&amp;license_key)<span class="p">?</span>;
<span class="c">// What you do next is up to your business model.</span>
<span class="k">if</span> result.valid {
app.licensed = <span class="k">true</span>;
app.entitlements = result.entitlements;
}</pre>
<span class="c">// verify() returns Ok(VerifyOk) or Err on a bad key (see step 3).</span>
<span class="k">let</span> license = verifier.verify(&amp;license_key)<span class="p">?</span>;
app.licensed = <span class="k">true</span>;
app.entitlements = license.payload.entitlements;</pre>
<pre class="code lang-pane" data-lang="py" style="display:none"><span class="k">from</span> keysat_licensing_client <span class="k">import</span> Verifier, PublicKey
verifier = <span class="f">Verifier</span>(<span class="f">PublicKey</span>.<span class="f">from_pem</span>(ISSUER_PEM))
result = verifier.<span class="f">verify</span>(license_key_from_user)
<span class="c"># What you do with the result is your choice.</span>
<span class="k">if</span> result.valid:
app.licensed = <span class="k">True</span>
app.entitlements = result.entitlements</pre>
<span class="c"># verify() returns the verified license, or RAISES on a bad key (see step 3).</span>
license = verifier.<span class="f">verify</span>(license_key_from_user)
<p>The verifier returns a result object with the following fields:</p>
app.licensed = <span class="k">True</span>
app.entitlements = license.payload.entitlements</pre>
<p>On success, <code>verify()</code> returns a <code>VerifyOk</code> result. There is no <code>valid</code> boolean: an invalid key throws (TS / Python) or returns <code>Err</code> (Rust). See step 3. Field names are camelCase in TS/JS and snake_case in Rust/Python.</p>
<table class="t">
<thead><tr><th>Field</th><th>Type</th><th>Meaning</th></tr></thead>
<tbody>
<tr><td><code>valid</code></td><td><code>bool</code></td><td>Signature checked, expiry not exceeded.</td></tr>
<tr><td><code>product_id</code></td><td><code>string</code></td><td>The product slug this license was issued for.</td></tr>
<tr><td><code>policy_slug</code></td><td><code>string</code></td><td>Which policy was active at issue time.</td></tr>
<tr><td><code>license_id</code></td><td><code>string</code></td><td>UUID of the license; useful for support tickets.</td></tr>
<tr><td><code>issued_at</code></td><td><code>Date</code></td><td>UTC timestamp.</td></tr>
<tr><td><code>expires_at</code></td><td><code>Date | null</code></td><td><code>null</code> for perpetual.</td></tr>
<tr><td><code>is_trial</code></td><td><code>bool</code></td><td>Set by the policy at issue time.</td></tr>
<tr><td><code>seats</code></td><td><code>int</code></td><td>Max machines (0 = unlimited).</td></tr>
<tr><td><code>entitlements</code></td><td><code>Set&lt;string&gt;</code></td><td>Feature flags baked into the signed payload.</td></tr>
<tr><td><code>productId</code></td><td><code>string</code></td><td>UUID of the product this license was issued for.</td></tr>
<tr><td><code>licenseId</code></td><td><code>string</code></td><td>UUID of the license; useful for support tickets.</td></tr>
<tr><td><code>payload.entitlements</code></td><td><code>string[]</code></td><td>Feature slugs baked into the signed payload.</td></tr>
<tr><td><code>payload.issuedAt</code></td><td><code>number</code></td><td>Unix seconds at issue time.</td></tr>
<tr><td><code>payload.expiresAt</code></td><td><code>number</code></td><td>Unix seconds; <code>0</code> for perpetual.</td></tr>
<tr><td><code>payload.isTrial</code></td><td><code>bool</code></td><td>Set by the policy at issue time.</td></tr>
<tr><td><code>payload.isFingerprintBound</code></td><td><code>bool</code></td><td>True if the key is bound to one machine.</td></tr>
</tbody>
</table>
<div class="callout">
<i data-lucide="info"></i>
<p><strong><code>verify()</code> checks the signature and format, not expiry or revocation.</strong> A perpetual license never expires; to reject expired keys offline, compare the payload&rsquo;s <code>expiresAt</code> to now. Every SDK ships an <code>isExpiredAt</code>/<code>is_expired_at</code> helper for this; TS and Rust also offer a one-call <code>verifyWithTime(key, nowUnixSeconds)</code>. Live status (revoked, suspended, seats in use, the policy slug) isn&rsquo;t in the offline payload; get it from the online <a href="#renewals">validate</a> path below.</p>
</div>
<h2 id="errors">Step 3: Handle errors gracefully</h2>
<p>Verification can fail for benign reasons (the user hasn&rsquo;t pasted a license yet) or hostile ones (someone tampered with a license file). Distinguish them in your UX:</p>
<pre class="code lang-pane" data-lang="ts"><span class="k">try</span> {
<span class="k">const</span> result = verifier.<span class="f">verify</span>(licenseKey);
<span class="k">if</span> (result.valid) <span class="f">grantAccess</span>(result);
<span class="k">else</span> <span class="f">showRenewalPrompt</span>(result.expires_at);
<pre class="code lang-pane" data-lang="ts"><span class="k">import</span> { <span class="f">LicensingError</span> } <span class="k">from</span> <span class="s">'@keysat/licensing-client'</span>;
<span class="k">try</span> {
<span class="k">const</span> license = verifier.<span class="f">verify</span>(licenseKey); <span class="c">// throws if not valid</span>
<span class="f">grantAccess</span>(license);
} <span class="k">catch</span> (e) {
<span class="k">if</span> (e <span class="k">instanceof</span> <span class="f">SignatureError</span>) <span class="f">showTamperWarning</span>();
<span class="k">else</span> <span class="k">if</span> (e <span class="k">instanceof</span> <span class="f">FormatError</span>) <span class="f">showInputError</span>();
<span class="c">// Every failure is a LicensingError with a machine-readable .code:
// 'bad_signature' (tampered / forged), 'bad_format' or 'bad_encoding'
// (garbled input), 'bad_version', 'expired' (only from verifyWithTime).</span>
<span class="k">if</span> (e <span class="k">instanceof</span> <span class="f">LicensingError</span> &amp;&amp; e.code === <span class="s">'bad_signature'</span>) <span class="f">showTamperWarning</span>();
<span class="k">else</span> <span class="k">if</span> (e <span class="k">instanceof</span> <span class="f">LicensingError</span>) <span class="f">showInputError</span>();
<span class="k">else</span> <span class="f">showGenericError</span>(e);
}</pre>
<pre class="code lang-pane" data-lang="rs" style="display:none"><span class="k">match</span> verifier.verify(&amp;license_key) {
<span class="k">Ok</span>(r) <span class="k">if</span> r.valid =&gt; grant_access(&amp;r),
<span class="k">Ok</span>(r) =&gt; show_renewal_prompt(r.expires_at),
<span class="k">Err</span>(licensing_client::<span class="f">Error</span>::SignatureError) =&gt; show_tamper_warning(),
<span class="k">Err</span>(licensing_client::<span class="f">Error</span>::FormatError(_)) =&gt; show_input_error(),
<pre class="code lang-pane" data-lang="rs" style="display:none"><span class="k">use</span> licensing_client::<span class="f">Error</span>;
<span class="k">match</span> verifier.verify(&amp;license_key) {
<span class="k">Ok</span>(license) =&gt; grant_access(&amp;license),
<span class="k">Err</span>(<span class="f">Error</span>::BadSignature) =&gt; show_tamper_warning(),
<span class="k">Err</span>(<span class="f">Error</span>::BadFormat(_) | <span class="f">Error</span>::BadEncoding(_)) =&gt; show_input_error(),
<span class="k">Err</span>(e) =&gt; show_generic_error(e),
}</pre>
<pre class="code lang-pane" data-lang="py" style="display:none"><span class="k">from</span> keysat_licensing_client <span class="k">import</span> SignatureError, FormatError
<pre class="code lang-pane" data-lang="py" style="display:none"><span class="k">from</span> keysat_licensing_client <span class="k">import</span> LicensingError
<span class="k">try</span>:
result = verifier.<span class="f">verify</span>(license_key)
<span class="k">if</span> result.valid: grant_access(result)
<span class="k">else</span>: show_renewal_prompt(result.expires_at)
<span class="k">except</span> SignatureError:
show_tamper_warning()
<span class="k">except</span> FormatError:
show_input_error()</pre>
license = verifier.<span class="f">verify</span>(license_key) <span class="c"># raises if not valid</span>
grant_access(license)
<span class="k">except</span> LicensingError <span class="k">as</span> e:
<span class="k">if</span> e.kind == <span class="s">"bad_signature"</span>:
show_tamper_warning()
<span class="k">elif</span> e.kind.startswith(<span class="s">"bad_"</span>):
show_input_error()
<span class="k">else</span>:
show_generic_error(e)</pre>
<h2 id="renewals">Renewals &amp; revocation</h2>
<p>Keysat licenses are signed at issue time and do not phone home. If a license is revoked in the admin UI, the existing key continues to verify in your app. That&rsquo;s the trade-off for offline.</p>
@@ -251,7 +258,8 @@ result = verifier.<span class="f">verify</span>(license_key_from_user)
<tr><td><code>POST</code></td><td><code>/v1/admin/products</code></td><td>Create a product.</td></tr>
<tr><td><code>POST</code></td><td><code>/v1/admin/policies</code></td><td>Create a policy.</td></tr>
<tr><td><code>POST</code></td><td><code>/v1/admin/discount-codes</code></td><td>Create a discount or comp code.</td></tr>
<tr><td><code>GET</code></td><td><code>/v1/admin/licenses</code></td><td>List / search licenses by buyer email or BTCPay invoice id. (Backend also supports npub search; the buyer-side npub capture flow is still in progress.)</td></tr>
<tr><td><code>GET</code></td><td><code>/v1/admin/licenses</code></td><td>List a product&rsquo;s licenses; requires <code>?product_id=&lt;uuid&gt;</code>.</td></tr>
<tr><td><code>GET</code></td><td><code>/v1/admin/licenses/search</code></td><td>Search licenses by <code>buyer_email</code>, <code>nostr_npub</code>, or <code>invoice_id</code>.</td></tr>
<tr><td><code>POST</code></td><td><code>/v1/admin/licenses/&lt;id&gt;/revoke</code></td><td>Revoke a license.</td></tr>
<tr><td><code>POST</code></td><td><code>/v1/admin/webhook-endpoints</code></td><td>Register an outbound webhook.</td></tr>
<tr><td><code>GET</code></td><td><code>/v1/admin/audit</code></td><td>Read audit log.</td></tr>