import { Hono } from 'hono';
import { createHmac, randomUUID } from 'crypto';
import { getConnInfo } from '@hono/node-server/conninfo';
import { query, withTransaction } from '../db.js';
import { hashKey } from '../keys.js';
import { getEnv } from '../env.js';
import { signBalance } from '../balance-integrity.js';
// SEC-8: Rate limiting for provision endpoint
// A machine needs at most 1 provision call. Allow 3 for retries/race conditions.
const PROVISION_RATE_LIMIT = 3;
const PROVISION_WINDOW_MS = 60_000;
const provisionAttempts = new Map();
// SEC-8b: Machine-ID rate limiting to prevent automated scripts from
// provisioning keys with different machine_ids from the same or different IPs.
// 2 provisions per 5 minutes per machine_id (covers retry + one race condition).
const MACHINE_RATE_LIMIT = 2;
const MACHINE_WINDOW_MS = 300_000;
const machineAttempts = new Map();
function checkProvisionRateLimit(ip) {
    const now = Date.now();
    const entry = provisionAttempts.get(ip);
    if (!entry || now - entry.windowStart > PROVISION_WINDOW_MS) {
        provisionAttempts.set(ip, { count: 1, windowStart: now });
        return true;
    }
    if (entry.count >= PROVISION_RATE_LIMIT) {
        return false;
    }
    entry.count++;
    return true;
}
function checkMachineRateLimit(machineId) {
    const now = Date.now();
    const entry = machineAttempts.get(machineId);
    if (!entry || now - entry.windowStart > MACHINE_WINDOW_MS) {
        machineAttempts.set(machineId, { count: 1, windowStart: now });
        return true;
    }
    if (entry.count >= MACHINE_RATE_LIMIT) {
        return false;
    }
    entry.count++;
    return true;
}
// Cleanup old entries every 5 minutes to prevent memory leak
setInterval(() => {
    const now = Date.now();
    for (const [ip, entry] of provisionAttempts) {
        if (now - entry.windowStart > PROVISION_WINDOW_MS * 2) {
            provisionAttempts.delete(ip);
        }
    }
    for (const [mid, entry] of machineAttempts) {
        if (now - entry.windowStart > MACHINE_WINDOW_MS * 2) {
            machineAttempts.delete(mid);
        }
    }
}, 300_000).unref();
const provision = new Hono();
/**
 * POST /v1/provision — Auto-provision a license key for a machine.
 *
 * Deterministic: same machine_id always produces the same key
 * (HMAC of machine_id with LICENSE_SIGNING_KEY — the Ed25519 private key seed).
 * LICENSE_SIGNING_KEY is used as the HMAC key because it is:
 *   1. High-entropy (32-byte Ed25519 seed)
 *   2. Separate from session tokens (SESSION_SECRET)
 *   3. Never distributed to clients (only the Ed25519 public key is shared)
 *
 * Creates user + license key on first call; returns existing key on subsequent calls.
 * All auto-provisioned accounts start with $0 balance (paid tier, requires funds to process).
 */
