/**
 * CustomWebhookDestination -- Generic HTTPS POST destination with HMAC-SHA256 signing.
 *
 * Delivers governance events to any customer-controlled HTTPS endpoint using
 * deterministic HMAC-SHA256 request signing and zero-downtime dual-key rotation.
 *
 * Design decisions:
 *   - No external HTTP library. Plain HTTPS POST via Node built-ins (no supply-chain risk).
 *   - Secrets are NEVER stored in plaintext. Only KMS references are held.
 *     Resolved at send-time via injected SecretResolver.
 *   - Signing string: v1\nMETHOD\nPATH\ntimestamp\neventId\nsha256(body)\n
 *     Body hash prevents body tampering after signing.
 *   - Dual-key rotation: during the rotation window BOTH signatures travel in the same
 *     header, comma-separated. Receivers accept either.
 *     Window auto-completes after rotationWindowMs (default 24 h).
 *   - packBinding mandatory. Empty binding throws UnboundDataError at construction.
 *   - Secret bytes NEVER emitted to any log stream.
 *   - HTTPS enforced at construction. HTTP rejected. Raw IPs rejected.
 *   - 5xx + network = retryable. 4xx = DLQ.
 *
 * Signature header:
 *   X-AIAG-Signature: v1=<hex>                         (primary only)
 *   X-AIAG-Signature: v1=<hex_p>, v1=<hex_s>           (during rotation)
 *
 * @connexum/ai-governance
 */
