/** * 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= (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> = {}; private serviceTokens: Record> = { tr: new Map(), de: new Map(), }; /** In-process single-flight: concurrent callers share one login. */ private loginInFlight: Partial>> = {}; 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 = { 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("pl24.baseUrl", "https://www.partslink24.com"); this.companyCode = this.configService.get("pl24.companyCode", ""); this.username = this.configService.get("pl24.username", ""); this.password = this.configService.get("pl24.password", ""); this.companyCode2 = this.configService.get("pl24.companyCode2", ""); this.username2 = this.configService.get("pl24.username2", ""); this.password2 = this.configService.get("pl24.password2", ""); this.proxyUrl = this.configService.get("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 { 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 { 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 { 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 { 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 { try { const raw = await this.redis.getJson(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 { 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 { 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 { 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 { 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 { 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 { 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> { const cookie = await this.getSessionCookieForAccount(account); const headers: Record = { 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> { 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 { 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 { return this.authorizeServiceForAccount(serviceName, "tr"); } async getSessionCookie(): Promise { return this.getSessionCookieForAccount("tr"); } clearTokens(): void { this.clearTokensForAccount("tr"); } async buildAuthHeaders( serviceName?: string, includeContentType = false, ): Promise> { return this.buildAuthHeadersForAccount("tr", serviceName, includeContentType); } async buildFordLegacyHeaders(serviceName: string): Promise> { 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 { 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( [ "🔴 PL24 giriş yapılamıyor", "", `Hesap: ${company || account}`, `Süre: ${minutes} dakikadır başarısız`, `Son hata: ${reason.slice(0, 200)}`, "", "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 { 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( [ "🟢 PL24 girişi düzeldi", "", `Hesap: ${company || account}`, `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 0–1 logins; * anything near this cap means we are fighting for the session again. */ private async loginRateGate(account: PL24Account): Promise { 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 { 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"); } } }