/** * The contract check. * * Signs in once, calls every endpoint the console reads, and reports what came * back: the HTTP status, the envelope, whether `details` is an array or an * object or null, how many rows, and — the part that matters — the keys on the * first row against the keys `src/api/types.ts` says to expect. * * This exists because field-name drift is invisible until a real payload * arrives, and it is the single most likely way connecting to the backend goes * wrong. It has already happened once from fixtures alone: POS health returns * `terminal_id` while the sales split returns `terminalid`, and an index * signature on the type let the wrong one typecheck in silence. * * READ-ONLY. Nothing here writes. Every create, update and import is left to a * person on a scratch tenant, because several of them are unscoped and one of * them moves stock. * * Run: * npm run contract (prompts for the password, hidden) * * Or, for CI, set NEARLE_EMAIL and NEARLE_PASSWORD in the environment. Neither * is ever written into this file — see the note beside EMAIL below. * * Plain JavaScript on purpose: it runs with the node you already have, with no * install step and no TypeScript loader in the way. * * The credentials are read from the environment and never printed, logged or * written to a file. Put them in front of the command rather than in a script, * and they stay out of your shell history if your shell is configured for it. */ import { createInterface } from 'node:readline'; const BASE = process.env['NEARLE_API'] ?? 'https://fiesta.nearle.app'; /** * `/web/pos`, not `/pos`. * * The `/v1/pos` group sits behind the terminal's session guard; the console's * copies of the same reads are registered under `/v1/web/pos`. Sweeping the * wrong one would report a surface the console never calls. */ const WEB = '/live/api/v1/web'; const POS = '/live/api/v1/web/pos'; const MOB = '/live/api/v1/mob'; /** * The account to sweep with. * * The email defaults because it is not a secret. The PASSWORD is never * defaulted and never written into this file: a password in source is * committed, synced to every machine that clones the repo, and survives in the * history after it is changed. It is read from the environment if set, and * otherwise typed at the prompt below, where it is not echoed and does not * reach the shell history. */ const EMAIL = process.env['NEARLE_EMAIL'] ?? 'care@nearle.in'; /** Reads a line without echoing it. */ function askHidden(question) { return new Promise((resolve) => { const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true }); const onData = (char) => { // Stop echoing everything except the newline that ends the answer. if (char.toString() !== '\n' && char.toString() !== '\r' && char.toString() !== '\u0004') { process.stdout.write('\u001b[2K\u001b[200D' + question + '*'.repeat(rl.line.length)); } }; process.stdin.on('data', onData); rl.question(question, (answer) => { process.stdin.off('data', onData); rl.close(); process.stdout.write('\n'); resolve(answer); }); }); } const PASSWORD = process.env['NEARLE_PASSWORD'] ?? (await askHidden(`Password for ${EMAIL}: `)); if (!PASSWORD) { console.error('No password given — nothing to sign in with.'); process.exit(1); } async function call(path, init = {}) { const search = new URLSearchParams(); for (const [key, value] of Object.entries(init.params ?? {})) { if (value === undefined || value === null || value === '') continue; search.set(key, String(value)); } const query = search.toString(); const response = await fetch(`${BASE}${path}${query ? `?${query}` : ''}`, { method: init.method ?? 'GET', headers: init.body ? { Accept: 'application/json', 'Content-Type': 'application/json' } : { Accept: 'application/json' }, ...(init.body ? { body: JSON.stringify(init.body) } : {}), }); let envelope = {}; try { envelope = await response.json(); } catch { envelope = { message: 'not JSON' }; } return { http: response.status, envelope }; } /** * A call that reports a dead host rather than crashing the run. * * A wrong `NEARLE_API`, a VPN that is not up, or one endpoint timing out should * leave the other twenty-four results on screen — a stack trace at check four * tells you nothing about checks five to twenty-five. */ async function attempt(path, init = {}) { try { return await call(path, init); } catch (cause) { return { http: 0, envelope: { status: false, message: `could not reach the server (${String(cause)})` }, }; } } /* ── Sign in ─────────────────────────────────────────────────────────────── */ const login = await attempt(`${WEB}/users/applogin`, { method: 'POST', // `configid` is not optional: the lookup is `WHERE authname = ? AND configid = ?`. body: { authname: EMAIL, password: PASSWORD, configid: 1 }, }); if (login.envelope.status !== true || !login.envelope.details) { console.error( `Sign-in failed — HTTP ${login.http}, code ${login.envelope.code}: ${login.envelope.message}`, ); process.exit(1); } const me = login.envelope.details; const tenantid = Number(me['tenantid'] ?? 0); const locationid = Number(me['locationid'] ?? 0); const issuperadmin = me['issuperadmin'] === true; console.log('── signed in ─────────────────────────────────────────────'); console.log(`userid ${me['userid']} · roleid ${me['roleid']} · issuperadmin ${issuperadmin}`); console.log(`tenantid ${tenantid} · locationid ${locationid} · ${me['locationname'] ?? '—'}`); console.log('login keys:', Object.keys(me).sort().join(', ')); console.log(''); /** * A tenant and a branch to probe the scoped endpoints with. * * A super admin has neither of their own, so one is borrowed from the platform * list. Override with NEARLE_TENANT / NEARLE_LOCATION to aim at a specific one. */ let probeTenant = Number(process.env['NEARLE_TENANT'] ?? 0) || tenantid; let probeLocation = Number(process.env['NEARLE_LOCATION'] ?? 0) || locationid; if (!probeTenant) { const tenants = await attempt(`${WEB}/tenants/getalltenants`, { params: { pageno: 1, pagesize: 1 }, }); probeTenant = Number(tenants.envelope.details?.[0]?.['tenantid'] ?? 0); } if (probeTenant && !probeLocation) { const locations = await attempt(`${WEB}/tenants/gettenantlocations`, { params: { tenantid: probeTenant }, }); probeLocation = Number(locations.envelope.details?.[0]?.['locationid'] ?? 0); } console.log(`probing with tenantid ${probeTenant} · locationid ${probeLocation}\n`); /* ── What we expect ──────────────────────────────────────────────────────── */ /** * The keys each row should carry, taken from `src/api/types.ts`. * * Only the ones the console actually reads are listed — a backend that returns * MORE than this is fine and normal, and is reported as extras rather than as a * failure. What matters is anything missing. */ const CHECKS = [ { name: 'tenants/getalltenants', path: `${WEB}/tenants/getalltenants`, params: { pageno: 1, pagesize: 5 }, expect: ['tenantid', 'tenantname', 'locationid', 'locationname', 'status'], }, { name: 'tenants/search?pending', path: `${WEB}/tenants/search`, params: { status: 'pending' }, expect: ['tenantid', 'tenantname'], }, { name: 'tenants/gettenantlocations', path: `${WEB}/tenants/gettenantlocations`, params: { tenantid: probeTenant }, needs: 'tenant', expect: ['locationid', 'tenantid', 'locationname', 'status'], }, { name: 'utils/getappcategories', path: `${WEB}/utils/getappcategories`, expect: ['categoryid', 'categoryname'], }, { name: 'orders/getlocationsummary', path: `${WEB}/orders/getlocationsummary`, params: { tenantid: probeTenant }, needs: 'tenant', expect: ['locationid', 'locationname', 'total', 'delivered', 'cancelled'], }, { name: 'orders/getordersummary', path: `${WEB}/orders/getordersummary`, params: { tenantid: probeTenant }, needs: 'tenant', expect: ['total', 'delivered', 'cancelled'], }, { name: 'catalogue/getbrands', path: `${WEB}/catalogue/getbrands`, expect: ['brand', 'product_count'], }, { name: 'catalogue/getproducts', path: `${WEB}/catalogue/getproducts`, params: { pageno: 1, pagesize: 5 }, expect: ['id', 'brand', 'product_name'], }, { name: 'products/getimportedcatalogueproducts', path: `${WEB}/products/getimportedcatalogueproducts`, params: { tenantid: probeTenant }, needs: 'tenant', expect: ['brand', 'catalogueid'], }, { name: 'products/getproductcategories', path: `${WEB}/products/getproductcategories`, params: { tenantid: probeTenant }, needs: 'tenant', expect: ['categoryid', 'categoryname'], }, { name: 'products/gettenantcategories', path: `${WEB}/products/gettenantcategories`, params: { tenantid: probeTenant }, needs: 'tenant', expect: ['categoryid', 'categoryname'], }, { name: 'products/getlocationproducts', path: `${WEB}/products/getlocationproducts`, params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 }, needs: 'location', expect: ['productid', 'productname', 'price', 'publishedat', 'status'], }, { name: 'products/getallproducts', path: `${WEB}/products/getallproducts`, params: { tenantid: probeTenant, pageno: 1, pagesize: 5 }, needs: 'tenant', expect: ['productid', 'productname'], }, { name: 'products/getstockstatement', path: `${WEB}/products/getstockstatement`, params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 }, needs: 'location', expect: ['productid', 'opening', 'credit', 'debit', 'closing'], }, { name: 'products/getstockrequests', path: `${WEB}/products/getstockrequests`, params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 }, needs: 'tenant', expect: ['requestid', 'productid', 'qty', 'status'], }, { name: 'products/getsaletemplate', path: `${WEB}/products/getsaletemplate`, params: { tenantid: probeTenant, locationid: probeLocation }, needs: 'tenant', expect: ['tenantid', 'locations', 'products'], }, { name: 'customers/gettenantcustomers', path: `${WEB}/customers/gettenantcustomers`, params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 }, needs: 'tenant', expect: ['customerid', 'firstname', 'contactno'], }, { name: 'orders/getorders', path: `${WEB}/orders/tenant/getorders`, params: { tenantid: probeTenant, locationid: probeLocation, fromdate: isoDaysAgo(30), todate: isoDaysAgo(0), pageno: 1, pagesize: 5, }, needs: 'tenant', expect: ['orderheaderid', 'orderstatus'], }, { name: 'deliveries/getdeliveries', path: `${WEB}/deliveries/getdeliveries`, params: { tenantid: probeTenant, locationid: probeLocation, fromdate: isoDaysAgo(30), todate: isoDaysAgo(0), pageno: 1, pagesize: 5, }, needs: 'tenant', expect: ['orderheaderid', 'orderstatus'], }, { name: 'pos/sales/summary', path: `${POS}/sales/summary`, params: { locationid: probeLocation, fromdate: isoDaysAgo(7), todate: isoDaysAgo(0) }, needs: 'location', expect: ['billcount', 'grosssales', 'taxcollected'], }, { name: 'pos/sales', path: `${POS}/sales`, params: { locationid: probeLocation, pageno: 0, pagesize: 5 }, needs: 'location', expect: ['bills', 'total'], }, { name: 'pos/health/location', path: `${POS}/health/location`, params: { location_id: probeLocation }, needs: 'location', expect: ['total', 'online', 'terminals'], }, { name: 'tenants/getposusers', path: `${WEB}/tenants/getposusers`, params: { tenantid: probeTenant, locationid: probeLocation }, needs: 'location', expect: ['users', 'location_id'], }, { name: 'tenants/getstaffs (MOB)', path: `${MOB}/tenants/getstaffs`, params: { tenantid: probeTenant }, needs: 'tenant', expect: ['userid', 'rolename', 'firstname'], }, { name: 'tenants/posroles', path: `${WEB}/tenants/posroles`, expect: ['role_id', 'role'] }, { name: 'tenants/getstaffshifts', path: `${WEB}/tenants/getstaffshifts`, params: { tenantid: probeTenant, locationid: probeLocation }, needs: 'location', expect: ['shifts', 'location_id'], }, ]; function isoDaysAgo(days) { const date = new Date(); date.setDate(date.getDate() - days); return date.toISOString().slice(0, 10); } /* ── Run ─────────────────────────────────────────────────────────────────── */ let mismatches = 0; let unreachable = 0; for (const check of CHECKS) { if (check.needs === 'tenant' && !probeTenant) { console.log(`SKIP ${check.name} — no tenant to probe with`); continue; } if (check.needs === 'location' && !probeLocation) { console.log(`SKIP ${check.name} — no location to probe with`); continue; } const { http, envelope } = await attempt(check.path, { params: check.params }); const payload = envelope.details ?? envelope.data; const shape = Array.isArray(payload) ? `array(${payload.length})` : payload === null || payload === undefined ? 'null' : typeof payload; // The row to inspect: the first element of a list, or the object itself. const row = Array.isArray(payload) ? payload[0] : payload; const keys = row && typeof row === 'object' ? Object.keys(row) : []; const missing = check.expect.filter((key) => !keys.includes(key)); const ok = envelope.status !== false && http < 400 && missing.length === 0; if (!ok) mismatches += 1; if (http >= 400 || envelope.status === false) unreachable += 1; console.log( `${ok ? 'OK ' : 'CHECK'} ${check.name.padEnd(38)} http ${http} · code ${envelope.code ?? '—'} · ${shape}`, ); if (envelope.status === false || http >= 400) { console.log(` message: ${envelope.message ?? '(none)'}`); } if (missing.length > 0 && keys.length > 0) { console.log(` MISSING: ${missing.join(', ')}`); console.log(` got: ${keys.sort().join(', ')}`); } if (keys.length === 0 && shape !== 'null' && !Array.isArray(payload)) { console.log(` payload: ${JSON.stringify(payload).slice(0, 160)}`); } } console.log(''); console.log(`${CHECKS.length} checked · ${mismatches} to look at · ${unreachable} refused`); console.log( mismatches === 0 ? 'Every endpoint answered in the shape the console expects.' : 'Anything marked CHECK either refused the call or is missing a key the console reads.', );