import * as https from 'https';
import * as http from 'http';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../../util/log.js';
import { UnboundDataError } from '../../errors/index.js';
const log = createLogger('custom-webhook');
// ---------------------------------------------------------------------------
// Internal constants
// ---------------------------------------------------------------------------
const DEFAULT_SIGNATURE_HEADER = 'X-AIAG-Signature';
const DEFAULT_SIGNED_HEADERS = ['x-aiag-timestamp', 'x-aiag-event-id', 'content-type'];
const DEFAULT_BUFFER_SIZE = 64;
const DEFAULT_TIMEOUT_MS = 5_000;
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_RETRY_DELAY_MS = 1_000;
const DEFAULT_ROTATION_WINDOW_MS = 24 * 60 * 60 * 1_000; // 24 h
/** HTTP status codes indicating server cannot accept right now. Retryable. */
const RETRYABLE_STATUS_CODES = new Set([500, 502, 503, 504]);
/** Standard HTTPS port. Non-standard ports rejected. */
const STANDARD_HTTPS_PORT = 443;
/** Permission required to call rotateSecret / completeRotation. */
const ROTATE_PERMISSION = 'governance.rotate-secret';
// ---------------------------------------------------------------------------
// Main class
// ---------------------------------------------------------------------------
export class CustomWebhookDestination {
    cfg;
    resolver;
    roles;
    dlqFile;
    /** Pending event buffer. */
    pendingBuffer = [];
    /** Rotation window end (epoch ms). Undefined when not rotating. */
    rotationWindowEnd;
    /** Auto-complete timer handle. */
    rotationTimer;
    /** SHA-256 chain for rotation audit events. */
    lastRotationEventHash = '';
    /** Set of emitted event UUIDs (PT-WEBHOOK-1 defense-in-depth). */
    emittedEventIds = new Set();
    constructor(config, resolver, roles) {
        if (!config.packBinding || config.packBinding.length === 0) {
            throw new UnboundDataError('CustomWebhookDestination.config', 'unknown');
        }
        validateEndpoint(config.endpoint, config.allowInsecure ?? false);
        this.resolver = resolver;
        this.roles = roles;
        this.cfg = {
            signatureHeader: DEFAULT_SIGNATURE_HEADER,
            signatureScheme: 'hmac-sha256',
            signedHeaders: DEFAULT_SIGNED_HEADERS,
            bufferSize: DEFAULT_BUFFER_SIZE,
            timeoutMs: DEFAULT_TIMEOUT_MS,
            maxRetries: DEFAULT_MAX_RETRIES,
            retryDelayMs: DEFAULT_RETRY_DELAY_MS,
            rotationWindowMs: DEFAULT_ROTATION_WINDOW_MS,
            allowInsecure: false,
            ...config,
            secondarySecretRef: config.secondarySecretRef,
        };
        if (!fs.existsSync(this.cfg.dlqDir)) {
            fs.mkdirSync(this.cfg.dlqDir, { recursive: true });
        }
        this.dlqFile = path.join(this.cfg.dlqDir, 'custom-webhook-dlq.jsonl');
    }
    // ---------------------------------------------------------------------------
    // Public interface
    // ---------------------------------------------------------------------------
    /**
     * Deliver one event to the configured endpoint.
     * Buffers events; delivers immediately in v0 (no network batching).
     */
    async send(event) {
        this.pendingBuffer.push(event);
        if (this.pendingBuffer.length >= this.cfg.bufferSize) {
            this.pendingBuffer.length = 0;
        }
        return this.deliverEvent(event);
    }
    /**
     * Flush any pending events.
     * v0: events are delivered immediately in send(), so flush() is a no-op
     * unless there are buffered-but-undelivered events.
     */
    async flush() {
        if (this.pendingBuffer.length === 0) {
            return { success: true, attempts: 0 };
        }
        const event = this.pendingBuffer[this.pendingBuffer.length - 1];
        this.pendingBuffer.length = 0;
        return this.deliverEvent(event);
    }
    /** Get dead-letter queue entries. */
    getDLQ(limit = 100) {
        if (!fs.existsSync(this.dlqFile))
            return [];
        try {
            const lines = fs.readFileSync(this.dlqFile, 'utf-8')
                .trim().split('\n').filter(Boolean);
            return lines.slice(-limit)
                .map(line => { try {
                return JSON.parse(line);
            }
            catch {
                return null;
            } })
                .filter((e) => e !== null);
        }
        catch {
            return [];
        }
    }
    /**
     * Begin secret rotation.
     *
     * Atomic swap: secondary = primary, primary = newPrimary.
     * Subsequent sends carry both signatures (dual-sign window).
     * Auto-completes after cfg.rotationWindowMs if not completed manually.
     *
     * Requires: 'governance.rotate-secret' permission (ADMIN role).
     */
    async rotateSecret(newPrimaryRef, approverIdentity) {
        const check = this.roles.checkPermission(approverIdentity, ROTATE_PERMISSION);
        if (!check.allowed) {
            throw new Error(`rotateSecret denied: ${check.reason} (operator: ${approverIdentity})`);
        }
        this.cfg.secondarySecretRef = this.cfg.primarySecretRef;
        this.cfg.primarySecretRef = newPrimaryRef;
        this.rotationWindowEnd = Date.now() + this.cfg.rotationWindowMs;
        if (this.rotationTimer !== undefined)
            clearTimeout(this.rotationTimer);
        this.rotationTimer = setTimeout(() => this.autoCompleteRotation(), this.cfg.rotationWindowMs);
        this.lastRotationEventHash = this.emitRotationAuditEvent('WEBHOOK_SECRET_ROTATED', {
            approverIdentity,
            newPrimaryRef,
            rotationWindowEnds: new Date(this.rotationWindowEnd).toISOString(),
        }, 'ERROR', this.lastRotationEventHash);
        log.info('Secret rotation started', {
            endpoint: this.safeEndpoint(),
            rotationWindowEnds: new Date(this.rotationWindowEnd).toISOString(),
        });
    }
    /**
     * Complete rotation explicitly.
     *
     * Drops the secondary secret; subsequent sends carry only the primary signature.
     * Call after confirming consumers validate against new primary.
     *
     * Requires: 'governance.rotate-secret' permission (ADMIN role).
     */
    async completeRotation(approverIdentity) {
        const check = this.roles.checkPermission(approverIdentity, ROTATE_PERMISSION);
        if (!check.allowed) {
            throw new Error(`completeRotation denied: ${check.reason} (operator: ${approverIdentity})`);
        }
        this.dropSecondary('WEBHOOK_SECRET_ROTATION_COMPLETED', 'NOTICE', approverIdentity);
    }
    /** Whether the destination is currently in the dual-signing rotation window. */
    isRotating() {
        return (this.cfg.secondarySecretRef !== undefined &&
            this.rotationWindowEnd !== undefined &&
            Date.now() < this.rotationWindowEnd);
    }
    /** Cancel pending rotation timers and release resources. */
    destroy() {
        if (this.rotationTimer !== undefined) {
            clearTimeout(this.rotationTimer);
            this.rotationTimer = undefined;
        }
    }
    // ---------------------------------------------------------------------------
    // Private: delivery
    // ---------------------------------------------------------------------------
    async deliverEvent(event) {
        const body = buildBody(event);
        const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
        const url = new URL(this.cfg.endpoint);
        const method = 'POST';
        const urlPath = url.pathname + url.search;
        let lastStatus;
        let lastError = '';
        let retryable = true;
        for (let attempt = 1; attempt <= this.cfg.maxRetries; attempt++) {
            const timestamp = Date.now().toString();
            const eventId = this.generateEventId();
            const requestHeaders = {
                'content-type': 'application/json',
                'x-aiag-timestamp': timestamp,
                'x-aiag-event-id': eventId,
                'x-aiag-signature-version': 'v1',
                'User-Agent': '@connexum/ai-governance custom-webhook/1.0',
            };
            const signingString = buildSigningString(method, urlPath, timestamp, eventId, bodyHash);
            try {
                const primarySecret = await this.resolver(this.cfg.primarySecretRef);
                const primaryHex = computeHmac(primarySecret, signingString);
                let signatureValue = `v1=${primaryHex}`;
                if (this.isRotating() && this.cfg.secondarySecretRef) {
                    const secondarySecret = await this.resolver(this.cfg.secondarySecretRef);
                    const secondaryHex = computeHmac(secondarySecret, signingString);
                    signatureValue = `v1=${primaryHex}, v1=${secondaryHex}`;
                }
                requestHeaders[this.cfg.signatureHeader.toLowerCase()] = signatureValue;
                const statusCode = await this.httpPost(body, requestHeaders);
                lastStatus = statusCode;
                if (statusCode >= 200 && statusCode < 300) {
                    return { success: true, statusCode, attempts: attempt, deliveredAt: new Date().toISOString(), retryable: false };
                }
                if (RETRYABLE_STATUS_CODES.has(statusCode)) {
                    lastError = `HTTP ${statusCode}`;
                    retryable = true;
                    log.warn('Custom webhook retryable error', { attempt, status: statusCode, endpoint: this.safeEndpoint() });
                }
                else {
                    lastError = `HTTP ${statusCode} (non-retryable)`;
                    retryable = false;
                    log.error('Custom webhook non-retryable error', { status: statusCode, endpoint: this.safeEndpoint() });
                    break;
                }
            }
            catch (err) {
                lastError = err.message ?? 'Connection error';
                retryable = true;
                log.warn('Custom webhook connection error', { attempt, endpoint: this.safeEndpoint() });
            }
            if (attempt < this.cfg.maxRetries && retryable) {
                const base = this.cfg.retryDelayMs * Math.pow(2, attempt - 1);
                const jitter = Math.floor(Math.random() * base * 0.25);
                await new Promise(r => setTimeout(r, base + jitter));
            }
        }
        this.writeDLQ(event, lastError, this.cfg.maxRetries, retryable);
        return { success: false, statusCode: lastStatus, attempts: this.cfg.maxRetries, error: lastError, retryable };
    }
    // ---------------------------------------------------------------------------
    // Private: HTTP transport
    // ---------------------------------------------------------------------------
    httpPost(body, headers) {
        return new Promise((resolve, reject) => {
            const url = new URL(this.cfg.endpoint);
            const isHttps = url.protocol === 'https:';
            const transport = isHttps ? https : http;
            const allHeaders = {
                ...headers,
                'Content-Length': Buffer.byteLength(body).toString(),
            };
            const req = transport.request({
                hostname: url.hostname,
                port: url.port || (isHttps ? 443 : 80),
                path: url.pathname + url.search,
                method: 'POST',
                headers: allHeaders,
                timeout: this.cfg.timeoutMs,
            }, (res) => {
                res.resume();
                resolve(res.statusCode ?? 0);
            });
            req.on('error', (err) => {
                reject(new Error(`Webhook connection error: ${err.code ?? 'UNKNOWN'}`));
            });
            req.on('timeout', () => { req.destroy(new Error('Custom webhook request timeout')); });
            req.write(body);
            req.end();
        });
    }
    // ---------------------------------------------------------------------------
    // Private: rotation internals
    // ---------------------------------------------------------------------------
    autoCompleteRotation() {
        if (!this.cfg.secondarySecretRef)
            return;
        this.dropSecondary('WEBHOOK_SECRET_ROTATION_AUTO_COMPLETED', 'WARNING', 'system:auto-complete');
        log.warn('Rotation window expired: secondary secret auto-dropped', { endpoint: this.safeEndpoint() });
    }
    dropSecondary(eventName, severity, actor) {
        this.cfg.secondarySecretRef = undefined;
        this.rotationWindowEnd = undefined;
        if (this.rotationTimer !== undefined) {
            clearTimeout(this.rotationTimer);
            this.rotationTimer = undefined;
        }
        this.lastRotationEventHash = this.emitRotationAuditEvent(eventName, { actor, endpoint: this.safeEndpoint() }, severity, this.lastRotationEventHash);
    }
    // ---------------------------------------------------------------------------
    // Private: audit chain for rotation events
    // ---------------------------------------------------------------------------
    emitRotationAuditEvent(eventName, data, severity, prevHash) {
        const entry = {
            event: eventName,
            severity,
            timestamp: new Date().toISOString(),
            endpoint: this.safeEndpoint(),
            data,
            _prevHash: prevHash,
        };
        const entryStr = JSON.stringify(entry);
        const hash = crypto.createHash('sha256').update(entryStr).digest('hex');
        log.info(`Rotation audit: ${eventName}`, { hash, severity });
        return hash;
    }
    // ---------------------------------------------------------------------------
    // Private: event ID dedup (PT-WEBHOOK-1)
    // ---------------------------------------------------------------------------
    generateEventId() {
        const id = crypto.randomUUID();
        if (this.emittedEventIds.has(id)) {
            log.error('WEBHOOK_DUPLICATE_EVENT_ID_GENERATED', { id, endpoint: this.safeEndpoint() });
        }
        this.emittedEventIds.add(id);
        return id;
    }
    // ---------------------------------------------------------------------------
    // Private: DLQ
    // ---------------------------------------------------------------------------
    writeDLQ(event, error, attempts, retryable) {
        const entry = { event, error, attempts, failedAt: new Date().toISOString(), retryable };
        try {
            fs.appendFileSync(this.dlqFile, JSON.stringify(entry) + '\n');
        }
        catch {
            log.error('Failed to write custom-webhook DLQ entry', { dlqFile: this.dlqFile });
        }
    }
    safeEndpoint() {
        try {
            const u = new URL(this.cfg.endpoint);
            u.password = '';
            u.username = '';
            return u.toString();
        }
        catch {
            return '[invalid-endpoint]';
        }
    }
}
// ---------------------------------------------------------------------------
// Module-level pure helpers (exported for test use)
// ---------------------------------------------------------------------------
/**
 * Build the deterministic signing string.
 *
 * Format: v1\n<METHOD>\n<PATH>\n<x-aiag-timestamp>\n<x-aiag-event-id>\n<sha256(body) hex>\n
 *
 * Each field separated by \n; explicit trailing \n after body hash.
 * Including the body hash prevents body tampering after signing.
 */
