From 116ed0d1f83a414811265503ffd8f27107984004 Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 8 May 2026 08:05:19 -0500 Subject: [PATCH] =?UTF-8?q?v0.1.0:41=20=E2=80=94=20second=20hotfix=20to=20?= =?UTF-8?q?migration=200009;=20migration=20regression=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.1.0:40 migration was correct on clean installs but crashed at COMMIT on any database with rows in discount_redemptions: SQLite's deferred FK check saw the dropped parent's bookkeeping as unsatisfied even after the rename. Fix is to rebuild discount_redemptions in the same transaction (stash → drop → rebuild → restore) plus orphan cleanup. Migration is idempotent; operators on :40 with a checksum mismatch recover by deleting the version=9 row from _sqlx_migrations and restarting. Lands the missing migration test scaffolding too. The four tests in licensing-service/tests/migrations.rs apply migrations against a realistic populated database (products, policies, invoices, licenses, machines, discount codes, redemptions, webhooks, tip attempts). The regression test fails with the exact 787 error against the v40 migration — would have caught the bug pre-release. KEYSAT_INTEGRATION.md is removed from this repo; it now lives in the parent licensing/ folder. --- KEYSAT_INTEGRATION.md | 1378 ------- licensing-service/Cargo.lock | 3375 +++++++++++++++++ licensing-service/Cargo.toml | 5 + .../0009_discount_codes_set_price.sql | 97 +- licensing-service/tests/migrations.rs | 413 ++ startos/versions/v0.1.0.ts | 30 +- 6 files changed, 3909 insertions(+), 1389 deletions(-) delete mode 100644 KEYSAT_INTEGRATION.md create mode 100644 licensing-service/Cargo.lock create mode 100644 licensing-service/tests/migrations.rs diff --git a/KEYSAT_INTEGRATION.md b/KEYSAT_INTEGRATION.md deleted file mode 100644 index f56a4d8..0000000 --- a/KEYSAT_INTEGRATION.md +++ /dev/null @@ -1,1378 +0,0 @@ -# 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 `