Files
sase.tr/apps/api/src/integrations/pl24/pl24-auth.service.ts
semih 3cfa3d588a
Some checks are pending
QA Gate (P0/P1) / Test affected app (pull_request) Waiting to run
feat(pl24): PL24 girişi 1 saat başarısızsa Telegram uyarısı
İki hesap banı da günler sonra, DB satır sayılarından fark edildi (tr 07-24 →
08-18'de, de 09-04 → 09-16'da). Üçüncüsünde bir saat içinde haber olsun.

- Yeni global `TelegramService` (aynı bot: TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID;
  yapılandırılmamışsa sessizce devre dışı, istek yolunu asla etkilemez).
- `PL24AuthService` kesinti saatini Redis'te tutuyor (`pl24:auth:fail-since:<hesap>`),
  böylece api+worker ve yeniden başlatmalar aynı başlangıcı görür — crash-loop
  saati sıfırlayamaz. Başarılı login saati siler.
- Kesinti 60 dakikayı geçince tek sefer uyarı (`pl24:auth:alerted:<hesap>` NX,
  6 saat) — hesap kodu, süre, son hata ve "banlanmış olabilir" notu ile.
  Düzelince sessiz "🟢 düzeldi" mesajı.
- Devre kesici açıkken de saat işler: kesici login denemesini bastırdığı için
  aksi halde hesap en ölü olduğu anda saat duruyordu.
