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:
+123
-23
@@ -54,7 +54,7 @@
|
||||
<p>This guide covers the <em>operator side</em> of Keysat: running, configuring, and performing day-to-day operations. For the <em>buyer side</em> (validating licenses inside your app), see <a href="integrate.html">Integrate the SDK</a>.</p>
|
||||
|
||||
<h2 id="quick-start">Quick start</h2>
|
||||
<pre><code># 1. Discover the API surface
|
||||
<pre class="code"># 1. Discover the API surface
|
||||
curl https://your-keysat-host/v1/openapi.json
|
||||
|
||||
# 2. Generate a scoped API key (admin UI: Settings → API keys, or via curl)
|
||||
@@ -66,11 +66,11 @@ curl -X POST https://your-keysat-host/v1/admin/api-keys \
|
||||
|
||||
# 3. Use the scoped key
|
||||
curl https://your-keysat-host/v1/admin/licenses?status=active \
|
||||
-H "Authorization: Bearer ks_..."</code></pre>
|
||||
-H "Authorization: Bearer ks_..."</pre>
|
||||
|
||||
<h2 id="auth">Authentication</h2>
|
||||
<p>All admin endpoints use HTTP Bearer auth:</p>
|
||||
<pre><code>Authorization: Bearer <token></code></pre>
|
||||
<pre class="code">Authorization: Bearer <token></pre>
|
||||
<p>Two kinds of tokens are accepted.</p>
|
||||
|
||||
<p><strong>Master admin API key</strong>: the env-configured <code>KEYSAT_ADMIN_API_KEY</code> (visible in StartOS Actions → Show credentials on first install). Full access to every endpoint. This is the operator's credential. Don't hand it to agents.</p>
|
||||
@@ -84,6 +84,7 @@ curl https://your-keysat-host/v1/admin/licenses?status=active \
|
||||
<tr><td><code>read-only</code></td><td>List / get every resource. Mutate nothing.</td></tr>
|
||||
<tr><td><code>license-issuer</code></td><td>All <code>read-only</code> scopes + issue / revoke / suspend / change-tier on licenses. Cannot touch products, policies, or codes.</td></tr>
|
||||
<tr><td><code>support</code></td><td>All <code>license-issuer</code> scopes + cancel subscriptions + force-deactivate machines.</td></tr>
|
||||
<tr><td><code>merchant-onboard</code></td><td>All <code>read-only</code> scopes + create / update products, policies, and licenses. The self-serve catalog role: stand up a product, define its tiers, and issue licenses without ever touching the master key. Cannot change settings, connect payment providers, or manage API keys.</td></tr>
|
||||
<tr><td><code>full-admin</code></td><td>Every scope. Equivalent to the master key for most endpoints.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -108,12 +109,12 @@ curl https://your-keysat-host/v1/admin/licenses?status=active \
|
||||
|
||||
<h2 id="envelope">Response envelope conventions</h2>
|
||||
<p>Every error response uses the same JSON envelope:</p>
|
||||
<pre><code>{
|
||||
<pre class="code">{
|
||||
"ok": false,
|
||||
"error": "tier_cap",
|
||||
"message": "Your Creator tier allows up to 5 products. You're at 5...",
|
||||
"upgrade_url": "https://licensing.keysat.xyz/buy/keysat?policy=pro"
|
||||
}</code></pre>
|
||||
}</pre>
|
||||
<p><code>error</code> is a stable machine-readable code; <code>message</code> is human-readable. The <code>upgrade_url</code> field appears on 402 (tier cap) responses so a UI can render an upgrade CTA without parsing message strings.</p>
|
||||
|
||||
<h3>Error codes</h3>
|
||||
@@ -151,62 +152,160 @@ curl https://your-keysat-host/v1/admin/licenses?status=active \
|
||||
|
||||
<h2 id="workflows">Common workflows</h2>
|
||||
|
||||
<h3>Create a product</h3>
|
||||
<pre class="code">curl -X POST $KS/v1/admin/products \
|
||||
-H "Authorization: Bearer ks_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"slug": "acme-app",
|
||||
"name": "Acme App",
|
||||
"price_currency": "SAT",
|
||||
"price_value": 50000,
|
||||
"entitlements_catalog": [
|
||||
{ "slug": "pro_export", "name": "Pro export", "description": "CSV export" }
|
||||
]
|
||||
}'</pre>
|
||||
<p><code>price_value</code> is the write field: the price in the smallest unit of
|
||||
<code>price_currency</code> (sats for <code>SAT</code>, cents for <code>USD</code> /
|
||||
<code>EUR</code>). The response also echoes a legacy <code>price_sats</code> field; it's
|
||||
still accepted on create for backward compatibility, but new callers should send
|
||||
<code>price_value</code> + <code>price_currency</code> instead. <code>entitlements_catalog</code> is the
|
||||
closed list of feature slugs your policies may grant; omit it to allow free-text
|
||||
entitlements instead.</p>
|
||||
<p><em>Scope required: <code>products:write</code>.</em></p>
|
||||
|
||||
<h3>Add a tier (policy)</h3>
|
||||
<pre class="code">curl -X POST $KS/v1/admin/policies \
|
||||
-H "Authorization: Bearer ks_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"product_slug": "acme-app",
|
||||
"name": "Lifetime Pro",
|
||||
"slug": "pro",
|
||||
"duration_seconds": 0,
|
||||
"max_machines": 0,
|
||||
"entitlements": ["pro_export"]
|
||||
}'</pre>
|
||||
<p><code>duration_seconds: 0</code> = perpetual; <code>max_machines: 0</code> = unlimited
|
||||
seats. Each entry in <code>entitlements</code> must be a slug declared in the product's
|
||||
<code>entitlements_catalog</code> (when one is set). Returns the created policy, including
|
||||
its <code>id</code>.</p>
|
||||
<p><em>Scope required: <code>policies:write</code>.</em></p>
|
||||
|
||||
<h3>Issue a comp license</h3>
|
||||
<pre><code>curl -X POST $KS/v1/admin/licenses \
|
||||
<pre class="code">curl -X POST $KS/v1/admin/licenses \
|
||||
-H "Authorization: Bearer ks_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"product_slug": "recap",
|
||||
"policy_slug": "pro",
|
||||
"buyer_email": "alice@example.com",
|
||||
"buyer_note": "Conference speaker comp"
|
||||
}'</code></pre>
|
||||
"note": "Conference speaker comp"
|
||||
}'</pre>
|
||||
<p>Returns the issued license object including <code>license_key</code>. The buyer pastes the key into their app; subsequent validate calls return <code>ok: true</code> with the policy's entitlements.</p>
|
||||
<p><em>Scope required: <code>licenses:write</code> (any role except <code>read-only</code>).</em></p>
|
||||
|
||||
<h3>Revoke a license</h3>
|
||||
<pre><code>curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \
|
||||
<pre class="code">curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \
|
||||
-H "Authorization: Bearer ks_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"reason":"customer request"}'</code></pre>
|
||||
-d '{"reason":"customer request"}'</pre>
|
||||
<p>Idempotent. The next online validate from the buyer's app returns <code>reason: revoked</code>.</p>
|
||||
<p><em>Scope required: <code>licenses:write</code>.</em></p>
|
||||
|
||||
<h3>Find a license by email</h3>
|
||||
<pre><code>curl "$KS/v1/admin/licenses?buyer_email=alice@example.com" \
|
||||
-H "Authorization: Bearer ks_..."</code></pre>
|
||||
<pre class="code">curl "$KS/v1/admin/licenses/search?buyer_email=alice@example.com" \
|
||||
-H "Authorization: Bearer ks_..."</pre>
|
||||
<p>Returns matching licenses (without the <code>license_key</code> field, which is only returned on issue / recover). Use the <code>id</code> for follow-up operations.</p>
|
||||
<p><em>Scope required: <code>licenses:read</code>.</em></p>
|
||||
|
||||
<h3>Cancel a buyer's subscription</h3>
|
||||
<pre><code># Look up the subscription id first (filter by license_id if you have it)
|
||||
<pre class="code"># Look up the subscription id first (filter by license_id if you have it)
|
||||
curl "$KS/v1/admin/subscriptions?status=active" \
|
||||
-H "Authorization: Bearer ks_..."
|
||||
|
||||
# Then cancel
|
||||
curl -X POST $KS/v1/admin/subscriptions/$SUB_ID/cancel \
|
||||
-H "Authorization: Bearer ks_..." \
|
||||
-d '{"reason":"buyer requested"}'</code></pre>
|
||||
-d '{"reason":"buyer requested"}'</pre>
|
||||
<p>License stays valid through the current cycle's <code>expires_at</code>. Renewal worker stops issuing new invoices.</p>
|
||||
<p><em>Scope required: <code>subscriptions:write</code>.</em></p>
|
||||
|
||||
<h3>Free a machine seat</h3>
|
||||
<pre><code>curl -X POST $KS/v1/admin/machines/$MACHINE_ID/deactivate \
|
||||
<pre class="code">curl -X POST $KS/v1/admin/machines/$MACHINE_ID/deactivate \
|
||||
-H "Authorization: Bearer ks_..." \
|
||||
-d '{"reason":"buyer moved devices"}'</code></pre>
|
||||
-d '{"reason":"buyer moved devices"}'</pre>
|
||||
<p>The seat opens up. The buyer's next validate from any machine takes the freed seat.</p>
|
||||
<p><em>Scope required: <code>machines:write</code>.</em></p>
|
||||
|
||||
<h3>Programmatic tier change (comp upgrade)</h3>
|
||||
<pre><code>curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/change-tier \
|
||||
<pre class="code">curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/change-tier \
|
||||
-H "Authorization: Bearer ks_..." \
|
||||
-d '{
|
||||
"target_policy_slug": "pro",
|
||||
"reason": "support resolution"
|
||||
}'</code></pre>
|
||||
}'</pre>
|
||||
<p>Always applies as comp (no invoice) from the admin path. Buyer-initiated paid upgrades go through <code>/v1/upgrade</code> (different endpoint, signed-license auth).</p>
|
||||
<p><em>Scope required: <code>licenses:write</code>.</em></p>
|
||||
|
||||
<h2 id="worked-example">Worked example: gate an app behind a license</h2>
|
||||
<p>End to end, with nothing but a <code>merchant-onboard</code> scoped key and the SDK: stand up a product, issue a license, and gate a feature in a Next.js app. No payment provider is needed for this path (you're issuing comp/dev licenses by hand; wiring BTCPay so buyers can self-checkout is covered in <a href="install.html">Install & setup</a>). This is the minimal true path, nothing more.</p>
|
||||
|
||||
<p><strong>1. On the server.</strong> Create the catalog and issue a license. Replace <code>$KS</code> with your Keysat URL and <code>ks_...</code> with your scoped key.</p>
|
||||
<pre class="code"># Fetch the issuer public key; you'll embed it in your app
|
||||
curl $KS/v1/issuer/public-key
|
||||
# -> { "public_key_pem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n" }
|
||||
|
||||
# Define the product, declaring the entitlements its tiers may grant
|
||||
curl -X POST $KS/v1/admin/products -H "Authorization: Bearer ks_..." -H "Content-Type: application/json" -d '{
|
||||
"slug": "acme-reports", "name": "Acme Reports",
|
||||
"price_currency": "SAT", "price_value": 50000,
|
||||
"entitlements_catalog": [{ "slug": "pro_export", "name": "Pro export", "description": "CSV export" }]
|
||||
}'
|
||||
|
||||
# Add a perpetual tier that grants pro_export
|
||||
curl -X POST $KS/v1/admin/policies -H "Authorization: Bearer ks_..." -H "Content-Type: application/json" -d '{
|
||||
"product_slug": "acme-reports", "name": "Lifetime Pro", "slug": "pro",
|
||||
"duration_seconds": 0, "max_machines": 0, "entitlements": ["pro_export"]
|
||||
}'
|
||||
|
||||
# Issue a comp license; the response carries the LIC1-... license_key
|
||||
curl -X POST $KS/v1/admin/licenses -H "Authorization: Bearer ks_..." -H "Content-Type: application/json" -d '{
|
||||
"product_slug": "acme-reports", "policy_slug": "pro",
|
||||
"buyer_email": "dev@example.com", "note": "Dev comp"
|
||||
}'</pre>
|
||||
|
||||
<p><strong>2. In your app.</strong> Install the SDK and verify the license offline. <code>verify()</code> returns on success and throws a <code>LicensingError</code> on any bad key; there is no <code>valid</code> boolean.</p>
|
||||
<pre class="code">npm install @keysat/licensing-client</pre>
|
||||
<pre class="code">// app/api/export/route.ts
|
||||
import { Verifier, PublicKey, LicensingError } from "@keysat/licensing-client";
|
||||
|
||||
const ISSUER_PEM = `-----BEGIN PUBLIC KEY-----
|
||||
<paste your public_key_pem here>
|
||||
-----END PUBLIC KEY-----`;
|
||||
|
||||
const verifier = new Verifier(PublicKey.fromPem(ISSUER_PEM));
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const key = request.headers.get("X-License-Key");
|
||||
if (!key) return Response.json({ error: "no_license" }, { status: 401 });
|
||||
try {
|
||||
verifier.verify(key); // throws if missing / forged / garbled
|
||||
} catch (e) {
|
||||
const code = e instanceof LicensingError ? e.code : "unknown";
|
||||
return Response.json({ error: code }, { status: 403 });
|
||||
}
|
||||
// Verified. Serve the protected resource.
|
||||
return new Response(toCsv(ROWS), { status: 200, headers: { "Content-Type": "text/csv" } });
|
||||
}</pre>
|
||||
|
||||
<p><strong>3. Confirm the gate.</strong> A valid license is accepted; absent and tampered keys are refused. <code>$APP</code> is your running app.</p>
|
||||
<pre class="code">curl -s -o /dev/null -w "%{http_code}\n" $APP/api/export # 401 (no header)
|
||||
curl -s -o /dev/null -w "%{http_code}\n" $APP/api/export -H "X-License-Key: $LICENSE_KEY" # 200 + CSV
|
||||
curl -s -o /dev/null -w "%{http_code}\n" $APP/api/export -H "X-License-Key: LIC1-TAMPERED" # 403</pre>
|
||||
|
||||
<p>That's the whole path: catalog, license, and an offline-verified gate. No network call to Keysat happens during request handling, so your app keeps working even if your Keysat instance is briefly unreachable.</p>
|
||||
|
||||
<h2 id="webhooks">Webhooks: react to events instead of polling</h2>
|
||||
<p>Configure webhook endpoints in admin UI → Webhooks. The daemon POSTs JSON payloads, HMAC-SHA256 signed with the endpoint's secret, on these events:</p>
|
||||
<table>
|
||||
@@ -225,11 +324,11 @@ curl -X POST $KS/v1/admin/subscriptions/$SUB_ID/cancel \
|
||||
</table>
|
||||
|
||||
<p>Verify signatures:</p>
|
||||
<pre><code>import hmac, hashlib
|
||||
<pre class="code">import hmac, hashlib
|
||||
|
||||
def verify(body_bytes: bytes, signature_header: str, secret: str) -> bool:
|
||||
expected = hmac.new(secret.encode(), body_bytes, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature_header)</code></pre>
|
||||
return hmac.compare_digest(expected, signature_header)</pre>
|
||||
<p>The header is <code>X-Keysat-Signature</code>. Failed deliveries retry with exponential backoff up to 10 attempts; permanently-failed deliveries land in the DLQ visible at admin UI → Webhooks → Failed.</p>
|
||||
|
||||
<h2 id="robust">Designing a robust agent</h2>
|
||||
@@ -251,7 +350,7 @@ def verify(body_bytes: bytes, signature_header: str, secret: str) -> bool:
|
||||
<p><code>internal_error</code> (500) is a bug or a transient DB lock. Retry with exponential backoff (1s, 2s, 4s, 8s, give up). Don't retry on 4xx. Those are deterministic client errors.</p>
|
||||
|
||||
<h2 id="recipe">Concrete recipe: "Comp a license to anyone who emails support@"</h2>
|
||||
<pre><code>import os, requests, imaplib, email
|
||||
<pre class="code">import os, requests, imaplib, email
|
||||
|
||||
KS = os.environ["KEYSAT_URL"]
|
||||
TOKEN = os.environ["KEYSAT_API_KEY"] # license-issuer-scoped key
|
||||
@@ -264,14 +363,14 @@ def issue_comp_license(buyer_email: str, product_slug: str, reason: str) -> str:
|
||||
"product_slug": product_slug,
|
||||
"policy_slug": "default",
|
||||
"buyer_email": buyer_email,
|
||||
"buyer_note": reason,
|
||||
"note": reason,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["license_key"]
|
||||
|
||||
# Poll IMAP, parse incoming requests, call issue_comp_license, reply with the key</code></pre>
|
||||
# Poll IMAP, parse incoming requests, call issue_comp_license, reply with the key</pre>
|
||||
<p>That's the entire pattern. The agent doesn't need full admin, just the license-issuer role. If it ever gets compromised, you revoke the scoped key in the admin UI and generate a new one in 30 seconds.</p>
|
||||
|
||||
<h2 id="not-exposed">What's NOT exposed to agents</h2>
|
||||
@@ -297,6 +396,7 @@ def issue_comp_license(buyer_email: str, product_slug: str, reason: str) -> str:
|
||||
<a href="#discovery">Discovering the API</a>
|
||||
<a href="#envelope">Response envelope</a>
|
||||
<a href="#workflows">Common workflows</a>
|
||||
<a href="#worked-example">Worked example</a>
|
||||
<a href="#webhooks">Webhooks</a>
|
||||
<a href="#robust">Designing a robust agent</a>
|
||||
<a href="#recipe">Recipe: comp-license bot</a>
|
||||
|
||||
+58
-50
@@ -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:
|
||||
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">except</span> FormatError:
|
||||
show_input_error()</pre>
|
||||
<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>
|
||||
|
||||
+4
-3
@@ -117,10 +117,11 @@ MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
|
||||
<p>This is the same encoding that <code>openssl pkey -pubout</code> produces. Keysat exposes it at <code>GET /v1/issuer/public-key</code>:</p>
|
||||
|
||||
<pre class="code">{
|
||||
<span class="s">"public_key_pem"</span>: <span class="s">"-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n"</span>,
|
||||
<span class="s">"public_key_b64"</span>: <span class="s">"mz7q8r4t1v…h3k2pXq9wL"</span>,
|
||||
<span class="s">"fingerprint_hex"</span>: <span class="s">"feed face cafe babe …"</span>
|
||||
<span class="s">"key_algorithm"</span>: <span class="s">"ed25519"</span>,
|
||||
<span class="s">"key_format_version"</span>: <span class="n">2</span>,
|
||||
<span class="s">"public_key_pem"</span>: <span class="s">"-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n"</span>
|
||||
}</pre>
|
||||
<p>The <code>public_key_pem</code> field is the one you embed in your app. Verification needs only the PEM; the SDKs parse it with <code>PublicKey.fromPem(...)</code>.</p>
|
||||
|
||||
<h2 id="porting">Porting to a new language</h2>
|
||||
<p>The wire format is small enough to port in an afternoon. The order is:</p>
|
||||
|
||||
Reference in New Issue
Block a user