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>
|
||||
|
||||
Reference in New Issue
Block a user