Add apiv2 bridge router to server for legacy API integration

This commit is contained in:
2026-06-09 18:45:09 -04:00
parent 3e38d0e5ce
commit 9ff744399f
3 changed files with 415 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
// apiv2-bridge — a SIDE-SERVICE that lets external apps post to the legacy PHP
// `/apiv2/*` API without a browser cookie.
//
// This is an ISOLATED, ADDITIVE router. It does not touch, wrap, or sit in the
// path of any existing flow:
// - The browser frontend keeps hitting Caddy's `/apiv2` -> PHP directly with
// its own session cookie. Unchanged.
// - This router is mounted at `/api/apiv2-bridge` (a deliberately distinct
// prefix that cannot be confused with the real `/apiv2`). It inherits the
// same user-JWT auth every `/api/*` route already enforces — the same token
// the product-import skill already sends.
//
// Flow: external app --(user JWT)--> /api/apiv2-bridge/<subpath>
// -> acotBackendSession swaps in a server-held PHP cookie
// -> POST backend.acherryontop.com/apiv2/<subpath>
// -> PHP JSON response relayed back verbatim.
//
// It is a constrained passthrough: only the known product/category/PO write
// surfaces are allowed, so a valid token can't be used to proxy arbitrary
// backend paths.
import express from 'express';
import { apiv2Request, checkSession, isHtmlResponse } from '../utils/acotBackendSession.js';
import { logger } from '../../shared/logging/logger.js';
const router = express.Router();
// Allowed `/apiv2/<...>` path prefixes. Covers product edits/imports, category
// creation, and purchase-order creation — the surfaces the skills need.
const ALLOWED_PREFIXES = ['product/', 'prod_cat/', 'po/'];
function isAllowed(subpath) {
return ALLOWED_PREFIXES.some((prefix) => subpath === prefix.slice(0, -1) || subpath.startsWith(prefix));
}
// Health/credential check: forces a fresh login and reports the result so an
// operator can validate ACOT_BACKEND_USERID/PIN without sending a real payload.
router.get('/_health', async (_req, res) => {
try {
const status = await checkSession();
res.json({ ok: status.authenticated, ...status });
} catch (err) {
logger.error({ err }, 'apiv2-bridge health check failed');
res.status(502).json({ ok: false, error: err.message });
}
});
// Reconstruct an outbound body + content-type from the already-parsed Express
// request. The global json/urlencoded parsers have consumed the raw stream, so
// we re-serialize from req.body to match what the caller sent.
function serializeBody(req) {
const incoming = (req.headers['content-type'] || '').toLowerCase();
if (incoming.includes('application/json')) {
return { contentType: 'application/json; charset=utf-8', body: JSON.stringify(req.body ?? {}) };
}
if (incoming.includes('application/x-www-form-urlencoded')) {
// The real payloads use flat string fields (e.g. `products`/`items` are
// already JSON strings), which round-trip cleanly through URLSearchParams.
const params = new URLSearchParams();
for (const [key, value] of Object.entries(req.body ?? {})) {
params.append(key, typeof value === 'string' ? value : JSON.stringify(value));
}
return { contentType: 'application/x-www-form-urlencoded; charset=utf-8', body: params.toString() };
}
// No/own content-type: forward as-is (string or undefined).
return { contentType: incoming || undefined, body: req.body };
}
// Single passthrough handler for every allowed apiv2 subpath. The wildcard
// captures everything after the mount point (e.g. `product/setup_new`).
router.all('/*', async (req, res, next) => {
// `req.params[0]` is the wildcard capture; strip any leading slash.
const subpath = String(req.params[0] || '').replace(/^\/+/, '');
if (!subpath || subpath.startsWith('_')) return next();
if (!isAllowed(subpath)) {
return res.status(403).json({
error: 'Path not allowed through the apiv2 bridge',
allowed: ALLOWED_PREFIXES,
});
}
// Preserve the original query string (e.g. `?use_test_data_source=1`).
const qIndex = req.originalUrl.indexOf('?');
const query = qIndex === -1 ? '' : req.originalUrl.slice(qIndex + 1);
const { contentType, body } = serializeBody(req);
try {
const upstream = await apiv2Request({
method: req.method,
path: `/apiv2/${subpath}`,
query,
contentType,
body,
});
// If PHP still served the HTML login page (re-login inside apiv2Request
// couldn't recover the session), don't relay it as a misleading 200 — the
// caller would have to sniff HTML to notice. Surface it as a clear 502.
if (isHtmlResponse(upstream.data)) {
logger.error({ subpath, status: upstream.status }, 'apiv2-bridge got HTML (PHP session unavailable)');
return res.status(502).json({
error: 'Backend session unavailable',
message: 'The PHP backend returned its login page — the service account session could not be established. Check /api/apiv2-bridge/_health.',
});
}
logger.info(
{ user: req.user?.username, method: req.method, subpath, status: upstream.status },
'apiv2-bridge relayed request'
);
res.status(upstream.status).type(upstream.contentType).send(upstream.data);
} catch (err) {
logger.error({ err, subpath }, 'apiv2-bridge relay failed');
res.status(502).json({ error: 'Upstream backend request failed', message: err.message });
}
});
export default router;
+5
View File
@@ -37,6 +37,7 @@ import productEditorAuditLogRouter from './routes/product-editor-audit-log.js';
import newsletterRouter from './routes/newsletter.js'; import newsletterRouter from './routes/newsletter.js';
import linesAggregateRouter from './routes/linesAggregate.js'; import linesAggregateRouter from './routes/linesAggregate.js';
import repeatOrdersRouter from './routes/repeat-orders.js'; import repeatOrdersRouter from './routes/repeat-orders.js';
import apiv2BridgeRouter from './routes/apiv2-bridge.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -142,6 +143,10 @@ async function startServer() {
app.use('/api/newsletter', newsletterRouter); app.use('/api/newsletter', newsletterRouter);
app.use('/api/lines-aggregate', linesAggregateRouter); app.use('/api/lines-aggregate', linesAggregateRouter);
app.use('/api/repeat-orders', repeatOrdersRouter); app.use('/api/repeat-orders', repeatOrdersRouter);
// Side-service: lets external apps (import / product-edit skills) post to the
// legacy PHP /apiv2 API without a browser cookie. Isolated, additive router;
// inherits the same user-JWT auth applied to /api above. See apiv2-bridge.js.
app.use('/api/apiv2-bridge', apiv2BridgeRouter);
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ res.json({
@@ -0,0 +1,285 @@
// 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('<!doctype html') || trimmed.startsWith('<html');
}
// Find the `name=` of the first <input> whose `type=` matches, tolerant of
// attribute order and quote style.
function inputNameByType(html, type) {
const tags = String(html).match(/<input\b[^>]*>/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 /<input\b[^>]*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 = /<form\b[^>]*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),
};
}