import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { mkdirSync } from 'fs';
import { initDb, closeDb, query, withTransaction } from './db.js';
import { ensureSchema } from './schema.js';
import { loadEnv } from './env.js';
import { startJobs, stopJobs } from './jobs.js';
import { verifyBalance, signBalance } from './balance-integrity.js';
import authRoutes from './routes/auth.js';
import chargeRoutes from './routes/charge.js';
import usersRoutes from './routes/users.js';
import checkoutRoutes from './routes/checkout.js';
import balanceRoutes from './routes/balance.js';
import adminRoutes from './routes/admin.js';
import dashboardRoutes from './routes/dashboard.js';
import provisionRoutes from './routes/provision.js';
// Load environment
const env = loadEnv();
// Initialize SQLite database
const dbPath = env.LICENSE_DB_PATH || join(homedir(), '.ocr-provenance-mcp', '_license_server.db');
mkdirSync(dirname(dbPath), { recursive: true });
initDb(dbPath);
ensureSchema();
console.error(`[DB] SQLite initialized at ${dbPath}`);
// Verify balance integrity on startup — re-sign records with current secrets.
// D1 is the source of truth for balances. Local DB is a cache.
// If secrets changed (upgrade, re-provision), old HMACs will mismatch.
// We re-sign with current secrets rather than crashing — the local balance
// is informational only; D1 authorization is what controls processing.
function verifyBalanceIntegrity() {
    const keys = query('SELECT id, key_prefix, balance_cents, balance_hmac FROM license_keys');
    let resigned = 0;
    let unsigned = 0;
    for (const key of keys.rows) {
        if (!key.balance_hmac) {
            // No HMAC at all — sign it (migration from pre-HMAC version)
            const hmac = signBalance(key.id, key.balance_cents);
            query('UPDATE license_keys SET balance_hmac = ? WHERE id = ?', [hmac, key.id]);
            unsigned++;
        }
        else if (!verifyBalance(key.id, key.balance_cents, key.balance_hmac)) {
            // HMAC mismatch — secrets changed (upgrade) or data corrupted.
            // Re-sign with current secrets. D1 is the source of truth, not local balance.
            const hmac = signBalance(key.id, key.balance_cents);
            query('UPDATE license_keys SET balance_hmac = ? WHERE id = ?', [hmac, key.id]);
            resigned++;
            console.error(`[startup] Re-signed key=${key.key_prefix}... (HMAC mismatch — secrets likely changed during upgrade)`);
        }
    }
    if (unsigned > 0) {
        console.error(`[startup] Signed ${unsigned} pre-existing balance record(s) with HMAC`);
    }
    if (resigned > 0) {
        console.error(`[startup] Re-signed ${resigned} record(s) with updated secrets (D1 is source of truth)`);
    }
}
verifyBalanceIntegrity();
/**
 * BUG-2/BUG-4: Clean up orphan users and license keys.
 *
 * Finds ALL active license keys across ALL users. Keeps only one active key
 * globally. The winner is the key with the HIGHEST balance (not newest).
 * If balances are tied, the most recently created key wins as tiebreaker.
 *
 * CRITICAL SAFETY INVARIANT: A key with balance > 0 is NEVER revoked in favor
 * of a key with balance = 0. This prevents catastrophic balance loss when a
 * container restart creates a phantom $0 key from a new machine_id.
 *
 * This is aggressive: ONE installation = ONE active key. Period.
 * If multiple users/keys exist from old provisioning bugs, only the richest survives.
 *
 * Runs once at startup. Future orphans prevented by provision.ts dedup.
 */
