kaid/ : kaid.ts — client KA ID v2 pour apps Next (track/fetchPrefs/affinity, HMAC serveur, fail-open)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 changed file +124 −0
added
kaid/kaid.ts
+124 −0
@@ -0,0 +1,124 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// ----------------------------------------------------------------------------- | |
| 3 | +// Groupe KA — kaid.ts : client KA ID v2 (personnalisation) pour les apps Next. | |
| 4 | +// SOURCE CANONIQUE : ka-ui.git/kaid/kaid.ts — copié dans src/lib/kaid.ts des | |
| 5 | +// apps TypeScript (vrai-prix, valoplex, trouve-ka). Ne pas diverger. | |
| 6 | +// | |
| 7 | +// Équivalent TS de kaid.py : track() (journal, fire-and-forget) et | |
| 8 | +// fetchPrefs() (profil appris, cache 90 s, fail-open). Appels SERVEUR | |
| 9 | +// uniquement (route handlers) — le secret SSO ne va jamais au navigateur. | |
| 10 | +// sig = HMAC-SHA256(KA_SSO_SECRET, `${clientId}.${kaId}.${ts}`) en hex. | |
| 11 | +// Aucune dépendance : node:crypto + fetch natif. | |
| 12 | +// ----------------------------------------------------------------------------- | |
| 13 | +import { createHmac } from "node:crypto"; | |
| 14 | + | |
| 15 | +export type KaidEvent = { | |
| 16 | + type: string; | |
| 17 | + entity_type?: string; | |
| 18 | + entity_id?: string; | |
| 19 | + query?: string; | |
| 20 | + filters?: Record<string, unknown>; | |
| 21 | + position?: number; | |
| 22 | + features?: Record<string, unknown>; | |
| 23 | + dwell_ms?: number; | |
| 24 | + session_id?: string; | |
| 25 | +}; | |
| 26 | + | |
| 27 | +export type KaidPrefs = { | |
| 28 | + ok: boolean; | |
| 29 | + personalization: boolean; | |
| 30 | + profile: { | |
| 31 | + app: { | |
| 32 | + n: number; | |
| 33 | + dims: Record<string, { values: Record<string, number>; conf: number }>; | |
| 34 | + ranges: Record<string, { p25: number; p50: number; p75: number; n: number }>; | |
| 35 | + } | null; | |
| 36 | + global: { location: { values: Record<string, number>; conf: number } }; | |
| 37 | + } | null; | |
| 38 | + hidden: string[]; | |
| 39 | +} | null; | |
| 40 | + | |
| 41 | +const TIMEOUT_MS = 5_000; | |
| 42 | +const PREFS_TTL_MS = 90_000; | |
| 43 | +const prefsCache = new Map<string, { t: number; data: KaidPrefs }>(); | |
| 44 | + | |
| 45 | +function hubUrl(): string { | |
| 46 | + return (process.env.KA_HUB_URL ?? "https://www.groupe-ka.com").replace(/\/+$/, ""); | |
| 47 | +} | |
| 48 | + | |
| 49 | +function signedParams(clientId: string, kaId: string): URLSearchParams | null { | |
| 50 | + const secret = process.env.KA_SSO_SECRET; | |
| 51 | + if (!secret || !kaId.startsWith("ka-")) return null; | |
| 52 | + const ts = String(Math.floor(Date.now() / 1000)); | |
| 53 | + const sig = createHmac("sha256", secret) | |
| 54 | + .update(`${clientId}.${kaId}.${ts}`) | |
| 55 | + .digest("hex"); | |
| 56 | + return new URLSearchParams({ client_id: clientId, ka_id: kaId, ts, sig }); | |
| 57 | +} | |
| 58 | + | |
| 59 | +/** Journalise des événements au hub — fire-and-forget, jamais bloquant. */ | |
| 60 | +export function track( | |
| 61 | + clientId: string, | |
| 62 | + kaId: string | null | undefined, | |
| 63 | + events: KaidEvent[], | |
| 64 | +): void { | |
| 65 | + if (!kaId || !events.length) return; | |
| 66 | + const p = signedParams(clientId, kaId); | |
| 67 | + if (!p) return; | |
| 68 | + const body = JSON.stringify({ | |
| 69 | + ...Object.fromEntries(p), | |
| 70 | + events: events.slice(0, 20), | |
| 71 | + }); | |
| 72 | + fetch(`${hubUrl()}/api/sso/events`, { | |
| 73 | + method: "POST", | |
| 74 | + headers: { "Content-Type": "application/json" }, | |
| 75 | + body, | |
| 76 | + signal: AbortSignal.timeout(TIMEOUT_MS), | |
| 77 | + }).catch(() => {}); | |
| 78 | +} | |
| 79 | + | |
| 80 | +/** Profil de personnalisation (cache 90 s) — null si indisponible (fail-open). */ | |
| 81 | +export async function fetchPrefs( | |
| 82 | + clientId: string, | |
| 83 | + kaId: string | null | undefined, | |
| 84 | +): Promise<KaidPrefs> { | |
| 85 | + if (!kaId) return null; | |
| 86 | + const hit = prefsCache.get(kaId); | |
| 87 | + if (hit && Date.now() - hit.t < PREFS_TTL_MS) return hit.data; | |
| 88 | + const p = signedParams(clientId, kaId); | |
| 89 | + if (!p) return null; | |
| 90 | + let data: KaidPrefs = null; | |
| 91 | + try { | |
| 92 | + const r = await fetch(`${hubUrl()}/api/sso/prefs?${p}`, { | |
| 93 | + signal: AbortSignal.timeout(TIMEOUT_MS), | |
| 94 | + }); | |
| 95 | + if (r.ok) data = (await r.json()) as KaidPrefs; | |
| 96 | + } catch { | |
| 97 | + data = null; | |
| 98 | + } | |
| 99 | + prefsCache.set(kaId, { t: Date.now(), data }); | |
| 100 | + if (prefsCache.size > 500) | |
| 101 | + for (const k of [...prefsCache.keys()].slice(0, 100)) prefsCache.delete(k); | |
| 102 | + return data; | |
| 103 | +} | |
| 104 | + | |
| 105 | +/** Affinité [−1,1] d'une valeur dans une dimension du profil (0 si inconnue). */ | |
| 106 | +export function affinity( | |
| 107 | + prefs: KaidPrefs, | |
| 108 | + dim: string, | |
| 109 | + value: string | null | undefined, | |
| 110 | +): number { | |
| 111 | + const d = prefs?.profile?.app?.dims?.[dim]; | |
| 112 | + if (!d || !value) return 0; | |
| 113 | + return d.values[value.trim().toLowerCase()] ?? 0; | |
| 114 | +} | |
| 115 | + | |
| 116 | +/** Affinité de localisation transversale (tous univers confondus). */ | |
| 117 | +export function globalLocationAffinity( | |
| 118 | + prefs: KaidPrefs, | |
| 119 | + value: string | null | undefined, | |
| 120 | +): number { | |
| 121 | + const loc = prefs?.profile?.global?.location; | |
| 122 | + if (!loc || !value) return 0; | |
| 123 | + return (loc.values[value.trim().toLowerCase()] ?? 0) * (loc.conf || 0.3); | |
| 124 | +} | |
| 125 | ||