import { string } from 'alga-js' import getDhlMainCredential, { invalidateDhlMainCredential, type DhlMainCredential } from './dhlMainCredential' // DHL ROPC tokens are app-level (not per-user), so we share one cached token across // all operators hitting the same Node process. TTL of 50 minutes leaves a safety // margin under DHL's typical 1-hour token lifetime. The per-user cookie is kept // as a fallback so a fresh process can pick up a still-valid token without re-auth. const TOKEN_TTL_MS = 50 * 60 * 1000 const COOKIE_MAX_AGE_S = 50 * 60 let cachedToken: string | null = null let cachedTokenExpiry = 0 // Wraps a failed DHL OAuth (ROPC) token request into a plain, human-readable // Error with no `.data` payload of its own. That's deliberate: every catch // block downstream (commission routes' itemErrors/status.detail extraction, // errorHandlingHelper's generic fallback) prioritizes fields off `err.data` // (the shipment-creation error shape) before falling back to `err.message` — // stripping `.data` here means those all naturally surface THIS message // instead of a raw/cryptic OAuth error body or "DHL API Error". // // Three cases, told apart by whether DHL answered at all: // no HTTP status → network/DNS/timeout: the credentials were never checked // (2026-10-21: an Unbound SERVFAIL on api-eu.dhl.com was // chased as a wrong password for an hour because of the // old one-size-fits-all message) // 5xx → DHL-side outage, credentials not the problem either // anything else → DHL rejected the login (invalid_grant, 401, 400 …) const httpStatusOf = (err: any): number => Number(err?.statusCode ?? err?.status ?? err?.response?.status ?? 0) || 0 // ofetch nests the socket error: FetchError.cause = TypeError('fetch failed'), // whose .cause carries the Node code (ENOTFOUND, ECONNREFUSED, ETIMEDOUT, …). // A refused connection arrives as an AggregateError (one entry per address // tried), so the code sits on `.errors[0]` instead of the error itself. const networkCodeOf = (err: any): string => { let cur = err for (let i = 0; i < 5 && cur; i++) { if (typeof cur?.code === 'string' && cur.code !== '') return cur.code const first = Array.isArray(cur?.errors) ? cur.errors[0] : undefined if (typeof first?.code === 'string' && first.code !== '') return first.code cur = cur.cause } return '' } export function buildDhlAuthError(err: any): Error { const detail = err?.data?.error_description || err?.data?.error || err?.data?.detail || err?.data?.title || err?.statusMessage || err?.message || 'Unbekannter Fehler' const status = httpStatusOf(err) if (status === 0) { const code = networkCodeOf(err) const hint = code === 'ENOTFOUND' || code === 'EAI_AGAIN' ? 'DNS-Auflösung von api-eu.dhl.com fehlgeschlagen (Resolver/Firewall prüfen)' : code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'UND_ERR_HEADERS_TIMEOUT' ? 'Zeitüberschreitung beim Verbindungsaufbau (Internet/Firewall prüfen)' : code === 'ECONNREFUSED' || code === 'ECONNRESET' ? 'Verbindung abgelehnt/abgebrochen (Firewall/Proxy prüfen)' : 'keine Antwort von DHL (Netzwerk/DNS prüfen)' return new Error(`DHL-API nicht erreichbar — ${hint}. Die Zugangsdaten wurden dabei NICHT geprüft.${code ? ' Fehlercode: ' + code + '.' : ''} Details: ${detail}`) } if (status >= 500) { return new Error(`DHL-API gestört (HTTP ${status}) — DHL hat den Anmelde-Request nicht verarbeitet, die Zugangsdaten sind nicht die Ursache. Bitte später erneut versuchen. Details: ${detail}`) } return new Error(`DHL-Anmeldung fehlgeschlagen (HTTP ${status}) — bitte die DHL-Zugangsdaten (Benutzername/Passwort/API-Key) auf dem DHL-Shipper bzw. der Order Source prüfen. Details: ${detail}`) } const fetchTokenWith = async (cred: DhlMainCredential) => { try { return await $fetch(cred.dhlurl+'/account/auth/ropc/v1/token', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, body: `grant_type=password&username=${string.urlEncode(cred.dhluser)}&password=${string.urlEncode(cred.dhlpass)}&client_id=${string.urlEncode(cred.dhlkey)}&client_secret=${string.urlEncode(cred.dhlsecret)}` }) } catch(err: any) { throw buildDhlAuthError(err) } } const getToken = async (event: any) => { const cred = await getDhlMainCredential(event) try { return await fetchTokenWith(cred) } catch(err: any) { // The credentials may have just been rotated on the DHL shipper record — // bypass the 10-min credential cache once and retry only if they changed. invalidateDhlMainCredential() const fresh = await getDhlMainCredential(event) if(fresh.dhlpass !== cred.dhlpass || fresh.dhluser !== cred.dhluser || fresh.dhlkey !== cred.dhlkey || fresh.dhlsecret !== cred.dhlsecret || fresh.dhlurl !== cred.dhlurl) { return await fetchTokenWith(fresh) } throw err } } const persistToken = (event: any, token: string) => { cachedToken = token cachedTokenExpiry = Date.now() + TOKEN_TTL_MS setCookie(event, 'logship_bdhtl', token, { maxAge: COOKIE_MAX_AGE_S }) } const resolveToken = async (event: any): Promise => { if (cachedToken && cachedTokenExpiry > Date.now()) { return cachedToken } const cookieToken = getCookie(event, 'logship_bdhtl') ?? '' if (cookieToken !== '') { return cookieToken } const res = await getToken(event) if (res?.access_token) { persistToken(event, res.access_token) return res.access_token } return '' } export default async function dhlHelper(event: any, url: string, method: string = 'GET', body: any) { const cred = await getDhlMainCredential(event) let token = await resolveToken(event) let options: any = {} if(body) { options = { ...options, body: body } } try { return await $fetch(cred.dhlurl+'/'+url, { method: method, headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, ...options }) } catch(err: any) { // If it's a validation error (400), don't retry with new token - it won't help if(err?.statusCode === 400 || err?.status === 400) { console.error('[DHL API Error] Validation error (400):', JSON.stringify(err?.data, null, 2)) throw err } // Invalidate cache: token might be revoked even though our TTL says it's good. cachedToken = null cachedTokenExpiry = 0 const res2 = await getToken(event) if(res2?.access_token) { token = res2.access_token persistToken(event, token) } return await $fetch(cred.dhlurl+'/'+url, { method: method, headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, ...options }) } }