/**
 * src/security/license.ts
 *
 * Al Moasis · المؤسس — License + machine-fingerprint gate (v3.0).
 *
 * Business model: Al Moasis is sold as a one-time 999 SAR perpetual download
 * (.dmg / .msi / AppImage). To prevent casual sharing — torrents, "send me
 * your install" between coworkers, etc. — this module enforces:
 *
 *   1. **Activation:** on first launch, the user pastes a license key. Gateway
 *      calls https://license.almoasis.ai/activate, which binds the key to this
 *      machine's fingerprint server-side. Returns a signed license file.
 *
 *   2. **Heartbeat:** every 24h the gateway re-validates with the license
 *      server. Server can revoke (refund, abuse, key shared) → revocation
 *      propagates within 24h.
 *
 *   3. **Offline grace:** if the license server is unreachable, the gateway
 *      continues running for 7 days using the cached signed file. After
 *      7 days offline the gateway enters read-only mode (incoming messages
 *      logged but no replies sent) until heartbeat succeeds.
 *
 *   4. **Machine fingerprint:** sha256(hostname + platform.uuid + cpu.model).
 *      Stable across reboots, changes on hardware swap → forces re-activation.
 *
 * Honest disclaimer: this is a speed-bump, not a vault. A determined attacker
 * who decompiles the binary can patch out the checks. The bar is "non-technical
 * pirates give up; technical pirates spend 2 weeks." For 999 SAR, that's enough.
 *
 * NEVER hardcode the public key here — load from the build manifest at startup.
 */
