// Server-side session manager for the legacy PHP backend (backend.acherryontop.com). // // WHY THIS EXISTS // --------------- // The PHP `/apiv2/*` write endpoints (product edit, image_changes, taxonomy // set, setup_new, prod_cat/new, po/new, po/add_products) authenticate via a // PHP session cookie. In the browser the frontend gets that for free with // `credentials: 'include'` because the user is logged into // backend.acherryontop.com in the same browser — that flow is unchanged and // this module does NOT touch it. // // This module backs a separate SIDE-SERVICE (see routes/apiv2-bridge.js) that // lets EXTERNAL apps (the product-import skill, a future product-edit skill) // post to the PHP API without a browser cookie. The inventory-server holds the // PHP session itself: it logs in once with a SERVICE account, caches every // cookie the backend hands back, and replays them on outbound `/apiv2/*` // requests. Callers authenticate to the inventory-server with the normal user // JWT; this PHP-session layer is invisible to them. // // LOGIN FLOW (discovered from the live login page) // ------------------------------------------------ // 1. GET /login -> sets affinity/session cookies + embeds a // hidden `anti_csrf` token in the form HTML. // 2. POST /login/login -> { anti_csrf, userid, pin, after_login:'', // Submit:'Submit' } with the cookies from (1). // On success the backend rotates/sets the // authenticated session cookie. // // EXPIRY DETECTION // ---------------- // Unauthenticated `/apiv2/*` calls return HTTP 200 with an HTML body (the login // page), NOT a 401. So we sniff the response body the same way the frontend // does (isHtmlResponse) and treat an HTML payload as "session dead" -> re-login // once and retry. import axios from 'axios'; import { logger } from '../../shared/logging/logger.js'; const BASE_URL = (process.env.ACOT_BACKEND_URL || 'https://backend.acherryontop.com').replace(/\/$/, ''); const USERID = process.env.ACOT_BACKEND_USERID; const PIN = process.env.ACOT_BACKEND_PIN; const USER_AGENT = 'inventory-server/apiv2-bridge'; // Single host => a simple name->value map is a sufficient cookie jar. We do NOT // hardcode any cookie name: the backend sets a load-balancer affinity cookie // (`S=...`) AND the real session cookie, and both must be replayed for the // session to stick. Capturing every Set-Cookie generically handles both. let cookieJar = new Map(); // Mutex: collapse concurrent login attempts into one in-flight promise so a // burst of bridged requests doesn't trigger N parallel logins. let loginInFlight = null; function cookieHeader() { return Array.from(cookieJar.entries()) .map(([name, value]) => `${name}=${value}`) .join('; '); } function storeSetCookies(setCookieHeaders) { if (!setCookieHeaders) return; const list = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]; for (const raw of list) { const first = String(raw).split(';')[0]; const eq = first.indexOf('='); if (eq === -1) continue; const name = first.slice(0, eq).trim(); const value = first.slice(eq + 1).trim(); if (!name) continue; // A `name=deleted`/empty value means the backend is clearing the cookie. if (value === '' || value.toLowerCase() === 'deleted') { cookieJar.delete(name); } else { cookieJar.set(name, value); } } } export function isHtmlResponse(payload) { if (typeof payload !== 'string') return false; const trimmed = payload.trim().toLowerCase(); return trimmed.startsWith(' whose `type=` matches, tolerant of // attribute order and quote style. function inputNameByType(html, type) { const tags = String(html).match(/]*>/gi) || []; const typeRe = new RegExp(`type=['"]${type}['"]`, 'i'); for (const tag of tags) { if (typeRe.test(tag)) { const nm = /name=['"]([^'"]+)['"]/i.exec(tag); if (nm) return nm[1]; } } return null; } function hasPasswordField(html) { return /]*type=['"]password['"]/i.test(String(html)); } async function performLogin() { if (!USERID || !PIN) { throw new Error( 'ACOT backend service credentials are not configured (set ACOT_BACKEND_USERID and ACOT_BACKEND_PIN)' ); } // Fresh jar for a clean login. cookieJar = new Map(); // Step 1: GET the login page for cookies + the anti-CSRF token. The login is // IP-based and serves DIFFERENT forms per network (office: userid/pin; outside: // username/password), so we parse the served form rather than hardcoding field // names — the credential pair maps to whatever identifier/secret fields appear. const getRes = await axios.get(`${BASE_URL}/login`, { maxRedirects: 0, validateStatus: () => true, headers: { 'User-Agent': USER_AGENT }, responseType: 'text', transformResponse: (d) => d, }); storeSetCookies(getRes.headers['set-cookie']); const page = getRes.data || ''; const csrfMatch = /name=['"]anti_csrf['"][^>]*value=['"]([^'"]+)['"]/i.exec(page); if (!csrfMatch) { throw new Error('Could not locate anti_csrf token on the backend login page'); } const antiCsrf = csrfMatch[1]; const actionMatch = /]*action=['"]([^'"]+)['"]/i.exec(page); const action = actionMatch ? actionMatch[1] : '/login/login'; const loginUrl = action.startsWith('http') ? action : `${BASE_URL}${action}`; // Identifier field is the first text/email input; secret is the password input. const idField = inputNameByType(page, 'text') || inputNameByType(page, 'email') || 'username'; const secretField = inputNameByType(page, 'password') || 'password'; // Step 2: POST credentials with the cookies from step 1. const form = new URLSearchParams(); form.append('anti_csrf', antiCsrf); form.append(idField, USERID); form.append(secretField, PIN); form.append('after_login', ''); form.append('Submit', 'Submit'); const postRes = await axios.post(loginUrl, form.toString(), { maxRedirects: 0, validateStatus: () => true, headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', 'User-Agent': USER_AGENT, Cookie: cookieHeader(), }, responseType: 'text', transformResponse: (d) => d, }); storeSetCookies(postRes.headers['set-cookie']); // Success heuristic: a failed login re-renders the login form (a page that // still has a password field). A redirect, or any non-form response, is // treated as success here — but the authoritative check is the /apiv2 probe in // checkSession(), since the form may differ across deployments. const redirected = postRes.status >= 300 && postRes.status < 400; if (!redirected && hasPasswordField(postRes.data)) { throw new Error('Backend login failed — credentials rejected (login form re-served)'); } logger.info( { status: postRes.status, idField, secretField, cookies: cookieJar.size }, 'acot backend session established' ); } async function ensureSession() { if (cookieJar.size > 0) return; if (!loginInFlight) { loginInFlight = performLogin().finally(() => { loginInFlight = null; }); } await loginInFlight; } async function forceRelogin() { cookieJar = new Map(); if (!loginInFlight) { loginInFlight = performLogin().finally(() => { loginInFlight = null; }); } await loginInFlight; } /** * Make an authenticated request to a PHP `/apiv2/...` path, transparently * handling login and a single re-login-on-expiry retry. * * @param {object} opts * @param {string} opts.method HTTP method (GET/POST/...). * @param {string} opts.path Path beginning with `/apiv2/...`. * @param {string} [opts.query] Raw query string (without leading `?`). * @param {string} [opts.contentType] Outbound Content-Type header. * @param {string|Buffer} [opts.body] Serialized request body. * @returns {Promise<{status:number, contentType:string, data:string}>} */ export async function apiv2Request({ method, path, query, contentType, body }) { await ensureSession(); const url = `${BASE_URL}${path}${query ? `?${query}` : ''}`; const send = () => axios.request({ url, method, data: body, maxRedirects: 0, validateStatus: () => true, responseType: 'text', transformResponse: (d) => d, headers: { 'User-Agent': USER_AGENT, Cookie: cookieHeader(), ...(contentType ? { 'Content-Type': contentType } : {}), }, }); let res = await send(); // Session expired / not actually authenticated -> the backend serves the // HTML login page. Re-login once and retry. if (isHtmlResponse(res.data)) { logger.warn({ path }, 'acot apiv2 returned HTML (session expired) — re-logging in'); await forceRelogin(); res = await send(); } return { status: res.status, contentType: res.headers['content-type'] || 'application/json', data: typeof res.data === 'string' ? res.data : JSON.stringify(res.data), }; } /** Force a fresh login, then PROVE the session by probing `/apiv2`. A login * that merely "doesn't show a login form" is not proof — the only reliable * signal is that `/apiv2` answers with JSON rather than the HTML login page. * The probe is a no-op: an empty product edit (`products=[]`) edits nothing. * Reports cookie NAMES only (never values). Used by the bridge `_health` route. */ export async function checkSession() { let loginError = null; try { await forceRelogin(); } catch (err) { loginError = err.message; } let probe; try { probe = await apiv2Request({ method: 'POST', path: '/apiv2/product/edit', contentType: 'application/x-www-form-urlencoded; charset=utf-8', body: 'products=%5B%5D', // products=[] }); } catch (err) { return { authenticated: false, error: `apiv2 probe failed: ${err.message}`, loginError, cookieNames: Array.from(cookieJar.keys()), }; } const servedLoginPage = isHtmlResponse(probe.data); return { authenticated: !servedLoginPage, probeStatus: probe.status, cookieNames: Array.from(cookieJar.keys()), ...(loginError ? { loginError } : {}), sample: servedLoginPage ? '(received HTML login page — PHP rejected the session)' : String(probe.data).slice(0, 160), }; }