Initial public commit
This commit is contained in:
+20
@@ -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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Keysat
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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.`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user