// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // ----------------------------------------------------------------------------- // hubfav — favoris « Mon univers Ka » : le hub Groupe KA (groupe-ka.com) est le // MAGASIN CENTRAL des favoris du groupe — ValoPlex ne stocke RIEN localement. // Chaque ♥ est poussé au hub (POST signé HMAC, synchrone : un échec remonte à // l'appelant) et la liste est lue au hub (GET signé, cache mémoire 30 s, // invalidé à chaque toggle). Même secret que le SSO (KA_SSO_SECRET). // sig = HMAC-SHA256(KA_SSO_SECRET, "valoplex..") en hex, // ts en secondes epoch (fenêtre ±300 s côté hub). // ----------------------------------------------------------------------------- import { createHmac } from "node:crypto"; import { CLIENT_ID, KA_HUB_URL } from "@/lib/ka-auth"; const CACHE_TTL_MS = 30_000; // liste des favoris const TIMEOUT_MS = 6_000; /** Item de favori tel qu'accepté par le hub (champ → longueur maximale). */ const FIELDS: Record = { item_id: 120, title: 200, subtitle: 200, price_label: 60, image_url: 500, url: 500, }; export type FavItem = { item_id: string; title: string; subtitle?: string; price_label?: string; image_url?: string; url?: string; } & Record; const cache = new Map(); 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 typeof kaId === "string" && kaId.startsWith("ka-"); } /** Ne garde que les champs d'item connus, en chaînes tronquées. */ export function cleanItem(raw: Record): FavItem { const out: Record = {}; for (const [k, max] of Object.entries(FIELDS)) { const v = raw?.[k]; if (v != null && String(v)) out[k] = String(v).slice(0, max); } return out as FavItem; } /** 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: string, 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; } let ok = false; try { const r = await fetch(`${KA_HUB_URL}/api/sso/favorites`, { method: "POST", headers: { "Content-Type": "application/json" }, signal: AbortSignal.timeout(TIMEOUT_MS), body: JSON.stringify({ client_id: CLIENT_ID, ka_id: kaId, ts: String(ts), sig: s, action, item, }), }); ok = r.status === 200; } catch { ok = false; } if (ok) cache.delete(kaId); // la prochaine lecture reflète le toggle return ok; } /** Favoris ValoPlex 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; let favs: FavItem[]; try { const q = new URLSearchParams({ client_id: CLIENT_ID, ka_id: kaId, ts: String(ts), sig: s, }); const r = await fetch(`${KA_HUB_URL}/api/sso/favorites?${q}`, { signal: AbortSignal.timeout(TIMEOUT_MS), cache: "no-store", }); if (r.status !== 200) return null; const body = (await r.json()) as { favorites?: unknown }; if (!Array.isArray(body.favorites)) return null; favs = body.favorites.filter( (f): f is FavItem => typeof f === "object" && f !== null, ); } catch { return null; } cache.set(kaId, { at: now, favs }); return favs; }