Files

141 lines
5.1 KiB
Markdown

# StartOS activate-license template
A drop-in folder that adds Keysat 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.
- **"Finish license purchase"** — after the buyer pays, fetch the issued key and persist it locally.
- **"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.
5. The action files import `sdk` from `'../sdk'` — a module you must provide. Point that path at (or alias it to) your package's own `@start9labs/start-sdk` singleton; otherwise the template won't compile.
## Install
Copy these folders into your buyer-side package's `startos/` directory:
```
startos/licensing/ ← config, store shape, runtime gate
startos/actions/ ← six 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 four constants to set: `LICENSING_BASE_URL`, `PRODUCT_SLUG`,
`ISSUER_PUBKEY_PEM`, and `PRODUCT_DISPLAY_NAME`. Each is documented inline.
`PRODUCT_DISPLAY_NAME` is interpolated into every user-visible string, so
leaving it at the default is a real mistake, not a cosmetic one.
### 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.redeemFreeLicense)
.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 six 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.