- compose'a eksik env referansları: TELEGRAM_*, PL24_HTTP_DAILY_MAX,
  PL24_HTTP_USER_RESERVE, PREFETCH_PL24_FAST_DEPTH, PREFETCH_PL24_DELAY_MS
  (Coolify env'i ancak compose'da referanslıysa container'a ulaşıyor).

Test: 3 yeni test (1 saat dolmadan susar, dolunca tek sefer uyarır, düzelince
kurtarma mesajı + saat sıfırlanır). Redis stub'ı gerçek SET NX semantiğine
çekildi (alarm tekrarının bastırılması buna dayanıyor). 80 PL24 testi geçti.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 13:53:15 +03:00

894 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* PartsLink24 Authentication Service
*
* SESSION MODEL (rewritten 2026-09-17, see /home/s/ss/plv2.md §4.1)
* ----------------------------------------------------------------
* PL24 allows ONE session per account and its JWTs live only 600s. The old code
* treated the 600s base JWT as the session, so it re-ran `login` with
* squeezeOut:true every ~9 minutes (~144 logins/day) — the automation signature
* that preceded both account bans. PL24's own portal instead:
*
* 1. logs in once → Set-Cookie PL24TOKEN=<sessionToken> (no expiry)
* 2. POST /auth/.../authorize with ONLY that cookie → fresh 600s service JWT
* 3. repeats step 2 before each expiry; logs in again only when the server
* says the session is gone (`session_status: "gone"` or HTTP 401).
*
* We now do the same: `ensureSession()` owns the cookie (single-flight, shared
* across processes via Redis) and `authorizeServiceForAccount()` mints service
* tokens from it. Catalog JSON calls need only the Bearer; P4 `.action` pages
* need only the cookie (verified live).
*
* Two accounts are still modelled ('tr' / 'de'); PL24_TR_DISABLED transparently
* maps tr → de while the tr account is inactive on PL24's side.
*/
import { Injectable, Logger, type OnModuleInit, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { TelegramService } from "../../common/telegram.service";
import { RedisService } from "../../redis/redis.service";
import { PL24BudgetService } from "./pl24-budget.service";
import { PL24_DEFAULTS, PL24_ENDPOINTS, PL24_USER_AGENT } from "./pl24.constants";
import {
PL24AuthorizeRequest,
PL24AuthorizeResponse,
PL24JWTPayload,
PL24LoginProblem,
PL24LoginRequest,
PL24LoginRequestV2,
PL24LoginResponse,
PL24LoginResponseV2,
PL24Session,
PL24_LOGIN_PROBLEM,
} from "./pl24.types";
type PL24Account = "tr" | "de";
interface ServiceToken {
token: string;
/** epoch ms */
expiresAt: number;
}
/** Login failures that will not fix themselves — back off for hours, not minutes. */
const PERMANENT_LOGIN_PROBLEMS: string[] = [
PL24_LOGIN_PROBLEM.ACCOUNT_NOT_ACTIVE,
PL24_LOGIN_PROBLEM.USER_NOT_ACTIVE,
PL24_LOGIN_PROBLEM.AUTHENTICATION_FAILED,
PL24_LOGIN_PROBLEM.ACCOUNT_PENDING,
PL24_LOGIN_PROBLEM.TWO_FA_REQUIRED,
];
@Injectable()
export class PL24AuthService implements OnModuleInit {
private readonly logger = new Logger(PL24AuthService.name);
// ── Session + service tokens, per account ──────────────────────────────────
private sessions: Partial<Record<PL24Account, PL24Session>> = {};
private serviceTokens: Record<PL24Account, Map<string, ServiceToken>> = {
tr: new Map(),
de: new Map(),
};
/** In-process single-flight: concurrent callers share one login. */
private loginInFlight: Partial<Record<PL24Account, Promise<PL24Session>>> = {};
private proxyAgent: any = null; // undici.ProxyAgent, lazy-init
// ── Login circuit breaker ──────────────────────────────────────────────────
private static readonly BREAKER_THRESHOLD = 3;
private static readonly BREAKER_COOLDOWN_MS = 5 * 60_000;
/** A dead/disabled account must not be retried every 5 minutes for weeks. */
private static readonly BREAKER_PERMANENT_MS = 6 * 60 * 60_000;
private readonly loginBreaker: Record<PL24Account, { fails: number; openUntil: number }> = {
tr: { fails: 0, openUntil: 0 },
de: { fails: 0, openUntil: 0 },
};
/** Hard ceiling on logins per account per hour (PL24 counts sessions, not requests). */
private static readonly MAX_LOGINS_PER_HOUR = 6;
/**
* How long PL24 auth may stay broken before a human is paged on Telegram.
* Both previous bans were only noticed days later, from DB row counts.
*/
private static readonly ALERT_AFTER_MS = 60 * 60_000;
/** Do not re-page more often than this while the outage continues. */
private static readonly ALERT_REPEAT_MS = 6 * 60 * 60_000;
// ── Config ─────────────────────────────────────────────────────────────────
private readonly baseUrl: string;
private readonly companyCode: string;
private readonly username: string;
private readonly password: string;
private readonly companyCode2: string;
private readonly username2: string;
private readonly password2: string;
private readonly proxyUrl: string | null;
private readonly timeout: number;
constructor(
private configService: ConfigService,
private readonly redis: RedisService,
private readonly budget: PL24BudgetService,
private readonly telegram: TelegramService,
) {
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
this.companyCode = this.configService.get<string>("pl24.companyCode", "");
this.username = this.configService.get<string>("pl24.username", "");
this.password = this.configService.get<string>("pl24.password", "");
this.companyCode2 = this.configService.get<string>("pl24.companyCode2", "");
this.username2 = this.configService.get<string>("pl24.username2", "");
this.password2 = this.configService.get<string>("pl24.password2", "");
this.proxyUrl = this.configService.get<string>("pl24.proxyDe", "") || null;
this.timeout = 30000;
if (!this.companyCode || !this.username || !this.password) {
this.logger.warn(
"PL24 account 1 credentials not configured. Set PL24_COMPANY_CODE, PL24_USERNAME, PL24_PASSWORD",
);
}
if (this.companyCode2 && !this.proxyUrl) {
this.logger.warn("PL24 account 2 (de) configured but PL24_PROXY_DE not set");
}
}
onModuleInit(): void {
if (this.trDisabled()) {
this.logger.warn(
"PL24_TR_DISABLED=true — routing ALL PL24 traffic (auth + proxy egress) to the de account",
);
}
// A (re)deploy clears the in-memory caches, so the first VIN decode would
// otherwise pay the login handshake on the request path. Warm it in the
// background — never block or fail boot. PL24_PREWARM=false disables it.
if (process.env.PL24_PREWARM === "false") return;
if (!this.hasCredentials("tr") && !this.hasCredentials("de")) return;
void this.prewarm().catch((err) =>
this.logger.warn(`PL24 auth pre-warm error: ${(err as Error).message}`),
);
}
/**
* Warm the session (one login at most) plus a single service token. The old
* prewarm fired two parallel logins and eight authorizes; with one session per
* account the parallel logins squeezed each other out (observed live: one of
* the two returned HTTP 500 on every boot).
*/
async prewarm(): Promise<void> {
const t0 = Date.now();
const account = this.effectiveAccount("tr");
if (!this.hasCredentials(account)) {
this.logger.log("PL24 auth pre-warm skipped (no credentials for the active account)");
return;
}
let sessionOk = false;
let tokenOk = false;
try {
await this.ensureSession(account);
sessionOk = true;
// One authorize proves the session is really alive and seeds the shared
// common-services scope; per-brand tokens are minted lazily on first use.
await this.authorizeServiceForAccount("vw_parts", account);
tokenOk = true;
} catch (err) {
this.logger.warn(`PL24 auth pre-warm: ${(err as Error).message}`);
}
this.logger.log(
`PL24 auth pre-warm done in ${Date.now() - t0}ms (account=${account}, session=${
sessionOk ? "ok" : "failed"
}, serviceToken=${tokenOk ? "ok" : "failed"})`,
);
}
// ═══════════════════════════════════════════════════════════════════════════
// ── Account helpers ──────────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════════════════
private trDisabled(): boolean {
return process.env.PL24_TR_DISABLED === "true";
}
/** Map tr → de while PL24_TR_DISABLED is set (and de is configured). */
private effectiveAccount(account: PL24Account): PL24Account {
return account === "tr" && this.trDisabled() && this.companyCode2 ? "de" : account;
}
private hasCredentials(account: PL24Account): boolean {
return account === "de"
? Boolean(this.companyCode2 && this.username2 && this.password2)
: Boolean(this.companyCode && this.username && this.password);
}
private credentials(account: PL24Account): { account: string; user: string; password: string } {
return account === "de"
? { account: this.companyCode2, user: this.username2, password: this.password2 }
: { account: this.companyCode, user: this.username, password: this.password };
}
// ═══════════════════════════════════════════════════════════════════════════
// ── Session (the PL24TOKEN cookie) ───────────────────────────────────────
// ═══════════════════════════════════════════════════════════════════════════
private sessionKey(account: PL24Account): string {
return `${PL24_DEFAULTS.CACHE_PREFIX}auth:session:${account}`;
}
private loginLockKey(account: PL24Account): string {
return `${PL24_DEFAULTS.CACHE_PREFIX}auth:login-lock:${account}`;
}
private loginRateKey(account: PL24Account): string {
return `${PL24_DEFAULTS.CACHE_PREFIX}auth:login-count:${account}:${new Date().toISOString().slice(0, 13)}`;
}
/**
* Return a live session for the account, logging in only if we have none.
* Order: memory → Redis (another process may hold it) → login (single-flight).
*/
async ensureSession(requested: PL24Account): Promise<PL24Session> {
const account = this.effectiveAccount(requested);
const inMemory = this.sessions[account];
if (inMemory) return inMemory;
const shared = await this.hydrateSessionFromRedis(account);
if (shared) {
this.sessions[account] = shared;
return shared;
}
const inFlight = this.loginInFlight[account];
if (inFlight) return inFlight;
const promise = this.loginWithLock(account).finally(() => {
delete this.loginInFlight[account];
});
this.loginInFlight[account] = promise;
return promise;
}
/** Cookie header value for the account's session. */
async getSessionCookieForAccount(requested: PL24Account): Promise<string> {
const session = await this.ensureSession(requested);
return `PL24TOKEN=${session.sessionToken}`;
}
/** PL24TOKEN value (Ford legacy passes it as the `hintstoken` query param). */
getPL24TokenValueForAccount(requested: PL24Account): string | null {
return this.sessions[this.effectiveAccount(requested)]?.sessionToken ?? null;
}
/** Forget the session (and its service tokens) — next call logs in again. */
async dropSessionForAccount(requested: PL24Account, reason: string): Promise<void> {
const account = this.effectiveAccount(requested);
delete this.sessions[account];
this.serviceTokens[account].clear();
await this.redis.del(this.sessionKey(account)).catch(() => {});
this.logger.warn(`PL24 ${account} session dropped (${reason})`);
}
/**
* Drop only the minted service tokens. Used on a first 401: the cheap fix is a
* fresh authorize, not a new login (which would squeeze our own session out).
*/
clearTokensForAccount(requested: PL24Account): void {
const account = this.effectiveAccount(requested);
this.serviceTokens[account].clear();
this.logger.log(`PL24 ${account} service tokens cleared (session kept)`);
}
private async hydrateSessionFromRedis(account: PL24Account): Promise<PL24Session | null> {
try {
const raw = await this.redis.getJson<PL24Session>(this.sessionKey(account));
if (!raw?.sessionToken) return null;
this.logger.log(`PL24 ${account} session hydrated from Redis (login skipped)`);
return raw;
} catch {
return null;
}
}
private async persistSession(account: PL24Account, session: PL24Session): Promise<void> {
await this.redis
.setJson(this.sessionKey(account), session, PL24_DEFAULTS.SESSION_TTL_S)
.catch(() => {});
}
/** Refresh the shared session's TTL after any successful upstream call. */
private async touchSession(account: PL24Account): Promise<void> {
const session = this.sessions[account];
if (!session) return;
session.lastOkAt = Date.now();
await this.redis.expire(this.sessionKey(account), PL24_DEFAULTS.SESSION_TTL_S).catch(() => {});
}
// ═══════════════════════════════════════════════════════════════════════════
// ── Login ────────────────────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════════════════
/**
* Cross-process single flight. PL24 kills the previous session on every login,
* so two processes logging in at once leave one of them holding a dead cookie
* (and look like a bot). The Redis lock makes the loser wait for the winner's
* session instead of racing it.
*/
private async loginWithLock(account: PL24Account): Promise<PL24Session> {
const gotLock = await this.redis
.setNx(this.loginLockKey(account), String(process.pid), 30)
.catch(() => true);
if (!gotLock) {
for (let i = 0; i < 40; i++) {
await new Promise((r) => setTimeout(r, 500));
const shared = await this.hydrateSessionFromRedis(account);
if (shared) {
this.sessions[account] = shared;
return shared;
}
}
this.logger.warn(`PL24 ${account} login lock wait timed out — logging in anyway`);
}
try {
return await this.login(account);
} finally {
if (gotLock) await this.redis.del(this.loginLockKey(account)).catch(() => {});
}
}
private async login(account: PL24Account): Promise<PL24Session> {
if (!this.hasCredentials(account)) {
throw new UnauthorizedException(`PL24 account (${account}) credentials not configured`);
}
if (account === "tr" && this.trDisabled()) {
// effectiveAccount normally maps tr → de; this guards stray callers so the
// inactive account is never hit while the flag is set.
throw new UnauthorizedException(
"PL24 tr login skipped: PL24_TR_DISABLED=true (account inactive on PL24 side)",
);
}
this.breakerGate(account);
await this.loginRateGate(account);
// Ask politely first: squeezeOut:false tells us whether somebody else (our
// other process, or a human in the browser) holds the session.
let result = await this.attemptLogin(account, false);
if (result.sessionLimitExceeded) {
this.logger.warn(
`PL24 ${account}: session limit exceeded (another session is active) — retrying with squeezeOut`,
);
result = await this.attemptLogin(account, true);
}
if (!result.sessionToken) {
this.breakerFail(account, result.permanent);
const detail = result.error || "Token alinamadi";
void this.noteLoginFailure(account, detail);
this.logger.error(`PL24 login (${account}) failed: ${detail}`);
throw new UnauthorizedException(`PL24 (${account}) giris hatasi: ${detail}`);
}
const session: PL24Session = {
sessionToken: result.sessionToken,
loginAt: Date.now(),
lastOkAt: Date.now(),
};
this.sessions[account] = session;
await this.persistSession(account, session);
this.breakerOk(account);
void this.noteLoginSuccess(account);
this.logger.log(`PL24 login (${account}) successful — session established`);
return session;
}
private async attemptLogin(
account: PL24Account,
squeezeOut: boolean,
): Promise<{
sessionToken?: string;
sessionLimitExceeded?: boolean;
permanent?: boolean;
error?: string;
}> {
const legacy = process.env.PL24_LOGIN_API === "legacy";
const creds = this.credentials(account);
this.logger.log(
`Logging in to PL24 (${account}${squeezeOut ? ", squeezeOut" : ""}${legacy ? ", legacy api" : ""})...`,
);
const body: PL24LoginRequestV2 | PL24LoginRequest = legacy
? {
authentication: { account: creds.account, user: creds.user, pwd: creds.password },
device: { id: "0", os: "Windows 10", offset: "0", lang: "en-US", "os-version": "0" },
"app-version": "",
squeezeOut,
}
: { account: creds.account, user: creds.user, password: creds.password, squeezeOut };
try {
const dispatcher = await this.getProxyAgent4Account(account);
const fetchOpts: RequestInit & { dispatcher?: any } = {
method: "POST",
redirect: "manual",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"Cache-Control": "no-cache",
"User-Agent": PL24_USER_AGENT,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.timeout),
};
if (dispatcher) fetchOpts.dispatcher = dispatcher;
const url = `${this.baseUrl}${legacy ? PL24_ENDPOINTS.LOGIN_LEGACY : PL24_ENDPOINTS.LOGIN}`;
await this.budget.consume("auth");
const startedAt = Date.now();
const response = await fetch(url, fetchOpts).catch((err) => {
this.budget.record({
kind: "auth",
url,
proxied: Boolean(dispatcher),
account,
success: false,
startedAt,
error: err,
});
throw err;
});
this.budget.record({
kind: "auth",
url,
proxied: Boolean(dispatcher),
account,
statusCode: response.status,
success: response.ok,
startedAt,
});
const cookieToken = this.extractSessionToken(response);
// 400/412 carry an RFC7807 problem body naming the reason.
if (response.status === 400 || response.status === 412) {
const problem = (await response.json().catch(() => ({}))) as PL24LoginProblem;
if (problem.type === PL24_LOGIN_PROBLEM.SESSION_LIMIT_EXCEEDED) {
return { sessionLimitExceeded: true };
}
return {
permanent: problem.type ? PERMANENT_LOGIN_PROBLEMS.includes(problem.type) : false,
error: problem.detail || problem.title || problem.type || `HTTP ${response.status}`,
};
}
if (!response.ok && response.status !== 301 && response.status !== 302) {
return {
permanent: response.status === 401 || response.status === 403,
error: `HTTP ${response.status}: ${response.statusText}`,
};
}
// The portal endpoint answers {loginStatus, sessionToken}; the legacy one
// {status, token:{access_token}} whose JWT `sid` IS the cookie value.
const data = (await response.json().catch(() => ({}))) as PL24LoginResponseV2 &
PL24LoginResponse;
if (data.status === "USER_ALREADY_LOGGED_IN") {
return { sessionLimitExceeded: true };
}
const sessionToken =
cookieToken ||
data.sessionToken ||
(data.token?.access_token ? this.decodeJWT(data.token.access_token).sid : undefined);
if (!sessionToken) {
return {
permanent: data.status === "INVALID_CREDENTIALS",
error: data.message || data.status || data.loginStatus || "no session token in response",
};
}
return { sessionToken };
} catch (error) {
const err = error as Error;
if (err.name === "TimeoutError") return { error: "giris zaman asimina ugradi" };
return { error: err.message };
}
}
/** PL24TOKEN from Set-Cookie (both login endpoints set it). */
private extractSessionToken(response: Response): string | undefined {
const headers = response.headers as Headers & { getSetCookie?: () => string[] };
const all = headers.getSetCookie?.() ?? [response.headers.get("set-cookie") ?? ""];
for (const raw of all) {
const match = raw?.match(/PL24TOKEN=([^;]+)/);
if (match) return match[1];
}
return undefined;
}
// ═══════════════════════════════════════════════════════════════════════════
// ── Service tokens (authorize) ───────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════════════════
/**
* Mint (or reuse) a 600s service JWT for a catalog. Uses ONLY the session
* cookie — no Bearer, no login — exactly like the portal does.
*/
async authorizeServiceForAccount(serviceName: string, requested: PL24Account): Promise<string> {
const account = this.effectiveAccount(requested);
const cache = this.serviceTokens[account];
const cached = cache.get(serviceName);
if (cached && cached.expiresAt - PL24_DEFAULTS.SERVICE_TOKEN_SKEW_MS > Date.now()) {
return cached.token;
}
try {
const token = await this.requestServiceToken(serviceName, account, true);
cache.set(serviceName, { token, expiresAt: this.decodeJWT(token).exp * 1000 });
await this.touchSession(account);
return token;
} catch (error) {
const err = error as Error;
this.logger.error(`Service authorization error (${account}): ${err.message}`);
throw err instanceof UnauthorizedException
? err
: new UnauthorizedException(`Servis yetkilendirme hatasi: ${err.message}`);
}
}
private async requestServiceToken(
serviceName: string,
account: PL24Account,
allowRelogin: boolean,
): Promise<string> {
const cookie = await this.getSessionCookieForAccount(account);
this.logger.log(`Authorizing service ${serviceName} for account ${account}`);
const authorizeRequest: PL24AuthorizeRequest = {
serviceNames: [
"cart",
"pl24-full-vin-data",
"pl24-orderbridge",
"pl24-orderbridge-cart",
"pl24-sendbtmail",
"pl24-qparts",
"orderBook",
"pl24-usage",
"pl24-tls-pilot",
// Portal-level WMI/VIN resolution (`/pl24-wmi/ext/api/2.0/decode`) is
// 403 without this grant.
"pl24-wmidata",
serviceName,
],
serviceCategoryNames: ["pl24-shop-universal", "pl24-shop-tools"],
withLogin: true,
};
const dispatcher = await this.getProxyAgent4Account(account);
const fetchOpts: RequestInit & { dispatcher?: any } = {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Cookie: cookie,
"User-Agent": PL24_USER_AGENT,
},
body: JSON.stringify(authorizeRequest),
signal: AbortSignal.timeout(this.timeout),
};
if (dispatcher) fetchOpts.dispatcher = dispatcher;
const authorizeUrl = `${this.baseUrl}${PL24_ENDPOINTS.AUTHORIZE}`;
await this.budget.consume("auth");
const startedAt = Date.now();
const response = await fetch(authorizeUrl, fetchOpts).catch((err) => {
this.budget.record({
kind: "auth",
url: authorizeUrl,
proxied: Boolean(dispatcher),
account,
success: false,
startedAt,
error: err,
});
throw err;
});
this.budget.record({
kind: "auth",
url: authorizeUrl,
proxied: Boolean(dispatcher),
account,
statusCode: response.status,
success: response.ok,
startedAt,
});
// A dead session shows up here, not at login time.
if (response.status === 401 || response.status === 403) {
if (!allowRelogin) throw new UnauthorizedException(`HTTP ${response.status} on authorize`);
await this.dropSessionForAccount(account, `authorize HTTP ${response.status}`);
return this.requestServiceToken(serviceName, account, false);
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as PL24AuthorizeResponse;
if (data.session_status === "gone") {
if (!allowRelogin) throw new UnauthorizedException("PL24 session gone");
await this.dropSessionForAccount(account, "session_status=gone");
return this.requestServiceToken(serviceName, account, false);
}
const accessToken = data.access_token || data.token?.access_token;
if (!accessToken) throw new Error("No service token in response");
const scope = (data.scope || "").split(" ").filter(Boolean);
if (scope.length && !scope.includes(serviceName)) {
// Not licensed: PL24 silently drops unknown/unlicensed names from scope.
this.logger.warn(`PL24 ${account}: service ${serviceName} not in granted scope`);
}
this.logger.log(`Service ${serviceName} authorized for account ${account}`);
return accessToken;
}
// ═══════════════════════════════════════════════════════════════════════════
// ── Headers ──────────────────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════════════════
/** JSON API headers: service JWT as Bearer (+ cookie, harmless and cheap). */
async buildAuthHeadersForAccount(
account: PL24Account,
serviceName?: string,
includeContentType = false,
): Promise<Record<string, string>> {
const cookie = await this.getSessionCookieForAccount(account);
const headers: Record<string, string> = {
Cookie: cookie,
Accept: "application/json",
"Accept-Language": "tr,en-US;q=0.9,en;q=0.8",
"User-Agent": PL24_USER_AGENT,
};
if (serviceName) {
headers.Authorization = `Bearer ${await this.authorizeServiceForAccount(serviceName, account)}`;
}
if (includeContentType) headers["Content-Type"] = "application/json";
return headers;
}
/**
* P4 (.action) pages authenticate off the session cookie alone — verified
* live against Ford/Opel/Hyundai with no Bearer at all. Skipping the authorize
* here removes a service-token mint from every legacy page fetch.
*/
async buildFordLegacyHeadersForAccount(
_serviceName: string,
account: PL24Account,
): Promise<Record<string, string>> {
const cookie = await this.getSessionCookieForAccount(account);
return {
Cookie: cookie,
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "tr,en-US;q=0.9,en;q=0.8",
"User-Agent": PL24_USER_AGENT,
};
}
/**
* The account a request will really use (tr → de under PL24_TR_DISABLED).
* Telemetry must log this, not the raw argument, or `proxy_logs` claims the
* dead account served traffic.
*/
activeAccount(account: PL24Account): PL24Account {
return this.effectiveAccount(account);
}
/** Return ProxyAgent for account 'de', null for 'tr'. */
async getProxyAgent4Account(account: PL24Account): Promise<any | null> {
if (this.effectiveAccount(account) !== "de") return null;
return this.getProxyAgent();
}
// ═══════════════════════════════════════════════════════════════════════════
// ── Legacy wrappers (always 'tr', which maps to the live account) ────────
// ═══════════════════════════════════════════════════════════════════════════
async authorizeService(serviceName: string): Promise<string> {
return this.authorizeServiceForAccount(serviceName, "tr");
}
async getSessionCookie(): Promise<string> {
return this.getSessionCookieForAccount("tr");
}
clearTokens(): void {
this.clearTokensForAccount("tr");
}
async buildAuthHeaders(
serviceName?: string,
includeContentType = false,
): Promise<Record<string, string>> {
return this.buildAuthHeadersForAccount("tr", serviceName, includeContentType);
}
async buildFordLegacyHeaders(serviceName: string): Promise<Record<string, string>> {
return this.buildFordLegacyHeadersForAccount(serviceName, "tr");
}
getPL24TokenValue(): string | null {
return this.getPL24TokenValueForAccount("tr");
}
// ═══════════════════════════════════════════════════════════════════════════
// ── Breaker + rate gate ──────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════════════════
private breakerGate(account: PL24Account): void {
const b = this.loginBreaker[account];
if (Date.now() < b.openUntil) {
// The breaker suppresses login attempts, so without this the outage clock
// would stop ticking exactly when the account is most likely dead.
void this.noteLoginFailure(account, `login breaker open (${b.fails} failures)`);
throw new UnauthorizedException(
`PL24 ${account} login breaker open (${b.fails} consecutive failures) — ` +
`retrying after ${new Date(b.openUntil).toISOString()}`,
);
}
}
private breakerFail(account: PL24Account, permanent = false): void {
const b = this.loginBreaker[account];
b.fails += 1;
const cooldown = permanent
? PL24AuthService.BREAKER_PERMANENT_MS
: PL24AuthService.BREAKER_COOLDOWN_MS;
const threshold = permanent ? 1 : PL24AuthService.BREAKER_THRESHOLD;
if (b.fails >= threshold && Date.now() >= b.openUntil) {
b.openUntil = Date.now() + cooldown;
this.logger.warn(
`PL24 ${account} login breaker OPEN after ${b.fails} failure(s)${
permanent ? " (account-level rejection)" : ""
} — pausing login attempts for ${Math.round(cooldown / 60_000)}m`,
);
}
}
// ── Outage tracking → Telegram ────────────────────────────────────────────
private failSinceKey(account: PL24Account): string {
return `${PL24_DEFAULTS.CACHE_PREFIX}auth:fail-since:${account}`;
}
private alertedKey(account: PL24Account): string {
return `${PL24_DEFAULTS.CACHE_PREFIX}auth:alerted:${account}`;
}
/**
* Record a login failure and page a human once the account has been unusable
* for an hour. Shared via Redis so api+worker (and restarts) agree on when the
* outage started — a crash-looping process must not reset the clock.
*/
private async noteLoginFailure(account: PL24Account, reason: string): Promise<void> {
try {
const key = this.failSinceKey(account);
const existing = await this.redis.get(key);
const since = existing ? Number(existing) : Date.now();
if (!existing) await this.redis.set(key, String(since), 3 * 86_400);
const downForMs = Date.now() - since;
if (downForMs < PL24AuthService.ALERT_AFTER_MS) return;
if (
!(await this.redis.setNx(
this.alertedKey(account),
"1",
Math.floor(PL24AuthService.ALERT_REPEAT_MS / 1000),
))
) {
return; // already paged recently
}
const minutes = Math.round(downForMs / 60_000);
const company = account === "de" ? this.companyCode2 : this.companyCode;
await this.telegram.send(
[
"🔴 <b>PL24 giriş yapılamıyor</b>",
"",
`Hesap: <code>${company || account}</code>`,
`Süre: <b>${minutes} dakikadır</b> başarısız`,
`Son hata: <code>${reason.slice(0, 200)}</code>`,
"",
"Hesap banlanmış olabilir (önceki iki ban: tr 2026-07-24, de 2026-09-04).",
"Katalog decode'ları PL24 olmadan pcat/emex'e düşüyor.",
].join("\n"),
);
this.logger.error(`PL24 ${account}: auth down for ${minutes}m — Telegram alert sent`);
} catch (err) {
this.logger.warn(`PL24 outage alert failed: ${(err as Error).message}`);
}
}
/** Clear the outage clock and, if we had paged, say it recovered. */
private async noteLoginSuccess(account: PL24Account): Promise<void> {
try {
const key = this.failSinceKey(account);
const since = await this.redis.get(key);
if (!since) return;
await this.redis.del(key);
const wasAlerted = await this.redis.exists(this.alertedKey(account));
await this.redis.del(this.alertedKey(account));
if (wasAlerted) {
const minutes = Math.round((Date.now() - Number(since)) / 60_000);
const company = account === "de" ? this.companyCode2 : this.companyCode;
await this.telegram.send(
[
"🟢 <b>PL24 girişi düzeldi</b>",
"",
`Hesap: <code>${company || account}</code>`,
`Kesinti: ~${minutes} dakika`,
].join("\n"),
{ silent: true },
);
}
} catch {
// never block auth on alerting
}
}
private breakerOk(account: PL24Account): void {
const b = this.loginBreaker[account];
if (b.fails >= PL24AuthService.BREAKER_THRESHOLD) {
this.logger.log(`PL24 ${account} login breaker reset (login succeeded)`);
}
b.fails = 0;
b.openUntil = 0;
}
/**
* Hourly login ceiling. With the session model a healthy day needs 01 logins;
* anything near this cap means we are fighting for the session again.
*/
private async loginRateGate(account: PL24Account): Promise<void> {
try {
const key = this.loginRateKey(account);
const n = await this.redis.incr(key);
if (n === 1) await this.redis.expire(key, 3600);
if (n > PL24AuthService.MAX_LOGINS_PER_HOUR) {
throw new UnauthorizedException(
`PL24 ${account}: login rate limit reached (${n} this hour) — refusing to log in again`,
);
}
} catch (err) {
if (err instanceof UnauthorizedException) throw err;
// Redis down → do not block auth on telemetry.
}
}
// ═══════════════════════════════════════════════════════════════════════════
// ── Misc helpers ─────────────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════════════════
/** Lazy-init ProxyAgent for the de account. */
private async getProxyAgent(): Promise<any> {
if (this.proxyAgent) return this.proxyAgent;
if (!this.proxyUrl) return null;
try {
const { ProxyAgent } = await import("undici");
this.proxyAgent = new ProxyAgent(this.proxyUrl);
this.logger.log("PL24 DE ProxyAgent initialized");
} catch (err) {
this.logger.error(`Failed to init ProxyAgent: ${(err as Error).message}`);
return null;
}
return this.proxyAgent;
}
private decodeJWT(token: string): PL24JWTPayload {
try {
const parts = token.split(".");
if (parts.length !== 3) throw new Error("Invalid JWT format");
const payload = Buffer.from(parts[1], "base64").toString("utf-8");
return JSON.parse(payload);
} catch {
this.logger.error("Failed to decode JWT");
throw new Error("Invalid JWT token");
}
}
}