Initial public commit

This commit is contained in:
Keysat
2026-05-07 10:41:57 -05:00
commit 8c9bc75e24
12 changed files with 906 additions and 0 deletions
+20
View File
@@ -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
+21
View File
@@ -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.
+135
View File
@@ -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.
+133
View File
@@ -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<string> {
// 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)
}
+181
View File
@@ -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<Client> {
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.`,
)
}
+93
View File
@@ -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<Client> {
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)
}
+34
View File
@@ -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.`,
}
},
)
+28
View File
@@ -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,
}
+123
View File
@@ -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)
}
+60
View File
@@ -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'
+47
View File
@@ -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 }
+31
View File
@@ -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,
}