// tools/license-gate.ts — Runtime license enforcement for MCP tool handlers.
// Provides getCurrentTier() with in-memory cache and withLicenseGate() wrapper.
import { createHash } from 'node:crypto';
import { licenseStore } from '../storage/index.js';
import * as usageStore from '../storage/usage-store.js';
import { resolveCurrentTier, checkToolAccess, isValidationFresh, isInGracePeriod, validateLicense, computeMachineFingerprint, isOpenAccessActive, } from '../engine/license-validator.js';
import { evaluateRateLimit, isTrialActive, trialDaysRemaining, recordUsageEvent, } from '../engine/usage-tracker/index.js';
import { getSessionContext } from '../transports/session-context.js';
// ---------------------------------------------------------------------------
// Owner token override (SHA-256 verified, not reversible)
// ---------------------------------------------------------------------------
// Salt prefix for owner token hashing (v1). New token hashes must be generated
// as: sha256(HASH_SALT + token). To regenerate OWNER_TOKEN_HASH run:
//   node -e "const{createHash}=require('crypto');console.log(createHash('sha256').update('planu-owner-v1'+process.argv[1]).digest('hex'))" <token>
const HASH_SALT = 'planu-owner-v1';
// Hash generated without salt (legacy). Kept for migration: if you rotate the
// owner token, generate a new salted hash and replace OWNER_TOKEN_HASH_V1.
const OWNER_TOKEN_HASH = '9f9e8410fd0a718540fe1e29f7beaa2506b539c8732915d8eddcb3bcff9b9465';
function checkOwnerOverride() {
    const token = process.env.SDD_OWNER_TOKEN;
    if (!token) {
        return null;
    }
    const trimmed = token.trim();
    // Try salted hash first (current scheme).
    const saltedHash = createHash('sha256')
        .update(HASH_SALT + trimmed)
        .digest('hex');
    if (saltedHash === OWNER_TOKEN_HASH) {
        return 'enterprise';
    }
    // Fall back to legacy unsalted hash so existing tokens keep working.
    // DEPRECATED: regenerate OWNER_TOKEN_HASH with salt when rotating the token.
    const legacyHash = createHash('sha256').update(trimmed).digest('hex');
    return legacyHash === OWNER_TOKEN_HASH ? 'enterprise' : null;
}
// ---------------------------------------------------------------------------
// In-memory tier cache (avoids disk read on every tool call)
// ---------------------------------------------------------------------------
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
let cachedTier = null;
let cacheTimestamp = 0;
export async function getCurrentTier() {
    // Per-session license key (hosted multi-tenant mode via HTTP transport)
    const sessionCtx = getSessionContext();
    if (sessionCtx?.licenseKey) {
        return resolveSessionTier(sessionCtx.licenseKey);
    }
    const now = Date.now();
    if (cachedTier !== null && now - cacheTimestamp < CACHE_TTL_MS) {
        return cachedTier;
    }
    const ownerTier = checkOwnerOverride();
    if (ownerTier) {
        cachedTier = ownerTier;
        cacheTimestamp = now;
        return cachedTier;
    }
    let state = await licenseStore.getLicenseState();
    // Synchronous online validation when license is stale (>7 days)
    if (state && !isValidationFresh(state)) {
        state = await performOnlineValidation(state);
    }
    cachedTier = resolveCurrentTier(state);
    cacheTimestamp = now;
    return cachedTier;
}
export function invalidateTierCache() {
    cachedTier = null;
    cacheTimestamp = 0;
}
/**
 * Synchronous tier estimate based on environment variables only — no disk I/O.
 * Used at server startup to set the display name before async validation runs.
 * Returns 'enterprise' for owner token, 'pro' if a license key env var is set, 'free' otherwise.
 */
