Réseaux : contrôle réel de validité des jetons et outils d'inspection

Un jeton de page Facebook n'a pas de date d'expiration, donc `expiryStatus`
renvoyait « never » et aucune alerte ne pouvait se déclencher. Mais il meurt
quand même : mot de passe du compte changé, rôle d'administrateur perdu sur la
page, autorisation retirée, révocation par Meta. On ne l'apprenait qu'au moment
où une publication échouait.

- `checkTokenHealth()` interroge la plateforme (`/me` côté Graph, les ACL
  d'organisation côté LinkedIn) et rend un verdict motivé.
- L'écran Réseaux sociaux contrôle à chaque affichage : pastille « Jeton
  expiré », bandeau rouge et réponse brute de la plateforme quand il est mort,
  ligne verte de confirmation sinon.
- Le verdict est persisté (`social_accounts.last_check_*`) : le tableau de bord
  alerte via `tokenHealthCached()` (fenêtre de 6 h) sans appeler la plateforme à
  chaque affichage, et croise ce signal avec la dernière tentative de
  publication refusée pour motif d'autorisation.
- Bloc « Outils pour inspecter un jeton » dans l'aide de chaque réseau :
  débogueur de jeton, outil de jetons d'accès, explorateur d'API Graph,
  documentation des jetons de longue durée — en précisant que le bouton
  Connecter fait déjà l'échange court → longue durée → jeton de page, et qu'il
  n'y a aucun jeton à copier à la main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
This commit is contained in:
Claude committed 2026-09-06 10:43:11 +00:00
1 parent 17511a73c3
commit 81b99ed5fb
8 files changed
+1618 -16

No files matched your search

+86
View File
@@ -141,6 +141,92 @@ export function expiryStatus(expiresAt: Date | null | undefined): ExpiryStatus {
return remainingMs <= EXPIRY_WARNING_DAYS * 86_400_000 ? "soon" : "ok";
}
/**
* Vérifie que le jeton fonctionne encore, en interrogeant la plateforme.
*
* « N'expire pas » ne veut pas dire « ne meurt jamais » : un jeton de page
* Facebook est invalidé si le mot de passe du compte connecté change, si ce
* compte perd son rôle d'administrateur de la page, si l'autorisation est
* retirée, ou si Meta le révoque. Sans cette vérification, on ne l'apprend
* qu'au moment où une publication échoue.
*/
export type TokenHealth =
| { ok: true; detail: string }
| { ok: false; reason: string };
export async function checkTokenHealth(network: SocialNetwork): Promise<TokenHealth | null> {
const health = await probeToken(network);
if (health) await rememberCheck(network, health);
return health;
}
/** Contrôle mis en cache : évite un appel réseau à chaque affichage. */
export async function tokenHealthCached(
network: SocialNetwork,
maxAgeMs = 6 * 3_600_000
): Promise<TokenHealth | null> {
const account = await getSocialAccount(network);
if (!account?.accessToken || !account.targetId) return null;
const fresh =
account.lastCheckAt && Date.now() - new Date(account.lastCheckAt).getTime() < maxAgeMs;
if (fresh && account.lastCheckOk !== null) {
return account.lastCheckOk
? { ok: true, detail: "Vérifié récemment" }
: { ok: false, reason: account.lastCheckError ?? "Jeton refusé" };
}
return checkTokenHealth(network);
}
async function rememberCheck(network: SocialNetwork, health: TokenHealth) {
await db
.update(socialAccounts)
.set({
lastCheckAt: new Date(),
lastCheckOk: health.ok,
lastCheckError: health.ok ? null : health.reason.slice(0, 2000),
})
.where(eq(socialAccounts.network, network));
}
async function probeToken(network: SocialNetwork): Promise<TokenHealth | null> {
const credentials = await resolveCredentials(network);
if (!credentials) return null;
try {
if (network === "facebook") {
// Un jeton de page répond `/me` avec la page elle-même.
const res = await fetch(
`https://graph.facebook.com/${FACEBOOK_GRAPH_VERSION}/me?${new URLSearchParams({
fields: "id,name",
access_token: credentials.accessToken,
})}`
);
if (!res.ok) return { ok: false, reason: await readError(res) };
const page = (await res.json()) as { id?: string; name?: string };
if (page.id && credentials.targetId && page.id !== credentials.targetId) {
return {
ok: false,
reason: `Le jeton ne pointe plus vers la bonne page (${page.name ?? page.id}).`,
};
}
return { ok: true, detail: page.name ?? "Page accessible" };
}
const res = await fetch(
"https://api.linkedin.com/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED&count=1",
{ headers: linkedinHeaders(credentials.accessToken) }
);
if (!res.ok) return { ok: false, reason: await readError(res) };
return { ok: true, detail: "Accès à la page confirmé" };
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : "Plateforme injoignable",
};
}
}
// ---- OAuth ----
export class SocialAuthError extends Error {}