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:
+59
-51
@@ -109,7 +109,7 @@ MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
|
||||
</div>
|
||||
|
||||
<h2 id="verify">Step 2: Verify a license at startup</h2>
|
||||
<p>Read the user’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’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(&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(&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<string></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’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’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’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> && 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(&license_key) {
|
||||
<span class="k">Ok</span>(r) <span class="k">if</span> r.valid => grant_access(&r),
|
||||
<span class="k">Ok</span>(r) => show_renewal_prompt(r.expires_at),
|
||||
<span class="k">Err</span>(licensing_client::<span class="f">Error</span>::SignatureError) => show_tamper_warning(),
|
||||
<span class="k">Err</span>(licensing_client::<span class="f">Error</span>::FormatError(_)) => 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(&license_key) {
|
||||
<span class="k">Ok</span>(license) => grant_access(&license),
|
||||
<span class="k">Err</span>(<span class="f">Error</span>::BadSignature) => show_tamper_warning(),
|
||||
<span class="k">Err</span>(<span class="f">Error</span>::BadFormat(_) | <span class="f">Error</span>::BadEncoding(_)) => show_input_error(),
|
||||
<span class="k">Err</span>(e) => 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 & 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’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’s licenses; requires <code>?product_id=<uuid></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/<id>/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>
|
||||
|
||||
Reference in New Issue
Block a user