Initial public commit

This commit is contained in:
Keysat
2026-05-07 10:42:46 -05:00
commit dc3719bd3f
10 changed files with 1287 additions and 0 deletions
+283
View File
@@ -0,0 +1,283 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Keysat Docs — Integrate the SDK</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="stylesheet" href="docs.css">
</head>
<body>
<div class="topnav">
<a href="index.html" class="brand"><img src="assets/keysat-mark.svg" alt=""><span>Keysat</span></a>
<span class="docs-tag">Docs</span>
<nav>
<a href="install.html">Install</a>
<a href="integrate.html" class="active">Integrate</a>
<a href="wire-format.html">Wire format</a>
<a href="operate.html">Operate</a>
<a href="https://keysat.xyz">Marketing</a>
</nav>
</div>
<div class="layout">
<aside class="side">
<div class="group">
<div class="glabel">Get started</div>
<a href="index.html">Introduction</a>
<a href="install.html">Install &amp; setup</a>
<a href="integrate.html" class="active">Integrate the SDK</a>
</div>
<div class="group">
<div class="glabel">Concepts</div>
<a href="index.html#architecture">Architecture</a>
<a href="index.html#products-policies">Products &amp; policies</a>
<a href="index.html#discounts">Discount codes</a>
<a href="index.html#revocation">Revocation strategy</a>
</div>
<div class="group">
<div class="glabel">Reference</div>
<a href="wire-format.html">Wire format</a>
<a href="integrate.html#api">Admin API</a>
<a href="integrate.html#sdks">SDKs</a>
</div>
<div class="group">
<div class="glabel">Operate</div>
<a href="operate.html#backups">Backups</a>
<a href="operate.html#migrate">Migrate hardware</a>
<a href="operate.html#troubleshooting">Troubleshooting</a>
</div>
</aside>
<main class="prose">
<div class="crumb">Get started · Integrate the SDK</div>
<h1>Integrate the SDK.</h1>
<p class="lead">Wire Keysat licenses into your software in under an afternoon. The verifier is pure-function, offline, and ships in five lines.</p>
<h2 id="prereq">Prerequisites</h2>
<p>Before you start, you should have:</p>
<ul>
<li>A Keysat installation running on your Start9 &mdash; see <a href="install.html">Install &amp; setup</a>.</li>
<li>BTCPay Server connected to Keysat &mdash; ditto.</li>
<li>At least one product defined in the admin UI.</li>
</ul>
<h2 id="sdks">Pick an SDK</h2>
<p>Three official SDKs ship today. They are wire-compatible &mdash; a license issued by your Keysat verifies identically in any of them.</p>
<div class="lang-tabs" role="tablist">
<button class="active" data-lang="ts">TypeScript</button>
<button data-lang="rs">Rust</button>
<button data-lang="py">Python</button>
</div>
<pre class="code lang-pane" data-lang="ts"><span class="c"># npm</span>
npm install @keysat/licensing-client
<span class="c"># pnpm</span>
pnpm add @keysat/licensing-client</pre>
<pre class="code lang-pane" data-lang="rs" style="display:none"><span class="c"># Cargo.toml</span>
[dependencies]
licensing-client = <span class="s">"0.1"</span></pre>
<pre class="code lang-pane" data-lang="py" style="display:none"><span class="c"># pip</span>
pip install keysat-licensing-client
<span class="c"># or with poetry</span>
poetry add keysat-licensing-client</pre>
<p>If your language isn&rsquo;t covered, see <a href="wire-format.html">Wire format</a>. The format is small and porting takes about an afternoon.</p>
<h2 id="embed">Step 1 — Embed your public key</h2>
<p>In the admin UI, open <strong>Overview</strong> and copy the issuer public key from the "Embed your public key" card. (Or fetch it from <code>GET /v1/issuer/public-key</code>.) Paste it into your application&rsquo;s source code as a compile-time constant.</p>
<pre class="code lang-pane" data-lang="ts"><span class="k">const</span> <span class="f">ISSUER_PEM</span> = <span class="s">`-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
-----END PUBLIC KEY-----`</span>;</pre>
<pre class="code lang-pane" data-lang="rs" style="display:none"><span class="k">const</span> <span class="f">ISSUER_PEM</span>: &amp;<span class="k">str</span> = <span class="s">"-----BEGIN PUBLIC KEY-----\n\
MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL\n\
-----END PUBLIC KEY-----"</span>;</pre>
<pre class="code lang-pane" data-lang="py" style="display:none"><span class="f">ISSUER_PEM</span> = <span class="s">b"""-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
-----END PUBLIC KEY-----"""</span></pre>
<div class="callout">
<i data-lucide="info"></i>
<p><strong>Embed it. Don&rsquo;t fetch it.</strong> The whole point of offline verification is that your software can&rsquo;t be tricked by a network-level attacker. If you fetch the public key at runtime, you&rsquo;re back to trusting a server.</p>
</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>
<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>;
<span class="k">const</span> verifier = <span class="k">new</span> <span class="f">Verifier</span>(
<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="k">if</span> (!result.valid) {
<span class="f">exitUnlicensed</span>();
}
<span class="k">if</span> (!result.entitlements.<span class="f">has</span>(<span class="s">'export'</span>)) {
<span class="f">disableExport</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="k">if</span> !result.valid {
exit_unlicensed();
}
<span class="k">if</span> !result.entitlements.contains(<span class="s">"export"</span>) {
disable_export();
}</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="k">if</span> <span class="k">not</span> result.valid:
exit_unlicensed()
<span class="k">if</span> <span class="s">"export"</span> <span class="k">not in</span> result.entitlements:
disable_export()</pre>
<p>The verifier returns a result object with the following fields:</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>
</tbody>
</table>
<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);
} <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="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(),
<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
<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>
<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 &mdash; that&rsquo;s the trade-off for offline.</p>
<p>If you need revocation, ship a thin <em>online</em> check that runs on a cadence (e.g. once a week) against your Keysat&rsquo;s revocation feed:</p>
<pre class="code lang-pane" data-lang="ts"><span class="c">// Optional. Run on a cadence, ignore network errors.</span>
<span class="k">async function</span> <span class="f">checkRevocation</span>(licenseId: string) {
<span class="k">const</span> r = <span class="k">await</span> fetch(<span class="s">`https://your-keysat.example/v1/licenses/${licenseId}/status`</span>);
<span class="k">if</span> (r.ok) {
<span class="k">const</span> j = <span class="k">await</span> r.json();
<span class="k">if</span> (j.status === <span class="s">'revoked'</span>) <span class="f">disableApp</span>();
}
}</pre>
<pre class="code lang-pane" data-lang="rs" style="display:none"><span class="c">// Optional. Run on a cadence, ignore network errors.</span>
<span class="k">async fn</span> check_revocation(license_id: &amp;<span class="k">str</span>) {
<span class="k">if let</span> <span class="k">Ok</span>(r) = reqwest::get(format!(
<span class="s">"https://your-keysat.example/v1/licenses/{}/status"</span>,
license_id
)).<span class="k">await</span> {
<span class="k">if let</span> <span class="k">Ok</span>(j) = r.json::&lt;Status&gt;().<span class="k">await</span> {
<span class="k">if</span> j.status == <span class="s">"revoked"</span> { disable_app(); }
}
}
}</pre>
<pre class="code lang-pane" data-lang="py" style="display:none"><span class="c"># Optional. Run on a cadence, ignore network errors.</span>
<span class="k">def</span> <span class="f">check_revocation</span>(license_id):
<span class="k">try</span>:
r = requests.get(<span class="s">f"https://your-keysat.example/v1/licenses/{license_id}/status"</span>, timeout=<span class="n">5</span>)
<span class="k">if</span> r.json()[<span class="s">"status"</span>] == <span class="s">"revoked"</span>:
disable_app()
<span class="k">except</span> Exception:
<span class="k">pass</span></pre>
<div class="callout">
<i data-lucide="key-round"></i>
<p><strong>You decide the policy.</strong> Many indie developers ship no revocation at all. Once a key is sold, it stays valid &mdash; refunds happen offline via BTCPay. That&rsquo;s perfectly reasonable.</p>
</div>
<h2 id="api">Admin API</h2>
<p>The admin UI is a thin shell over a small JSON API. Bearer-auth all requests with your admin API key.</p>
<table class="t">
<thead><tr><th>Method</th><th>Path</th><th>Use</th></tr></thead>
<tbody>
<tr><td><code>GET</code></td><td><code>/v1/products</code></td><td>List products (public).</td></tr>
<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/search</code></td><td>Find licenses by email, npub, or invoice.</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>
<tr><td><code>POST</code></td><td><code>/v1/redeem</code></td><td>Redeem a free-license code (public).</td></tr>
</tbody>
</table>
<p>Full schemas for each endpoint live in <a href="wire-format.html">Wire format &amp; API reference</a>.</p>
</main>
<aside class="toc">
<div class="label">On this page</div>
<a href="#prereq">Prerequisites</a>
<a href="#sdks">Pick an SDK</a>
<a href="#embed">1. Embed your public key</a>
<a href="#verify">2. Verify at startup</a>
<a href="#errors">3. Handle errors</a>
<a href="#renewals">Renewals &amp; revocation</a>
<a href="#api">Admin API</a>
</aside>
</div>
<script src="https://unpkg.com/lucide@latest"></script>
<script>
lucide.createIcons();
// Sync language tabs across all .lang-pane code blocks on the page
function setLang(lang) {
document.querySelectorAll('.lang-tabs button').forEach(b =>
b.classList.toggle('active', b.dataset.lang === lang));
document.querySelectorAll('.lang-pane').forEach(p => {
p.style.display = (p.dataset.lang === lang) ? 'block' : 'none';
});
}
document.querySelectorAll('.lang-tabs button').forEach(b => {
b.addEventListener('click', () => setLang(b.dataset.lang));
});
</script>
</body>
</html>