Files
sase.tr/apps/api/src/integrations/pl24/pl24-auth.service.ts
semih 02ec3856d5
Some checks are pending
QA Gate (P0/P1) / Test affected app (pull_request) Waiting to run
perf(pl24): reaktif drill derinliğini sınırla, backfill'i jitter'lı aralıkla yavaşlat
Faz 1 / adım 2b (analiz: /home/s/ss/plv2.md, bulgu consumers_jobs-03).

SORUN: Ban'ı süren hacim backfill değil, her yeni decode'da çalışan fast-lane
TAM AĞAÇ drill'iydi. 2026-08-18→09-04 arasında (PL24_TR_DISABLED main lane'i
park etmişken) yeni kategorilerin %100'ü aynı gün decode edilen araçlardan
geldi: günde 3-17 decode → 1.4k-6.8k kategori (bir Passat 1.251, bir L200
2.323). Araç başına drill derinliği sınırsızdı ve PL24 istekleri arasında hiç
bekleme yoktu.

DEĞİŞİKLİK:
- `PREFETCH_PL24_FAST_DEPTH` (varsayılan 1): PL24 reaktif/fast lane yalnız üst
  gruplar + doğrudan çocuklarını gezer. Daha derini ya kullanıcı o düğümü
  açtığında (lazy) ya da bütçeli backfill lane'inde çekilir. Diğer kaynaklar ve
  PL24 main lane MAX_DEPTH kullanmaya devam eder. Fast lane'in derinlik tavanına
  ulaşması normal durum olduğu için log seviyesi warn değil log.
- `PREFETCH_PL24_DELAY_MS` (varsayılan 8000) + ±%50 jitter: PL24 BACKFILL
  işlerine per-job bekleme. Kullanıcının fast lane'i asla yavaşlatılmaz.
  Metronom gibi düzenli akış otomasyon imzasıdır; jitter onu kırar.
- Telemetri düzeltmesi: `proxy_logs`'a ham hesap etiketi ("tr") yerine gerçekte
  kullanılan hesap yazılıyor (PL24_TR_DISABLED altında "de"). Yeni
  `PL24AuthService.activeAccount()`.

Test: `__testables` ile maxDepthFor/jitter dışa açıldı + derinlik tavanı testi.
Etkilenen paketler: 198 test geçti. tsc + biome temiz.

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

799 lines
33 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 { 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;
// ── 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,
) {
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";
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);
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) {
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`,
);
}
}
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");
}
}
}