/** * Cache key derivation — HMAC-SHA256 over JSON-canonical form. * * Protocol-critical: must produce identical output to Python's derive_cache_key. * Cross-language conformance verified by pin tests. * * Key format: "ctx:{context}:{hmac_hex}" — enables broad purge by prefix scan. */ import { createHmac } from 'crypto' const CONTEXT_KEY_PREFIX = 'ctx:' /** * JSON.stringify with recursively sorted keys and no whitespace. * Equivalent to Python's json.dumps(obj, sort_keys=True, separators=(",", ":")) */ function stableStringify(obj: any): string { if (obj === null || obj === undefined) return 'null' if (typeof obj === 'string') return JSON.stringify(obj) if (typeof obj === 'number' || typeof obj === 'boolean') return String(obj) if (Array.isArray(obj)) { return '[' + obj.map(stableStringify).join(',') + ']' } const keys = Object.keys(obj).sort() const pairs = keys.map(k => JSON.stringify(k) + ':' + stableStringify(obj[k])) return '{' + pairs.join(',') + '}' } /** * Derive a deterministic HMAC-SHA256 cache key. * * Returns "ctx:{context}:{hmac_hex}". */ export function deriveCacheKey( secret: string, context: string, params: Record, userId?: string, rev: number = 0, ): string { const sortedParams: Record = {} for (const [k, v] of Object.entries(params).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) { sortedParams[k] = String(v) } const keyData: Record = { c: context, p: sortedParams, r: rev } if (userId !== undefined) { keyData.u = String(userId) } const message = stableStringify(keyData) const hmacHex = createHmac('sha256', secret).update(message).digest('hex') return `${CONTEXT_KEY_PREFIX}${context}:${hmacHex}` } export { CONTEXT_KEY_PREFIX }