/** * Phase 7.3 / 7.6 — transport de `/api/search`. * * Deux besoins nés dans la même route, regroupés parce que ce sont DEUX TRANSPORTS * du même contrat côté serveur : * - Phase 7.3 : le payload de diagnostic `?debug=1` (provenance + `raw` tronqué) ; * - Phase 7.6 : le media type `application/x-ndjson` du flux incrémental. * * Isolé dans son propre module (et non dans `index.mjs`) pour deux raisons : * - `index.mjs` fait 4 200+ lignes et démarre un serveur à l'import : la logique * de diagnostic ne peut donc pas être testée depuis le fichier d'entrée ; * - la troncature et la redaction sont une BARRIÈRE DE SÉCURITÉ. Elles * s'évaporent à la première refonte si elles vivent au milieu des routes. * * Chaîne du debug volontairement bornée : le but est de voir ce que le provider * a renvoyé et ce qui a survécu au mapping (pour comprendre un `views` manquant), * pas de transporter une réponse entière. Garde-fous : 2 items par provider, * 600 caractères de JSON, et suppression des clés d'habilitation. */ export const DEBUG_RAW_MAX_CHARS = 600; export const DEBUG_RAW_ITEMS_PER_PROVIDER = 2; /** * Phase 7.6 — media type du transport incrémental de `/api/search`. * * Exporté (et non écrit en littéral dans la route) parce que le client doit * négocier le MÊME type : un désaccord d'une lettre ferait retomber le front * silencieusement en mode atomique, sans le moindre message d'erreur — le pire * scénario possible pour une optimization de performance. */ export const APPLICATION_NDJSON = 'application/x-ndjson'; /** * Clés d'habilitation retirées du `raw`. Volontairement large : un `raw` * publié par erreur est journalisé en CI, donc en clair dans les artefacts de * build. Un faux positif coûte un `[redacted]` de plus dans un payload de debug. */ const DEBUG_SECRET_KEYS = /^(api[-_]?key|apikey|authorization|auth|token|access[-_]?token|refresh[-_]?token|cookie|set[-_]?cookie|password|passwd|secret|client[-_]?secret|private[-_]?key|signature)$/i; /** Copie profonde superficiellement, valeurs sensibles remplacées. */ export function redactDebugSecrets(value) { if (!value || typeof value !== 'object') return value; if (Array.isArray(value)) return value.map(redactDebugSecrets); const out = {}; for (const [k, v] of Object.entries(value)) { out[k] = DEBUG_SECRET_KEYS.test(k) ? '[redacted]' : redactDebugSecrets(v); } return out; } /** * @param {Record} groups groupes de résultats par provider * @param {Record} [errors] */ export function buildDebugPayload(groups, errors) { const out = {}; for (const [pid, items] of Object.entries(groups || {})) { const list = Array.isArray(items) ? items : []; const sample = list.slice(0, DEBUG_RAW_ITEMS_PER_PROVIDER).map((item) => { if (!item || typeof item !== 'object') return { value: item }; const { raw, ...rest } = item; const base = { id: rest.id ?? null, source: rest.source ?? null, capturedAt: rest.capturedAt ?? null, // Les NOMS des champs mappés, pas seulement leur nombre : c'est ce qui // permet de distinguer « `views` absent » de « `views` nul ». Triés pour // que deux debugs successifs soient comparables. fields: Object.keys(rest) .filter((k) => k !== 'id' && k !== 'source' && k !== 'capturedAt') .sort(), }; // `raw` n'est aujourd'hui produit par aucun adaptateur (ils normalisent // sans conserver la charge utile amont) : la branche reste pour le jour où // l'un d'eux l'expose, et `fields` prend le relais en attendant. if (raw === undefined) return base; let json; try { json = JSON.stringify(redactDebugSecrets(raw)); } catch { json = '[unserialisable]'; } return { ...base, raw: json.length > DEBUG_RAW_MAX_CHARS ? `${json.slice(0, DEBUG_RAW_MAX_CHARS)}… (${json.length} car.)` : json, }; }); out[pid] = { count: list.length, error: errors?.[pid]?.message ?? null, capturedAt: list.find((i) => i && i.capturedAt)?.capturedAt ?? null, source: list.find((i) => i && i.source)?.source ?? null, items: sample, }; } return out; }