Initial public commit
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
|||||||
|
# Editor / OS cruft
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# If we ever add a build step
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Keysat
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
|
|
||||||
|
<rect width="32" height="32" rx="6" fill="#F5F1E8"></rect>
|
||||||
|
<ellipse cx="16" cy="9" rx="9" ry="1.6" fill="#1E3A5F"></ellipse>
|
||||||
|
<rect x="7" y="9" width="18" height="16" fill="#FBF9F2" stroke="#1E3A5F" stroke-width="1.4"></rect>
|
||||||
|
<ellipse cx="16" cy="25" rx="9" ry="1.6" fill="#1E3A5F"></ellipse>
|
||||||
|
<circle cx="13" cy="17" r="2.6" fill="none" stroke="#BFA068" stroke-width="1.4"></circle>
|
||||||
|
<rect x="15.6" y="16.4" width="6" height="1.5" fill="#BFA068"></rect>
|
||||||
|
<rect x="20" y="17.9" width="0.9" height="1.8" fill="#BFA068"></rect>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 618 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
|
||||||
|
|
||||||
|
<ellipse cx="50" cy="22" rx="28" ry="5" fill="#1E3A5F"></ellipse>
|
||||||
|
|
||||||
|
<rect x="22" y="22" width="56" height="56" fill="#FBF9F2" stroke="#1E3A5F" stroke-width="3"></rect>
|
||||||
|
|
||||||
|
<ellipse cx="50" cy="78" rx="28" ry="5" fill="#1E3A5F"></ellipse>
|
||||||
|
|
||||||
|
<line x1="32" y1="36" x2="68" y2="36" stroke="#1E3A5F" stroke-width="1.5" stroke-linecap="round"></line>
|
||||||
|
<line x1="32" y1="44" x2="62" y2="44" stroke="#1E3A5F" stroke-width="1.5" stroke-linecap="round"></line>
|
||||||
|
|
||||||
|
<circle cx="42" cy="60" r="6" fill="none" stroke="#BFA068" stroke-width="2.5"></circle>
|
||||||
|
<rect x="48" y="58.5" width="14" height="3" fill="#BFA068"></rect>
|
||||||
|
<rect x="58" y="61.5" width="2" height="4" fill="#BFA068"></rect>
|
||||||
|
<rect x="62" y="61.5" width="2" height="3" fill="#BFA068"></rect>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 843 B |
@@ -0,0 +1,239 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Keysat docs — shared layout. Inline-only deps allowed.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--navy-950:#0E1F33; --navy-900:#142A47; --navy-800:#1E3A5F; --navy-700:#2A4A75;
|
||||||
|
--navy-100:#E4EAF1;
|
||||||
|
--cream-50:#FBF9F2; --cream-100:#F5F1E8; --cream-200:#EDE7D7;
|
||||||
|
--gold-700:#8A6F3D; --gold-500:#BFA068; --gold-400:#D4B985;
|
||||||
|
--ink-900:#0E1F33; --ink-700:#2C3E54; --ink-500:#5A6B7F; --ink-400:#7E8C9D;
|
||||||
|
--success:#2D7A5F; --success-bg:#E3F0EA;
|
||||||
|
--warning:#B8861F; --warning-bg:#F7EFD7;
|
||||||
|
--danger:#B23A3A; --danger-bg:#F4E0E0;
|
||||||
|
--border-1:rgba(14,31,51,0.12);
|
||||||
|
--border-2:rgba(14,31,51,0.20);
|
||||||
|
--font-display:'Manrope','Helvetica Neue',Arial,sans-serif;
|
||||||
|
--font-body:'Inter','Helvetica Neue',Arial,sans-serif;
|
||||||
|
--font-mono:'JetBrains Mono',ui-monospace,'SF Mono',Menlo,monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
*{box-sizing:border-box} html,body{margin:0;padding:0}
|
||||||
|
body{
|
||||||
|
font-family:var(--font-body); color:var(--ink-900); background:var(--cream-100);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(rgba(14,31,51,0.022) 1px, transparent 1px),
|
||||||
|
radial-gradient(rgba(138,111,61,0.020) 1px, transparent 1px);
|
||||||
|
background-size:3px 3px, 7px 7px;
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
|
}
|
||||||
|
a{color:var(--navy-800); text-decoration:none}
|
||||||
|
a:hover{text-decoration:underline; text-decoration-thickness:1.5px; text-underline-offset:3px}
|
||||||
|
|
||||||
|
/* Top nav */
|
||||||
|
.topnav{
|
||||||
|
position:sticky; top:0; z-index:10;
|
||||||
|
background:rgba(245,241,232,0.9); backdrop-filter:blur(10px);
|
||||||
|
border-bottom:1px solid var(--border-1);
|
||||||
|
padding:14px 28px; display:flex; align-items:center; gap:18px;
|
||||||
|
}
|
||||||
|
.topnav .brand{
|
||||||
|
display:flex; align-items:center; gap:10px;
|
||||||
|
font-family:var(--font-display); font-weight:500;
|
||||||
|
color:var(--navy-900); font-size:14px;
|
||||||
|
letter-spacing:0.28em; text-transform:uppercase;
|
||||||
|
}
|
||||||
|
.topnav .brand img{width:26px; height:26px}
|
||||||
|
.topnav .docs-tag{
|
||||||
|
font-size:11px; font-weight:700; letter-spacing:0.18em;
|
||||||
|
text-transform:uppercase; color:var(--gold-700);
|
||||||
|
padding-left:10px; border-left:1px solid var(--border-2);
|
||||||
|
}
|
||||||
|
.topnav nav{margin-left:auto; display:flex; gap:22px; font-size:13.5px; color:var(--ink-700)}
|
||||||
|
.topnav nav a{color:var(--ink-700)}
|
||||||
|
.topnav nav a:hover{color:var(--navy-900); text-decoration:none}
|
||||||
|
.topnav nav a.active{color:var(--navy-900); font-weight:600}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.layout{
|
||||||
|
display:grid; grid-template-columns:240px 1fr 220px;
|
||||||
|
max-width:1280px; margin:0 auto; gap:32px;
|
||||||
|
padding:28px 28px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside.side{
|
||||||
|
position:sticky; top:74px; align-self:start;
|
||||||
|
font-size:13.5px; max-height:calc(100vh - 90px);
|
||||||
|
overflow:auto; padding-right:8px;
|
||||||
|
}
|
||||||
|
aside.side .group{margin-bottom:18px}
|
||||||
|
aside.side .group .glabel{
|
||||||
|
font-size:10.5px; font-weight:700; letter-spacing:0.16em;
|
||||||
|
text-transform:uppercase; color:var(--gold-700);
|
||||||
|
margin:6px 8px 6px;
|
||||||
|
}
|
||||||
|
aside.side a{
|
||||||
|
display:block; padding:5px 10px; border-radius:5px;
|
||||||
|
color:var(--ink-700); line-height:1.4;
|
||||||
|
}
|
||||||
|
aside.side a:hover{background:var(--cream-200); text-decoration:none}
|
||||||
|
aside.side a.active{background:var(--navy-800); color:var(--cream-50); font-weight:600}
|
||||||
|
|
||||||
|
main.prose{min-width:0}
|
||||||
|
.prose .crumb{font-size:12px; color:var(--ink-500); margin-bottom:8px; letter-spacing:0.04em}
|
||||||
|
.prose h1{
|
||||||
|
font-family:var(--font-display); font-weight:500; font-size:38px;
|
||||||
|
letter-spacing:-0.022em; color:var(--navy-950);
|
||||||
|
margin:0 0 8px; line-height:1.1;
|
||||||
|
}
|
||||||
|
.prose .lead{
|
||||||
|
font-size:17px; line-height:1.55; color:var(--ink-700);
|
||||||
|
margin:0 0 24px; max-width:640px;
|
||||||
|
}
|
||||||
|
.prose h2{
|
||||||
|
font-family:var(--font-display); font-weight:700; font-size:24px;
|
||||||
|
letter-spacing:-0.015em; color:var(--navy-950);
|
||||||
|
margin:36px 0 12px;
|
||||||
|
padding-top:24px; border-top:1px solid var(--border-1);
|
||||||
|
}
|
||||||
|
.prose h3{
|
||||||
|
font-family:var(--font-display); font-weight:700; font-size:17px;
|
||||||
|
color:var(--navy-950); margin:22px 0 8px; letter-spacing:-0.01em;
|
||||||
|
}
|
||||||
|
.prose p{font-size:15px; line-height:1.65; color:var(--ink-700); margin:0 0 14px; max-width:680px}
|
||||||
|
.prose ul, .prose ol{padding-left:22px; margin:0 0 14px; max-width:680px}
|
||||||
|
.prose li{font-size:15px; line-height:1.65; color:var(--ink-700); margin-bottom:4px}
|
||||||
|
.prose code{
|
||||||
|
font-family:var(--font-mono); font-size:13px;
|
||||||
|
background:var(--cream-200); padding:2px 6px; border-radius:4px;
|
||||||
|
color:var(--navy-900);
|
||||||
|
}
|
||||||
|
.prose strong{color:var(--navy-950); font-weight:700}
|
||||||
|
.prose em{color:var(--ink-700)}
|
||||||
|
.prose a{color:var(--navy-800); font-weight:500}
|
||||||
|
|
||||||
|
pre.code{
|
||||||
|
background:var(--navy-950); color:var(--cream-50);
|
||||||
|
padding:18px 22px; border-radius:10px;
|
||||||
|
overflow-x:auto;
|
||||||
|
font-family:var(--font-mono); font-size:13px; line-height:1.7;
|
||||||
|
margin:14px 0 20px; border:1px solid var(--navy-900);
|
||||||
|
max-width:680px;
|
||||||
|
}
|
||||||
|
pre.code .c{color:rgba(245,241,232,0.45)}
|
||||||
|
pre.code .k{color:var(--gold-400)}
|
||||||
|
pre.code .s{color:#d4b985}
|
||||||
|
pre.code .f{color:var(--cream-50)}
|
||||||
|
pre.code .n{color:#a6b7cf}
|
||||||
|
pre.code .p{color:rgba(245,241,232,0.55)}
|
||||||
|
|
||||||
|
.callout{
|
||||||
|
border:1px solid var(--border-1); border-left:3px solid var(--gold-500);
|
||||||
|
background:var(--cream-50); border-radius:8px;
|
||||||
|
padding:14px 16px; margin:14px 0 22px;
|
||||||
|
display:flex; gap:12px; align-items:flex-start;
|
||||||
|
max-width:680px;
|
||||||
|
}
|
||||||
|
.callout [data-lucide]{
|
||||||
|
color:var(--gold-700); width:18px; height:18px;
|
||||||
|
flex-shrink:0; margin-top:2px;
|
||||||
|
}
|
||||||
|
.callout p{margin:0; font-size:14px}
|
||||||
|
.callout strong{color:var(--navy-950); font-weight:700}
|
||||||
|
.callout.warn{border-left-color:var(--warning); background:var(--warning-bg)}
|
||||||
|
.callout.warn [data-lucide]{color:var(--warning)}
|
||||||
|
.callout.danger{border-left-color:var(--danger); background:var(--danger-bg)}
|
||||||
|
.callout.danger [data-lucide]{color:var(--danger)}
|
||||||
|
|
||||||
|
/* Right TOC */
|
||||||
|
aside.toc{
|
||||||
|
position:sticky; top:74px; align-self:start;
|
||||||
|
font-size:12.5px; border-left:1px solid var(--border-1);
|
||||||
|
padding:8px 0 8px 18px;
|
||||||
|
max-height:calc(100vh - 90px); overflow:auto;
|
||||||
|
}
|
||||||
|
aside.toc .label{
|
||||||
|
font-size:10.5px; font-weight:700; letter-spacing:0.16em;
|
||||||
|
text-transform:uppercase; color:var(--gold-700); margin-bottom:10px;
|
||||||
|
}
|
||||||
|
aside.toc a{
|
||||||
|
display:block; padding:4px 0; color:var(--ink-500); line-height:1.4;
|
||||||
|
}
|
||||||
|
aside.toc a:hover{color:var(--navy-900); text-decoration:none}
|
||||||
|
aside.toc a.active{
|
||||||
|
color:var(--navy-900); font-weight:600;
|
||||||
|
border-left:2px solid var(--gold-500);
|
||||||
|
margin-left:-20px; padding-left:18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables in prose */
|
||||||
|
table.t{
|
||||||
|
width:100%; border-collapse:separate; border-spacing:0;
|
||||||
|
background:var(--cream-50); border:1px solid var(--border-1);
|
||||||
|
border-radius:10px; overflow:hidden; margin:14px 0 20px; max-width:680px;
|
||||||
|
}
|
||||||
|
table.t th{
|
||||||
|
text-align:left; font-size:11px; font-weight:700;
|
||||||
|
letter-spacing:0.12em; text-transform:uppercase; color:var(--ink-500);
|
||||||
|
padding:12px 16px; background:var(--cream-100);
|
||||||
|
border-bottom:1px solid var(--border-1);
|
||||||
|
}
|
||||||
|
table.t td{
|
||||||
|
padding:12px 16px; border-bottom:1px solid var(--border-1);
|
||||||
|
font-size:13.5px; color:var(--ink-700); vertical-align:top;
|
||||||
|
}
|
||||||
|
table.t tr:last-child td{border-bottom:0}
|
||||||
|
table.t code{font-size:12.5px}
|
||||||
|
|
||||||
|
/* Cards inside prose for "what's next" links */
|
||||||
|
.next-grid{
|
||||||
|
display:grid; grid-template-columns:1fr 1fr; gap:16px;
|
||||||
|
margin:18px 0 24px; max-width:680px;
|
||||||
|
}
|
||||||
|
.next-card{
|
||||||
|
display:block; background:var(--cream-50);
|
||||||
|
border:1px solid var(--border-1); border-radius:10px;
|
||||||
|
padding:18px; transition:all 120ms;
|
||||||
|
}
|
||||||
|
.next-card:hover{
|
||||||
|
border-color:var(--border-2); text-decoration:none;
|
||||||
|
background:var(--cream-100);
|
||||||
|
}
|
||||||
|
.next-card .eyebrow{
|
||||||
|
font-size:10.5px; font-weight:700; letter-spacing:0.18em;
|
||||||
|
text-transform:uppercase; color:var(--gold-700);
|
||||||
|
margin-bottom:6px; display:block;
|
||||||
|
}
|
||||||
|
.next-card h4{
|
||||||
|
font-family:var(--font-display); font-weight:700; font-size:15px;
|
||||||
|
margin:0 0 4px; letter-spacing:-0.01em; color:var(--navy-950);
|
||||||
|
}
|
||||||
|
.next-card p{
|
||||||
|
font-size:13px; color:var(--ink-700); line-height:1.5;
|
||||||
|
margin:0; max-width:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code-language tabs (for SDK examples) */
|
||||||
|
.lang-tabs{
|
||||||
|
display:flex; gap:0; margin:14px 0 0; max-width:680px;
|
||||||
|
border-bottom:1px solid var(--border-1);
|
||||||
|
}
|
||||||
|
.lang-tabs button{
|
||||||
|
background:transparent; border:0; cursor:pointer;
|
||||||
|
font-family:var(--font-body); font-weight:500; font-size:13px;
|
||||||
|
color:var(--ink-500); padding:10px 16px;
|
||||||
|
border-bottom:2px solid transparent;
|
||||||
|
margin-bottom:-1px;
|
||||||
|
}
|
||||||
|
.lang-tabs button:hover{color:var(--navy-900)}
|
||||||
|
.lang-tabs button.active{color:var(--navy-900); font-weight:600; border-bottom-color:var(--gold-500)}
|
||||||
|
|
||||||
|
@media (max-width: 980px){
|
||||||
|
.layout{grid-template-columns:1fr; gap:18px; padding:18px}
|
||||||
|
aside.side, aside.toc{position:static; max-height:none; overflow:visible; border:0; padding:0}
|
||||||
|
aside.toc{display:none}
|
||||||
|
.topnav nav{display:none}
|
||||||
|
.next-grid{grid-template-columns:1fr}
|
||||||
|
}
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Keysat Docs — Introduction</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">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" class="active">Introduction</a>
|
||||||
|
<a href="install.html">Install & setup</a>
|
||||||
|
<a href="integrate.html">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 & 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 · Introduction</div>
|
||||||
|
<h1>Welcome to Keysat.</h1>
|
||||||
|
<p class="lead">Keysat is a self-hosted licensing service for software creators who want to be paid in Bitcoin. Buyers pay through your own BTCPay; your software verifies signed keys offline. You own the signing key, the customer list, and the payment rails.</p>
|
||||||
|
|
||||||
|
<p>These docs cover both ends:</p>
|
||||||
|
|
||||||
|
<div class="next-grid">
|
||||||
|
<a class="next-card" href="install.html">
|
||||||
|
<span class="eyebrow">Operator</span>
|
||||||
|
<h4>Install & setup →</h4>
|
||||||
|
<p>Sideload the package, connect BTCPay, define your first product. About an afternoon, end to end.</p>
|
||||||
|
</a>
|
||||||
|
<a class="next-card" href="integrate.html">
|
||||||
|
<span class="eyebrow">Developer</span>
|
||||||
|
<h4>Integrate the SDK →</h4>
|
||||||
|
<p>Add the SDK to your app, embed your public key, verify a license at startup. About five lines of code.</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="architecture">Architecture</h2>
|
||||||
|
<p>Keysat is the licensing layer sitting on top of your existing payments stack. Three boxes:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>BTCPay Server</strong> — takes the payment. On-chain Bitcoin or Lightning, settling to your wallet. Lives on your Start9.</li>
|
||||||
|
<li><strong>Keysat</strong> — your private licensing service. Holds the Ed25519 signing key. Hosts the public purchase URLs at <code>/buy/<product></code>. Listens for BTCPay payment webhooks and issues a signed license on each settlement. Lives on your Start9.</li>
|
||||||
|
<li><strong>Your software</strong> — the thing you sell. Ships with the Keysat <em>public</em> key embedded at compile time. On startup it reads the user’s license and verifies the signature offline. No network call.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>The key word is <em>offline</em>. Once a license is issued, your software does not need to phone home to verify it. The verification is a pure function of the license bytes and the public key. This is the same model used by signed JWTs, except wrapped in a small fixed-width format that’s comfortable to print on a receipt.</p>
|
||||||
|
|
||||||
|
<div class="callout">
|
||||||
|
<i data-lucide="info"></i>
|
||||||
|
<p><strong>Why offline matters.</strong> Online license servers are a single point of failure for every customer who ever bought your software. With Keysat, if your Start9 disappears tomorrow, every previously-issued license still verifies. That’s sovereignty.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="products-policies">Products & policies</h2>
|
||||||
|
<p>You declare two things in Keysat: products and policies.</p>
|
||||||
|
<p>A <strong>product</strong> is the thing you sell — "Bitcoin Ticker Pro", "Aurora Plugin", whatever. It has a slug, a display name, a description, and a price in sats.</p>
|
||||||
|
<p>A <strong>policy</strong> is a license template attached to a product. It specifies:</p>
|
||||||
|
|
||||||
|
<table class="t">
|
||||||
|
<thead><tr><th>Field</th><th>Meaning</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>duration_seconds</code></td><td>How long the license is valid. <code>0</code> means perpetual.</td></tr>
|
||||||
|
<tr><td><code>grace_seconds</code></td><td>Extra time after expiry before the verifier rejects.</td></tr>
|
||||||
|
<tr><td><code>max_machines</code></td><td>Seat cap. <code>0</code> means unlimited.</td></tr>
|
||||||
|
<tr><td><code>is_trial</code></td><td>Sets a <code>TRIAL</code> bit so your app can show a "trial" banner.</td></tr>
|
||||||
|
<tr><td><code>entitlements</code></td><td>Free-form list of feature flags baked into the signed key (e.g. <code>core</code>, <code>sync</code>, <code>export</code>).</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>Each product has one policy slugged <code>default</code> — that’s the one consumed by the public purchase URL. You can attach additional named policies for manual issuance: a longer-duration "Lifetime" policy you hand out at conferences, a richer-entitlement "Pro" policy for upsells, etc.</p>
|
||||||
|
|
||||||
|
<h2 id="discounts">Discount codes</h2>
|
||||||
|
<p>Three kinds:</p>
|
||||||
|
|
||||||
|
<table class="t">
|
||||||
|
<thead><tr><th>Kind</th><th>What it does</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>percent</code></td><td>Buyer appends <code>?code=FOUNDERS50</code> to the purchase URL; price drops by N%.</td></tr>
|
||||||
|
<tr><td><code>fixed_sats</code></td><td>Like above, but a flat sat amount comes off.</td></tr>
|
||||||
|
<tr><td><code>free_license</code></td><td>No payment at all. Buyer redeems the code via <code>POST /v1/redeem</code> and gets a signed license back.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>Codes can be capped at N uses, dated to expire, restricted to a single product, and tagged with a referrer label so you can see which campaign drove which sales in the audit log.</p>
|
||||||
|
|
||||||
|
<h2 id="revocation">Revocation strategy</h2>
|
||||||
|
<p>This is the one piece of the architecture that requires a design decision from you.</p>
|
||||||
|
<p>Because verification is offline, a license that was once issued continues to verify forever — even if you mark it as revoked in the admin UI. The verifier in your app doesn’t know about your admin actions.</p>
|
||||||
|
<p>You have three options:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Don’t support revocation at all.</strong> Many indie developers do this. Once a key is sold, it stays valid. Refunds are still possible — you send sats back via BTCPay; the key still works but the customer agreed to stop using it.</li>
|
||||||
|
<li><strong>Periodic online check.</strong> Your app fetches a small revocation list from your Keysat (or a CDN you point at it) once a week / month. Adds a "soft-online" requirement.</li>
|
||||||
|
<li><strong>Short-lived licenses with renewal.</strong> Issue 30-day licenses; the app fetches a fresh signed token before expiry. v0.2 will ship recurring renewals as a first-class flow.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="callout">
|
||||||
|
<i data-lucide="key-round"></i>
|
||||||
|
<p><strong>You decide the policy.</strong> Keysat doesn’t force a particular revocation model. The default is no revocation — that’s the simplest, sovereign-by-default choice. If you need stronger guarantees, layer them on with the patterns above.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="next">Where to next</h2>
|
||||||
|
<div class="next-grid">
|
||||||
|
<a class="next-card" href="install.html">
|
||||||
|
<span class="eyebrow">Step 1 for operators</span>
|
||||||
|
<h4>Install & setup →</h4>
|
||||||
|
<p>Get Keysat running on your Start9, connect BTCPay, define your first product.</p>
|
||||||
|
</a>
|
||||||
|
<a class="next-card" href="integrate.html">
|
||||||
|
<span class="eyebrow">Step 1 for integrators</span>
|
||||||
|
<h4>Integrate the SDK →</h4>
|
||||||
|
<p>Embed your public key, add the SDK to your app, verify a license offline.</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="toc">
|
||||||
|
<div class="label">On this page</div>
|
||||||
|
<a href="#architecture">Architecture</a>
|
||||||
|
<a href="#products-policies">Products & policies</a>
|
||||||
|
<a href="#discounts">Discount codes</a>
|
||||||
|
<a href="#revocation">Revocation strategy</a>
|
||||||
|
<a href="#next">Where to next</a>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
|
<script>lucide.createIcons();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Keysat Docs — Install & setup</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" class="active">Install</a>
|
||||||
|
<a href="integrate.html">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" class="active">Install & setup</a>
|
||||||
|
<a href="integrate.html">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 & 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 · Install & setup</div>
|
||||||
|
<h1>Install & setup.</h1>
|
||||||
|
<p class="lead">From bare Start9 to your first issued license, in roughly the order you’ll do it. Allow about an afternoon.</p>
|
||||||
|
|
||||||
|
<h2 id="prereq">Prerequisites</h2>
|
||||||
|
<ul>
|
||||||
|
<li>A Start9 server running StartOS 0.4.x (Server Pro, Server One, or DIY install).</li>
|
||||||
|
<li>Administrative access to the StartOS dashboard.</li>
|
||||||
|
<li>About 2 GB of free disk for Keysat itself; BTCPay’s requirements are larger and depend on your Bitcoin node mode.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="install">Step 1 — Install Keysat</h2>
|
||||||
|
<p>Two ways. Either gets you to the same place.</p>
|
||||||
|
|
||||||
|
<h3>Option A: from the Keysat marketplace (recommended)</h3>
|
||||||
|
<ol>
|
||||||
|
<li>In your StartOS dashboard, go to <strong>Marketplace → Add</strong>.</li>
|
||||||
|
<li>Paste <code>https://registry.keysat.xyz</code> as the URL.</li>
|
||||||
|
<li>Find <em>Keysat</em> in the marketplace listing and click <strong>Install</strong>.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Option B: sideload</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Download <code>keysat_x86_64.s9pk</code> from the <a href="https://github.com/keysat-xyz/keysat/releases">GitHub releases page</a>.</li>
|
||||||
|
<li>In your StartOS dashboard, go to <strong>Sideload</strong> and drag the file in.</li>
|
||||||
|
<li>Click <strong>Install</strong>.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p>BTCPay Server is declared as a required dependency. If you don’t have it installed yet, StartOS will prompt you to install it as part of the same flow.</p>
|
||||||
|
|
||||||
|
<h2 id="operator-name">Step 2 — Set your operator name</h2>
|
||||||
|
<p>Open the Keysat service page in StartOS. Go to <strong>Actions → Set operator name</strong>. Pick a short label that identifies <em>you</em> as the seller — e.g. "aurora-software", "northpath", "my-name". This shows up on the public purchase pages and in the audit log.</p>
|
||||||
|
<p>This change is live-reloaded; you don’t need to restart the service.</p>
|
||||||
|
|
||||||
|
<h2 id="connect-btcpay">Step 3 — Connect BTCPay</h2>
|
||||||
|
<p>Make sure BTCPay Server is running and has at least one <strong>store</strong> with a configured <strong>payment method</strong> (on-chain wallet or Lightning node). Without a payment method, BTCPay will reject Keysat’s invoice creation.</p>
|
||||||
|
|
||||||
|
<p>In Keysat’s service page, click <strong>Actions → Connect BTCPay</strong>. You’ll be redirected to BTCPay’s authorize page, where you grant Keysat the permissions it needs:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><code>btcpay.store.canviewinvoices</code></li>
|
||||||
|
<li><code>btcpay.store.cancreateinvoice</code></li>
|
||||||
|
<li><code>btcpay.store.canmodifywebhooks</code></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Once you confirm, BTCPay redirects back to Keysat with an API key and store id. Keysat:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Stores the API key and store id in its local SQLite (encrypted at rest by StartOS).</li>
|
||||||
|
<li>Registers an outbound webhook subscription on the store, pointed at Keysat’s <code>/btcpay</code> webhook endpoint.</li>
|
||||||
|
<li>Verifies the connection by fetching the store’s payment-method list.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="callout">
|
||||||
|
<i data-lucide="info"></i>
|
||||||
|
<p><strong>Connect is idempotent.</strong> If you click it again later, Keysat detects the existing connection and returns success without re-authorizing. To force a re-authorize, run the <strong>Disconnect BTCPay</strong> action first.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Click <strong>Actions → Check BTCPay connection</strong> to verify the wiring. It should report:</p>
|
||||||
|
|
||||||
|
<pre class="code"><span class="c"># Expected output:</span>
|
||||||
|
status: <span class="s">connected</span>
|
||||||
|
store: <span class="s">YOUR_STORE_ID</span>
|
||||||
|
webhook: <span class="s">registered</span>
|
||||||
|
payment_methods: <span class="s">[BTC-OnChain, BTC-LightningNetwork]</span></pre>
|
||||||
|
|
||||||
|
<p>If <code>payment_methods</code> is empty, head back to BTCPay and configure at least one before continuing.</p>
|
||||||
|
|
||||||
|
<h2 id="admin-key">Step 4 — Get your admin API key</h2>
|
||||||
|
<p>Go to <strong>Actions → Show admin API key</strong>. This reveals the 64-hex-character key that gates all <code>/v1/admin/*</code> endpoints, including the admin UI.</p>
|
||||||
|
|
||||||
|
<div class="callout warn">
|
||||||
|
<i data-lucide="alert-triangle"></i>
|
||||||
|
<p><strong>Treat this key like a password.</strong> Anyone with it can issue, revoke, or read every license you’ve ever sold. Don’t paste it into Slack. Don’t check it into Git.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="admin-ui">Step 5 — Open the admin UI</h2>
|
||||||
|
<p>Click the <strong>Launch UI</strong> button on Keysat’s service page. (StartOS surfaces this for any service that defines a <code>type: 'ui'</code> interface.) Paste the admin key from the previous step into the sign-in form.</p>
|
||||||
|
|
||||||
|
<p>From here on, you mostly work in the admin UI. The StartOS Actions tab is reserved for setup-only operations (operator name, BTCPay connect/disconnect/check, show admin key).</p>
|
||||||
|
|
||||||
|
<h2 id="first-product">Step 6 — Define your first product</h2>
|
||||||
|
<p>In the admin UI, go to <strong>Products → Create a new product</strong> and fill in:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Slug</strong> — lowercase, hyphens, will appear in the public URL. e.g. <code>bitcoin-ticker-pro</code>.</li>
|
||||||
|
<li><strong>Display name</strong> — shown on the buyer’s purchase page and on receipts.</li>
|
||||||
|
<li><strong>Description</strong> — one or two sentences; rendered as plain text.</li>
|
||||||
|
<li><strong>Price (sats)</strong> — an integer. e.g. <code>50000</code> for ~$30 USD at current rates.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>The product is created with no policies attached. Next:</p>
|
||||||
|
|
||||||
|
<h2 id="first-policy">Step 7 — Define a default policy</h2>
|
||||||
|
<p>Go to <strong>Policies → Create a new policy</strong>. Pick the product, then:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Set <strong>slug</strong> to <code>default</code>. This is the policy consumed by the public purchase flow; other slugs are reserved for manual issuance.</li>
|
||||||
|
<li>Set <strong>duration_seconds</strong>. Common choices: <code>0</code> (perpetual), <code>31536000</code> (1 year), <code>2592000</code> (30 days for trials).</li>
|
||||||
|
<li>Set <strong>max_machines</strong>. Use <code>1</code> for single-seat licenses or <code>0</code> for unlimited.</li>
|
||||||
|
<li>Optionally add <strong>entitlements</strong> — comma-separated feature flags. These are baked into the signed key and your app reads them at verify time.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="purchase-url">Step 8 — Share your purchase URL</h2>
|
||||||
|
<p>Your public purchase URL is now live at:</p>
|
||||||
|
|
||||||
|
<pre class="code">https://<your-keysat-host>/buy/<product-slug></pre>
|
||||||
|
|
||||||
|
<p>Buyers hit it, see your product, click "Pay", and BTCPay’s checkout takes over. On payment confirmation, Keysat receives a webhook from BTCPay, signs a license, and emails it to the buyer (if they entered an email) and shows it on the receipt page.</p>
|
||||||
|
|
||||||
|
<p>Test it end-to-end by creating a free-license discount code and redeeming it — the same code path runs, just without the payment leg.</p>
|
||||||
|
|
||||||
|
<h2 id="next">What’s next</h2>
|
||||||
|
<div class="next-grid">
|
||||||
|
<a class="next-card" href="integrate.html">
|
||||||
|
<span class="eyebrow">Hook it up</span>
|
||||||
|
<h4>Integrate the SDK →</h4>
|
||||||
|
<p>Embed your public key in your app and verify licenses offline.</p>
|
||||||
|
</a>
|
||||||
|
<a class="next-card" href="operate.html">
|
||||||
|
<span class="eyebrow">Run it</span>
|
||||||
|
<h4>Operate →</h4>
|
||||||
|
<p>Backups, migration to new hardware, and troubleshooting.</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="toc">
|
||||||
|
<div class="label">On this page</div>
|
||||||
|
<a href="#prereq">Prerequisites</a>
|
||||||
|
<a href="#install">1. Install Keysat</a>
|
||||||
|
<a href="#operator-name">2. Set operator name</a>
|
||||||
|
<a href="#connect-btcpay">3. Connect BTCPay</a>
|
||||||
|
<a href="#admin-key">4. Get admin key</a>
|
||||||
|
<a href="#admin-ui">5. Open the admin UI</a>
|
||||||
|
<a href="#first-product">6. First product</a>
|
||||||
|
<a href="#first-policy">7. Default policy</a>
|
||||||
|
<a href="#purchase-url">8. Purchase URL</a>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
|
<script>lucide.createIcons();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+283
@@ -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 & 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 & 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 — 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>Three official SDKs ship today. They are wire-compatible — 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’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="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(&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<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/search</code></td><td>Find licenses by email, npub, or invoice.</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>
|
||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Keysat Docs — Operate</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">Integrate</a>
|
||||||
|
<a href="wire-format.html">Wire format</a>
|
||||||
|
<a href="operate.html" class="active">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 & setup</a>
|
||||||
|
<a href="integrate.html">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 & 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" class="active">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">Operate · Day-to-day</div>
|
||||||
|
<h1>Operate.</h1>
|
||||||
|
<p class="lead">Backups, migration, recovery, and the things that go wrong. The "you didn’t expect to need this page until you needed it" page.</p>
|
||||||
|
|
||||||
|
<h2 id="backups">Backups</h2>
|
||||||
|
<p>StartOS handles backups for you. By default, every service in your StartOS install is included in the same backup snapshot — you set the destination once (encrypted external drive, S3-compatible cloud, etc.) and StartOS schedules nightly snapshots.</p>
|
||||||
|
|
||||||
|
<p>The Keysat backup payload is intentionally tiny. It contains:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>The signing keypair (<code>/data/issuer-key.pem</code>).</li>
|
||||||
|
<li>The SQLite database (<code>/data/keysat.db</code>).</li>
|
||||||
|
<li>Migration history.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>That’s it. No log files (those rotate locally), no caches.</p>
|
||||||
|
|
||||||
|
<div class="callout warn">
|
||||||
|
<i data-lucide="alert-triangle"></i>
|
||||||
|
<p><strong>Verify your backup destination at least once.</strong> Restoring on a fresh Start9 with a corrupted backup is exactly the wrong moment to discover that your destination wasn’t actually configured. StartOS → Settings → Backups → Test.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Your BTCPay store data and your Bitcoin wallet are separate backups, handled by the BTCPay and Bitcoin Core packages respectively. Keep them on the same backup destination so they restore in lockstep.</p>
|
||||||
|
|
||||||
|
<h2 id="migrate">Migrating to new hardware</h2>
|
||||||
|
<p>The full migration path:</p>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>On the old Start9, ensure your most recent backup is complete and includes Keysat. Confirm the destination is writable and that snapshots have finished.</li>
|
||||||
|
<li>On the new Start9, complete first-time setup with a fresh password. Don’t install any services yet.</li>
|
||||||
|
<li>StartOS → Settings → Backups → Restore. Point at the same destination. Pick the most recent snapshot.</li>
|
||||||
|
<li>StartOS restores all services in dependency order. Keysat will restore alongside BTCPay and Bitcoin Core. Bitcoin will need to re-sync if you’re using Bitcoin Core (consider <a href="https://utxo.live">utxo.live</a> for assumeutxo to skip IBD).</li>
|
||||||
|
<li>Once Keysat is running on the new box, your purchase URLs change — the LAN/Tor hostnames are different. Update any links you’ve published.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p>The signing keypair restores along with the database, so all previously-issued licenses verify identically against the same public key. You don’t need to re-distribute the public key to your customers.</p>
|
||||||
|
|
||||||
|
<h2 id="signing-key">Rotating the signing key</h2>
|
||||||
|
<p>You generally don’t want to rotate the signing key — doing so invalidates every license you’ve ever issued. v0.1 doesn’t support rotation; the key is generated once at first start and never changed.</p>
|
||||||
|
|
||||||
|
<p>If you absolutely need to rotate (e.g. you suspect the keypair has leaked off the box):</p>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>Stop Keysat.</li>
|
||||||
|
<li>Move <code>/data/issuer-key.pem</code> aside.</li>
|
||||||
|
<li>Restart Keysat — it will generate a fresh keypair on first run.</li>
|
||||||
|
<li>Re-issue all active licenses to existing customers using the new key. The admin UI doesn’t support bulk re-issuance yet; this is a manual SQL exercise.</li>
|
||||||
|
<li>Push a software update that swaps the embedded public key.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p>The cleaner path, for v0.2 onward, will be to support a rolling rotation where both keys verify for a transition period.</p>
|
||||||
|
|
||||||
|
<h2 id="troubleshooting">Troubleshooting</h2>
|
||||||
|
|
||||||
|
<h3 id="t-btcpay-url">"Invalid BTCPay URL" when clicking Connect BTCPay</h3>
|
||||||
|
<p>Keysat is selecting a BTCPay URL that isn’t reachable from your browser. This usually means the picked URL is a StartTunnel-local <code>10.59.x.x</code> address rather than your LAN/mDNS one.</p>
|
||||||
|
<p>Fix: open BTCPay’s service page in a separate tab, copy the URL it shows under "Network — mDNS" or "Network — LAN", and confirm Keysat is using a similar shape. If you’re on Tor only, BTCPay needs to expose its admin UI over Tor too.</p>
|
||||||
|
|
||||||
|
<h3 id="t-payment-method">"Payment method unavailable" on first invoice creation</h3>
|
||||||
|
<p>BTCPay rejects the invoice request because the store has no configured wallet. Open BTCPay, find your store, and configure either an on-chain wallet or a Lightning node before retrying.</p>
|
||||||
|
|
||||||
|
<h3 id="t-webhook">Webhook deliveries failing</h3>
|
||||||
|
<p>Check the audit log in the admin UI — failed deliveries land there with the response status. Common causes:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Endpoint URL no longer reachable. Hit it manually with <code>curl</code> from your laptop to confirm.</li>
|
||||||
|
<li>Endpoint rejecting on signature mismatch. Verify your endpoint is HMAC-validating against the secret you registered with.</li>
|
||||||
|
<li>Endpoint timing out. Endpoints have a 10s deadline. Move slow work behind a queue on your end.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 id="t-db-locked">"database is locked" errors in logs</h3>
|
||||||
|
<p>Almost always a sign that two daemon instances are racing on the same SQLite file — usually because of a misconfigured supervisor. Confirm only one Keysat container is running. If you’re seeing this on a fresh install with no customizations, file a bug report against the package version you’re running.</p>
|
||||||
|
|
||||||
|
<h3 id="t-time-skew">Licenses verifying as "expired" immediately after issue</h3>
|
||||||
|
<p>Clock skew. Either the issuing host or the verifying host has the wrong time. Run NTP. StartOS keeps your Start9 in sync automatically; the issue is usually on the verifier side (e.g. an air-gapped buyer machine).</p>
|
||||||
|
|
||||||
|
<h2 id="logs">Reading the logs</h2>
|
||||||
|
<p>Keysat logs to stdout, captured by StartOS. Tail them from the StartOS dashboard — Service page → Logs → Live tail.</p>
|
||||||
|
|
||||||
|
<p>Useful log lines to grep for:</p>
|
||||||
|
|
||||||
|
<table class="t">
|
||||||
|
<thead><tr><th>Pattern</th><th>What it means</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>license issued</code></td><td>Successful license issuance. Includes license id and product.</td></tr>
|
||||||
|
<tr><td><code>btcpay webhook received</code></td><td>BTCPay delivered an event. Followed by a "settled" or "expired" disposition line.</td></tr>
|
||||||
|
<tr><td><code>auth failed</code></td><td>Bad admin API key on a request to <code>/v1/admin/*</code>.</td></tr>
|
||||||
|
<tr><td><code>signature mismatch</code></td><td>BTCPay webhook arrived with the wrong HMAC. Either misconfigured or actively malicious.</td></tr>
|
||||||
|
<tr><td><code>migration applied</code></td><td>A schema migration ran on startup. Normal during package updates.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 id="getting-help">Getting help</h2>
|
||||||
|
<p>If you’re stuck:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>File an issue at <a href="https://github.com/keysat-xyz/keysat/issues">github.com/keysat-xyz/keysat/issues</a>. Include the package version (visible in the StartOS service page) and any relevant log lines.</li>
|
||||||
|
<li>Email <a href="mailto:licensing@keysat.xyz">licensing@keysat.xyz</a> for security-sensitive issues you don’t want to disclose publicly.</li>
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="toc">
|
||||||
|
<div class="label">On this page</div>
|
||||||
|
<a href="#backups">Backups</a>
|
||||||
|
<a href="#migrate">Migrate hardware</a>
|
||||||
|
<a href="#signing-key">Rotate signing key</a>
|
||||||
|
<a href="#troubleshooting">Troubleshooting</a>
|
||||||
|
<a href="#logs">Reading the logs</a>
|
||||||
|
<a href="#getting-help">Getting help</a>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
|
<script>lucide.createIcons();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Keysat Docs — Wire format reference</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">Integrate</a>
|
||||||
|
<a href="wire-format.html" class="active">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 & setup</a>
|
||||||
|
<a href="integrate.html">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 & 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" class="active">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">Reference · Wire format</div>
|
||||||
|
<h1>Wire format reference.</h1>
|
||||||
|
<p class="lead">The bytes-over-the-wire spec for a Keysat license. Stable across SDKs and across language ports. About 90 lines of pseudocode to implement in a new language.</p>
|
||||||
|
|
||||||
|
<h2 id="overview">Overview</h2>
|
||||||
|
<p>A Keysat license key looks like this on a receipt:</p>
|
||||||
|
|
||||||
|
<pre class="code">KS-9F2A-7C41-XK22-6D8E-LM77-PQ91</pre>
|
||||||
|
|
||||||
|
<p>Strip the <code>KS-</code> prefix and the dashes, and you have a Crockford base32-encoded blob. Base32-decode that blob, and you get the binary <em>license envelope</em>: a fixed-layout struct followed by an Ed25519 signature.</p>
|
||||||
|
|
||||||
|
<h2 id="layout">Binary layout</h2>
|
||||||
|
<p>All multi-byte integers are big-endian.</p>
|
||||||
|
|
||||||
|
<table class="t">
|
||||||
|
<thead><tr><th>Offset</th><th>Length</th><th>Field</th><th>Notes</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>0</code></td><td>4</td><td>Magic</td><td>ASCII <code>KSAT</code> (0x4B 0x53 0x41 0x54).</td></tr>
|
||||||
|
<tr><td><code>4</code></td><td>1</td><td>Version</td><td>Currently <code>0x01</code>. Decoders MUST reject unknown versions.</td></tr>
|
||||||
|
<tr><td><code>5</code></td><td>1</td><td>Flags</td><td>Bit 0: <code>TRIAL</code>. Bit 1: <code>PERPETUAL</code>. Bits 2–7 reserved.</td></tr>
|
||||||
|
<tr><td><code>6</code></td><td>16</td><td>License ID</td><td>UUIDv4 binary form.</td></tr>
|
||||||
|
<tr><td><code>22</code></td><td>16</td><td>Issuer fingerprint</td><td>SHA-256 of the issuer public key, truncated to 16 bytes.</td></tr>
|
||||||
|
<tr><td><code>38</code></td><td>8</td><td>Issued-at</td><td>Unix seconds, signed.</td></tr>
|
||||||
|
<tr><td><code>46</code></td><td>8</td><td>Expires-at</td><td>Unix seconds, signed. <code>0</code> if <code>PERPETUAL</code> flag is set.</td></tr>
|
||||||
|
<tr><td><code>54</code></td><td>2</td><td>Seats</td><td>Max machines. <code>0</code> = unlimited.</td></tr>
|
||||||
|
<tr><td><code>56</code></td><td>2</td><td>Payload length</td><td>Length <code>L</code> of the variable-size payload that follows.</td></tr>
|
||||||
|
<tr><td><code>58</code></td><td><code>L</code></td><td>Payload</td><td>UTF-8 JSON: <code>{ "product": "...", "policy": "...", "entitlements": [...] }</code>.</td></tr>
|
||||||
|
<tr><td><code>58 + L</code></td><td>64</td><td>Signature</td><td>Ed25519 signature over bytes <code>0 .. (58 + L)</code>.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 id="encoding">Crockford base32</h2>
|
||||||
|
<p>Keysat uses <a href="https://www.crockford.com/base32.html">Crockford’s base32 alphabet</a> (<code>0123456789ABCDEFGHJKMNPQRSTVWXYZ</code>) without checksum, without padding, and case-insensitive on decode.</p>
|
||||||
|
|
||||||
|
<p>The reason for Crockford over standard base32: human-friendly. <code>I</code>, <code>L</code>, <code>O</code>, <code>U</code> are excluded from the alphabet to avoid ambiguity when typing keys off a printed receipt.</p>
|
||||||
|
|
||||||
|
<h2 id="grouping">Dash grouping & prefix</h2>
|
||||||
|
<p>For display, keys are upper-cased, then grouped into 4-character chunks separated by dashes, and prefixed with <code>KS-</code>:</p>
|
||||||
|
|
||||||
|
<pre class="code"><span class="c">// raw base32, length depends on payload size</span>
|
||||||
|
9F2A7C41XK226D8ELM77PQ91RR54VV01
|
||||||
|
|
||||||
|
<span class="c">// grouped + prefixed for display</span>
|
||||||
|
KS-9F2A-7C41-XK22-6D8E-LM77-PQ91-RR54-VV01</pre>
|
||||||
|
|
||||||
|
<p>Decoders MUST strip the <code>KS-</code> prefix (case-insensitive), strip whitespace and dashes, and case-fold to upper before base32-decoding.</p>
|
||||||
|
|
||||||
|
<h2 id="signature">Signature</h2>
|
||||||
|
<p>The signature covers the entire envelope from offset <code>0</code> through the end of the payload — that is, all bytes <em>before</em> the 64-byte signature itself.</p>
|
||||||
|
|
||||||
|
<p>Verify with the issuer’s Ed25519 public key. The fingerprint at offset 22 lets the verifier confirm that the key it has matches the key the license was signed with: SHA-256 the public key bytes, truncate to 16 bytes, compare. If it doesn’t match, the verifier MUST reject before attempting signature check — this gives a clear "wrong issuer" error rather than a generic "bad signature".</p>
|
||||||
|
|
||||||
|
<h2 id="example">Worked example</h2>
|
||||||
|
<p>Test vector for the Python SDK’s cross-check tests (issuer fingerprint <code>0xfeed face cafe babe...</code>, single-seat perpetual license):</p>
|
||||||
|
|
||||||
|
<pre class="code"><span class="c"># Hex dump of the binary envelope</span>
|
||||||
|
00000000 4B 53 41 54 01 02 9F 2A 7C 41 XK 22 6D 8E LM 77 <span class="c">|KSAT...*|A.."m..w|</span>
|
||||||
|
00000010 PQ 91 RR 54 VV 01 FE ED FA CE CA FE BA BE 00 00 <span class="c">|...T....|........|</span>
|
||||||
|
00000020 00 00 00 00 65 4F 12 34 00 00 00 00 00 00 00 00 <span class="c">|....eO.4|........|</span>
|
||||||
|
00000030 00 01 00 24 7B 22 70 72 6F 64 75 63 74 22 3A 22 <span class="c">|...${"product":"|</span>
|
||||||
|
00000040 73 75 6E 64 69 61 6C 22 2C 22 70 6F 6C 69 63 79 <span class="c">|sundial","policy|</span>
|
||||||
|
00000050 22 3A 22 64 65 66 61 75 6C 74 22 7D ...sig... <span class="c">|":"default"}.....|</span>
|
||||||
|
|
||||||
|
<span class="c"># As displayed</span>
|
||||||
|
KS-9F2A-7C41-XK22-6D8E-LM77-PQ91-…</pre>
|
||||||
|
|
||||||
|
<p>The full vector lives in <code>licensing-client-python/tests/fixtures/canonical.json</code> and is what every official SDK is tested against.</p>
|
||||||
|
|
||||||
|
<h2 id="public-key">Issuer public key format</h2>
|
||||||
|
<p>Public keys are exchanged in PEM format, SubjectPublicKeyInfo encoded:</p>
|
||||||
|
|
||||||
|
<pre class="code">-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
|
||||||
|
-----END PUBLIC KEY-----</pre>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
}</pre>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>Copy the test vectors from <a href="https://github.com/keysat-xyz/licensing-client-python/blob/main/tests/fixtures/canonical.json">licensing-client-python/tests/fixtures/canonical.json</a>.</li>
|
||||||
|
<li>Implement Crockford base32 decode (~30 lines).</li>
|
||||||
|
<li>Implement the binary unmarshal (~40 lines, mostly offset arithmetic).</li>
|
||||||
|
<li>Wire it up to your language’s Ed25519 verifier from a vetted crypto library.</li>
|
||||||
|
<li>Run the cross-check tests — if they pass, you’re wire-compatible.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p>See <a href="https://github.com/keysat-xyz/keysat/blob/main/PORTING_SDK_TO_NEW_LANGUAGES.md">PORTING_SDK_TO_NEW_LANGUAGES.md</a> in the repo for the full contributor guide.</p>
|
||||||
|
|
||||||
|
<h2 id="versioning">Versioning policy</h2>
|
||||||
|
<p>The version byte at offset 4 is a hard gate. Decoders MUST reject any version they don’t implement. We commit to:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Never silently changing the v1 layout. Any change ⇒ new version byte.</li>
|
||||||
|
<li>Maintaining v1 verifier support indefinitely — even if v2 ships, your existing customer keys stay verifiable.</li>
|
||||||
|
<li>Publishing test vectors for every new version under <code>tests/fixtures/</code> in the canonical SDK.</li>
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="toc">
|
||||||
|
<div class="label">On this page</div>
|
||||||
|
<a href="#overview">Overview</a>
|
||||||
|
<a href="#layout">Binary layout</a>
|
||||||
|
<a href="#encoding">Crockford base32</a>
|
||||||
|
<a href="#grouping">Dash grouping</a>
|
||||||
|
<a href="#signature">Signature</a>
|
||||||
|
<a href="#example">Worked example</a>
|
||||||
|
<a href="#public-key">Public key format</a>
|
||||||
|
<a href="#porting">Porting</a>
|
||||||
|
<a href="#versioning">Versioning policy</a>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
|
<script>lucide.createIcons();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user