// AppsXi Backend Kit (PRD 20.4) for Electron and Node apps: remote config and feature flags, // per-buyer storage, crash reports, usage counts, an AI proxy and announcements, with no // server of your own. No dependencies. Works with appxi-signin.ts. // // const appxi = new AppsXi({ clientId: "pk_live_…", publicKeys: [""], storageDir }); // const kit = new AppXiKit(appxi, { appVersion: app.getVersion() }); // const { flags } = await kit.config(); // cached for offline launches // if (kit.flag("new_editor")) enableNewEditor(); // await kit.storage.put("presets", presets); // per buyer, synced across computers // const res = await kit.ai("anthropic", "messages", { model: "claude-haiku-4-5", max_tokens: 400, messages }); // kit.track("export"); // anonymous counts; only if the buyer opted in // process.on("uncaughtException", (e) => crashOptIn && kit.reportCrash(e)); import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { AppXiError, type AppsXi } from "./appxi-signin"; export type KitConfig = { config: Record; flags: Record; version: number; environment: "live" | "sandbox"; /** true when AppsXi could not be reached and this is the last answer saved on disk. */ offline: boolean; }; export type StoredValue = { key: string; value: T; version: number; updated_at: string }; export type Announcement = { id: string; title: string; body: string; url: string | null; created_at: string }; export class AppXiKit { private cfg: KitConfig | null = null; private queue = new Map(); private timer: ReturnType | null = null; constructor( private appxi: AppsXi, private o: { appVersion?: string; timeoutMs?: number } = {}, ) {} // ---------------------------------------------------------------- config and flags /** Config and flags for this app version and computer. Falls back to the last answer offline. */ async config(): Promise { const s = this.appxi.kitSession(); const file = path.join(s.storageDir, "appxi-kit-config.json"); try { const r = await this.call>("GET", "/api/v1/kit/config"); this.cfg = { ...r, offline: false }; save(file, r); } catch (e) { const saved = load>(file); if (!saved) { if (e instanceof AppXiError && e.code !== "network") throw e; this.cfg = { config: {}, flags: {}, version: 0, environment: s.clientId.startsWith("pk_test_") ? "sandbox" : "live", offline: true }; } else this.cfg = { ...saved, offline: true }; } return this.cfg; } /** A flag from the last config() answer; `fallback` before the first one. */ flag(name: string, fallback = false): boolean { return this.cfg?.flags[name] ?? fallback; } /** A config value from the last config() answer. */ value(name: string, fallback: T): T { return (this.cfg?.config[name] as T | undefined) ?? fallback; } // ---------------------------------------------------------------- per-buyer storage /** * Settings, presets and sync state for the signed-in buyer, shared by all their computers. * 64 KB per value, 500 keys, 2 MB in all. Writes need a license or a trial. * Pass `ifVersion` to write only if nobody changed the value since you read it; a conflict * throws AppXiError("conflict") with the current version in `message`. */ readonly storage = { list: () => this.call<{ keys: { key: string; version: number; bytes: number; updated_at: string }[]; bytes: number }>("GET", "/api/v1/kit/storage", undefined, true), get: async (key: string): Promise | null> => { try { return await this.call>("GET", `/api/v1/kit/storage/${keyPath(key)}`, undefined, true); } catch (e) { if (e instanceof AppXiError && e.code === "not_found") return null; throw e; } }, put: async (key: string, value: unknown, ifVersion?: number) => this.call<{ key: string; version: number; updated_at: string }>("PUT", `/api/v1/kit/storage/${keyPath(key)}`, { value, ...(ifVersion !== undefined ? { if_version: ifVersion } : {}) }, true), delete: async (key: string) => this.call<{ deleted: boolean }>("DELETE", `/api/v1/kit/storage/${keyPath(key)}`, undefined, true), }; // ---------------------------------------------------------------- crash reports /** * Sends a crash report. Ask the buyer first (a "Send crash reports" setting): AppsXi removes * home folder names from the stack, but only send what they agreed to. */ async reportCrash(error: unknown, context: Record = {}) { const e = error instanceof Error ? error : new Error(String(error)); const s = this.appxi.kitSession(); return this.call<{ ok: true; group: string }>("POST", "/api/v1/kit/crash", { client_id: s.clientId, device_id: s.deviceId, app_version: this.o.appVersion, title: `${e.name}: ${e.message}`.slice(0, 300), stack: (e.stack ?? `${e.name}: ${e.message}`).slice(0, 60_000), os: process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : process.platform, os_version: os.release(), arch: process.arch, context, }).catch(() => null); } // ---------------------------------------------------------------- usage counts /** Counts an event (letters, digits, _ . : -). Sent in batches; only if the buyer opted in. */ track(name: string, count = 1) { if (!/^[A-Za-z0-9_.:-]{1,64}$/.test(name)) return; this.queue.set(name, (this.queue.get(name) ?? 0) + count); if (!this.timer) { this.timer = setTimeout(() => void this.flush(), 5 * 60_000); this.timer.unref?.(); } } /** Sends queued counts now (call on quit). */ async flush() { if (this.timer) clearTimeout(this.timer); this.timer = null; if (!this.queue.size) return; const events = [...this.queue].slice(0, 50).map(([name, count]) => ({ name, count: Math.min(count, 1000) })); this.queue.clear(); const s = this.appxi.kitSession(); await this.call("POST", "/api/v1/kit/track", { client_id: s.clientId, device_id: s.deviceId, app_version: this.o.appVersion, events }).catch(() => {}); } // ---------------------------------------------------------------- AI proxy /** * Calls OpenAI or Anthropic through AppsXi with the buyer's session; your API key stays on * AppsXi. `body` is the provider's own request. Returns the provider's Response, so streaming * (`stream: true`) works as usual. Errors from AppsXi: 401 sign in again, 402 not licensed, * 403 not enabled or model not allowed, 429 daily limit. */ async ai(provider: "openai" | "anthropic", endpoint: string, body: Record, opts: { signal?: AbortSignal; anthropicVersion?: string } = {}) { const s = this.appxi.kitSession(); if (!s.refreshToken) throw new AppXiError("signed_out", "Sign in first."); return fetch(`${s.api}/api/v1/kit/ai/${provider}/${endpoint.replace(/^\/+/, "")}`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${s.refreshToken}`, "X-AppXi-Client": s.clientId, "X-AppXi-Device": s.deviceId, ...(opts.anthropicVersion ? { "anthropic-version": opts.anthropicVersion } : {}), }, body: JSON.stringify(body), signal: opts.signal ?? AbortSignal.timeout(120_000), }); } // ---------------------------------------------------------------- announcements /** Notes from you to people using this version of the app. */ async announcements(): Promise { const r = await this.call<{ announcements: Announcement[] }>("GET", "/api/v1/kit/announcements").catch(() => ({ announcements: [] })); return r.announcements; } // ---------------------------------------------------------------- internals private async call(method: string, p: string, body?: unknown, buyer = false): Promise { const s = this.appxi.kitSession(); if (buyer && !s.refreshToken) throw new AppXiError("signed_out", "Sign in first."); const headers: Record = { "X-AppXi-Client": s.clientId, "X-AppXi-Device": s.deviceId }; if (this.o.appVersion) headers["X-AppXi-App-Version"] = this.o.appVersion.slice(0, 40); if (buyer) headers.Authorization = `Bearer ${s.refreshToken}`; if (body !== undefined) headers["Content-Type"] = "application/json"; let res: Response; try { res = await fetch(`${s.api}${p}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(this.o.timeoutMs ?? 20_000) }); } catch { throw new AppXiError("network", "AppsXi could not be reached."); } const j = (await res.json().catch(() => null)) as (T & { error?: string; message?: string; version?: number }) | null; if (!j) throw new AppXiError("network", `AppsXi answered ${res.status}.`); if (j.error) throw new AppXiError(j.error, j.error === "conflict" ? String(j.version ?? 0) : (j.message ?? j.error)); return j; } } function keyPath(key: string) { if (!/^[A-Za-z0-9._:/-]{1,128}$/.test(key)) throw new AppXiError("invalid_request", "Keys are 1 to 128 letters, digits or . _ : / -"); return key.split("/").map(encodeURIComponent).join("/"); } function load(file: string): T | null { try { return JSON.parse(readFileSync(file, "utf8")) as T; } catch { return null; } } function save(file: string, v: unknown) { try { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(`${file}.tmp`, JSON.stringify(v), { mode: 0o600 }); renameSync(`${file}.tmp`, file); } catch { // a read-only profile folder only loses the offline fallback } }