From 4ac856bb108b98574e01ea17faedf73d4b0d1ad4 Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 8 May 2026 08:17:33 -0500 Subject: [PATCH] Restore KEYSAT_INTEGRATION.md (mistakenly deleted in v0.1.0:41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit removed the canonical 1378-line integration guide based on a misread of intent — the file's "moved to startos folder" note referred to *this* (licensing-service-startos) repo. The 12-line stub at the parent licensing/ folder is the forwarder, not the canonical. No version bump: doc-only restore, no on-disk or daemon behaviour change. v0.1.0:41 release notes contain an incidental line stating "KEYSAT_INTEGRATION.md is removed from this repo" — left as-is for now since the .s9pk hasn't been re-published since :41. If we re-publish :41 and the line bothers us, a separate commit can correct it before the next .s9pk build. --- KEYSAT_INTEGRATION.md | 1378 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1378 insertions(+) create mode 100644 KEYSAT_INTEGRATION.md diff --git a/KEYSAT_INTEGRATION.md b/KEYSAT_INTEGRATION.md new file mode 100644 index 0000000..f56a4d8 --- /dev/null +++ b/KEYSAT_INTEGRATION.md @@ -0,0 +1,1378 @@ +# Integrating Keysat licensing into your software + +This document is the complete instruction set for adding Keysat-based +licensing to any application. It covers Node/TypeScript, Python, and Rust. +Hand it to an LLM (or a developer) along with your codebase and ask them +to wire it up — they should have everything they need. + +## How to use this document + +Drop `KEYSAT_INTEGRATION.md` into a fresh session against the target +codebase, then prompt: + +> Read this `KEYSAT_INTEGRATION.md`. Then add Keysat licensing to this +> codebase. Ask the software creator how they would like to license, +> monetize and/or gate their software, including how unlicensed software +> should operate. Use the creator's Keysat public key which can be found +> in the Keysat admin UI. After wiring, summarize what you changed. + +> **Reading this as an LLM?** Your job is to add license-key reading, +> signature verification, and entitlement-based feature gating to the +> target application. Before you write any code, ask the software creator +> the questions in section 0. Then read sections 1–6, then jump to the +> language section (7a/7b/7c) that matches your target codebase. The +> "Worked example" in section 14 is the canonical pattern to mimic. + +--- + +## 0. Questions to ask the software creator before writing code + +Don't write any code until you have answers. The whole licensing model +hangs on these: + +1. **What's the operator's Keysat instance URL?** + (e.g. `https://licensing.example.com`. Used for online validation + and the in-app purchase flow.) +2. **What's the operator's product slug?** + (Short string the operator chose when creating the product in their + Keysat admin. License keys are scoped to this slug.) +3. **What's the operator's signing public key?** + (PEM-formatted Ed25519 public key. Get it from the Keysat admin + Overview tab → "Embed your public key" → Copy. The operator pastes + it; you embed it.) +4. **How should unlicensed users experience the app?** Three legitimate + patterns; pick whichever fits the operator's business model. **None + is "wrong."** + - **Hard gate** — the app downloads freely from the Start9 registry, + but won't function without a paid license. The binary is essentially + a locked installer until the buyer activates. Common for closed-source + paid apps and for open-source apps that the operator chooses to + monetize through the registry distribution. See section 8 for the + two flavors of hard gating (refuse-to-start vs. activate-screen-only). + - **Soft gate** — the app runs and provides basic functionality + unlicensed; specific paid features return 402 with an "Upgrade to + unlock" message. Recommended for free → paid migrations and for + freemium products. + - **Nag mode** — no enforcement; just a "support development" banner + when unlicensed. Pure honor system. Useful when the app is + fundamentally free-to-use but the operator wants a tip-jar. + + Nudge the operator if their answer doesn't match their business + reality. Closed-source-paid + nag-mode is incoherent; freemium + + hard-gate alienates the existing user base. +5. **What are the entitlement strings, and what does each unlock?** + The operator decides; ask them. Common patterns: + - `["self_host"]` for a free tier — "you can run the app, no premium features" + - `["self_host", "export", "ai_features", "team_seats"]` for a paid tier + - `["patron"]` extra for a vanity supporter tier + Document the mapping (entitlement string → feature unlocked) in your + integration so the operator can ship the right policies. +6. **Where should the license key live on disk at runtime?** + Default: `/data/license.txt` for server / containerized apps, or + `~/.config//license.key` for desktop apps. Operator may + override. +7. **Which pricing tiers exist** and roughly what they cost? (Optional + for the integration itself, but useful for shaping the "Upgrade" + message that shows when an unlicensed user hits a paid feature.) + +If the creator doesn't know yet, propose sensible defaults from the +ranges above and confirm before coding. + +8. **Compile a config card before writing code.** After answering 1–7, + produce a short summary the operator can paste into the Keysat admin + without re-deriving anything. This is the single highest-leverage + step for avoiding "wait, what entitlements did we agree on?" churn + later. The card has three parts: + + - **Product**: the slug from question 2. + - **Policies**: each policy's name and the entitlement set it issues. + Treat this as the operator's pricing menu — one policy per tier. + - **Behavior matrix**: caller state → what happens. Lets the operator + sanity-check the gating model (question 4) against the policy set. + + Show the card to the operator, get explicit confirmation, *then* + write code. Example for a two-tier hard-gate-flavor-2 freemium app: + + ``` + Product slug: youtube-summarizer + + Policies to create in Keysat admin: + • Core → entitlements: ["core"] + • Pro → entitlements: ["core", "subscriptions", "history", "library"] + + Entitlement → unlocks: + core — past the activation screen; basic summarize + subscriptions — channel subscriptions, auto-queue + history — saved summary library + library — bulk import/export + + Behavior matrix: + no license → 402 license_required everywhere + Core license → summarize works; subs/history/library = 402 feature_not_in_tier + Pro license → all features available + ``` + + Without this card, mid-implementation drift is near-certain — the LLM + gates on `library_io`, the operator creates a policy with `library`, + and the buyer sees a "feature not in tier" error on a feature they + thought they paid for. + +--- + +## 1. What Keysat does, in one paragraph + +Keysat lets independent software creators sell their work on their own +terms. The operator (the creator) runs a Keysat instance — typically on +a Start9 box — and Keysat handles the buy page, the Bitcoin payment via +BTCPay, and issuing each buyer a signed license key in `LIC1-…-…` form. +**Your software's job** is to read that key from somewhere on disk +(a file, an env var, a config setting) and verify its signature against +the operator's public key. What happens after verification is up to the +creator: maybe the app refuses to function without a license (one-time +purchase model), maybe specific features unlock (free + paid tiers), +maybe nothing changes and the verified license is just used to show a +"thanks for supporting development" badge. You never talk to a Keysat +server at runtime unless you want to — verification is offline, fast +(~1ms), and doesn't depend on the network. + +--- + +## 2. The whole integration in 30 seconds + +``` +1. Install the Keysat SDK in your language. +2. Embed the operator's PUBLIC key into your app at build time. +3. On startup, read the license key from disk; verify it; populate an + `entitlements` set. +4. Throughout your code, gate paid features with `if entitlements.has("X")`. +5. (Optional) On a timer, also call /v1/validate to catch revocations. +``` + +Everything else is polish. + +--- + +## 3. Prerequisites — three things you need from the operator + +1. **A Keysat instance reachable on the public internet.** Typically + something like `https://licensing.example.com`. The operator already + has this; you don't need to install one. +2. **A product slug** the operator created in their Keysat. This is a + short string (`acme-paint-pro`, `myapp`, etc.). Licenses issued for + one slug won't validate against another — this is intentional and + stops a customer from buying a cheap product and using its key to + unlock an expensive one. +3. **The operator's signing public key in PEM form.** This is what you + embed in source. Get it from: + - The admin Overview tab → "Embed your public key" tip card → Copy + - Or `curl https://licensing.example.com/v1/issuer/public-key | jq -r .public_key_pem` + + The PEM is non-secret — **anyone with the public key can verify + licenses but not mint them.** It's safe to commit to source control + and ship in your binary. + +4. **The public buy URL for your product.** Each product on a Keysat + instance has a buyer-facing page at + `/buy/`. Use this for "Buy a key" / + "Upgrade to Pro" links in your app's activation screen, settings, and + per-feature upsell tiles. Compute it from the same constants you've + already embedded — don't hard-code a separate URL that can drift: + + ```ts + const buyUrl = `${KEYSAT_BASE_URL.replace(/\/$/, "")}/buy/${PRODUCT_SLUG}` + ``` + + The simpler "link to a buy page" path (this URL) is fine for most + apps. If you want a more integrated checkout, see section 11 for + `client.startPurchase()`. + +--- + +## 4. The wire format you'll be reading + +License keys look like: + +``` +LIC1-AIAMCWOS5JVHSQE2UMP6PNKXODHSIPHM5O3XQQ2J6CE4XV6WVNMA3BIAAAAA… +``` + +A `LIC1-` prefix, then two base32 segments separated by `-`. The first +segment is a binary payload; the second is an Ed25519 signature over +the payload. The SDK parses and verifies in one call. You should never +need to handle the encoding manually. + +The signed payload contains: +- `product_id` (UUID) — for matching against your product slug +- `license_id` (UUID) — useful for logging +- `issued_at` (Unix seconds) +- `expires_at` (Unix seconds; 0 means perpetual) +- `flags` (bitfield; `FLAG_TRIAL=1`) +- `entitlements: string[]` — **this is the array you gate features on** +- `fingerprint_hash` (32 bytes; for online machine-binding) + +Your software reads `entitlements` and decides what to unlock. + +--- + +## 5. Where to read the license from + +There's no one-size-fits-all answer; pick one based on how your users +interact with your app. **Recommended order**: + +1. **A file in the user's data directory.** On Linux this is typically + `~/.config//license.key`, or `/data/license.txt` for + server software running in a container. The file contains exactly + one line: the raw `LIC1-…` string. This is the most common pattern. + +2. **An environment variable** like `MYAPP_LICENSE_KEY`. Useful for + server-side software, CLIs, Docker Compose, and systemd. Easy to + set, but users forget they set it and lose track. + +3. **A "paste your license key" UI** in your app's settings, with the + value persisted to localStorage / OS keychain / your own config. + Most familiar to users coming from commercial software. + +4. **Multiple of the above.** A common pattern is: "env var first, + then file, then UI prompt." All three give you a license string + either way; the SDK doesn't care where it came from. + +For Start9 packages: there's a [activate-license-template](./activate-license-template/) +that wires this up for you using StartOS Actions and the package store. +Copy that template, replace the slug, and you've got Pattern 1 + a +StartOS Actions UI for buyers to paste keys into. + +--- + +## 6. The canonical integration pattern + +Every integration follows the same shape regardless of language and +regardless of which enforcement model from question 4 the operator picked. +The verify-once-at-startup primitive is the same; what you do with the +result is what changes. + +``` +on startup: + raw_key = read_license_string() # file, env, or UI value + license_state = {state: 'unlicensed', entitlements: []} + if raw_key is not None: + result = verify(raw_key, ISSUER_PEM) # SDK call + if result.is_valid: + license_state = { + state: 'licensed', + entitlements: result.entitlements, + license_id: result.license_id, + expires_at: result.expires_at, + } + else: + log("license rejected: " + result.reason) + + # Then — depending on the operator's chosen model: + # + # HARD GATE : if not licensed, exit (Flavor 1) or block all + # business endpoints (Flavor 2). See section 7d. + # + # SOFT GATE : run normally; specific feature handlers consult + # license_state.entitlements before unlocking. + # See section 7a/7b/7c. + # + # NAG MODE : run normally; show a "support development" banner + # in the UI when license_state.state != 'licensed'. +``` + +The verify-and-populate-state step is identical for all three models. +The doc is structured the same way: section 7 covers the verify +primitive in each language; section 7d covers the hard-gate enforcement +flavors; the worked examples in section 14 show soft-gate; the +patterns are mix-and-match. + +**One universal rule across all three models:** never hard-fail on +*network* errors during the optional online `validate()` call (section 9). +That's separate from refusing to start when no license is present — which +is fine for hard-gate Flavor 1. The thing to avoid is making your app's +uptime depend on the operator's licensing server being reachable. + +**Don't forget background workers.** HTTP middleware gates only catch +incoming requests. If you have in-process timers, schedulers, queue +consumers, or other background jobs that exercise gated features, add +an explicit early-return at the top of each one: + +```js +async function checkSubscriptionsBackground() { + if (!LIC.entitlements.has("subscriptions")) return // skip silently + // … existing work +} +``` + +Otherwise an unlicensed (or insufficient-tier) instance will keep doing +work the buyer didn't pay for — wasting bandwidth, API quota, and +server CPU, and producing stale state in the UI when entitlements are +later restored. This bites people because the server returns 402 to +direct callers but the timer keeps humming along. + +--- + +## 7. Language-specific implementations + +### 7a. TypeScript / Node + +**Install (preferred, once published):** + +```bash +npm install @keysat/licensing-client +``` + +**GitHub fallback** (if the npm package isn't published yet). Several +prerequisites must be met for this path to work end-to-end: + +1. The `keysat-xyz/keysat-client-ts` repo must be **public** on GitHub. + Private repos require credentials, which fails inside hermetic build + environments (Docker, CI, fresh dev machines without an SSH key). If + the repo flips public temporarily for one build, every future build + re-hits this wall — prefer publishing to npm if at all possible. +2. The repo must include a `prepare` script in `package.json` that + builds `dist/` on git-install. This is fixed as of this doc; if you + see `Cannot find module '...dist/index.cjs'` after install, the SDK + you're pulling pre-dates the fix and you need a newer commit. +3. **Use the explicit `git+https://` URL form**, not the `github:` + shorthand: + + ```jsonc + // package.json + "@keysat/licensing-client": "git+https://github.com/keysat-xyz/keysat-client-ts.git" + ``` + + The `github:user/repo` shorthand often resolves to `git+ssh://...` + on machines with an existing GitHub SSH key, which then breaks for + any subsequent integrator without a key (CI, Docker, a fresh laptop). + +4. **If you switched from `github:` to `git+https://`, also delete the + stale lock-file entry.** `npm install` will keep the previous + `resolved: "git+ssh://..."` line in `package-lock.json` even after + you change the spec in `package.json`. The fastest fix is: + + ```bash + rm package-lock.json node_modules + npm cache clean --force + npm install + ``` + + Or hand-edit the `resolved:` field of the offending entry to swap + `git+ssh://` → `git+https://`, leaving the commit hash unchanged. + +When all four are satisfied: + +```bash +npm install github:keysat-xyz/keysat-client-ts +``` + +**Embed the public key.** The simplest way is to commit the PEM file +to your repo at `assets/issuer.pub` and import it as a raw string: + +```ts +// in your bundler config (Vite shown) +import issuerPem from './assets/issuer.pub?raw' +``` + +Or in plain Node: + +```ts +import { readFileSync } from 'node:fs' +import * as path from 'node:path' +const issuerPem = readFileSync(path.join(__dirname, 'assets/issuer.pub'), 'utf8') +``` + +**Verify on startup:** + +```ts +import { Verifier, PublicKey } from '@keysat/licensing-client' +import { readFileSync } from 'node:fs' + +const PRODUCT_SLUG = '' +const LICENSE_PATH = process.env.MYAPP_LICENSE_KEY_PATH || '/data/license.txt' + +function readLicenseKey(): string | null { + if (process.env.MYAPP_LICENSE_KEY) return process.env.MYAPP_LICENSE_KEY.trim() + try { return readFileSync(LICENSE_PATH, 'utf8').trim() } + catch { return null } +} + +const verifier = new Verifier(PublicKey.fromPem(issuerPem)) + +export interface LicenseState { + state: 'licensed' | 'unlicensed' | 'invalid' + reason?: string + licenseId?: string + entitlements: Set + expiresAt?: Date + isTrial?: boolean +} + +export function checkLicense(): LicenseState { + const raw = readLicenseKey() + if (!raw) return { state: 'unlicensed', entitlements: new Set() } + try { + const ok = verifier.verify(raw) + // (optional) reject keys for the wrong product slug + if (ok.payload.productSlug && ok.payload.productSlug !== PRODUCT_SLUG) { + return { state: 'invalid', reason: 'product_mismatch', entitlements: new Set() } + } + return { + state: 'licensed', + licenseId: ok.payload.licenseId, + entitlements: new Set(ok.payload.entitlements || []), + expiresAt: ok.payload.expiresAt + ? new Date(ok.payload.expiresAt * 1000) + : undefined, + isTrial: !!(ok.payload.flags & 1), + } + } catch (e: any) { + return { state: 'invalid', reason: e.message, entitlements: new Set() } + } +} +``` + +**Use the state object** wherever a feature is gated: + +```ts +const lic = checkLicense() +console.log(`[license] state=${lic.state} entitlements=[${[...lic.entitlements].join(',')}]`) + +// In an Express route: +app.post('/api/export', (req, res) => { + if (!lic.entitlements.has('export')) { + return res.status(402).json({ + error: 'feature_not_in_tier', + message: 'Export requires a paid license. See .', + }) + } + // ... existing export logic +}) +``` + +### 7b. Python + +**Install (preferred, once published):** + +```bash +pip install keysat-licensing-client +``` + +**GitHub fallback** (if the PyPI package isn't published yet). The +`keysat-xyz/keysat-client-python` repo must be **public** on GitHub +for this to work in clean environments: + +```bash +pip install git+https://github.com/keysat-xyz/keysat-client-python.git +``` + +(Python's pip-from-git path is simpler than npm's — no separate build +step is required since pure-Python packages are installable from source.) + +**Embed the public key** at a path your code can read: + +```python +# myapp/license.py +from pathlib import Path +ISSUER_PEM = (Path(__file__).parent / 'assets' / 'issuer.pub').read_text() +``` + +**Verify on startup:** + +```python +# myapp/license.py +import os +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Optional + +from keysat_licensing_client import Verifier + +PRODUCT_SLUG = '' +LICENSE_PATH = os.environ.get('MYAPP_LICENSE_KEY_PATH', '/data/license.txt') + +ISSUER_PEM = (Path(__file__).parent / 'assets' / 'issuer.pub').read_text() +_verifier = Verifier.from_pem(ISSUER_PEM) + + +@dataclass +class LicenseState: + state: str # 'licensed' | 'unlicensed' | 'invalid' + reason: Optional[str] = None + license_id: Optional[str] = None + entitlements: set = field(default_factory=set) + expires_at: Optional[datetime] = None + is_trial: bool = False + + +def _read_license_key() -> Optional[str]: + if env := os.environ.get('MYAPP_LICENSE_KEY'): + return env.strip() + try: + return Path(LICENSE_PATH).read_text().strip() + except (FileNotFoundError, PermissionError): + return None + + +def check_license() -> LicenseState: + raw = _read_license_key() + if not raw: + return LicenseState(state='unlicensed') + try: + ok = _verifier.verify(raw) + return LicenseState( + state='licensed', + license_id=str(ok.payload.license_id), + entitlements=set(ok.payload.entitlements or []), + expires_at=datetime.fromtimestamp(ok.payload.expires_at) + if ok.payload.expires_at else None, + is_trial=bool(ok.payload.flags & 1), + ) + except Exception as e: + return LicenseState(state='invalid', reason=str(e)) +``` + +**Use it:** + +```python +# myapp/server.py +from .license import check_license + +LIC = check_license() +print(f'[license] state={LIC.state} entitlements={LIC.entitlements}') + +@app.post('/api/export') +def export_endpoint(): + if 'export' not in LIC.entitlements: + abort(402, description={ + 'error': 'feature_not_in_tier', + 'message': 'Export requires a paid license.', + }) + # ... do the thing +``` + +### 7c. Rust + +**Install (preferred, once published):** + +```toml +# Cargo.toml +[dependencies] +keysat-licensing-client = "0.1" +``` + +**Git fallback** (if not on crates.io yet). The +`keysat-xyz/keysat-client-rust` repo must be **public** on GitHub: + +```toml +keysat-licensing-client = { git = "https://github.com/keysat-xyz/keysat-client-rust.git" } +``` + +Cargo builds from source, so no separate build step is required. + +**Embed the public key:** + +```rust +const ISSUER_PEM: &str = include_str!("../assets/issuer.pub"); +``` + +**Verify on startup:** + +```rust +// src/license.rs +use keysat_licensing_client::{Verifier, PublicKeyPem}; +use std::collections::HashSet; +use std::path::PathBuf; + +pub const PRODUCT_SLUG: &str = ""; +pub const ISSUER_PEM: &str = include_str!("../assets/issuer.pub"); + +#[derive(Debug, Clone)] +pub struct LicenseState { + pub state: &'static str, // "licensed" | "unlicensed" | "invalid" + pub reason: Option, + pub license_id: Option, + pub entitlements: HashSet, + pub expires_at: Option>, + pub is_trial: bool, +} + +impl Default for LicenseState { + fn default() -> Self { + Self { + state: "unlicensed", + reason: None, + license_id: None, + entitlements: HashSet::new(), + expires_at: None, + is_trial: false, + } + } +} + +fn read_license_key() -> Option { + if let Ok(s) = std::env::var("MYAPP_LICENSE_KEY") { + let s = s.trim().to_string(); + if !s.is_empty() { return Some(s) } + } + let path = std::env::var("MYAPP_LICENSE_KEY_PATH") + .unwrap_or_else(|_| "/data/license.txt".to_string()); + std::fs::read_to_string(PathBuf::from(path)) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +pub fn check_license() -> LicenseState { + let raw = match read_license_key() { + Some(s) => s, + None => return LicenseState::default(), + }; + let pubkey = match PublicKeyPem::from_str(ISSUER_PEM) { + Ok(k) => k, + Err(e) => return LicenseState { + state: "invalid", reason: Some(format!("bad pubkey embedded: {e}")), + ..Default::default() + }, + }; + let verifier = Verifier::new(pubkey); + match verifier.verify(&raw) { + Ok(ok) => LicenseState { + state: "licensed", + license_id: Some(ok.payload.license_id.to_string()), + entitlements: ok.payload.entitlements.into_iter().collect(), + expires_at: if ok.payload.expires_at == 0 { + None + } else { + chrono::DateTime::from_timestamp(ok.payload.expires_at, 0) + }, + is_trial: (ok.payload.flags & 1) != 0, + ..Default::default() + }, + Err(e) => LicenseState { + state: "invalid", reason: Some(e.to_string()), + ..Default::default() + }, + } +} +``` + +**Use it:** + +```rust +let lic = license::check_license(); +tracing::info!(state = lic.state, entitlements = ?lic.entitlements, "license loaded"); + +// At a feature gate: +if !lic.entitlements.contains("export") { + return Err(MyError::PaymentRequired( + "Export requires a paid license.".into() + )); +} +``` + +### 7d. Hard-gate patterns — "the app doesn't function without a license" + +If the operator chose **hard gate** in the section-0 questions (binary +freely downloadable, but locked until activated), use one of these two +flavors instead of the entitlements-as-feature-flags pattern above. The +verifier helpers from 7a / 7b / 7c are still the right primitive — the +difference is what you do with the result. + +**Flavor 1: Refuse to start.** The daemon exits at boot with a clear +log line if there's no valid license. StartOS will show the service as +crashing — the operator's README needs to tell buyers "install the +license first via Actions → Set license, then start the service." + +```ts +// TypeScript / Node +const lic = checkLicense() +if (lic.state !== 'licensed') { + console.error(`[license] not licensed (${lic.state}): ${lic.reason || ''}`) + console.error(`[license] paste a license key into ${LICENSE_PATH} via the StartOS "Set license" action, then restart.`) + process.exit(1) +} +``` + +```python +# Python +lic = check_license() +if lic.state != 'licensed': + log.error(f'[license] not licensed ({lic.state}): {lic.reason or ""}') + log.error(f'[license] paste a license key, then restart.') + raise SystemExit(1) +``` + +```rust +// Rust +let lic = license::check_license(); +if lic.state != "licensed" { + eprintln!("[license] not licensed ({}): {}", lic.state, lic.reason.unwrap_or_default()); + eprintln!("[license] paste a license key, then restart."); + std::process::exit(1); +} +``` + +This is the most aggressive option. Use when (a) the app is closed-source +and there's no "free version" of the binary anyone could compile, and +(b) the operator is OK with StartOS surfacing the service as +unhealthy until activated. + +**Flavor 2: Run, but block all real work behind an "Activate" screen.** +The daemon starts normally, but every business endpoint returns 402 +until a license is activated. Only the activation endpoint(s) and a +status endpoint are open. Buyers see a clean "paste your license to +get started" UI on first run; StartOS shows the service as healthy. +Generally a better buyer experience than Flavor 1. + +```ts +// TypeScript / Express — middleware that gates everything except +// the activation paths. +const ACTIVATION_PATHS = new Set([ + '/api/license-status', // for the frontend to render activation UI + '/api/activate', // accepts a pasted license key, writes to file, refreshes state + '/healthz', // for StartOS / orchestration +]) + +let LIC = checkLicense() // mutable; refresh after activation + +app.use((req, res, next) => { + if (ACTIVATION_PATHS.has(req.path)) return next() + if (LIC.state !== 'licensed') { + return res.status(402).json({ + error: 'license_required', + message: 'This service requires a Keysat license to function.', + activate_url: '/activate', // your frontend's activation page + state: LIC.state, + reason: LIC.reason, + }) + } + next() +}) + +// Activation endpoint — accepts a pasted key, writes it, re-checks. +app.post('/api/activate', express.json(), (req, res) => { + const key = (req.body.license_key || '').trim() + if (!key.startsWith('LIC1-')) { + return res.status(400).json({ error: 'bad_format', message: 'Expected a LIC1-… key.' }) + } + fs.writeFileSync(LICENSE_PATH, key + '\n') + LIC = checkLicense() + if (LIC.state === 'licensed') { + return res.json({ ok: true, state: 'licensed', entitlements: [...LIC.entitlements] }) + } + return res.status(400).json({ error: 'invalid', state: LIC.state, reason: LIC.reason }) +}) +``` + +```python +# Python / Flask — same idea +ACTIVATION_PATHS = {'/api/license-status', '/api/activate', '/healthz'} +LIC = check_license() # module-level; reload after activation + +@app.before_request +def license_gate(): + if request.path in ACTIVATION_PATHS: + return None + if LIC.state != 'licensed': + return jsonify({ + 'error': 'license_required', + 'message': 'This service requires a Keysat license to function.', + 'state': LIC.state, + 'reason': LIC.reason, + }), 402 + +@app.post('/api/activate') +def activate(): + global LIC + key = (request.json or {}).get('license_key', '').strip() + if not key.startswith('LIC1-'): + return {'error': 'bad_format'}, 400 + Path(LICENSE_PATH).write_text(key + '\n') + LIC = check_license() + if LIC.state == 'licensed': + return {'ok': True, 'entitlements': sorted(LIC.entitlements)} + return {'error': 'invalid', 'state': LIC.state, 'reason': LIC.reason}, 400 +``` + +```rust +// Rust / axum — same idea: a middleware layer that guards all admin +// routes, plus an /api/activate endpoint that accepts a key and updates +// the in-memory state. +// +// Sketch (full impl follows the existing axum middleware pattern): +// Router::new() +// .route("/api/license-status", get(license_status)) +// .route("/api/activate", post(activate)) +// .route("/healthz", get(healthz)) +// .nest("/api", api_routes()) +// .layer(axum::middleware::from_fn_with_state(state.clone(), license_gate)) +// +// Inside `license_gate`, return 402 unless the request path is in +// ACTIVATION_PATHS or `state.license.read().await.state == "licensed"`. +``` + +**How would Keysat itself do this?** Keysat already has the `Mode::Enforce` +build-time flag in [`license_self.rs`](./licensing-service-startos/licensing-service/src/license_self.rs): +when built with `KEYSAT_LICENSE_ENFORCE=1`, missing or invalid licenses +cause the daemon to refuse to start (Flavor 1). Default Permissive +builds run unlicensed at Creator-tier caps. To switch Keysat to Flavor 2 +("run but block until activated") would mean: keep the existing boot-time +license check non-fatal, expose `/admin/login`-style activation endpoints +under a hardcoded allowlist, and have an axum middleware return 402 on +every other admin/business endpoint until `state.self_tier` flips from +`Unlicensed` to `Licensed`. The pieces are all there — it's a few hundred +lines of axum middleware + an SPA "Activate" splash screen. + +### 7e. Packaging gotchas — Docker, s9pk, hermetic builds + +Most non-trivial integrations end up packaged in Docker (Start9 s9pk, +generic container deploys, CI-built images). The following gotchas +together account for ~80% of the "it works locally but the build +fails" failure mode: + +**1. Slim base images don't ship `git`, `ssh`, or `ca-certificates`.** +`node:20-slim`, `python:3.11-slim`, etc. are intentionally minimal. +If you have a git-URL dependency (e.g. the GitHub fallback above), +you'll need at least these in the *builder* stage: + +```dockerfile +RUN apt-get update && apt-get install -y --no-install-recommends \ + git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +``` + +Without `git`: npm errors with `spawn git ENOENT` when resolving the +dependency. Without `ca-certificates`: HTTPS clones fail with +`SSL certificate problem: unable to get local issuer certificate`. + +**2. npm's git resolver tries `ssh://` for github.com URLs first.** +Even if your `package.json` spec and `package-lock.json` `resolved` +both say `git+https://`, npm internally tries SSH first when the host +is github.com. In a container with no SSH client or key, this fails. +Force git to silently rewrite SSH URLs to HTTPS: + +```dockerfile +RUN git config --global --add url."https://github.com/".insteadOf "ssh://git@github.com/" \ + && git config --global --add url."https://github.com/".insteadOf "git@github.com:" \ + && git config --global --add url."https://github.com/".insteadOf "git://github.com/" +``` + +The `--add` flag matters — without it, each subsequent invocation +overwrites the previous one (they share a key) and only the last +rewrite is active. + +**3. Don't forget to `COPY` your new license module.** If your +Dockerfile lists individual server files explicitly: + +```dockerfile +COPY server/package.json ./server/ +COPY server/index.js ./server/ +COPY public/ ./public/ +COPY assets/ ./assets/ +``` + +…the build will succeed, the image will start, and then crash at +runtime with `Cannot find module './license.js'`. Add a line for the +license module: + +```dockerfile +COPY server/license.js ./server/ # ← easy to miss +``` + +This is the single most common "package builds, container won't boot" +failure when retro-fitting licensing into an existing app. + +**4. Make's incremental rebuild can mask uncommitted changes.** s9pk +build chains often look like `make x86 → start-cli s9pk pack → docker +build`. Make may decide nothing's newer than the existing `.s9pk` +because its dependencies typically include `.git/index` (which only +updates on `git add`). Symptom: you change a source file, rebuild, +get an instant "✅ Build Complete!" with the same package as before. + +Either stage your changes (`git add -A`) so `.git/index` updates, or +delete the existing `.s9pk` to force a rebuild: + +```bash +rm myapp_x86_64.s9pk && make x86 +``` + +**5. The `--ignore-scripts` flag will skip the SDK's `prepare` build.** +If your Dockerfile uses `npm ci --ignore-scripts` (a common security +hardening), the SDK won't build its `dist/` and you'll hit the +"Cannot find module" runtime error from §7a. Either drop +`--ignore-scripts` for the builder stage, or pre-build the SDK +elsewhere and vendor `dist/` in. + +### 7f. Frontend integration for hard-gate Flavor 2 + +If you picked hard-gate Flavor 2 (server starts, business endpoints +return 402 until activated), **the frontend is half the work** — +otherwise unlicensed users see a sea of fetch errors instead of a +clean activation screen. The pattern below is framework-agnostic and +works in vanilla JS, React, Vue, etc. + +**Step 1: Fetch license-status before any other API call.** It's the +prerequisite for deciding what to render. + +```js +async function loadLicenseStatus() { + const r = await fetch("/api/license-status") + return r.json() // { state, entitlements, productSlug, keysatBaseUrl, … } +} +``` + +**Step 2: Render the activation screen as a top-level guard.** If +`state !== "licensed"` (or the `core` entitlement is missing), replace +the entire app body with the activation card. Don't render the normal +UI underneath — every API call would 402 anyway, producing visible +broken state. + +```jsx +if (lic.state !== "licensed" || !lic.entitlements.includes("core")) { + return activate(key)} /> +} +return +``` + +**Step 3: The activation card needs four things:** +- A `