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 | |||||