/**
 * @fileoverview Simple in-memory session store for MCP HTTP transport.
 * Implements session management as per MCP Spec 2025-06-18.
 * @see {@link https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management | MCP Session Management}
 * @module src/mcp-server/transports/http/sessionStore
 */
import { validateSessionIdFormat } from '../../../mcp-server/transports/http/sessionIdUtils.js';
import { invalidParams, serviceUnavailable } from '../../../types-global/errors.js';
import { logger } from '../../../utils/internal/logger.js';
import { requestContextService } from '../../../utils/internal/requestContext.js';
import { ATTR_MCP_SESSION_EVENT } from '../../../utils/telemetry/attributes.js';
import { createCounter, createHistogram } from '../../../utils/telemetry/metrics.js';
let sessionEventCounter;
let sessionDuration;
function getSessionMetrics() {
    sessionEventCounter ??= createCounter('mcp.sessions.events', 'Session lifecycle events', '{events}');
    sessionDuration ??= createHistogram('mcp.session.duration', 'Session duration from creation to termination', 's');
    return { sessionEventCounter, sessionDuration };
}
/** Eagerly creates the session event counter so the series exists from startup. */
export function initSessionMetrics() {
    getSessionMetrics();
}
/**
 * Simple in-memory session store for stateful MCP sessions.
 * In production, consider using Redis or another persistent store.
 */
