23aa121afb
Five-page sweep to match the current daemon state. install.html: - Step 6 (first product): "Price (sats)" → reflects the currency picker (sats / USD / EUR) shipped in migration 0010. - Step 7 (first policy): drop the "default slug is consumed by public flow" myth — buy page renders a tier picker for any product with ≥2 public policies. Add references to entitlements catalog, hide-on-buy toggles, marketing bullets, recurring subscriptions, and the drag-to-reorder policy grid. integrate.html: - "Three official SDKs" → "Four official SDKs" + Go tab + install snippet. Notes the daemon's cross-check fixtures assert byte-for-byte parity across all four. - Admin API table: drop "by npub" from the licenses search description (backend supports it; UI hasn't surfaced it yet since the purchase flow doesn't capture npubs). operate.html: - Backups section: drop the imaginary `/data/issuer-key.pem` file — the signing keypair lives in the `server_keys` SQLite table, not in a PEM file on disk. Mention the self-license file path (`/data/keysat-license.txt`). - Rotation: drop the "v0.1 doesn't support / v0.2 will" framing; rotation isn't on the v0.2 / v0.3 roadmap and the v0.1 caveat is misleading. Update steps to reflect SQLite-as-keystore. - Webhook troubleshooting: point at the dedicated Webhooks → Failed (DLQ) view rather than the audit log. pricing.html: - Creator: 21,000 sats one-time → Free forever (matches actual master Keysat configuration). - Pro: 250,000 sats/yr → 100,000 sats/yr (recurring). Note recurring + tier upgrades have shipped; only Zaprite remains v0.3. - Patron: 500,000 sats/yr → 250,000 sats one-time perpetual. Differentiation rewritten: perpetual license + direct 1:1 support (not just "Pro with a badge"). - Active discount-code cap: 5 → 10 (real cap). - New "Prices shown are a snapshot" note pointing at the canonical live source (keysat.xyz#tiers + the buy page). - Updated unlicensed-caps line to show 5/5/10 with units. wire-format.html: - Replace the entirely-fabricated "KS-base32-blob with KSAT magic bytes" layout with the actual LIC1 envelope: `LIC1-<base32 payload>-<base32 signature>` split on dashes. - Document BOTH payload versions: v1 (legacy 74-byte fixed) and v2 (current default, 83-byte head + variable entitlements table). Field offsets, flag bits, signature scope all match the daemon source. - Drop the bogus Crockford-base32 + dash-grouping sections — the daemon uses RFC 4648 base32 with single-dash structure separators, not grouped-dashes for readability. - Drop the fabricated hex-dump worked example. - Porting section now points at `licensing-service/tests/crosscheck/` (the actual fixtures location) instead of a Python-SDK path. - Versioning policy: clarify envelope-tag vs payload-version cadence.
284 lines
16 KiB
HTML
284 lines
16 KiB
HTML
<!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="https://keysat.xyz" class="brand" title="Back to keysat.xyz"><img src="assets/keysat-mark.svg" alt=""><span>Keysat</span></a>
|
|
<span class="docs-tag">Docs</span>
|
|
</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 & setup</a>
|
|
<a href="integrate.html" class="active">Integrate the SDK</a>
|
|
<a href="agent.html">Agent integration</a>
|
|
</div>
|
|
<div class="group">
|
|
<div class="glabel">Concepts</div>
|
|
<a href="index.html#architecture">Architecture</a>
|
|
<a href="index.html#products-policies">Products & 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. What you do with the result — refuse to start without a license, unlock specific features, just show a "supporter" badge — is your call. The SDK is the primitive; the business model is yours.</p>
|
|
|
|
<h2 id="prereq">Prerequisites</h2>
|
|
<p>Before you start, you should have:</p>
|
|
<ul>
|
|
<li>A Keysat installation running on your Start9 — see <a href="install.html">Install & setup</a>.</li>
|
|
<li>BTCPay Server connected to Keysat — ditto.</li>
|
|
<li>At least one product defined in the admin UI.</li>
|
|
</ul>
|
|
|
|
<h2 id="sdks">Pick an SDK</h2>
|
|
<p>Four official SDKs ship today. They are wire-compatible — a license issued by your Keysat verifies identically in any of them. Cross-check fixtures in the daemon repo prove each SDK accepts the same bytes the daemon mints.</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>
|
|
<button data-lang="go">Go</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>
|
|
<pre class="code lang-pane" data-lang="go" style="display:none"><span class="c">// go.mod</span>
|
|
go get github.com/keysat-xyz/keysat-client-go
|
|
|
|
<span class="c">// stdlib only — no third-party Go dependencies</span></pre>
|
|
|
|
<p>If your language isn’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’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>: &<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’t fetch it.</strong> The whole point of offline verification is that your software can’t be tricked by a network-level attacker. If you fetch the public key at runtime, you’re back to trusting a server.</p>
|
|
</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>
|
|
|
|
<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="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>
|
|
<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>
|
|
<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>
|
|
|
|
<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<string></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’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(&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(),
|
|
<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
|
|
|
|
<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 & 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>
|
|
|
|
<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’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: &<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::<Status>().<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 — refunds happen offline via BTCPay. That’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</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>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>
|
|
<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 & 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 & 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>
|