function orphanCleanup() {
    // Count total active keys across ALL users
    // Sort by balance_cents DESC (richest first), then created_at DESC (newest as tiebreaker)
    const allActiveKeys = query(`SELECT lk.id as key_id, lk.key_prefix, lk.user_id, u.email as user_email,
            lk.balance_cents, u.stripe_customer_id, lk.created_at
     FROM license_keys lk
     JOIN users u ON lk.user_id = u.id
     WHERE lk.status = 'active'
     ORDER BY lk.balance_cents DESC, lk.created_at DESC`);
    if (allActiveKeys.rows.length <= 1) {
        return; // 0 or 1 active key — nothing to clean
    }
    // We intentionally don't filter by email pattern — ANY duplicate active keys are orphans.
    // Log what we found for auditability.
    const duplicateEmails = new Map();
    for (const k of allActiveKeys.rows) {
        duplicateEmails.set(k.user_email, (duplicateEmails.get(k.user_email) ?? 0) + 1);
    }
    // Winner = key with highest balance (first in the list, sorted by balance DESC, then created_at DESC)
    const winner = allActiveKeys.rows[0];
    const losers = allActiveKeys.rows.slice(1);
    // SAFETY CHECK: Log FATAL warning if ANY loser has significant balance (>= $1.00)
    // This should never happen if the sort is correct, but guards against logic errors.
    const losersWithBalance = losers.filter(l => l.balance_cents > 0);
    if (losersWithBalance.length > 0) {
        for (const l of losersWithBalance) {
            if (l.balance_cents > winner.balance_cents) {
                // This should be IMPOSSIBLE given the sort order, but if it happens, ABORT.
                console.error(`[orphan-cleanup] FATAL: About to revoke key=${l.key_prefix}... with balance=${l.balance_cents} ` +
                    `in favor of winner with balance=${winner.balance_cents}. ABORTING — this is a bug in sort logic.`);
                return; // Do NOT proceed — refuse to destroy money
            }
            if (l.balance_cents >= 100) { // >= $1.00
                console.error(`[orphan-cleanup] WARNING: Revoking key=${l.key_prefix}... with balance=${l.balance_cents} cents ($${(l.balance_cents / 100).toFixed(2)}). ` +
                    `Winner key=${winner.key_prefix}... has higher balance=${winner.balance_cents} cents ($${(winner.balance_cents / 100).toFixed(2)}).`);
            }
        }
    }
    console.error(`[orphan-cleanup] Found ${allActiveKeys.rows.length} active keys across ${duplicateEmails.size} user(s). ` +
        `Keeping richest (balance=${winner.balance_cents}), revoking ${losers.length} orphan(s).`);
    let totalRevoked = 0;
    let totalStripeTransfers = 0;
    withTransaction(() => {
        console.error(`[orphan-cleanup] Winner: key=${winner.key_prefix}... user=${winner.user_email} balance=${winner.balance_cents}`);
        // Step 1: Find the best stripe_customer_id to keep (prefer winner's, then first loser's)
        let bestStripeCustomerId = winner.stripe_customer_id;
        if (!bestStripeCustomerId) {
            for (const loser of losers) {
                if (loser.stripe_customer_id) {
                    bestStripeCustomerId = loser.stripe_customer_id;
                    break;
                }
            }
        }
        // Step 2: Clear ALL loser stripe_customer_ids first (avoids UNIQUE constraint collisions)
        const loserUserIds = new Set(losers.map(l => l.user_id));
        for (const userId of loserUserIds) {
            if (userId === winner.user_id)
                continue; // Don't clear winner's user
            query('UPDATE users SET stripe_customer_id = NULL WHERE id = ?', [userId]);
        }
        // Step 3: Set winner's stripe_customer_id if it needs one
        if (bestStripeCustomerId && !winner.stripe_customer_id) {
            query('UPDATE users SET stripe_customer_id = ? WHERE id = ?', [bestStripeCustomerId, winner.user_id]);
            totalStripeTransfers++;
            console.error(`[orphan-cleanup] Transferred stripe_customer_id=${bestStripeCustomerId} to winner user=${winner.user_id.slice(0, 8)}...`);
        }
        // Step 4: Consolidate balance — sum ALL balances into the winner.
        // The winner already has the highest balance. Add any remaining balance
        // from losers so no money is lost even when multiple keys have funds.
        let totalLoserBalance = 0;
        for (const loser of losers) {
            totalLoserBalance += loser.balance_cents;
        }
        if (totalLoserBalance > 0) {
            const consolidatedBalance = winner.balance_cents + totalLoserBalance;
            const newHmac = signBalance(winner.key_id, consolidatedBalance);
            query('UPDATE license_keys SET balance_cents = ?, balance_hmac = ? WHERE id = ?', [consolidatedBalance, newHmac, winner.key_id]);
            console.error(`[orphan-cleanup] Consolidated balance: winner ${winner.balance_cents} + losers ${totalLoserBalance} = ${consolidatedBalance} cents`);
        }
        // Step 5: Revoke ALL loser keys (their balance has been consolidated into winner)
        for (const loser of losers) {
            query("UPDATE license_keys SET status = 'revoked' WHERE id = ?", [loser.key_id]);
            totalRevoked++;
            console.error(`[orphan-cleanup] Revoked key=${loser.key_prefix}... user=${loser.user_email} balance=${loser.balance_cents} (consolidated into winner)`);
        }
    });
    console.error(`[orphan-cleanup] Complete: revoked ${totalRevoked} orphan key(s), transferred ${totalStripeTransfers} Stripe customer(s)`);
}
orphanCleanup();
// Create Hono app
const app = new Hono();
// CORS — accept requests from the configured dashboard URL and any proxy origin
// On cloud platforms (RunPod, Vast.ai), the dashboard URL is a proxy URL that
// differs from env.DASHBOARD_URL (which is the internal localhost address).
app.use('/*', cors({
    origin: (origin) => {
        // Always allow the configured dashboard URL
        if (origin === env.DASHBOARD_URL)
            return origin;
        // Allow localhost variants (internal service communication)
        if (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:'))
            return origin;
        // Allow known cloud proxy patterns
        if (origin.includes('.proxy.runpod.net'))
            return origin;
        if (origin.includes('.trycloudflare.com'))
            return origin;
        // Allow any HTTPS origin (cloud proxies use HTTPS)
        if (origin.startsWith('https://'))
            return origin;
        return env.DASHBOARD_URL;
    },
    allowMethods: ['GET', 'POST', 'PUT', 'OPTIONS'],
    allowHeaders: ['Content-Type', 'Authorization', 'X-License-Key'],
    exposeHeaders: [],
    credentials: true,
}));
// Health check
app.get('/health', (c) => c.json({ status: 'ok', version: '1.1.0', transport: 'sqlite', auto_provision: true }));
// Dashboard HTML pages (must be before API routes so / doesn't 404)
app.route('/', dashboardRoutes);
// Auth routes (public)
app.route('/v1/auth', authRoutes);
// Auto-provisioning route (public, no auth required)
app.route('/v1', provisionRoutes);
// License-key authenticated routes
app.route('/v1', chargeRoutes);
app.route('/v1', balanceRoutes);
// Session-authenticated routes
app.route('/v1/users', usersRoutes);
app.route('/v1/checkout', checkoutRoutes);
// Admin routes (session + admin guard)
app.route('/v1/admin', adminRoutes);
// Start periodic jobs
startJobs();
// Start server — always bind to LICENSE_PORT, never PORT.
// PORT is reserved for the dashboard (Next.js). Prevent accidental override.
console.error(`[LICENSE] Binding to port ${env.LICENSE_PORT} (LICENSE_PORT env var)`);
if (process.env.PORT && Number(process.env.PORT) !== env.LICENSE_PORT) {
    console.error(`[LICENSE] WARNING: PORT=${process.env.PORT} is set but ignored — license server uses LICENSE_PORT=${env.LICENSE_PORT}`);
}
const server = serve({ fetch: app.fetch, port: env.LICENSE_PORT, hostname: '0.0.0.0' }, (info) => {
    if (info.port !== env.LICENSE_PORT) {
        console.error(`FATAL: Expected port ${env.LICENSE_PORT} but bound to ${info.port}`);
        process.exit(1);
    }
    console.error(`License server listening on http://${info.address}:${info.port}`);
    console.error(`Health: http://localhost:${info.port}/health`);
});
// Graceful shutdown
function handleShutdown(signal) {
    console.error(`[Shutdown] Received ${signal}`);
    stopJobs();
    closeDb();
    server.close(() => {
        console.error('[Shutdown] Server closed');
        process.exit(0);
    });
    setTimeout(() => {
        console.error('[Shutdown] Forced exit after timeout');
        process.exit(1);
    }, 5000).unref();
}
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
process.on('SIGINT', () => handleShutdown('SIGINT'));
//# sourceMappingURL=index.js.map