provision.post('/provision', async (c) => {
    // SEC-8: Rate limit provision requests
    // Use x-forwarded-for if present (reverse proxy), otherwise fall back to
    // x-real-ip, then the raw socket remote address via Hono's getConnInfo().
    // This ensures requests from inside the container (127.0.0.1) are properly
    // identified rather than all sharing the single 'unknown' bucket, which
    // defeated per-IP rate limiting for intra-container requests.
    const connInfo = getConnInfo(c);
    const clientIp = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ||
        c.req.header('x-real-ip')?.trim() ||
        connInfo.remote.address ||
        '127.0.0.1';
    if (!checkProvisionRateLimit(clientIp)) {
        console.error(`[provision] Rate limited: ${clientIp}`);
        return c.json({ error: 'Too many provision requests. Try again in 1 minute.' }, 429);
    }
    let body;
    try {
        body = await c.req.json();
    }
    catch {
        return c.json({ error: 'Invalid JSON body' }, 400);
    }
    if (!body.machine_id || typeof body.machine_id !== 'string') {
        return c.json({ error: 'machine_id is required (string)' }, 400);
    }
    if (body.machine_id.length < 8) {
        return c.json({ error: 'machine_id must be at least 8 characters' }, 400);
    }
    const env = getEnv();
    const machineId = body.machine_id;
    // SEC-8b: Machine-ID rate limit (2 per 5 minutes per machine_id)
    if (!checkMachineRateLimit(machineId)) {
        console.error(`[provision] Machine rate limited: ${machineId.slice(0, 8)}...`);
        return c.json({ error: 'Too many provision requests for this machine. Try again in 5 minutes.' }, 429);
    }
    // Deterministic key from machine_id + LICENSE_SIGNING_KEY (Ed25519 private key seed as HMAC key).
    // The 'provision:' prefix prevents domain confusion with session HMACs.
    const keyRaw = createHmac('sha256', env.LICENSE_SIGNING_KEY)
        .update(`provision:${machineId}`)
        .digest('base64url')
        .slice(0, 32);
    const key = `lk_${keyRaw}`;
    const keyHash = hashKey(key);
    const prefix = key.slice(0, 8);
    // Check if this key already exists (idempotent — same machine_id + same LICENSE_SIGNING_KEY)
    const existingKey = query("SELECT id, balance_cents FROM license_keys WHERE key_hash = ? AND status = 'active'", [keyHash]);
    if (existingKey.rows.length > 0) {
        const row = existingKey.rows[0];
        console.error(`[provision] Returning existing key ${prefix}... for machine=${machineId.slice(0, 8)}... balance=${row.balance_cents}`);
        return c.json({
            license_key: key,
            key_prefix: prefix,
            balance_cents: row.balance_cents,
            created: false,
        });
    }
    // BUG-3 fix: Dedup by machine_id via email pattern.
    // If LICENSE_SIGNING_KEY changed (e.g., secrets regenerated), the HMAC produces a different
    // key_hash, so the lookup above misses the existing key. But the email pattern encodes the
    // machine_id, so we can find the existing user+key and update the hash to match the new key.
    // This prevents orphaned keys accumulating in the license_keys table.
    const email = `auto-${machineId.slice(0, 16)}@machine.ocrprovenance.com`;
    const existingByMachine = query(`SELECT lk.id, lk.balance_cents, lk.balance_hmac, u.email as user_email
     FROM license_keys lk
     JOIN users u ON lk.user_id = u.id
     WHERE u.email = ? AND lk.status = 'active'
     ORDER BY lk.created_at DESC
     LIMIT 1`, [email]);
    if (existingByMachine.rows.length > 0) {
        const row = existingByMachine.rows[0];
        // Update the existing key's hash and prefix to match the new deterministic key.
        // This preserves the balance and all other key metadata.
        const updatedHmac = signBalance(row.id, row.balance_cents);
        query('UPDATE license_keys SET key_hash = ?, key_prefix = ?, balance_hmac = ? WHERE id = ?', [keyHash, prefix, updatedHmac, row.id]);
        console.error(`[provision] BUG-3: Reused existing key for machine=${machineId.slice(0, 8)}... (signing key changed). ` +
            `Updated hash for key_id=${row.id.slice(0, 8)}... balance=${row.balance_cents}`);
        return c.json({
            license_key: key,
            key_prefix: prefix,
            balance_cents: row.balance_cents,
            created: false,
        });
    }
    const result = withTransaction(() => {
        // Upsert user (same machine = same email)
        const userId = randomUUID();
        query(`INSERT INTO users (id, email) VALUES (?, ?)
       ON CONFLICT (email) DO UPDATE SET email = EXCLUDED.email`, [userId, email]);
        // Get actual user (may be existing from a prior revoked key)
        const userResult = query('SELECT id FROM users WHERE email = ?', [email]);
        if (userResult.rows.length === 0) {
            throw new Error(`Failed to create or find user for machine ${machineId.slice(0, 8)}`);
        }
        const actualUserId = userResult.rows[0].id;
        // Create license key with $0 balance, paid tier
        const keyId = randomUUID();
        const initialHmac = signBalance(keyId, 0);
        query("INSERT INTO license_keys (id, user_id, key_hash, key_prefix, balance_cents, tier, balance_hmac) VALUES (?, ?, ?, ?, 0, 'paid', ?)", [keyId, actualUserId, keyHash, prefix, initialHmac]);
        // BUG-4 fix: Revoke any OTHER active keys for this email to prevent orphan accumulation.
        // This catches edge cases where the dedup paths above were bypassed (e.g., race conditions,
        // concurrent provisions, or old code that didn't dedup). The newly created key is the winner.
        const revokedOrphans = query(`SELECT lk.id, lk.key_prefix
       FROM license_keys lk
       JOIN users u ON lk.user_id = u.id
       WHERE u.email = ? AND lk.status = 'active' AND lk.id != ?`, [email, keyId]);
        for (const orphan of revokedOrphans.rows) {
            query("UPDATE license_keys SET status = 'revoked' WHERE id = ?", [orphan.id]);
            console.error(`[provision] BUG-4: Revoked orphan key=${orphan.key_prefix}... for email=${email} (new key=${prefix}... wins)`);
        }
        return { keyId, userId: actualUserId, orphansRevoked: revokedOrphans.rows.length };
    });
    if (result.orphansRevoked > 0) {
        console.error(`[provision] Auto-provisioned key ${prefix}... for machine=${machineId.slice(0, 8)}... user=${result.userId} (revoked ${result.orphansRevoked} orphan(s))`);
    }
    else {
        console.error(`[provision] Auto-provisioned key ${prefix}... for machine=${machineId.slice(0, 8)}... user=${result.userId}`);
    }
    return c.json({
        license_key: key,
        key_prefix: prefix,
        balance_cents: 0,
        created: true,
    });
});
export default provision;
//# sourceMappingURL=provision.js.map