/** * Platform authentication: NP_API_KEY → access token, once, for everyone. * * The API expects a Bearer *token*, not the raw API key — sending the key * straight answers 401 "Invalid token". Historically only the lifecycle shim * did the exchange correctly while the `api` module sent the key, which is why * every real plugin ended up hand-rolling its own HTTP client. This module is * the one implementation both paths (and ctx.api) share. * * The token is cached UNTIL IT EXPIRES, not forever: a warm worker is * long-lived, and a token cached for the life of the process eventually 401s * and silently wedges every call. Refresh 60s before the JWT's `exp` (or every * 5 minutes when `exp` is unreadable); pass force=true to re-exchange after a * 401 — the standard retry-once pattern for callers. */ import { cfg } from "./config"; let cachedToken: string | undefined; let tokenExpiresAt = 0; // epoch ms // Best-effort `exp` claim (seconds → ms); 0 when the token isn't a parseable // JWT, so callers fall back to a short TTL. function jwtExpMs(token: string): number { try { const payload = token.split(".")[1] ?? ""; const b64 = payload.replace(/-/g, "+").replace(/_/g, "/"); const json = JSON.parse(Buffer.from(b64, "base64").toString("utf-8")) as { exp?: number; }; return typeof json.exp === "number" ? json.exp * 1000 : 0; } catch { return 0; } } export async function accessToken( force = false, fetchFn: typeof fetch = fetch ): Promise { const now = Date.now(); if (!force && cachedToken && now < tokenExpiresAt) return cachedToken; const apiKey = cfg.apiKey(); if (!apiKey) { throw new Error( "NP_API_KEY is not set — the plugin cannot authenticate against the platform" ); } const res = await fetchFn(`${cfg.apiUrl()}/token`, { method: "POST", headers: { "Content-Type": "application/json" }, // The documented field is `api_key`; the deployed API also accepts the // legacy `apikey`. Send both so this works against every environment. body: JSON.stringify({ api_key: apiKey, apikey: apiKey }), }); if (!res.ok) throw new Error(`Token exchange failed: ${res.status} ${res.statusText}`); const data = (await res.json()) as { access_token?: string }; if (!data.access_token) throw new Error("Token exchange returned no access_token"); cachedToken = data.access_token; const exp = jwtExpMs(cachedToken); tokenExpiresAt = exp > 0 ? exp - 60_000 : now + 5 * 60_000; return cachedToken; } /** Test hook: forget the cached token so each test starts cold. */ export function resetTokenCache(): void { cachedToken = undefined; tokenExpiresAt = 0; }