Files
keysat-activate-template/startos/actions/activateLicense.ts
T
2026-05-07 10:41:57 -05:00

134 lines
4.6 KiB
TypeScript

// 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)
}