// AppsXi Level 1 license helper for Electron and Node apps (PRD 15.4, LIC-5). No dependencies. // // const lic = new AppXiLicense({ productKey: "pk_live_…", publicKeys: [""], // appSlug: "my-app", storageDir: app.getPath("userData") }); // const r = await lic.activate("AXI-…"); // once, online // const e = lic.current(); // every launch, offline // if (lic.needsRefresh()) await lic.refresh().catch(() => {}); // // The key, token and feed URL are cached in /appxi-license.json. Tokens are Ed25519 // JWTs signed by AppsXi; current() verifies the signature and expiry with the embedded public key. import { createHash, createPublicKey, verify } from "node:crypto"; import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { hostname } from "node:os"; import path from "node:path"; export type Options = { productKey: string; /** Base64url Ed25519 public keys ("x" from AppsXi's JWK set). More than one during a key rotation. */ publicKeys: string[]; appSlug: string; storageDir: string; apiBase?: string; }; export type Entitlement = { status: "active" | "trial"; license: string; buyer: string; environment: "live" | "sandbox"; expiresAt: Date; trialExpiresAt: Date | null; seats: number; }; export type ValidateResult = | { valid: true; entitlement: Entitlement | null; seatsUsed: number; seatsMax: number; feedBase: string | null; feedToken: string | null } | { valid: false; reason: string; seatsUsed?: number; seatsMax?: number }; type Cache = { key: string; token: string | null; trial_expires_at: string | null; feed_base: string | null; feed_token: string | null; checked_at: string }; export class AppXiLicense { private api: string; private file: string; constructor(private o: Options) { if (!/^pk_(live|test)_[0-9a-f]{32}$/.test(o.productKey)) throw new Error("productKey must be a pk_live_ or pk_test_ key"); this.api = (o.apiBase ?? "https://appsxi.com").replace(/\/$/, ""); this.file = path.join(o.storageDir, "appxi-license.json"); } /** A stable, app-specific device id: SHA-256 of the machine id and the product key. Never the raw id. */ deviceId(): string { let raw = ""; try { if (process.platform === "darwin") { raw = /"IOPlatformUUID" = "([^"]+)"/.exec(execFileSync("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], { encoding: "utf8" }))?.[1] ?? ""; } else if (process.platform === "win32") { raw = /MachineGuid\s+REG_SZ\s+(\S+)/.exec(execFileSync("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"], { encoding: "utf8" }))?.[1] ?? ""; } else if (existsSync("/etc/machine-id")) { raw = readFileSync("/etc/machine-id", "utf8").trim(); } } catch { raw = ""; } return createHash("sha256").update(`${raw || hostname()}:${this.o.productKey.slice(0, 8)}:${this.o.appSlug}`).digest("hex"); } /** Validates a key online, takes a seat on this device, and caches the signed token. */ async activate(licenseKey: string): Promise { const res = await fetch(`${this.api}/api/v1/licenses/validate`, { method: "POST", headers: { Authorization: `Bearer ${this.o.productKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ license_key: licenseKey.trim(), device_id: this.deviceId(), device_name: hostname().slice(0, 80), platform: platformName() }), signal: AbortSignal.timeout(15_000), }); const j = (await res.json().catch(() => ({ valid: false, reason: `http_${res.status}` }))) as { valid: boolean; reason?: string; seats_used?: number; seats_max?: number; token?: string | null; trial_expires_at?: string | null; updates?: { feed_token: string; feed_base: string } | null; }; if (!j.valid) { // A revoked or expired license must stop working offline too. if (j.reason && ["revoked", "expired", "unknown_license"].includes(j.reason)) this.forget(); return { valid: false, reason: j.reason ?? "invalid", seatsUsed: j.seats_used, seatsMax: j.seats_max }; } this.save({ key: licenseKey.trim(), token: j.token ?? null, trial_expires_at: j.trial_expires_at ?? null, feed_base: j.updates?.feed_base ?? null, feed_token: j.updates?.feed_token ?? null, checked_at: new Date().toISOString(), }); return { valid: true, entitlement: this.current(), seatsUsed: j.seats_used ?? 0, seatsMax: j.seats_max ?? 0, feedBase: j.updates?.feed_base ?? null, feedToken: j.updates?.feed_token ?? null }; } /** Validates the cached key again (call when needsRefresh() is true and the network is up). */ async refresh(): Promise { const c = this.load(); if (!c) return { valid: false, reason: "no_license" }; return this.activate(c.key); } /** The verified entitlement from the cached token, offline. Null if missing, tampered or expired. */ current(now = new Date()): Entitlement | null { const c = this.load(); if (!c?.token) return null; const claims = verifyToken(c.token, this.o.publicKeys); if (!claims || typeof claims.exp !== "number" || claims.exp * 1000 <= now.getTime()) return null; if (claims.status !== "active" && claims.status !== "trial") return null; const env = this.o.productKey.startsWith("pk_test_") ? "sandbox" : "live"; if (claims.env !== env) return null; return { status: claims.status, license: String(claims.license), buyer: String(claims.sub), environment: env, expiresAt: new Date(claims.exp * 1000), trialExpiresAt: claims.status === "trial" && c.trial_expires_at ? new Date(c.trial_expires_at) : null, seats: Number((claims.seats as { max?: number } | undefined)?.max ?? 1), }; } /** True when the token is missing or has less than seven days left. */ needsRefresh(now = new Date()): boolean { const e = this.current(now); return !e || e.expiresAt.getTime() - now.getTime() < 7 * 86400_000; } /** Trial state for your UI. */ trialStatus(now = new Date()): { inTrial: boolean; daysLeft: number; expired: boolean } { const e = this.current(now); if (e?.status === "trial") { const end = (e.trialExpiresAt ?? e.expiresAt).getTime(); return { inTrial: true, daysLeft: Math.max(0, Math.ceil((end - now.getTime()) / 86400_000)), expired: end <= now.getTime() }; } const c = this.load(); const expired = !e && !!c?.trial_expires_at && Date.parse(c.trial_expires_at) <= now.getTime(); return { inTrial: false, daysLeft: 0, expired }; } /** Where buyers pay: your AppsXi checkout. Open it with shell.openExternal (Electron). */ checkoutUrl(): string { return `${this.api}/checkout/${encodeURIComponent(this.o.appSlug)}`; } /** * The license-bound update feed for paid apps, or null before activation. * electron-updater: autoUpdater.setFeedURL({ provider: "generic", url: lic.updateFeed("macos")!.url, * requestHeaders: lic.updateFeed("macos")!.headers }) */ updateFeed(platform: "macos" | "windows" = platformName(), channel: "stable" | "beta" = "stable"): { url: string; appcastUrl: string; headers: Record } | null { const c = this.load(); if (!c?.feed_base || !c.feed_token) return null; const url = `${c.feed_base}/${platform}/${channel}`; return { url, appcastUrl: `${url}/appcast.xml?token=${c.feed_token}`, headers: { Authorization: `Bearer ${c.feed_token}` } }; } /** Removes the cached key and token (sign out, or after a refund). */ forget() { try { writeFileSync(this.file, "{}", { mode: 0o600 }); } catch { // nothing cached } } private load(): Cache | null { try { const c = JSON.parse(readFileSync(this.file, "utf8")) as Partial; return c.key ? (c as Cache) : null; } catch { return null; } } private save(c: Cache) { mkdirSync(path.dirname(this.file), { recursive: true }); writeFileSync(this.file, JSON.stringify(c), { mode: 0o600 }); } } function platformName(): "macos" | "windows" { return process.platform === "win32" ? "windows" : "macos"; } /** Verifies a compact EdDSA JWT against any of the given base64url Ed25519 public keys. */ export function verifyToken(token: string, publicKeys: string[]): Record & { exp?: number; status?: string; env?: string } | null { const parts = token.split("."); if (parts.length !== 3) return null; try { const header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8")) as { alg?: string }; if (header.alg !== "EdDSA") return null; const data = Buffer.from(`${parts[0]}.${parts[1]}`); const sig = Buffer.from(parts[2], "base64url"); const ok = publicKeys.some((x) => { try { return verify(null, data, createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x }, format: "jwk" }), sig); } catch { return false; } }); return ok ? JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) : null; } catch { return null; } }