export function buildSigningString(method, urlPath, timestamp, eventId, bodyHashHex) {
    return `v1\n${method}\n${urlPath}\n${timestamp}\n${eventId}\n${bodyHashHex}\n`;
}
/**
 * Compute HMAC-SHA256 over the signing string using the given secret.
 * Returns lowercase hex.
 */
export function computeHmac(secret, signingString) {
    return crypto.createHmac('sha256', secret).update(signingString).digest('hex');
}
/**
 * Serialize an audit event to canonical JSON (deterministic key order).
 * Prevents hash mismatch between signer and verifier due to key ordering.
 */
export function buildBody(event) {
    const raw = event;
    const canonical = {};
    for (const key of Object.keys(raw).sort()) {
        const v = raw[key];
        if (key === 'data' && v && typeof v === 'object') {
            const sorted = {};
            for (const dk of Object.keys(v).sort()) {
                sorted[dk] = v[dk];
            }
            canonical[key] = sorted;
        }
        else {
            canonical[key] = v;
        }
    }
    return JSON.stringify(canonical);
}
/**
 * Validate an endpoint URL at construction time.
 * - Must be HTTPS (unless allowInsecure).
 * - Must not be a raw IPv4 address.
 * - Must use standard HTTPS port (443) or omit port.
 */
export function validateEndpoint(endpoint, allowInsecure) {
    let url;
    try {
        url = new URL(endpoint);
    }
    catch {
        throw new Error(`CustomWebhookDestination: malformed endpoint URL "${endpoint}". ` +
            'Provide a valid HTTPS URL, e.g. https://hooks.corp.example.com/governance');
    }
    if (!allowInsecure && url.protocol !== 'https:') {
        throw new Error(`CustomWebhookDestination: endpoint must use HTTPS. Received protocol: "${url.protocol}". ` +
            'Set allowInsecure:true only in test environments.');
    }
    const ipv4Re = /^\d{1,3}(\.\d{1,3}){3}$/;
    if (ipv4Re.test(url.hostname)) {
        throw new Error(`CustomWebhookDestination: raw IP address endpoints are not permitted. ` +
            `Received hostname: "${url.hostname}". Use a domain name.`);
    }
    if (url.port && url.protocol === 'https:' && url.port !== STANDARD_HTTPS_PORT.toString()) {
        throw new Error(`CustomWebhookDestination: non-standard port "${url.port}" is not permitted on HTTPS endpoints. ` +
            'Use the standard HTTPS port (443) or omit the port.');
    }
}
//# sourceMappingURL=custom-webhook.js.map