/** Default maximum number of concurrent sessions before new ones are rejected. */
const DEFAULT_MAX_SESSIONS = 10_000;
export class SessionStore {
    sessions = new Map();
    staleTimeout;
    maxSessions;
    cleanupInterval;
    constructor(staleTimeoutMs, maxSessions = DEFAULT_MAX_SESSIONS) {
        this.staleTimeout = staleTimeoutMs;
        this.maxSessions = maxSessions;
        // Clean up stale sessions every minute. unref() prevents blocking graceful shutdown.
        this.cleanupInterval = setInterval(() => this.cleanupStaleSessions(), 60_000);
        this.cleanupInterval.unref?.();
    }
    /**
     * Stops the cleanup interval and clears all sessions.
     * Call this during transport shutdown to prevent resource leaks.
     */
    destroy() {
        clearInterval(this.cleanupInterval);
        this.sessions.clear();
    }
    /**
     * Creates or retrieves a session with optional identity binding.
     * If identity is provided, binds the session to prevent cross-tenant/client hijacking.
     *
     * @param sessionId - The session identifier
     * @param identity - Optional identity info (tenantId, clientId, subject)
     * @returns The session object
     * @throws {McpError} If session ID format is invalid
     */
    getOrCreate(sessionId, identity) {
        // Validate session ID format to prevent injection attacks
        if (!validateSessionIdFormat(sessionId)) {
            const context = requestContextService.createRequestContext({
                operation: 'SessionStore.getOrCreate',
                sessionIdPrefix: sessionId.substring(0, 16),
            });
            logger.warning('Invalid session ID format rejected', context);
            throw invalidParams('Invalid session ID format. Session IDs must be 64 hexadecimal characters.', context);
        }
        let session = this.sessions.get(sessionId);
        if (!session) {
            // Enforce maximum session capacity to prevent unbounded memory growth
            if (this.sessions.size >= this.maxSessions) {
                const context = requestContextService.createRequestContext({
                    operation: 'SessionStore.getOrCreate',
                    currentSessions: this.sessions.size,
                    maxSessions: this.maxSessions,
                });
                logger.warning('Session capacity reached, rejecting new session', context);
                throw serviceUnavailable(`Maximum session capacity reached (${this.maxSessions}). Try again later.`, context);
            }
            // Build session object conditionally to satisfy exactOptionalPropertyTypes.
            // Identity is bound atomically as a snapshot on creation.
            const hasIdentity = !!(identity?.tenantId || identity?.clientId || identity?.subject);
            const now = new Date();
            const newSession = {
                id: sessionId,
                createdAt: now,
                lastAccessedAt: now,
                identityBound: hasIdentity,
            };
            // Only set identity fields if they have actual values (not undefined)
            if (identity?.tenantId)
                newSession.tenantId = identity.tenantId;
            if (identity?.clientId)
                newSession.clientId = identity.clientId;
            if (identity?.subject)
                newSession.subject = identity.subject;
            session = newSession;
            this.sessions.set(sessionId, session);
            getSessionMetrics().sessionEventCounter.add(1, { [ATTR_MCP_SESSION_EVENT]: 'created' });
            const context = requestContextService.createRequestContext({
                operation: 'SessionStore.create',
                sessionId,
                tenantId: identity?.tenantId,
            });
            logger.debug('Session created with identity binding', context);
        }
        else {
            session.lastAccessedAt = new Date();
            // Bind identity atomically on first authenticated request after an
            // unauthenticated session creation. All fields are snapshotted together
            // to prevent chimeric identities from per-field races.
            if (identity && !session.identityBound) {
                const hasIdentity = !!(identity.tenantId || identity.clientId || identity.subject);
                if (hasIdentity) {
                    if (identity.tenantId)
                        session.tenantId = identity.tenantId;
                    if (identity.clientId)
                        session.clientId = identity.clientId;
                    if (identity.subject)
                        session.subject = identity.subject;
                    session.identityBound = true;
                    const context = requestContextService.createRequestContext({
                        operation: 'SessionStore.bindIdentity',
                        sessionId,
                        tenantId: identity.tenantId,
                    });
                    logger.debug('Session identity bound atomically on authenticated request', context);
                }
            }
        }
        return session;
    }
    /**
     * Validates a session with identity binding checks.
     * Prevents session hijacking by verifying the session belongs to the requesting identity.
     *
     * Security checks:
     * 1. Session existence
     * 2. Staleness timeout
     * 3. Tenant ID match (if session has tenantId)
     * 4. Client ID match (if session has clientId)
     * 5. Subject match (if session has subject)
     *
     * @param sessionId - The session identifier
     * @param identity - The identity to validate against (from auth)
     * @returns True if session is valid and matches identity
     */
    isValidForIdentity(sessionId, identity) {
        const session = this.sessions.get(sessionId);
        if (!session) {
            return false;
        }
        // Check staleness
        if (Date.now() - session.lastAccessedAt.getTime() > this.staleTimeout) {
            this.terminate(sessionId);
            return false;
        }
        // If session has no identity bound, allow (backwards compatibility / no-auth mode)
        if (!session.tenantId && !session.clientId && !session.subject) {
            return true;
        }
        // Lazy-create context only when a warning is likely
        const warn = (message, extra) => {
            const context = requestContextService.createRequestContext({
                operation: 'SessionStore.isValidForIdentity',
                sessionId,
            });
            logger.warning(message, extra ? { ...context, ...extra } : context);
        };
        // If request has no identity but session does, reject (security: session was authenticated)
        if (!identity) {
            warn('Session requires authentication but request has no identity');
            getSessionMetrics().sessionEventCounter.add(1, { [ATTR_MCP_SESSION_EVENT]: 'rejected' });
            return false;
        }
        // Verify tenant ID match — reject if session is bound but request lacks or mismatches
        if (session.tenantId && session.tenantId !== identity.tenantId) {
            warn('Session tenant mismatch - possible hijacking attempt', {
                sessionTenant: session.tenantId,
                requestTenant: identity.tenantId,
            });
            getSessionMetrics().sessionEventCounter.add(1, { [ATTR_MCP_SESSION_EVENT]: 'rejected' });
            return false;
        }
        // Verify client ID match — reject if session is bound but request lacks or mismatches
        if (session.clientId && session.clientId !== identity.clientId) {
            warn('Session client mismatch - possible hijacking attempt', {
                sessionClient: session.clientId,
                requestClient: identity.clientId,
            });
            getSessionMetrics().sessionEventCounter.add(1, { [ATTR_MCP_SESSION_EVENT]: 'rejected' });
            return false;
        }
        // Verify subject match — reject if session is bound but request lacks or mismatches
        if (session.subject && session.subject !== identity.subject) {
            warn('Session subject mismatch - possible hijacking attempt', {
                sessionSubject: session.subject,
                requestSubject: identity.subject,
            });
            getSessionMetrics().sessionEventCounter.add(1, { [ATTR_MCP_SESSION_EVENT]: 'rejected' });
            return false;
        }
        return true;
    }
    /**
     * Terminates a session.
     * @param sessionId - The session identifier
     */
    terminate(sessionId) {
        const session = this.sessions.get(sessionId);
        if (session) {
            this.sessions.delete(sessionId);
            const metrics = getSessionMetrics();
            metrics.sessionEventCounter.add(1, { [ATTR_MCP_SESSION_EVENT]: 'terminated' });
            metrics.sessionDuration.record((Date.now() - session.createdAt.getTime()) / 1000);
            const context = requestContextService.createRequestContext({
                operation: 'SessionStore.terminate',
                sessionId,
            });
            logger.info('Session terminated', context);
        }
    }
    /**
     * Cleans up stale sessions that haven't been accessed recently.
     */
    cleanupStaleSessions() {
        const now = Date.now();
        let cleanedCount = 0;
        const metrics = getSessionMetrics();
        for (const [id, session] of this.sessions.entries()) {
            if (now - session.lastAccessedAt.getTime() > this.staleTimeout) {
                metrics.sessionDuration.record((now - session.createdAt.getTime()) / 1000);
                this.sessions.delete(id);
                cleanedCount++;
            }
        }
        if (cleanedCount > 0) {
            metrics.sessionEventCounter.add(cleanedCount, {
                [ATTR_MCP_SESSION_EVENT]: 'stale_cleanup',
            });
            const context = requestContextService.createRequestContext({
                operation: 'SessionStore.cleanup',
            });
            logger.debug('Cleaned up stale sessions', {
                ...context,
                count: cleanedCount,
            });
        }
    }
    /**
     * Gets the current number of active sessions.
     * @returns The number of sessions
     */
    getSessionCount() {
        return this.sessions.size;
    }
}
//# sourceMappingURL=sessionStore.js.map