Some checks are pending
QA Gate (P0/P1) / Test affected app (pull_request) Waiting to run
İ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>
894 lines
37 KiB
TypeScript
894 lines
37 KiB
TypeScript
/**
|
||
* 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 0–1 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");
|
||
}
|
||
}
|
||
}
|