Initial public commit
This commit is contained in:
@@ -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