// Sign in with AppsXi (Licensing Level 2) for Electron and Node apps (PRD 15.5, 22.3). No dependencies. // // const appxi = new AppsXi({ clientId: "pk_live_…", publicKeys: [""], // storageDir: app.getPath("userData") }); // let e = await appxi.check(); // every launch: offline first, refreshes when due // if (!e.signedIn) e = await appxi.signIn((url) => shell.openExternal(url)); // switch (e.status) { // case "active": unlockAll(); break; // case "trial": unlockAll(); showBanner(`${e.daysLeft} days left`); break; // case "none": if (e.trialAvailable) e = await appxi.startTrial(); else showPaywall(); break; // case "trial_expired": showPaywall(); break; // Buy → appxi.openCheckout(shell.openExternal), then // } // e = await appxi.waitForUnlock() // // Plugins and apps that cannot receive a browser redirect use signInWithCode(): show the code, // the buyer approves it at appxi.com/activate. Tokens are Ed25519 JWTs that AppsXi signs; this file // verifies them offline, bound to this device, with clock rollback detection. import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { homedir, hostname } from "node:os"; import path from "node:path"; export type Options = { /** The product's publishable key: pk_test_… while developing, pk_live_… in release builds. */ clientId: string; /** Base64url Ed25519 public keys ("x" from AppsXi's JWK set). More than one during a key rotation. */ publicKeys: string[]; /** Where the session and token are cached (Electron: app.getPath("userData")). */ storageDir: string; /** Optional: your app's public id (app_…). When set, tokens for any other product are refused. */ productId?: string; apiBase?: string; /** Shown on the consent and activation pages. Default: the computer's name. */ deviceName?: string; }; export type Status = "active" | "trial" | "trial_expired" | "none" | "seat_limit" | "revoked" | "signed_out"; export type Entitlement = { status: Status; /** true for active and trial: unlock the app. */ licensed: boolean; signedIn: boolean; buyer: string | null; license: string | null; trialExpiresAt: Date | null; daysLeft: number | null; trialAvailable: boolean; trialEndBehavior: "locked" | "read_only" | "limited"; /** Until when this computer may use the app without reaching AppsXi. */ offlineUntil: Date | null; /** true when the answer came from the cached token, not from AppsXi just now. */ offline: boolean; }; export class AppXiError extends Error { constructor( readonly code: string, message: string, ) { super(message); } } type Cache = { refresh_token?: string; token?: string | null; status?: Status; buyer?: string | null; license?: string | null; trial_expires_at?: string | null; trial_available?: boolean; trial_end_behavior?: Entitlement["trialEndBehavior"]; checkout_url?: string; product?: string; feed_base?: string | null; feed_token?: string | null; server_time?: number; // the latest time AppsXi told us (clock rollback detection) }; type TokenResponse = { error?: string; error_description?: string; refresh_token?: string; status: Exclude; entitlement: string | null; product: string; license: string | null; buyer: string; trial_expires_at: string | null; trial_available: boolean; trial_end_behavior: Entitlement["trialEndBehavior"]; checkout_url: string; updates: { feed_token: string; feed_base: string } | null; }; const DAY = 86_400_000; const CLOCK_SLACK = 10 * 60_000; export class AppsXi { private api: string; private file: string; private env: "live" | "sandbox"; constructor(private o: Options) { if (!/^pk_(live|test)_[0-9a-f]{32}$/.test(o.clientId)) throw new Error("clientId must be a pk_live_ or pk_test_ key"); if (!o.publicKeys.length) throw new Error("publicKeys is required (from /api/v1/licenses/public-key)"); this.api = (o.apiBase ?? "https://appsxi.com").replace(/\/$/, ""); this.file = path.join(o.storageDir, "appxi-signin.json"); this.env = o.clientId.startsWith("pk_test_") ? "sandbox" : "live"; } /** SHA-256 of the machine id and this product. The raw id never leaves the computer. */ 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.clientId.slice(0, 8)}:appxi-l2`).digest("hex"); } // ---------------------------------------------------------------- sign in /** * Opens the system browser on AppsXi's consent page and waits for the answer on a loopback * address (RFC 8252). `open` is shell.openExternal in Electron. */ async signIn(open: (url: string) => unknown, opts: { timeoutMs?: number } = {}): Promise { const ticket = await this.signInWithDesktop(); if (ticket) return ticket; const verifier = randomBytes(48).toString("base64url"); const challenge = createHash("sha256").update(verifier).digest("base64url"); const state = randomBytes(16).toString("base64url"); let redirectUri = ""; const code = await new Promise((resolve, reject) => { const server = createServer((req, res) => { const u = new URL(req.url ?? "/", "http://127.0.0.1"); if (u.pathname !== "/callback") return void res.writeHead(404).end(); const ok = u.searchParams.get("state") === state && !!u.searchParams.get("code"); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }); res.end( `AppsXi` + (ok ? "

