SPB Git

spb/ka-ui

Public

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>
Simon-Pierre Boucher committed (Aug 26, 2026) parent 9be7acc Browse files

1 changed file +124 −0

Unified Split
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
Menu

Theme