commit 8c9bc75e24310006074a6d61387ff358d5415d63 Author: Keysat Date: Thu May 7 10:41:57 2026 -0500 Initial public commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbe5f69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Node / StartOS SDK +node_modules/ +javascript/ + +# Build artifacts +*.s9pk +dist/ +build/ + +# Editor / OS cruft +.DS_Store +.idea/ +.vscode/ +*.swp +*.bak +*.tmp + +# Env / secrets +.env +.env.local diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1b63053 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc68f86 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# StartOS activate-license template + +A drop-in folder that adds Bitcoin-paid licensing to your existing Start9 +(StartOS 0.4.0.x) package. Your buyers get: + +- **"Buy license"** — pay with Bitcoin/Lightning on your BTCPay, never copy/paste the key. Optional discount/referral code field. +- **"Redeem free license"** — paste a free-license code from the seller; key is issued immediately, no payment. +- **"Activate license"** — paste a key you got out-of-band. +- **"Check license status"** — re-verify on demand. +- **"Deactivate license"** — wipe the key locally. + +All actions work offline for the common case (boot-time signature check) and +reach out to your Keysat instance for revocation and fingerprint enforcement +only when the device is online. + +## Prerequisites + +1. You (the seller) are running [`Keysat`](https://github.com/keysat-xyz/keysat) on your own Start9, with BTCPay connected. +2. You have created a product in your Keysat and have its **slug**. +3. You know your Keysat's **public URL** (clearnet, .onion, or both). +4. You have your Keysat's **Ed25519 public key in PEM form**. Get it from the "Show admin credentials" action on your Start9, or hit `GET /v1/pubkey` on your service. + +## Install + +Copy these folders into your buyer-side package's `startos/` directory: + +``` +startos/licensing/ ← config, store shape, runtime gate +startos/actions/ ← five actions (merge with your existing actions/) +``` + +Add the SDK to your package's `package.json`: + +```bash +npm install @keysat/licensing-client +``` + +## Wire-up, in 4 edits + +### 1. Fill in `startos/licensing/config.ts` + +There are three constants to set: `LICENSING_BASE_URL`, `PRODUCT_SLUG`, and +`ISSUER_PUBKEY_PEM`. Each is documented inline. + +### 2. Extend your store shape + +In the file where you call `sdk.setupStore(...)` (usually `startos/init/index.ts`): + +```ts +import { licensingStoreDefaults, type LicensingStoreFields } from '../licensing/store' + +export interface StoreShape extends LicensingStoreFields { + // ...your existing fields... +} + +export const initStore: StoreShape = { + // ...your existing defaults... + ...licensingStoreDefaults, +} +``` + +### 3. Register the actions + +In `startos/actions/index.ts`: + +```ts +import { licensingActions } from './licensing-actions' // or wherever you dropped them + +export const actions = sdk.Actions.of() + // ...your own actions... + .addAction(licensingActions.activateLicense) + .addAction(licensingActions.buyLicense) + .addAction(licensingActions.finishLicensePurchase) + .addAction(licensingActions.checkLicense) + .addAction(licensingActions.deactivateLicense) +``` + +### 4. Gate your app at startup + +In `startos/main.ts`, before you launch your daemon: + +```ts +import { checkLicenseGate } from './licensing/gate' + +export const main = sdk.setupMain(async ({ effects, started }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + const gate = checkLicenseGate(store) + + if (!gate.licensed) { + // Pick your poison: + // (a) hard-fail: + throw new Error( + `No valid license. Run the "Buy license" or "Activate license" action. ` + + `(${gate.reason})`, + ) + // (b) OR boot in trial mode by passing an env flag to your daemon, + // e.g. LICENSE_STATE=trial. + } + + // ...normal startup... +}) +``` + +## What the buyer sees + +1. Installs your package on their Start9. +2. Sees four new actions in the dashboard under the "License" group. +3. Either: + - Clicks **Buy license** → opens the returned URL → pays → clicks **Finish license purchase** → done. + - Pastes a key they got from you into **Activate license** → done. +4. Your app boots. + +No typing long keys. No copying bearer tokens. Keys live in the Start9 store, +which means they are included in the buyer's regular Start9 backups. + +## Revocation and fingerprint binding + +The **online** path (any time the buyer's Start9 can reach your licensing +server) does three things the offline path cannot: + +- **Revocation**: if you revoke a leaked key server-side, that rejection + propagates to the buyer on their next online check. +- **Fingerprint binding**: the first device to check in with a key binds it. + The same key replayed from a different machine is rejected. +- **Audit trail**: your admin dashboard sees the check-in. + +The offline path (signature-only) is the fallback. It always succeeds as long +as the key was legitimately issued and the signature is intact. This is the +right tradeoff: a buyer with a revoked key who never goes online can still +run the app, which is an edge case; a buyer whose internet drops should not +have their paid software held hostage. + +## License of this template + +MIT. diff --git a/startos/actions/activateLicense.ts b/startos/actions/activateLicense.ts new file mode 100644 index 0000000..c69aef2 --- /dev/null +++ b/startos/actions/activateLicense.ts @@ -0,0 +1,133 @@ +// Action: "Activate license" — buyer pastes a license key, we verify it (both +// offline against the embedded issuer public key, and online against the +// licensing server if reachable) and persist it in the package store. +// +// After this runs successfully, your main process can read +// `store.license_key` and inject it into your app. + +import { sdk } from '../sdk' +import { Verifier, PublicKey, Client } from '@keysat/licensing-client' +import { + LICENSING_BASE_URL, + LICENSING_BASE_URL_TOR, + PRODUCT_SLUG, + ISSUER_PUBKEY_PEM, + PRODUCT_DISPLAY_NAME, +} from '../licensing/config' + +const input = sdk.InputSpec.of({ + license_key: { + type: 'text', + name: 'License key', + description: + `Paste the license key you received after purchasing ${PRODUCT_DISPLAY_NAME}. ` + + `It looks like "LIC1-...-...". Case-sensitive; no surrounding whitespace.`, + required: true, + default: null, + masked: false, + }, +}) + +export const activateLicense = sdk.Action.withInput( + 'activate-license', + async ({ effects }) => ({ + name: 'Activate license', + description: + `Verify and save a license key for ${PRODUCT_DISPLAY_NAME}. ` + + `The key is verified cryptographically on-device, then cross-checked ` + + `with the issuing server if it is reachable.`, + warning: null, + allowedStatuses: 'any', + group: 'License', + visibility: 'enabled', + }), + input, + async ({ effects, input: form }) => { + const key = (form.license_key ?? '').trim() + if (!key) throw new Error('License key is empty.') + + // -- 1. Offline signature check (always runs). + let offline + try { + const verifier = new Verifier(PublicKey.fromPem(ISSUER_PUBKEY_PEM)) + offline = verifier.verify(key) + } catch (e) { + throw new Error( + `This key's signature did not verify against the embedded issuer ` + + `public key. It is either corrupt, truncated, or not issued by ` + + `${PRODUCT_DISPLAY_NAME}.\n\nDetails: ${errMsg(e)}`, + ) + } + + // -- 2. Online check (best-effort). Catches revocation and fingerprint + // mismatches from a previously-seen device. Failure here is NOT fatal + // — the buyer may be offline. We record the last-known status. + const fingerprint = await getMachineFingerprint(effects) + const onlineStatus = await tryOnlineValidate(key, fingerprint) + if (onlineStatus.reachable && !onlineStatus.ok) { + throw new Error( + `The licensing server rejected this key: ${onlineStatus.reason ?? 'unknown reason'}.\n\n` + + `Most common causes: key was revoked; key is bound to a different ` + + `machine; wrong product.`, + ) + } + + // -- 3. Persist. + await sdk.store.getOwn(effects, sdk.StorePath).merge({ + license_key: key, + license_activated_at: new Date().toISOString(), + license_last_status: onlineStatus.reachable + ? 'valid (online-checked)' + : 'valid (offline-only)', + license_pending_invoice_id: null, + }) + + return { + message: + `License activated.\n\n` + + `Product: ${offline.productId}\n` + + `License id: ${offline.licenseId}\n` + + `Status: ${onlineStatus.reachable ? 'valid (confirmed with issuing server)' : 'valid (signature only — server not reachable)'}\n\n` + + `You may need to restart ${PRODUCT_DISPLAY_NAME} for the change to take effect.`, + } + }, +) + +// --------------------------------------------------------------------------- + +async function tryOnlineValidate( + key: string, + fingerprint: string, +): Promise<{ reachable: boolean; ok?: boolean; reason?: string }> { + const urls = [LICENSING_BASE_URL, LICENSING_BASE_URL_TOR].filter(Boolean) as string[] + for (const base of urls) { + try { + const client = new Client(base) + const r = await client.validate(key, PRODUCT_SLUG, fingerprint) + return { reachable: true, ok: r.ok, reason: r.reason } + } catch (_) { + // try next URL + } + } + return { reachable: false } +} + +/** + * Derive a stable per-machine fingerprint. Start9 packages have a host UUID; + * if that's unavailable we fall back to the operating system's machine-id. + * The fingerprint is hashed by the SDK before being sent to the server. + */ +async function getMachineFingerprint(effects: unknown): Promise { + // Prefer: Start9-provided device identifier if available. + // Fallback: /etc/machine-id on the host. + try { + const { readFile } = await import('fs/promises') + const id = (await readFile('/etc/machine-id', 'utf8')).trim() + if (id) return `machine-id:${id}` + } catch (_) {} + return 'fingerprint:unknown' +} + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e) +} diff --git a/startos/actions/buyLicense.ts b/startos/actions/buyLicense.ts new file mode 100644 index 0000000..8fd56e5 --- /dev/null +++ b/startos/actions/buyLicense.ts @@ -0,0 +1,181 @@ +// Action: "Buy license" — starts a purchase against the issuing licensing +// server and returns a checkout URL. The operator opens the URL, pays with +// Bitcoin on the seller's BTCPay, and the license key is automatically +// captured by the companion action "Finish license purchase" — no copy/paste. +// +// Flow: +// 1. Buyer clicks "Buy license" → this action calls POST /v1/purchase on +// the seller's licensing server and returns the checkout URL. +// 2. Buyer opens that URL, pays the invoice. +// 3. Buyer clicks "Finish license purchase" → pollPurchase() → key arrives, +// we verify it offline, and we persist it. No typing, no copy/paste. + +import { sdk } from '../sdk' +import { Client, Verifier, PublicKey } from '@keysat/licensing-client' +import { + LICENSING_BASE_URL, + LICENSING_BASE_URL_TOR, + PRODUCT_SLUG, + ISSUER_PUBKEY_PEM, + PRODUCT_DISPLAY_NAME, +} from '../licensing/config' + +// --- "Buy license" --------------------------------------------------------- + +const buyInput = sdk.InputSpec.of({ + buyer_email: { + type: 'text', + name: 'Email (optional)', + description: + 'If you would like a receipt or recovery email from the seller, enter it here. Otherwise leave blank.', + required: false, + default: null, + }, + code: { + type: 'text', + name: 'Discount / referral code (optional)', + description: + 'If the seller gave you a code (e.g. "FOUNDERS50"), enter it here. ' + + 'It will be applied to the BTCPay invoice. For free-license codes, ' + + 'use the "Redeem free license" action instead.', + required: false, + default: null, + }, +}) + +export const buyLicense = sdk.Action.withInput( + 'buy-license', + async ({ effects }) => ({ + name: 'Buy license', + description: + `Start a Bitcoin-paid purchase of a ${PRODUCT_DISPLAY_NAME} license. ` + + `You will get a checkout URL to open in your browser; after you pay, ` + + `run "Finish license purchase" to have the key automatically ` + + `captured — you never need to copy/paste anything.`, + warning: null, + allowedStatuses: 'any', + group: 'License', + visibility: 'enabled', + }), + buyInput, + async ({ effects, input: form }) => { + const client = await firstReachableClient() + // Tell BTCPay to land the buyer on the licensing server's "thank you" + // page after a successful payment. That page reminds the buyer to + // return to their StartOS dashboard and run "Finish license purchase". + // We use the same base URL the client just probed as reachable so it + // matches the network path the buyer's browser can actually open + // (clearnet vs Tor). + const redirectUrl = `${client.baseUrl()}/thank-you` + const session = await client.startPurchase(PRODUCT_SLUG, { + buyerEmail: form.buyer_email ?? undefined, + code: form.code ?? undefined, + redirectUrl, + }) + + await sdk.store.getOwn(effects, sdk.StorePath).merge({ + license_pending_invoice_id: session.invoiceId, + }) + + const codeApplied = form.code + ? `\nCode applied: ${form.code} (final amount above reflects the discount)` + : '' + return { + message: + `Open this URL in your browser to pay:\n\n${session.checkoutUrl}\n\n` + + `Amount: ${session.amountSats} satoshis${codeApplied}\n` + + `Invoice id: ${session.invoiceId}\n\n` + + `After payment confirms on-chain or on Lightning, run the ` + + `"Finish license purchase" action — we will fetch the key for you.`, + } + }, +) + +// --- "Finish license purchase" -------------------------------------------- + +export const finishLicensePurchase = sdk.Action.withoutInput( + 'finish-license-purchase', + async ({ effects }) => ({ + name: 'Finish license purchase', + description: + `Check on a pending purchase started by "Buy license". If payment has ` + + `settled, the license key is fetched, verified, and stored ` + + `automatically.`, + warning: null, + allowedStatuses: 'any', + group: 'License', + visibility: 'enabled', + }), + async ({ effects }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + const invoiceId = (store as { license_pending_invoice_id: string | null }) + .license_pending_invoice_id + if (!invoiceId) { + throw new Error( + `No pending purchase found. Run "Buy license" first to start one.`, + ) + } + + const client = await firstReachableClient() + const poll = await client.pollPurchase(invoiceId) + + if (poll.status === 'expired' || poll.status === 'invalid') { + await sdk.store.getOwn(effects, sdk.StorePath).merge({ + license_pending_invoice_id: null, + }) + throw new Error( + `This invoice ${poll.status}. Run "Buy license" again to start over.`, + ) + } + + if (!poll.licenseKey) { + return { + message: + `Payment has not settled yet. Current status: ${poll.status}. ` + + `Try again in a minute — Bitcoin confirmations can take a few ` + + `minutes, Lightning is near-instant.`, + } + } + + // Paid and key issued — verify and store. + const verifier = new Verifier(PublicKey.fromPem(ISSUER_PUBKEY_PEM)) + const ok = verifier.verify(poll.licenseKey) + + await sdk.store.getOwn(effects, sdk.StorePath).merge({ + license_key: poll.licenseKey, + license_activated_at: new Date().toISOString(), + license_last_status: 'valid (issued from purchase)', + license_pending_invoice_id: null, + }) + + return { + message: + `Payment settled — license captured and activated.\n\n` + + `Product: ${ok.productId}\n` + + `License id: ${ok.licenseId}\n\n` + + `You may need to restart ${PRODUCT_DISPLAY_NAME} for the change to ` + + `take effect. Keep your Start9 backup up to date — that is where your ` + + `license now lives.`, + } + }, +) + +// --------------------------------------------------------------------------- + +/** Return a Client pinned to the first reachable base URL, or throw. */ +async function firstReachableClient(): Promise { + const urls = [LICENSING_BASE_URL, LICENSING_BASE_URL_TOR].filter(Boolean) as string[] + for (const base of urls) { + const client = new Client(base) + try { + // /v1/pubkey is the cheapest health-check that still proves the service + // is responsive and talking to us. + await client.fetchPubkeyPem() + return client + } catch (_) {} + } + throw new Error( + `Could not reach the licensing server at any configured URL. ` + + `Check your network (and Tor, if applicable) and try again.`, + ) +} diff --git a/startos/actions/checkLicense.ts b/startos/actions/checkLicense.ts new file mode 100644 index 0000000..1448fb0 --- /dev/null +++ b/startos/actions/checkLicense.ts @@ -0,0 +1,93 @@ +// Action: "Check license status" — re-verifies the stored key (offline + +// online) and refreshes `license_last_status`. Useful for diagnosing a +// suddenly-rejecting app. + +import { sdk } from '../sdk' +import { Verifier, PublicKey, Client } from '@keysat/licensing-client' +import { + LICENSING_BASE_URL, + LICENSING_BASE_URL_TOR, + PRODUCT_SLUG, + ISSUER_PUBKEY_PEM, + PRODUCT_DISPLAY_NAME, +} from '../licensing/config' + +export const checkLicense = sdk.Action.withoutInput( + 'check-license', + async ({ effects }) => ({ + name: 'Check license status', + description: + `Re-run signature and server-side checks against the currently ` + + `stored license key for ${PRODUCT_DISPLAY_NAME}.`, + warning: null, + allowedStatuses: 'any', + group: 'License', + visibility: 'enabled', + }), + async ({ effects }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + const key = (store as { license_key: string | null }).license_key + if (!key) { + return { + message: + `No license key is stored. Run "Activate license" or "Buy license" first.`, + } + } + + // Offline + let offline + try { + offline = new Verifier(PublicKey.fromPem(ISSUER_PUBKEY_PEM)).verify(key) + } catch (e) { + await sdk.store.getOwn(effects, sdk.StorePath).merge({ + license_last_status: `offline-invalid: ${errMsg(e)}`, + }) + return { + message: + `The stored key no longer verifies cryptographically. This means ` + + `the key was edited, truncated, or the issuer public key was ` + + `rotated by the seller. Contact the seller.\n\nDetails: ${errMsg(e)}`, + } + } + + // Online + let onlineLine: string + try { + const client = await firstReachableClient() + const r = await client.validate(key, PRODUCT_SLUG, undefined) + onlineLine = r.ok + ? 'Server says: OK' + : `Server rejected the key: ${r.reason ?? 'unknown'}` + await sdk.store.getOwn(effects, sdk.StorePath).merge({ + license_last_status: r.ok ? 'valid (online-checked)' : `rejected: ${r.reason ?? 'unknown'}`, + }) + } catch (_) { + onlineLine = 'Server: unreachable (offline — relying on signature only)' + } + + return { + message: + `Signature: OK\n` + + `${onlineLine}\n\n` + + `Product: ${offline.productId}\n` + + `License id: ${offline.licenseId}\n` + + `Issued at: ${offline.payload.issuedAt}`, + } + }, +) + +async function firstReachableClient(): Promise { + const urls = [LICENSING_BASE_URL, LICENSING_BASE_URL_TOR].filter(Boolean) as string[] + for (const base of urls) { + const client = new Client(base) + try { + await client.fetchPubkeyPem() + return client + } catch (_) {} + } + throw new Error('no reachable URL') +} + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e) +} diff --git a/startos/actions/deactivateLicense.ts b/startos/actions/deactivateLicense.ts new file mode 100644 index 0000000..a7ccae9 --- /dev/null +++ b/startos/actions/deactivateLicense.ts @@ -0,0 +1,34 @@ +// Action: "Deactivate license" — clears the stored key locally. Does NOT +// revoke the key server-side (only the seller can revoke, from their licensing +// server's admin dashboard). The operator is reminded of this. + +import { sdk } from '../sdk' +import { PRODUCT_DISPLAY_NAME } from '../licensing/config' + +export const deactivateLicense = sdk.Action.withoutInput( + 'deactivate-license', + async ({ effects }) => ({ + name: 'Deactivate license', + description: + `Remove the stored license key from this ${PRODUCT_DISPLAY_NAME} ` + + `install. The key itself is NOT revoked — to revoke, contact the seller.`, + warning: + `This only clears the key from this device. If you want the key ` + + `disabled everywhere (e.g. because it leaked), ask the seller to ` + + `revoke it server-side.`, + allowedStatuses: 'any', + group: 'License', + visibility: 'enabled', + }), + async ({ effects }) => { + await sdk.store.getOwn(effects, sdk.StorePath).merge({ + license_key: null, + license_activated_at: null, + license_last_status: 'deactivated', + license_pending_invoice_id: null, + }) + return { + message: `License cleared. ${PRODUCT_DISPLAY_NAME} will no longer consider itself licensed on next start.`, + } + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts new file mode 100644 index 0000000..f046f57 --- /dev/null +++ b/startos/actions/index.ts @@ -0,0 +1,28 @@ +// Register the licensing template's actions. +// +// In your package's top-level actions registry, spread these in: +// +// import { licensingActions } from './licensing/actions' +// export const actions = sdk.Actions.of() +// // ...your own actions... +// .addAction(licensingActions.activateLicense) +// .addAction(licensingActions.buyLicense) +// .addAction(licensingActions.finishLicensePurchase) +// .addAction(licensingActions.redeemFreeLicense) +// .addAction(licensingActions.checkLicense) +// .addAction(licensingActions.deactivateLicense) + +import { activateLicense } from './activateLicense' +import { buyLicense, finishLicensePurchase } from './buyLicense' +import { checkLicense } from './checkLicense' +import { deactivateLicense } from './deactivateLicense' +import { redeemFreeLicense } from './redeemFreeLicense' + +export const licensingActions = { + activateLicense, + buyLicense, + finishLicensePurchase, + redeemFreeLicense, + checkLicense, + deactivateLicense, +} diff --git a/startos/actions/redeemFreeLicense.ts b/startos/actions/redeemFreeLicense.ts new file mode 100644 index 0000000..bfd9961 --- /dev/null +++ b/startos/actions/redeemFreeLicense.ts @@ -0,0 +1,123 @@ +// Action: "Redeem free license code" — for codes the seller created with +// kind = 'free_license'. Bypasses BTCPay entirely: the buyer enters the +// code, we hit POST /v1/redeem on the seller's licensing server, get a +// signed key back immediately, verify it, and store it. No invoice, no +// payment, no polling. + +import { sdk } from '../sdk' +import { Verifier, PublicKey, Client } from '@keysat/licensing-client' +import { + LICENSING_BASE_URL, + LICENSING_BASE_URL_TOR, + PRODUCT_SLUG, + ISSUER_PUBKEY_PEM, + PRODUCT_DISPLAY_NAME, +} from '../licensing/config' + +const input = sdk.InputSpec.of({ + code: { + type: 'text', + name: 'Free-license code', + description: + `The code the seller of ${PRODUCT_DISPLAY_NAME} gave you. Codes are ` + + `case-insensitive. Only "free license" codes work here; for paid ` + + `discount codes, use the "Buy license" action and enter the code there.`, + required: true, + default: null, + masked: false, + }, + buyer_email: { + type: 'text', + name: 'Email (optional)', + description: + 'Optional email — the seller may use this for support or recovery.', + required: false, + default: null, + }, +}) + +export const redeemFreeLicense = sdk.Action.withInput( + 'redeem-free-license', + async ({ effects: _effects }) => ({ + name: 'Redeem free license', + description: + `Redeem a free-license code from the seller of ${PRODUCT_DISPLAY_NAME}. ` + + `The license key is issued immediately — no payment required. The ` + + `seller controls how many of these codes exist and how long they are valid.`, + warning: null, + allowedStatuses: 'any', + group: 'License', + visibility: 'enabled', + }), + input, + async ({ effects, input: form }) => { + const code = (form.code ?? '').trim() + if (!code) throw new Error('Code is empty.') + + // 1. Hit /v1/redeem on the first reachable licensing-server URL. + const urls = [LICENSING_BASE_URL, LICENSING_BASE_URL_TOR].filter(Boolean) as string[] + let lastErr: unknown = null + let result: { + licenseId: string + licenseKey: string + invoiceId: string + redemptionId: string + } | null = null + for (const base of urls) { + try { + const client = new Client(base) + result = await client.redeemFreeLicense(PRODUCT_SLUG, code, { + buyerEmail: form.buyer_email ?? undefined, + }) + break + } catch (e) { + lastErr = e + } + } + if (!result) { + throw new Error( + `Could not reach the licensing server, or the code was rejected. ` + + `Last error: ${errMsg(lastErr)}`, + ) + } + + // 2. Offline-verify the returned key against the embedded issuer pubkey, + // same as the activation flow. This catches a misconfigured server + // that's somehow returning keys signed by a different issuer. + let offline + try { + const verifier = new Verifier(PublicKey.fromPem(ISSUER_PUBKEY_PEM)) + offline = verifier.verify(result.licenseKey) + } catch (e) { + throw new Error( + `Server returned a key but its signature did not verify against the ` + + `embedded issuer public key. This usually means the buyer-side ` + + `package was built with the wrong ISSUER_PUBKEY_PEM, or the ` + + `licensing server's key was rotated.\n\nDetails: ${errMsg(e)}`, + ) + } + + // 3. Persist. + await sdk.store.getOwn(effects, sdk.StorePath).merge({ + license_key: result.licenseKey, + license_activated_at: new Date().toISOString(), + license_last_status: 'valid (issued from free code)', + license_pending_invoice_id: null, + }) + + return { + message: + `Code redeemed — license issued and activated.\n\n` + + `Product: ${offline.productId}\n` + + `License id: ${offline.licenseId}\n` + + `Code: ${code.toUpperCase()}\n\n` + + `You may need to restart ${PRODUCT_DISPLAY_NAME} for the change to ` + + `take effect. Keep your Start9 backup up to date — that is where your ` + + `license now lives.`, + } + }, +) + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e) +} diff --git a/startos/licensing/config.ts b/startos/licensing/config.ts new file mode 100644 index 0000000..c0153e8 --- /dev/null +++ b/startos/licensing/config.ts @@ -0,0 +1,60 @@ +// ----------------------------------------------------------------------------- +// Licensing template: CONFIGURATION — FILL IN THE THREE VALUES BELOW. +// ----------------------------------------------------------------------------- +// +// This is the one file you must edit to adopt the licensing template. The +// action files consume these constants; nothing else should need changes. +// +// After a buyer (Bob) installs your Start9 package, the "Activate license" and +// "Buy license" actions in his package dashboard will talk to YOUR licensing +// server (the `BASE_URL` below), paid in Bitcoin via your BTCPay. +// ----------------------------------------------------------------------------- + +/** + * The public URL of YOUR running licensing-service, as reachable from the + * buyer's Start9 server. Typically a Tor .onion, a clearnet domain, or both + * (see TOR_FALLBACK below). + * + * Example: 'https://license.example.com' + */ +export const LICENSING_BASE_URL = 'https://license.YOUR-DOMAIN.example' + +/** + * Optional Tor fallback. If set, the actions will try this URL if the primary + * URL fails — useful when your licensing server is behind Tor and the buyer's + * clearnet is flaky. + * + * Example: 'http://abc...xyz.onion' + */ +export const LICENSING_BASE_URL_TOR: string | null = null + +/** + * The product slug you configured in your licensing-service (via the + * "Create product" action or the admin API). This is how the service knows + * which product a license is for. + * + * Example: 'bitcoin-ticker-pro' + */ +export const PRODUCT_SLUG = 'your-product-slug' + +/** + * Your licensing-service's Ed25519 public key in PEM form. Embed the full + * multi-line block as a template literal. The buyer's device verifies license + * keys AGAINST THIS KEY offline — no network required for the common case. + * + * Get this from your licensing-service: run the "Show admin credentials" + * action on your own Start9, or hit GET /v1/pubkey. + * + * Example: + * -----BEGIN PUBLIC KEY----- + * MCowBQYDK2VwAyEA... + * -----END PUBLIC KEY----- + */ +export const ISSUER_PUBKEY_PEM = `-----BEGIN PUBLIC KEY----- +PASTE YOUR ISSUER PUBLIC KEY PEM HERE +-----END PUBLIC KEY-----` + +/** + * The product name shown in UI prompts. Purely cosmetic. + */ +export const PRODUCT_DISPLAY_NAME = 'Your Product' diff --git a/startos/licensing/gate.ts b/startos/licensing/gate.ts new file mode 100644 index 0000000..bd6ab00 --- /dev/null +++ b/startos/licensing/gate.ts @@ -0,0 +1,47 @@ +// Runtime helper: "Is this install licensed?" — call from `main.ts` before +// you start your app's main process, so the app can refuse to boot (or boot +// in a reduced-functionality trial mode) when no valid key is present. +// +// This never touches the network — only the signature. The offline check is +// what you want at startup: nothing on the internet should be able to keep +// the buyer's app from starting if the key is legitimate. + +import { Verifier, PublicKey, type VerifyOk } from '@keysat/licensing-client' +import { ISSUER_PUBKEY_PEM, PRODUCT_SLUG } from './config' + +export interface LicenseGateResult { + licensed: boolean + /** Populated if licensed === true. */ + details?: VerifyOk + /** Populated if licensed === false. Human-readable. */ + reason?: string +} + +/** + * Check the license key stored in the package store. Pure offline — runs in + * a few milliseconds. + * + * Typical usage in main.ts: + * + * const gate = checkLicenseGate(await sdk.store.getOwn(effects, sdk.StorePath).const()) + * if (!gate.licensed) { + * // options: exit, or inject a "trial mode" env var into your daemon + * } + */ +export function checkLicenseGate(store: { + license_key?: string | null +}): LicenseGateResult { + const key = store.license_key + if (!key) return { licensed: false, reason: 'no license key stored' } + try { + const ok = new Verifier(PublicKey.fromPem(ISSUER_PUBKEY_PEM)).verify(key) + return { licensed: true, details: ok } + } catch (e) { + return { + licensed: false, + reason: e instanceof Error ? e.message : 'verification failed', + } + } +} + +export { PRODUCT_SLUG } diff --git a/startos/licensing/store.ts b/startos/licensing/store.ts new file mode 100644 index 0000000..06726c6 --- /dev/null +++ b/startos/licensing/store.ts @@ -0,0 +1,31 @@ +// Store shape additions for the licensing template. +// +// Merge these fields into your package's existing store shape (e.g. in +// `startos/init/index.ts` or wherever you declare `sdk.setupStore`). +// +// Example merge: +// +// export const initStore: StoreShape = { +// // ...your existing fields... +// ...licensingStoreDefaults, +// } +// +// The actions here write into these fields; your main process reads them. + +export interface LicensingStoreFields { + /** The raw license key string, e.g. "LIC1-...-..." */ + license_key: string | null + /** ISO timestamp of when the key was activated. */ + license_activated_at: string | null + /** Last known validation status from the licensing server, for display. */ + license_last_status: string | null + /** Invoice id of an in-progress purchase, if any. */ + license_pending_invoice_id: string | null +} + +export const licensingStoreDefaults: LicensingStoreFields = { + license_key: null, + license_activated_at: null, + license_last_status: null, + license_pending_invoice_id: null, +}