// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // ----------------------------------------------------------------------------- // Groupe KA — kaid.ts : client KA ID v2 (personnalisation) pour les apps Next. // SOURCE CANONIQUE : ka-ui.git/kaid/kaid.ts — copié dans src/lib/kaid.ts des // apps TypeScript (vrai-prix, valoplex, trouve-ka). Ne pas diverger. // // Équivalent TS de kaid.py : track() (journal, fire-and-forget) et // fetchPrefs() (profil appris, cache 90 s, fail-open). Appels SERVEUR // uniquement (route handlers) — le secret SSO ne va jamais au navigateur. // sig = HMAC-SHA256(KA_SSO_SECRET, `${clientId}.${kaId}.${ts}`) en hex. // Aucune dépendance : node:crypto + fetch natif. // ----------------------------------------------------------------------------- import { createHmac } from "node:crypto"; export type KaidEvent = { type: string; entity_type?: string; entity_id?: string; query?: string; filters?: Record; position?: number; features?: Record; dwell_ms?: number; session_id?: string; }; export type KaidPrefs = { ok: boolean; personalization: boolean; profile: { app: { n: number; dims: Record; conf: number }>; ranges: Record; } | null; global: { location: { values: Record; conf: number } }; } | null; hidden: string[]; } | null; const TIMEOUT_MS = 5_000; const PREFS_TTL_MS = 90_000; const prefsCache = new Map(); function hubUrl(): string { return (process.env.KA_HUB_URL ?? "https://www.groupe-ka.com").replace(/\/+$/, ""); } function signedParams(clientId: string, kaId: string): URLSearchParams | null { const secret = process.env.KA_SSO_SECRET; if (!secret || !kaId.startsWith("ka-")) return null; const ts = String(Math.floor(Date.now() / 1000)); const sig = createHmac("sha256", secret) .update(`${clientId}.${kaId}.${ts}`) .digest("hex"); return new URLSearchParams({ client_id: clientId, ka_id: kaId, ts, sig }); } /** Journalise des événements au hub — fire-and-forget, jamais bloquant. */ export function track( clientId: string, kaId: string | null | undefined, events: KaidEvent[], ): void { if (!kaId || !events.length) return; const p = signedParams(clientId, kaId); if (!p) return; const body = JSON.stringify({ ...Object.fromEntries(p), events: events.slice(0, 20), }); fetch(`${hubUrl()}/api/sso/events`, { method: "POST", headers: { "Content-Type": "application/json" }, body, signal: AbortSignal.timeout(TIMEOUT_MS), }).catch(() => {}); } /** Profil de personnalisation (cache 90 s) — null si indisponible (fail-open). */ export async function fetchPrefs( clientId: string, kaId: string | null | undefined, ): Promise { if (!kaId) return null; const hit = prefsCache.get(kaId); if (hit && Date.now() - hit.t < PREFS_TTL_MS) return hit.data; const p = signedParams(clientId, kaId); if (!p) return null; let data: KaidPrefs = null; try { const r = await fetch(`${hubUrl()}/api/sso/prefs?${p}`, { signal: AbortSignal.timeout(TIMEOUT_MS), }); if (r.ok) data = (await r.json()) as KaidPrefs; } catch { data = null; } prefsCache.set(kaId, { t: Date.now(), data }); if (prefsCache.size > 500) for (const k of [...prefsCache.keys()].slice(0, 100)) prefsCache.delete(k); return data; } /** Affinité [−1,1] d'une valeur dans une dimension du profil (0 si inconnue). */ export function affinity( prefs: KaidPrefs, dim: string, value: string | null | undefined, ): number { const d = prefs?.profile?.app?.dims?.[dim]; if (!d || !value) return 0; return d.values[value.trim().toLowerCase()] ?? 0; } /** Affinité de localisation transversale (tous univers confondus). */ export function globalLocationAffinity( prefs: KaidPrefs, value: string | null | undefined, ): number { const loc = prefs?.profile?.global?.location; if (!loc || !value) return 0; return (loc.values[value.trim().toLowerCase()] ?? 0) * (loc.conf || 0.3); }