fix(openrouter): nettoyer la clé API souillée par un copier-coller

OpenRouter répondait « 401 Missing Authentication header », ce qui laisse
croire à un en-tête absent alors qu'il était bien envoyé.

Vérification faite contre l'API : ce message n'apparaît pas quand la clé
est invalide (l'API répond alors « User not found »), mais quand le jeton
placé derrière « Bearer » n'a pas la forme d'une clé — préfixe « Bearer »
recopié depuis un exemple, guillemets laissés autour de la valeur, ou
caractère invisible ramassé lors d'un copier-coller. String.trim() ne
retire pas ces derniers.

La clé est désormais nettoyée à l'enregistrement et à la lecture ; ce
second point remet en marche les clés déjà stockées sans ressaisie.

En cas de 401, le message indique en plus la longueur de la clé et si
elle commence par « sk-or- », sans jamais en révéler le contenu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
This commit is contained in:
Claude committed 2026-08-12 08:14:47 +00:00
1 parent 26cddb4d01
commit c097c5c8f3
2 files changed
+56 -6

No files matched your search

+8 -1
View File
@@ -9,7 +9,7 @@ import multer from "multer";
import bcrypt from "bcrypt";
import passport from "./auth";
import { z } from "zod";
import { openRouterService, describeGenerationError } from "./services/openrouter";
import { openRouterService, describeGenerationError, sanitizeApiKey } from "./services/openrouter";
import { minioService as cloudinaryService, buildMinioUrl } from "./services/minio";
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
import type { User, InsertUser, ScheduledPost } from "@shared/schema";
@@ -1853,6 +1853,13 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Check if config already exists
const existingConfig = await storage.getOpenrouterConfig(userId);
// Une clé collée depuis un navigateur traîne souvent un « Bearer », des
// guillemets ou un caractère invisible. Stockée telle quelle, elle fait
// répondre « Missing Authentication header » à OpenRouter.
if (typeof req.body?.apiKey === 'string') {
req.body.apiKey = sanitizeApiKey(req.body.apiKey);
}
let config;
if (existingConfig) {
// Pour les mises à jour, utiliser le schéma qui rend apiKey optionnel
+48 -5
View File
@@ -15,6 +15,36 @@ interface GeneratedText {
import crypto from 'crypto';
import { storage } from '../storage';
/**
* Nettoie une clé API collée depuis un navigateur ou une documentation.
*
* OpenRouter répond « Missing Authentication header » — et non « User not
* found » — dès que le jeton qui suit « Bearer » n'a pas la forme d'une clé :
* préfixe « Bearer » recopié par mégarde, guillemets autour de la valeur, ou
* espace insécable/de largeur nulle ramassé lors d'un copier-coller. Le message
* laisse croire à un en-tête absent alors que la clé est simplement souillée.
*
* Appliqué à l'enregistrement comme à la lecture, pour rattraper les clés déjà
* stockées sans imposer une ressaisie.
*/
export function sanitizeApiKey(raw: string): string {
let key = (raw || '')
// Caractères invisibles que String.trim() ne retire pas
.replace(/[​-‍]/g, '')
.trim();
// « Bearer sk-… » recopié depuis un exemple de documentation
key = key.replace(/^bearer\s+/i, '').trim();
// Valeur entourée de guillemets, typique d'un copier-coller depuis un JSON
const quoted = key.match(/^(["'])(.*)\1$/);
if (quoted) {
key = quoted[2].trim();
}
return key;
}
/**
* Extrait le message d'erreur d'une réponse OpenRouter, qu'elle soit au format
* JSON ({ error: { message } }) ou en texte brut. Tronqué pour éviter de
@@ -70,7 +100,12 @@ export class OpenRouterService {
throw new Error('Configuration OpenRouter non trouvée. Veuillez demander à un administrateur de configurer OpenRouter dans les Paramètres.');
}
if (!config.apiKey || !config.apiKey.trim()) {
// Rattrape les clés déjà stockées avec un « Bearer », des guillemets ou un
// caractère invisible : sans cela OpenRouter répond « Missing Authentication
// header », ce qui laisse croire à tort que la clé est absente.
const apiKey = sanitizeApiKey(config.apiKey);
if (!apiKey) {
throw new Error('Clé API OpenRouter manquante ou vide en base de données. Veuillez la ressaisir dans les Paramètres.');
}
@@ -79,14 +114,14 @@ export class OpenRouterService {
// Use provided model or fall back to config model
const modelToUse = modelOverride || config.model;
const keyFingerprint = crypto.createHash('sha256').update(config.apiKey).digest('hex').slice(0, 8);
console.log(`[OpenRouter] Generating with model="${modelToUse}" keyFingerprint=${keyFingerprint} keyLength=${config.apiKey.length} configUserId=${config.userId}`);
const keyFingerprint = crypto.createHash('sha256').update(apiKey).digest('hex').slice(0, 8);
console.log(`[OpenRouter] Generating with model="${modelToUse}" keyFingerprint=${keyFingerprint} keyLength=${apiKey.length} configUserId=${config.userId}`);
try {
const response = await fetch(this.baseUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${config.apiKey.trim()}`,
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": process.env.APP_URL || "http://localhost:5555",
"X-Title": "Social Flow"
@@ -110,9 +145,17 @@ export class OpenRouterService {
// Le motif renvoyé par OpenRouter (clé invalide, crédits épuisés, modèle
// inconnu…) est la seule information exploitable : on le fait remonter
// jusqu'à l'utilisateur, qui n'a pas accès aux logs du serveur.
// Sur un refus d'authentification, la forme de la clé est le seul
// indice utile — sans jamais en révéler le contenu.
const hint = response.status === 401
? ` La clé enregistrée fait ${apiKey.length} caractères et ` +
`${apiKey.startsWith('sk-or-') ? 'commence' : 'ne commence pas'} par "sk-or-" : ` +
`vérifiez-la dans Paramètres → OpenRouter.`
: '';
throw new Error(
`OpenRouter a refusé la requête (${response.status} ${response.statusText}) ` +
`avec le modèle "${modelToUse}" : ${extractUpstreamMessage(errorText)}`
`avec le modèle "${modelToUse}" : ${extractUpstreamMessage(errorText)}.${hint}`
);
}