export function getTierSync() {
    if (checkOwnerOverride()) {
        return 'enterprise';
    }
    if (process.env.SDD_LICENSE_KEY?.trim()) {
        return 'pro';
    }
    return 'free';
}
// ---------------------------------------------------------------------------
// Per-session license resolution (hosted multi-tenant mode)
// ---------------------------------------------------------------------------
const sessionTierCache = new Map();
const SESSION_TIER_TTL_MS = 10 * 60 * 1000; // 10 min — longer since online validation is expensive
const RATE_LIMIT_EXEMPT_TOOLS = new Set([
    'planu_status',
    'update_status_batch',
    'configure_code_graph',
    'code_graph_status',
]);
export function shouldBypassDailyRateLimit(toolName) {
    return RATE_LIMIT_EXEMPT_TOOLS.has(toolName);
}
async function resolveSessionTier(licenseKey) {
    const now = Date.now();
    const cached = sessionTierCache.get(licenseKey);
    if (cached && now < cached.expiresAt) {
        return cached.tier;
    }
    try {
        const result = await validateLicense(licenseKey, 'hosted');
        const tier = result.valid ? 'pro' : 'free';
        sessionTierCache.set(licenseKey, { tier, expiresAt: now + SESSION_TIER_TTL_MS });
        return tier;
    }
    catch {
        // API unreachable — degrade to free (no grace period for session keys)
        sessionTierCache.set(licenseKey, { tier: 'free', expiresAt: now + 60_000 }); // retry in 1 min
        return 'free';
    }
}
// ---------------------------------------------------------------------------
// License gate wrapper
// ---------------------------------------------------------------------------
export function withLicenseGate(toolName, handler) {
    return async (args, extra) => {
        const tier = await getCurrentTier();
        const trialContext = await resolveTrialContext(tier);
        const check = checkToolAccess(toolName, tier, trialContext);
        if (!check.allowed) {
            return { content: [{ type: 'text', text: check.message }], isError: true };
        }
        return handler(args, extra);
    };
}
/**
 * Resolves trial activity for free-tier users. Returns empty context for paid tiers
 * (no need to query trial state — they already have full access).
 */
async function resolveTrialContext(tier) {
    if (tier !== 'free') {
        return { trialActive: false, trialDaysRemaining: 0 };
    }
    try {
        const trialState = await usageStore.ensureTrialState();
        return {
            trialActive: isTrialActive(trialState.firstRunAt),
            trialDaysRemaining: trialDaysRemaining(trialState.firstRunAt),
        };
    }
    catch {
        return { trialActive: false, trialDaysRemaining: 0 };
    }
}
// ---------------------------------------------------------------------------
// Usage tracking wrapper
// ---------------------------------------------------------------------------
/**
 * Wraps a handler with rate limiting (free tier only) and usage event recording.
 * For free tier: checks daily limit + cooldown before executing.
 * For all tiers: records a usage event after execution (fire-and-forget).
 */
export function withUsageTracking(toolName, handler) {
    return async (args, extra) => {
        const tier = await getCurrentTier();
        // During open-access window, skip rate limiting for free tier
        if (tier === 'free' && !isOpenAccessActive() && !shouldBypassDailyRateLimit(toolName)) {
            const today = new Date().toISOString().slice(0, 10);
            const [trialState, callCount, lastCallAt] = await Promise.all([
                usageStore.ensureTrialState(),
                usageStore.getDailyCallCount(today),
                usageStore.getLastCallTimestamp(),
            ]);
            const maxCalls = isTrialActive(trialState.firstRunAt) ? 50 : 30;
            const check = evaluateRateLimit(callCount, maxCalls, lastCallAt, new Date());
            if (!check.allowed) {
                return { content: [{ type: 'text', text: check.message }], isError: true };
            }
        }
        // Cast to ToolResult | undefined: TypeScript says ToolResult, but JS runtime may
        // return undefined if a handler has a missing return path not caught at compile time.
        // noImplicitReturns in tsconfig prevents new occurrences; this guard handles old ones.
        const rawResult = (await handler(args, extra));
        const result = rawResult ?? {
            content: [{ type: 'text', text: 'Internal error: tool handler returned no result' }],
            isError: true,
        };
        const success = !result.isError;
        void usageStore
            .appendUsageEvent(recordUsageEvent(toolName, tier, success))
            .catch((err) => {
            console.error('[planu] appendUsageEvent failed:', err);
        });
        return result;
    };
}
// ---------------------------------------------------------------------------
// Synchronous online validation (replaces background fire-and-forget)
// ---------------------------------------------------------------------------
async function performOnlineValidation(state) {
    try {
        const result = await validateLicense(state.licenseKey, state.instanceId);
        if (!result.valid) {
            // License revoked remotely — clear local state
            await licenseStore.deleteLicenseState();
            return null;
        }
        const refreshed = {
            ...state,
            lastValidatedAt: new Date().toISOString(),
            activationUsage: result.license_key.activation_usage,
            machineFingerprint: computeMachineFingerprint(),
        };
        await licenseStore.saveLicenseState(refreshed);
        return refreshed;
    }
    catch {
        // API unreachable — allow if within grace period (TTL + 3 days)
        if (isInGracePeriod(state)) {
            return state;
        }
        // Grace period expired — degrade to free
        return null;
    }
}
//# sourceMappingURL=license-gate.js.map