import { createHash, randomBytes, verify as edVerify, createPublicKey } from 'node:crypto';
import { hostname, platform, arch, cpus } from 'node:os';
import { join } from 'node:path';
import { existsSync, readFileSync, writeFileSync, chmodSync, renameSync } from 'node:fs';
const LICENSE_FILE = 'license.bin';
const FINGERPRINT_FILE = 'machine.fp';
// Production points at the live Vercel deployment. Once a custom domain
// (license.almoasis.ai) is set up in DNS, this can change in v3.1.
// Override at runtime via LICENSE_SERVER_URL env var.
const LICENSE_SERVER_DEFAULT = process.env.LICENSE_SERVER_URL ?? 'https://aimoasis-license-server.vercel.app';
// Al Moasis license-signing PUBLIC key (Ed25519, PEM format).
// Generated 2026-05-22 — paired with the private key stored in the Vercel
// license-server project's LICENSE_SIGNING_PRIVATE_KEY env var.
//
// To rotate: regenerate keypair via almoasis-license-server/scripts/generate-keypair.mjs,
// paste the new public key here, rebuild the client, and update the Vercel env var
// with the new private key. ALL existing customer licenses will become invalid
// until they're re-signed and re-shipped.
const PLACEHOLDER_PUBKEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAxwo0d8fQhKG6EJ0KwBP8JRkjkRnuAg6nmnmULSVwq2M=
-----END PUBLIC KEY-----`;
/**
 * Compute the machine fingerprint. Stable across reboots, changes on
 * hardware swap (which is what we want — a license keyed to "this device").
 *
 * Components:
 *   - hostname (changes if user renames their Mac; rare)
 *   - platform + arch (darwin/arm64, win32/x64, linux/x64)
 *   - CPU model name (changes only on actual hardware replacement)
 *
 * NOT used: MAC address (changes per network interface, can be spoofed easily),
 * disk serial (requires platform-specific calls), Apple UUID (privacy-sensitive).
 * The chosen components are stable enough and don't require elevated permissions.
 */
export function getMachineFingerprint() {
    const cpuModel = cpus()[0]?.model ?? 'unknown-cpu';
    const components = [hostname(), platform(), arch(), cpuModel];
    const fp = createHash('sha256').update(components.join('|')).digest('hex');
    return {
        fingerprint: fp,
        hostname: hostname(),
        platform: platform(),
        arch: arch(),
        cpu_model: cpuModel,
    };
}
/**
 * Persist the machine fingerprint to <dataDir>/machine.fp. First-write only —
 * subsequent reads return the originally-stored fingerprint so a hardware
 * change doesn't silently invalidate the license (the server will reject the
 * heartbeat instead, with a clearer error).
 */
export function getStoredFingerprint(dataDir) {
    const path = join(dataDir, FINGERPRINT_FILE);
    if (existsSync(path)) {
        return readFileSync(path, 'utf-8').trim();
    }
    const fresh = getMachineFingerprint().fingerprint;
    writeFileSync(path, fresh, { encoding: 'utf-8', mode: 0o600 });
    try {
        chmodSync(path, 0o600);
    }
    catch { /* Windows tolerance */ }
    return fresh;
}
/**
 * Load the signed license file from disk and verify its signature against
 * the embedded public key. Returns null if absent or signature fails.
 *
 * Verification path: canonical-JSON-stringify(license without signature)
 * → Ed25519.verify(message, signature) using the embedded public key.
 */
export function loadAndVerifyLicense(dataDir, pubKeyPem) {
    const path = join(dataDir, LICENSE_FILE);
    if (!existsSync(path))
        return null;
    let parsed;
    try {
        parsed = JSON.parse(readFileSync(path, 'utf-8'));
    }
    catch {
        return null;
    }
    const { signature, ...payload } = parsed;
    const canonical = JSON.stringify(payload, Object.keys(payload).sort());
    try {
        const pubKey = createPublicKey(pubKeyPem ?? PLACEHOLDER_PUBKEY);
        const ok = edVerify(null, // Ed25519: no separate digest algorithm
        Buffer.from(canonical), pubKey, Buffer.from(signature, 'base64'));
        if (!ok)
            return null;
    }
    catch {
        return null;
    }
    // Bind to fingerprint stored on this machine. If the file was copied to a
    // different machine (the piracy vector this is built to stop), this check
    // fails and the user is forced to re-activate.
    const storedFp = getStoredFingerprint(dataDir);
    if (parsed.fingerprint !== storedFp)
        return null;
    process.env.ALMOASIS_LICENSE_KEY = parsed.key;
    return parsed;
}
/**
 * Persist a signed license file (mode 0600). Caller is responsible for
 * receiving the signed payload from the license server before calling this.
 */
export function storeSignedLicense(dataDir, license) {
    const path = join(dataDir, LICENSE_FILE);
    const tmp = `${path}.tmp`;
    writeFileSync(tmp, JSON.stringify(license, null, 2), { encoding: 'utf-8', mode: 0o600 });
    renameSync(tmp, path);
    try {
        chmodSync(path, 0o600);
    }
    catch { /* Windows tolerance */ }
}
/**
 * Get the current license state. Combines the on-disk license file, the
 * last heartbeat timestamp, and offline-grace policy into a single answer
 * the gateway can use to decide "should I serve requests."
 *
 * Returns:
 *   - state='unactivated': no license file, user must paste a key
 *   - state='active':      license verified, last heartbeat <24h ago
 *   - state='offline_grace': license verified but heartbeat stale 1-7d (still serving)
 *   - state='read_only':   heartbeat stale >7d (block outbound replies)
 *   - state='expired':     server-issued expires_at is in the past
 *   - state='revoked':     heartbeat returned revocation in last 24h
 */
export function getLicenseStatus(dataDir, pubKeyPem) {
    const fp = getStoredFingerprint(dataDir);
    const license = loadAndVerifyLicense(dataDir, pubKeyPem);
    if (!license) {
        return {
            ok: false,
            state: 'unactivated',
            message: 'يحتاج إلى تفعيل. الصق مفتاح الترخيص في تبويب الإعدادات.',
            fingerprint: fp,
            last_heartbeat_at: null,
            days_offline: 0,
            plan: null,
            expires_at: null,
            days_remaining: null,
        };
    }
    // Derive plan/expiry fields shared by every post-activation branch.
    const planFields = {
        plan: license.plan,
        expires_at: license.expires_at,
        days_remaining: license.expires_at
            ? Math.max(0, Math.ceil((new Date(license.expires_at).getTime() - Date.now()) / (24 * 60 * 60 * 1000)))
            : null,
    };
    // Server-issued expiry (rare for perpetual licenses but supported).
    if (license.expires_at && new Date(license.expires_at).getTime() < Date.now()) {
        return {
            ok: false,
            state: 'expired',
            message: 'انتهت صلاحية الترخيص. جدّد من almoasis.ai/account.',
            fingerprint: fp,
            last_heartbeat_at: license.last_heartbeat_at,
            days_offline: 0,
            ...planFields,
        };
    }
    const heartbeatMs = new Date(license.last_heartbeat_at).getTime();
    const daysOffline = Math.floor((Date.now() - heartbeatMs) / (24 * 60 * 60 * 1000));
    if (daysOffline >= 7) {
        return {
            ok: false,
            state: 'read_only',
            message: 'فشل الاتصال بخادم الترخيص لأكثر من 7 أيام. Al Moasis في وضع القراءة فقط. تحقق من اتصال الإنترنت.',
            fingerprint: fp,
            last_heartbeat_at: license.last_heartbeat_at,
            days_offline: daysOffline,
            ...planFields,
        };
    }
    if (daysOffline >= 1) {
        return {
            ok: true,
            state: 'offline_grace',
            message: `يعمل في وضع عدم الاتصال (${daysOffline} يوم/أيام بدون اتصال بالخادم). سيُعاد التحقق تلقائياً.`,
            fingerprint: fp,
            last_heartbeat_at: license.last_heartbeat_at,
            days_offline: daysOffline,
            ...planFields,
        };
    }
    return {
        ok: true,
        state: 'active',
        message: '✅ الترخيص نشط',
        fingerprint: fp,
        last_heartbeat_at: license.last_heartbeat_at,
        days_offline: 0,
        ...planFields,
    };
}
/**
 * Call the license server to activate a key against this machine's fingerprint.
 * Returns the signed license payload on success. The caller persists it via
 * storeSignedLicense.
 *
 * Network failure → throws. The gateway treats that as "user can't activate
 * right now, try again." We do NOT fall back to a permissive default — the
 * whole point of the gate is that activation requires network reach.
 */
export async function activateLicense(licenseKey, fingerprint, serverUrl = LICENSE_SERVER_DEFAULT) {
    const res = await fetch(`${serverUrl}/v1/activate`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            license_key: licenseKey,
            fingerprint: fingerprint.fingerprint,
            machine: {
                hostname: fingerprint.hostname,
                platform: fingerprint.platform,
                arch: fingerprint.arch,
                cpu_model: fingerprint.cpu_model,
            },
            client_nonce: randomBytes(16).toString('hex'),
        }),
        signal: AbortSignal.timeout(15_000),
    });
    if (!res.ok) {
        const text = await res.text().catch(() => '');
        throw new Error(`Activation failed (${res.status}): ${text || res.statusText}`);
    }
    return (await res.json());
}
/**
 * Heartbeat ping — server confirms the license is still valid for this
 * machine and refreshes the cached `last_heartbeat_at`. Server may also
 * issue a revoke response that the gateway honors immediately.
 */
export async function heartbeat(license, serverUrl = LICENSE_SERVER_DEFAULT) {
    const res = await fetch(`${serverUrl}/v1/heartbeat`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            license_key: license.key,
            fingerprint: license.fingerprint,
        }),
        signal: AbortSignal.timeout(10_000),
    });
    if (res.status === 410) {
        throw new Error('REVOKED');
    }
    if (!res.ok) {
        throw new Error(`heartbeat failed (${res.status})`);
    }
    return (await res.json());
}
//# sourceMappingURL=license.js.map