You're signed in

You can close this tab and go back to the app.

" : "

Sign in was cancelled

Go back to the app to try again.

") + "", ); clearTimeout(timer); server.close(); if (ok) resolve(u.searchParams.get("code")!); else reject(new AppXiError(u.searchParams.get("error") ?? "access_denied", "Sign in was cancelled.")); }); const timer = setTimeout(() => { server.close(); reject(new AppXiError("timeout", "Sign in timed out.")); }, opts.timeoutMs ?? 10 * 60_000); server.on("error", reject); server.listen(0, "127.0.0.1", () => { redirectUri = `http://127.0.0.1:${(server.address() as { port: number }).port}/callback`; const q = new URLSearchParams({ response_type: "code", client_id: this.o.clientId, redirect_uri: redirectUri, code_challenge: challenge, code_challenge_method: "S256", state, scope: "entitlement", }); Promise.resolve(open(`${this.api}/oauth/authorize?${q}`)).catch(reject); }); }); return this.grant({ grant_type: "authorization_code", code, code_verifier: verifier, redirect_uri: redirectUri }); } /** * Device code sign-in for plugins: show `userCode` and `verificationUri` (or open * `verificationUriComplete`), then this resolves once the buyer approves. */ async signInWithCode( show: (c: { userCode: string; verificationUri: string; verificationUriComplete: string; expiresIn: number }) => unknown, opts: { signal?: AbortSignal; /** Overrides AppsXi's polling interval (tests). */ intervalMs?: number } = {}, ): Promise { const start = await this.post<{ device_code: string; user_code: string; verification_uri: string; verification_uri_complete: string; interval: number; expires_in: number }>( "/api/v1/oauth/device_authorization", { client_id: this.o.clientId, device_name: this.deviceName(), platform: platformName() }, ); await show({ userCode: start.user_code, verificationUri: start.verification_uri, verificationUriComplete: start.verification_uri_complete, expiresIn: start.expires_in }); let wait = opts.intervalMs ?? Math.max(5, start.interval) * 1000; const until = Date.now() + start.expires_in * 1000; while (Date.now() < until) { await sleep(wait, opts.signal); try { return await this.grant({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: start.device_code }); } catch (e) { const code = (e as AppXiError).code; if (code === "authorization_pending") continue; if (code === "slow_down") wait += opts.intervalMs ?? 5000; else throw e; } } throw new AppXiError("expired_token", "The code expired. Start again."); } /** * AppsXi Desktop leaves a one-time ticket when it installs an app that uses Sign in with AppsXi, * so the buyer never sees a sign-in screen. Returns null when there is none. */ async signInWithDesktop(): Promise { const dir = desktopTicketDir(); if (!dir) return null; for (const name of [this.o.productId, this.o.clientId].filter(Boolean)) { const file = path.join(dir, `${name}.json`); let ticket = ""; try { ticket = (JSON.parse(readFileSync(file, "utf8")) as { ticket?: string }).ticket ?? ""; } catch { continue; } try { unlinkSync(file); // single use either way } catch { // already gone } if (!/^axt_[0-9a-f]{48}$/.test(ticket)) continue; try { return await this.grant({ grant_type: "urn:appxi:params:oauth:grant-type:desktop-ticket", ticket }); } catch { return null; } } return null; } // ---------------------------------------------------------------- checks /** The entitlement from the cached token, offline. */ entitlement(now = new Date()): Entitlement { const c = this.load(); const base = { buyer: c.buyer ?? null, license: c.license ?? null, trialAvailable: !!c.trial_available, trialEndBehavior: c.trial_end_behavior ?? "locked", offline: true, } as const; if (!c.refresh_token) return { ...base, status: "signed_out", licensed: false, signedIn: false, trialExpiresAt: null, daysLeft: null, offlineUntil: null }; const claims = c.token ? this.verified(c.token, now, c.server_time) : null; const trialEnd = c.trial_expires_at ? new Date(c.trial_expires_at) : null; if (claims) { const trial = claims.status === "trial"; const end = trial && typeof claims.trial_end === "number" ? new Date(claims.trial_end * 1000) : trialEnd; return { ...base, status: trial ? "trial" : "active", licensed: true, signedIn: true, buyer: String(claims.sub), license: (claims.license as string | null) ?? null, trialExpiresAt: trial ? end : null, daysLeft: trial && end ? Math.max(0, Math.ceil((end.getTime() - now.getTime()) / DAY)) : null, offlineUntil: new Date(claims.exp * 1000), }; } // No valid token: trust the last status AppsXi gave only for the locked states. let status: Status = c.status && !["active", "trial"].includes(c.status) ? c.status : "none"; if (c.status === "trial" && trialEnd && trialEnd <= now) status = "trial_expired"; return { ...base, status, licensed: false, signedIn: true, trialExpiresAt: trialEnd, daysLeft: trialEnd ? 0 : null, offlineUntil: null }; } /** Asks AppsXi now. On a network error the cached entitlement is returned (offline: true). */ async refresh(): Promise { const c = this.load(); if (!c.refresh_token) return this.entitlement(); try { return await this.grant({ grant_type: "refresh_token", refresh_token: c.refresh_token }); } catch (e) { if (e instanceof AppXiError && e.code === "invalid_grant") { // Signed out elsewhere (library) or the session ended: sign in again. this.save({}); return this.entitlement(); } return this.entitlement(); } } /** Call at launch and now and then: offline answer, refreshed online once a day. */ async check(now = new Date()): Promise { const c = this.load(); if (!c.refresh_token) return this.entitlement(now); const claims = c.token ? this.verified(c.token, now, c.server_time) : null; const due = !claims || typeof claims.ref !== "number" || claims.ref * 1000 <= now.getTime(); return due ? this.refresh() : this.entitlement(now); } /** Starts the free trial for this buyer and computer (one per account and per computer). */ async startTrial(): Promise { const c = this.load(); if (!c.refresh_token) throw new AppXiError("signed_out", "Sign in first."); const r = await this.postRaw("/api/v1/trials/start", { client_id: this.o.clientId, device_id: this.deviceId() }, c.refresh_token); if (r.status) this.store(r, c.refresh_token); if (r.error) throw new AppXiError(r.error, r.error_description ?? "The trial could not start."); return this.entitlement(); } /** In trial, days left, ended, or still available. */ trialStatus(now = new Date()) { const e = this.entitlement(now); return { inTrial: e.status === "trial", daysLeft: e.daysLeft ?? 0, expired: e.status === "trial_expired", available: e.trialAvailable }; } /** AppsXi checkout for this product; the buyer is already signed in there. */ checkoutUrl(source?: string): string { const base = this.load().checkout_url ?? `${this.api}/apps`; return source ? `${base}${base.includes("?") ? "&" : "?"}from=${encodeURIComponent(source.slice(0, 40))}` : base; } async openCheckout(open: (url: string) => unknown, source?: string) { await open(this.checkoutUrl(source)); } /** After openCheckout: checks every few seconds until the purchase unlocks the app. */ async waitForUnlock(opts: { timeoutMs?: number; intervalMs?: number; signal?: AbortSignal } = {}): Promise { const until = Date.now() + (opts.timeoutMs ?? 15 * 60_000); while (Date.now() < until) { const e = await this.refresh(); if (e.status === "active" && !e.offline) return e; await sleep(opts.intervalMs ?? 4000, opts.signal); } return this.entitlement(); } /** Signs out on this computer and frees its seat. */ async signOut() { const c = this.load(); if (c.refresh_token) { await this.post("/api/v1/oauth/revoke", { client_id: this.o.clientId, token: c.refresh_token, device_id: this.deviceId() }).catch(() => {}); } this.save({}); } /** License-bound update feed for paid apps (electron-updater generic provider, Sparkle). */ updateFeed(platform: "macos" | "windows" = platformName() as "macos" | "windows", channel: "stable" | "beta" = "stable") { 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}` } }; } /** For the Backend Kit (appxi-kit.ts): where to call and with which session. */ kitSession() { return { api: this.api, clientId: this.o.clientId, deviceId: this.deviceId(), refreshToken: this.load().refresh_token ?? null, storageDir: this.o.storageDir }; } // ---------------------------------------------------------------- internals private verified(token: string, now: Date, serverTime?: number) { // A clock set back before the last time AppsXi answered is treated as no token (22.3). if (serverTime && now.getTime() < serverTime * 1000 - CLOCK_SLACK) return null; const claims = verifyToken(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; if (claims.env !== this.env) return null; if (this.o.productId && claims.product !== this.o.productId) return null; if ((claims.seats as { this_device?: string } | undefined)?.this_device !== this.deviceId()) return null; return claims as typeof claims & { exp: number; sub: string; ref?: number; trial_end?: number }; } private async grant(params: Record): Promise { const r = await this.post("/api/v1/oauth/token", { ...params, client_id: this.o.clientId, device_id: this.deviceId(), device_name: this.deviceName(), platform: platformName(), }); const refresh = r.refresh_token ?? this.load().refresh_token; if (!refresh) throw new AppXiError("invalid_grant", "AppsXi did not return a session."); this.store(r, refresh); // The online answer wins (also when AppsXi could not sign a token this time). return { ...this.entitlement(), offline: false, status: r.status, licensed: r.status === "active" || r.status === "trial" }; } private store(r: TokenResponse, refreshToken: string) { const prev = this.load(); const claims = r.entitlement ? verifyToken(r.entitlement, this.o.publicKeys) : null; if (r.entitlement && !claims) throw new AppXiError("bad_signature", "AppsXi's answer did not verify. Check publicKeys."); this.save({ refresh_token: refreshToken, token: r.entitlement, status: r.status, buyer: r.buyer, license: r.license, trial_expires_at: r.trial_expires_at, trial_available: r.trial_available, trial_end_behavior: r.trial_end_behavior, checkout_url: r.checkout_url, product: r.product, feed_base: r.updates?.feed_base ?? prev.feed_base ?? null, feed_token: r.updates?.feed_token ?? prev.feed_token ?? null, server_time: Math.max(prev.server_time ?? 0, typeof claims?.iat === "number" ? (claims.iat as number) : Math.floor(Date.now() / 1000)), }); } private deviceName() { return (this.o.deviceName ?? hostname().replace(/\.local$/, "")).slice(0, 80); } private async post(p: string, body: Record): Promise { const r = await this.postRaw(p, body); if (r.error) throw new AppXiError(r.error, r.error_description ?? r.error); return r; } private async postRaw(p: string, body: Record, bearer?: string): Promise { const res = await fetch(`${this.api}${p}`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}) }, body: new URLSearchParams(body).toString(), signal: AbortSignal.timeout(20_000), }); const j = (await res.json().catch(() => null)) as T | null; if (!j) throw new AppXiError("network", `AppsXi answered ${res.status}.`); return j; } private load(): Cache { try { return JSON.parse(readFileSync(this.file, "utf8")) as Cache; } catch { return {}; } } private save(c: Cache) { mkdirSync(path.dirname(this.file), { recursive: true }); const tmp = `${this.file}.tmp`; writeFileSync(tmp, JSON.stringify(c), { mode: 0o600 }); renameSync(tmp, this.file); } } function platformName() { return process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux"; } function desktopTicketDir() { if (process.platform === "darwin") return path.join(homedir(), "Library", "Application Support", "AppsXi", "Tickets"); if (process.platform === "win32") return path.join(process.env.APPDATA ?? path.join(homedir(), "AppData", "Roaming"), "AppsXi", "Tickets"); return null; } function sleep(ms: number, signal?: AbortSignal) { return new Promise((resolve, reject) => { if (signal?.aborted) return reject(new AppXiError("aborted", "Cancelled.")); const t = setTimeout(resolve, ms); signal?.addEventListener("abort", () => { clearTimeout(t); reject(new AppXiError("aborted", "Cancelled.")); }); }); } /** 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; } }