// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // ----------------------------------------------------------------------------- // Favoris « Mon univers Ka » — le hub Groupe KA (groupe-ka.com) est le MAGASIN // CENTRAL des favoris du groupe : Vrai-Prix ne stocke RIEN localement. // · Chaque ♥ est poussé au hub (POST signé HMAC, SYNCHRONE : un échec // remonte à l'appelant) ; la liste est lue au hub (GET signé, cache // mémoire 30 s, invalidé à chaque toggle). Même secret que le SSO. // · sig = HMAC-SHA256(KA_SSO_SECRET, "vrai-prix..") en hex, // ts epoch en secondes (fenêtre ±300 s côté hub). // · Appels SERVEUR uniquement (route handlers) — jamais depuis le navigateur. // Aucune dépendance : node:crypto + fetch natif seulement. // Config .env.local : KA_SSO_SECRET, KA_HUB_URL (optionnel). // ----------------------------------------------------------------------------- import { createHmac } from "node:crypto"; import { CLIENT_ID, KA_HUB_URL } from "@/lib/ka-auth"; const CACHE_TTL_MS = 30_000; // 30 s — liste des favoris const TIMEOUT_MS = 6_000; // 6 s /** Item de favori tel qu'accepté / rendu par le hub. */ export type FavItem = { item_id: string; title: string; subtitle?: string; price_label?: string; image_url?: string; url?: string; meta?: Record; }; // champs d'item acceptés → longueur maximale (troncature défensive) const FIELDS: { key: Exclude; max: number }[] = [ { key: "item_id", max: 120 }, { key: "title", max: 200 }, { key: "subtitle", max: 200 }, { key: "price_label", max: 60 }, { key: "image_url", max: 500 }, { key: "url", max: 500 }, ]; /** Signature HMAC-SHA256 du hub : hex("vrai-prix.."). */ function sig(kaId: string, ts: number): string | null { const secret = process.env.KA_SSO_SECRET; if (!secret) return null; return createHmac("sha256", secret) .update(`${CLIENT_ID}.${kaId}.${ts}`) .digest("hex"); } /** Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe). */ export function linked(kaId: string | null | undefined): boolean { return !!kaId && String(kaId).startsWith("ka-"); } /** Ne garde que les champs d'item connus (chaînes tronquées) — null si pas * d'item_id exploitable. `title` retombe sur l'item_id. */ export function cleanItem(raw: unknown): FavItem | null { if (typeof raw !== "object" || raw === null) return null; const src = raw as Record; const out: Record = {}; for (const { key, max } of FIELDS) { const v = src[key]; if (v == null || v === "") continue; out[key] = String(v).slice(0, max); } if (typeof out.item_id !== "string" || !out.item_id) return null; if (typeof out.title !== "string" || !out.title) out.title = out.item_id; if (src.meta && typeof src.meta === "object" && !Array.isArray(src.meta)) { out.meta = src.meta as Record; } return out as FavItem; } // Cache mémoire process-wide (Map globale — survit au HMR en dev). type CacheEntry = { at: number; favs: FavItem[] }; const g = globalThis as unknown as { __vpHubFavCache?: Map }; const cache: Map = g.__vpHubFavCache ?? (g.__vpHubFavCache = new Map()); /** fetch avec délai maximal (AbortController) — null sur toute erreur réseau. */ async function hubFetch(url: string, init?: RequestInit): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS); try { return await fetch(url, { ...init, signal: ctrl.signal, cache: "no-store" }); } catch { return null; } finally { clearTimeout(timer); } } /** Favoris Vrai-Prix du membre, lus au hub (cache mémoire 30 s). * [] = aucun favori ; null = hub injoignable (erreur, jamais mise en cache). */ export async function hubList(kaId: string): Promise { if (!linked(kaId)) return []; // compte non relié au hub const now = Date.now(); const hit = cache.get(kaId); if (hit && now - hit.at < CACHE_TTL_MS) return hit.favs; const ts = Math.floor(now / 1000); const s = sig(kaId, ts); if (!s) return null; // KA_SSO_SECRET absent const url = `${KA_HUB_URL}/api/sso/favorites?client_id=${encodeURIComponent(CLIENT_ID)}` + `&ka_id=${encodeURIComponent(kaId)}&ts=${ts}&sig=${s}`; const r = await hubFetch(url); if (!r || !r.ok) return null; let favs: unknown; try { favs = ((await r.json()) as { favorites?: unknown })?.favorites ?? []; } catch { return null; } if (!Array.isArray(favs)) return null; const clean = favs.filter( (f): f is FavItem => typeof f === "object" && f !== null && typeof (f as FavItem).item_id === "string", ); cache.set(kaId, { at: now, favs: clean }); return clean; } /** Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s : * le hub est le magasin des favoris, l'échec doit remonter à l'appelant. */ export async function hubToggle( kaId: string, action: "add" | "remove", item: FavItem, ): Promise { const ts = Math.floor(Date.now() / 1000); const s = sig(kaId, ts); if (!s || !linked(kaId) || (action !== "add" && action !== "remove")) return false; const r = await hubFetch(`${KA_HUB_URL}/api/sso/favorites`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_id: CLIENT_ID, ka_id: kaId, ts: String(ts), sig: s, action, item, }), }); const ok = r?.status === 200; if (ok) cache.delete(kaId); // la prochaine lecture reflète le toggle return ok; }