From 50680600026f524eca2edc284163d04be5d590b2 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 19:49:40 -0700 Subject: [PATCH 1/4] Split the token server so the routes are readable again server.mjs was 360 lines when it landed in May and 610 now, because every new capability added its machinery to the same file. What a reader wants from it, the routes, sat under 480 lines of env parsing, host checking, token caching and redaction. No behaviour changes. The blocks moved as they were, into the same six modules the Android demo server now uses, so the two stay one thing to learn. server.mjs is 122 lines: the imports, the error handler, the five routes and the listen banner. Verified by running rather than by reading: every route answers as before against qa, a declined per-device lookup is still reported in unavailable, and a PAYABLI_ENV_FILE naming a file that does not exist still exits 1. All declarations that were in server.mjs are still present across the modules. --- .../LocalTokenServer/lib/card-present.mjs | 170 ++++++ .../LocalTokenServer/lib/errors.mjs | 37 ++ .../PayabliDemo/LocalTokenServer/lib/http.mjs | 68 +++ .../LocalTokenServer/lib/payabli-api.mjs | 47 ++ .../LocalTokenServer/lib/settings.mjs | 100 ++++ .../LocalTokenServer/lib/tokens.mjs | 141 +++++ .../LocalTokenServer/lib/upstream.mjs | 50 ++ .../PayabliDemo/LocalTokenServer/server.mjs | 552 +----------------- 8 files changed, 639 insertions(+), 526 deletions(-) create mode 100644 Example/PayabliDemo/LocalTokenServer/lib/card-present.mjs create mode 100644 Example/PayabliDemo/LocalTokenServer/lib/errors.mjs create mode 100644 Example/PayabliDemo/LocalTokenServer/lib/http.mjs create mode 100644 Example/PayabliDemo/LocalTokenServer/lib/payabli-api.mjs create mode 100644 Example/PayabliDemo/LocalTokenServer/lib/settings.mjs create mode 100644 Example/PayabliDemo/LocalTokenServer/lib/tokens.mjs create mode 100644 Example/PayabliDemo/LocalTokenServer/lib/upstream.mjs diff --git a/Example/PayabliDemo/LocalTokenServer/lib/card-present.mjs b/Example/PayabliDemo/LocalTokenServer/lib/card-present.mjs new file mode 100644 index 0000000..d4b3af2 --- /dev/null +++ b/Example/PayabliDemo/LocalTokenServer/lib/card-present.mjs @@ -0,0 +1,170 @@ +// The Tap to Pay device list and the activation challenge. + +import { LocalTokenServerError } from "./errors.mjs"; +import { defaultEntry, envFilePath, stringValue } from "./settings.mjs"; +import { payabliApi } from "./payabli-api.mjs"; + +// Observed values. Anything else is passed through as its raw number rather +// than guessed at. +const DEVICE_STATUS_ACTIVE = 1; + +const DEVICE_STATUS_PENDING = 2; + +// These endpoints report failure as HTTP 200 with `isSuccess: false`, so the +// real outcome is in the envelope rather than the transport status. +function envelopeDecline(payload) { + if (!payload || payload.isSuccess !== false) { + return null; + } + + const data = payload.responseData || {}; + return { + code: Number(data.resultCode) || 0, + text: stringValue(data.resultText) || stringValue(payload.responseText) || "Declined" + }; +} + +// Observed values. Anything else is passed through as its raw number rather +// than guessed at. + +function deviceStatusLabel(status) { + if (status === DEVICE_STATUS_ACTIVE) return "active"; + if (status === DEVICE_STATUS_PENDING) return "pending"; + return `status-${status}`; +} + +async function describeDevice(entry, deviceId, options = {}) { + const payload = await payabliApi( + `/Device/get/${encodeURIComponent(entry)}/${encodeURIComponent(deviceId)}`, + { options } + ); + // Reported rather than dropped. Returning null removed the device from the list with nothing + // said, so a lookup declined for provisioning or authorisation looked the same as a device that + // is not there. Which decline codes mean a stale row is documented nowhere this server can read, + // so it names what it skipped instead of deciding. + const decline = envelopeDecline(payload); + return decline ? { deviceId, decline } : { deviceId, device: payload.responseData || null }; +} + +// `/Device/list` omits pending devices, which are the only ones that can be +// activated, so the fuller `/Cloud/list` is the source and each row is then +// described individually to get its status. + +// `/Device/list` omits pending devices, which are the only ones that can be +// activated, so the fuller `/Cloud/list` is the source and each row is then +// described individually to get its status. +export async function listTapToPayDevices(entry, options = {}) { + if (!entry) { + throw new LocalTokenServerError( + 400, + `Set PAYABLI_ENTRY in ${envFilePath}, or pass entry in the request.` + ); + } + + const payload = await payabliApi(`/Cloud/list/${encodeURIComponent(entry)}`, { options }); + const decline = envelopeDecline(payload); + if (decline) { + throw new LocalTokenServerError(400, `Device list declined (${decline.code}): ${decline.text}`); + } + + const rows = Array.isArray(payload.responseList) ? payload.responseList : []; + const described = []; + for (let index = 0; index < rows.length; index += 6) { + const batch = await Promise.all( + rows.slice(index, index + 6).map((row) => describeDevice(entry, row.deviceId, options)) + ); + described.push(...batch); + } + + const unavailable = described + .filter((row) => row.decline) + .map((row) => ({ deviceId: row.deviceId, code: row.decline.code, text: row.decline.text })); + + const devices = described + .map((row) => row.device) + .filter((device) => device && stringValue(device.deviceType).toLowerCase() === "softpos") + .map((device) => ({ + deviceId: device.deviceId, + status: deviceStatusLabel(device.deviceStatus), + deviceStatus: device.deviceStatus, + model: device.model, + serialNumber: device.serialNumber, + friendlyName: device.friendlyName, + createdAt: device.createdAt, + updatedAt: device.updatedAt + })) + .sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || ""))); + + return { devices, unavailable }; +} + +// Requests the activation code for a pending device. Idempotent upstream: an +// unexpired code is returned again rather than reissued. + +// Requests the activation code for a pending device. Idempotent upstream: an +// unexpired code is returned again rather than reissued. +export async function requestActivationCode(options = {}) { + const entry = stringValue(options.entry) || defaultEntry; + if (!entry) { + throw new LocalTokenServerError( + 400, + `Set PAYABLI_ENTRY in ${envFilePath}, or pass entry in the request.` + ); + } + + let deviceId = stringValue(options.deviceId); + let resolvedFrom = "request"; + + // A serial number is the app's identifierForVendor and is shared by every + // record a reinstall leaves behind, so it cannot pick one device out. Only a + // deviceId does. Falling back to the newest pending device is a convenience + // for a single-device QA setup, and reports itself as such. + if (!deviceId) { + const { devices } = await listTapToPayDevices(entry, options); + const pending = devices.filter((device) => device.deviceStatus === DEVICE_STATUS_PENDING); + + if (pending.length === 0) { + throw new LocalTokenServerError( + 404, + `No pending Tap to Pay devices on ${entry}. Pass deviceId to target a specific device.` + ); + } + + deviceId = stringValue(pending[0].deviceId); + resolvedFrom = pending.length === 1 ? "onlyPendingDevice" : `newestOf${pending.length}Pending`; + } + + const payload = await payabliApi("/v2/device/taptopay/activate/challenge", { + method: "POST", + body: { entry, deviceId }, + options + }); + + const decline = envelopeDecline(payload); + if (decline) { + throw new LocalTokenServerError( + decline.code === 404 ? 404 : 400, + `Activation challenge declined (${decline.code}): ${decline.text}` + ); + } + + const data = payload.responseData || {}; + // An envelope that reports success and carries no code is an upstream fault, not an activation. + // Returned as 200 with an empty code it reads as issuance, and the device is never activated. + const code = stringValue(data.code); + if (!code) { + throw new LocalTokenServerError( + 502, + `Activation challenge for ${deviceId} on ${entry} reported success and returned no code.` + ); + } + + return { + entry, + deviceId, + resolvedFrom, + code, + expiresAt: stringValue(data.expiresAt), + alreadyIssued: Boolean(data.alreadyIssued) + }; +} diff --git a/Example/PayabliDemo/LocalTokenServer/lib/errors.mjs b/Example/PayabliDemo/LocalTokenServer/lib/errors.mjs new file mode 100644 index 0000000..1714bf5 --- /dev/null +++ b/Example/PayabliDemo/LocalTokenServer/lib/errors.mjs @@ -0,0 +1,37 @@ +// The error type the routes throw, and the two functions that decide what a client is told. +// +// Kept apart from everything else because every other module throws these and none of them should +// have to know how a message reaches a response. + +export class LocalTokenServerError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + } +} + +export function publicErrorMessage(error) { + return redactSensitiveText(error instanceof Error ? error.message : String(error)); +} + +export function redactSensitiveText(value) { + return value + .replace(/(bearer\s+)[a-z0-9._~+/-]+=*/gi, "$1[REDACTED]") + .replace( + /("(?:access_token|accessToken|token|clientSecret|client_secret|secret)"\s*:\s*)"[^"]*"/gi, + '$1"[REDACTED]"' + ) + .replace(/("(?:code|activationCode)"\s*:\s*)"[^"]*"/gi, '$1"[REDACTED]"') + .replace( + /((?:access_token|accessToken|token|clientSecret|client_secret|secret)=)[^\s&]+/gi, + "$1[REDACTED]" + ); +} + +export function safeJson(value) { + try { + return redactSensitiveText(JSON.stringify(value)); + } catch { + return redactSensitiveText(String(value)); + } +} diff --git a/Example/PayabliDemo/LocalTokenServer/lib/http.mjs b/Example/PayabliDemo/LocalTokenServer/lib/http.mjs new file mode 100644 index 0000000..dfbd2f2 --- /dev/null +++ b/Example/PayabliDemo/LocalTokenServer/lib/http.mjs @@ -0,0 +1,68 @@ +// Reading a request and writing a response. Nothing here knows what Payabli is. + +import { LocalTokenServerError } from "./errors.mjs"; +import { configuredCorsOrigins, maxRequestBodyBytes } from "./settings.mjs"; + +export function sendJson(res, status, body) { + res.writeHead(status, { + "Cache-Control": "no-store", + "Content-Type": "application/json; charset=utf-8", + "X-Content-Type-Options": "nosniff" + }); + res.end(JSON.stringify(body)); +} + +export function setCorsHeaders(req, res) { + const origin = req.headers.origin; + res.setHeader("Vary", "Origin"); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization"); + if (!origin) { + return true; + } + if (!isAllowedCorsOrigin(origin)) { + return false; + } + res.setHeader("Access-Control-Allow-Origin", origin); + return true; +} + +export async function readJsonBody(req) { + const chunks = []; + let totalBytes = 0; + for await (const chunk of req) { + totalBytes += chunk.length; + if (totalBytes > maxRequestBodyBytes) { + throw new LocalTokenServerError(413, `Request body is too large. Maximum is ${maxRequestBodyBytes} bytes.`); + } + chunks.push(chunk); + } + + const raw = Buffer.concat(chunks).toString("utf8").trim(); + if (!raw) { + return {}; + } + + try { + return JSON.parse(raw); + } catch { + throw new LocalTokenServerError(400, "Request body must be valid JSON."); + } +} + +function isAllowedCorsOrigin(origin) { + if (configuredCorsOrigins.has(origin.toLowerCase())) { + return true; + } + + if (configuredCorsOrigins.size > 0) { + return false; + } + + try { + const parsed = new URL(origin); + return ["127.0.0.1", "localhost", "::1", "[::1]"].includes(parsed.hostname.toLowerCase()); + } catch { + return false; + } +} diff --git a/Example/PayabliDemo/LocalTokenServer/lib/payabli-api.mjs b/Example/PayabliDemo/LocalTokenServer/lib/payabli-api.mjs new file mode 100644 index 0000000..ec9a35e --- /dev/null +++ b/Example/PayabliDemo/LocalTokenServer/lib/payabli-api.mjs @@ -0,0 +1,47 @@ +// One authenticated call to the Payabli API, with the allow-list applied to the endpoint it resolves. + +import { LocalTokenServerError, safeJson } from "./errors.mjs"; +import { defaultApiBaseUrl, stringValue } from "./settings.mjs"; +import { assertAllowedEndpoint, ensureTrailingSlash, normalizeBaseUrl } from "./upstream.mjs"; +import { resolveAccessToken } from "./tokens.mjs"; + +// Authenticated call to the Payabli API with the resolved access token. +export async function payabliApi(path, { method = "GET", body = null, options = {} } = {}) { + const apiBaseUrl = normalizeBaseUrl(stringValue(options.apiBaseUrl) || defaultApiBaseUrl); + const token = await resolveAccessToken(options); + const endpoint = new URL(path.replace(/^\/+/, ""), ensureTrailingSlash(apiBaseUrl)); + assertAllowedEndpoint(endpoint, "The resolved API endpoint"); + + // redirect: "manual", as the credential exchange does and for the same reason: a 307 or 308 replays + // the method, body and Authorization header to whatever origin the Location names. + const upstream = await fetch(endpoint, { + method, + redirect: "manual", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: body === null ? undefined : JSON.stringify(body) + }); + + const text = await upstream.text(); + let payload; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + payload = { raw: text }; + } + + if (!upstream.ok) { + throw new LocalTokenServerError( + upstream.status >= 500 ? 502 : upstream.status, + `Payabli ${path} failed with HTTP ${upstream.status}: ${safeJson(payload)}` + ); + } + + return payload; +} + +// These endpoints report failure as HTTP 200 with `isSuccess: false`, so the +// real outcome is in the envelope rather than the transport status. diff --git a/Example/PayabliDemo/LocalTokenServer/lib/settings.mjs b/Example/PayabliDemo/LocalTokenServer/lib/settings.mjs new file mode 100644 index 0000000..40eccf2 --- /dev/null +++ b/Example/PayabliDemo/LocalTokenServer/lib/settings.mjs @@ -0,0 +1,100 @@ +// Everything read from the environment, resolved once, at import. +// +// The env file is loaded here rather than in server.mjs so that importing any module below reads the +// same settings whatever the import order. Nothing else in this server touches process.env. + +import { existsSync, readFileSync } from "node:fs"; +import { dirname, isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const serverDir = dirname(join(fileURLToPath(import.meta.url), "..")); + +// PAYABLI_ENV_FILE picks the file, so a second environment is a second file rather than an edit to +// this one. A relative name resolves beside this server. An explicitly named file that is not there +// is fatal: the alternative is falling back to the sandbox defaults below and reporting nothing. +const envFileName = (process.env.PAYABLI_ENV_FILE || ".env").trim(); + +const envFilePath = isAbsolute(envFileName) ? envFileName : join(serverDir, envFileName); +if (process.env.PAYABLI_ENV_FILE && !existsSync(envFilePath)) { + console.error(`PAYABLI_ENV_FILE=${envFileName} does not exist at ${envFilePath}`); + process.exit(1); +} +loadEnv(envFilePath); + +loadEnv(envFilePath); + +export { envFilePath }; + +export const port = Number.parseInt(process.env.PORT || "8787", 10); + +export const bindHost = stringValue(process.env.PAYABLI_LOCAL_TOKEN_SERVER_HOST) || "127.0.0.1"; +// Sandbox, matching what the app and .env.example ship. Override with PAYABLI_API_BASE_URL. + +// Sandbox, matching what the app and .env.example ship. Override with PAYABLI_API_BASE_URL. +export const defaultApiBaseUrl = process.env.PAYABLI_API_BASE_URL || "https://api-sandbox.payabli.com/api"; + +export const defaultTokenPath = process.env.PAYABLI_TOKEN_PATH || "/v2/token/serverside"; + +export const defaultEntry = (process.env.PAYABLI_ENTRY || "").trim(); + +export const responseTokenField = (process.env.PAYABLI_RESPONSE_TOKEN_FIELD || "").trim(); + +export const cacheTtlSeconds = Number.parseInt(process.env.PAYABLI_TOKEN_CACHE_TTL_SECONDS || "300", 10); + +export const maxRequestBodyBytes = Number.parseInt(process.env.PAYABLI_MAX_REQUEST_BODY_BYTES || "32768", 10); + +export const allowedApiHosts = parseCsvSet( + process.env.PAYABLI_ALLOWED_API_HOSTS || + "api-sandbox.payabli.com,api-qa.payabli.com,api.payabli.com" +); + +export const configuredCorsOrigins = parseCsvSet(process.env.PAYABLI_ALLOWED_CORS_ORIGINS || ""); + +export function stringValue(value) { + return typeof value === "string" ? value.trim() : ""; +} + +export function parseCsvSet(value) { + return new Set( + value + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter(Boolean) + ); +} + +function loadEnv(path) { + if (!existsSync(path)) { + return; + } + + const lines = readFileSync(path, "utf8").split(/\r?\n/); + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + + const separatorIndex = line.indexOf("="); + if (separatorIndex === -1) { + continue; + } + + const key = line.slice(0, separatorIndex).trim(); + const value = stripQuotes(line.slice(separatorIndex + 1).trim()); + if (key && process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +function stripQuotes(value) { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + + return value; +} diff --git a/Example/PayabliDemo/LocalTokenServer/lib/tokens.mjs b/Example/PayabliDemo/LocalTokenServer/lib/tokens.mjs new file mode 100644 index 0000000..ba1317b --- /dev/null +++ b/Example/PayabliDemo/LocalTokenServer/lib/tokens.mjs @@ -0,0 +1,141 @@ +// Getting an access token: straight from the env file, or by exchanging client credentials. +// +// The cache is keyed on the credential and the endpoint, so two environments in one process cannot +// serve each other's token. + +import { createHash } from "node:crypto"; +import { LocalTokenServerError, safeJson } from "./errors.mjs"; +import { + cacheTtlSeconds, + defaultApiBaseUrl, + defaultTokenPath, + responseTokenField, + stringValue +} from "./settings.mjs"; +import { assertAllowedEndpoint, ensureTrailingSlash, normalizeBaseUrl, normalizeTokenPath } from "./upstream.mjs"; + +const tokenCache = new Map(); + +export async function resolveAccessToken(options = {}) { + const directToken = stringValue(options.accessToken) || stringValue(process.env.PAYABLI_ACCESS_TOKEN); + if (directToken) { + return directToken; + } + + const exchange = await exchangeCredentials(options); + return exchange.token; +} + +export async function exchangeCredentials(options = {}, { forceRefresh = false } = {}) { + const clientId = stringValue(options.clientId) || stringValue(process.env.PAYABLI_CLIENT_ID); + const clientSecret = stringValue(options.clientSecret) || stringValue(process.env.PAYABLI_CLIENT_SECRET); + const apiBaseUrl = normalizeBaseUrl(stringValue(options.apiBaseUrl) || defaultApiBaseUrl); + const tokenPath = normalizeTokenPath(stringValue(options.tokenPath) || defaultTokenPath); + const tokenField = stringValue(options.responseTokenField) || responseTokenField; + + if (!clientId || !clientSecret) { + throw new Error( + "Set PAYABLI_ACCESS_TOKEN, or provide PAYABLI_CLIENT_ID and PAYABLI_CLIENT_SECRET for credential exchange." + ); + } + + const cacheKey = JSON.stringify({ + clientIdHash: sha256(clientId), + clientSecretHash: sha256(clientSecret), + apiBaseUrl, + tokenPath, + tokenField + }); + const cached = tokenCache.get(cacheKey); + if (!forceRefresh && cached && cached.expiresAt > Date.now()) { + return { token: cached.token, upstreamStatus: 200 }; + } + + const endpoint = new URL(tokenPath.replace(/^\/+/, ""), ensureTrailingSlash(apiBaseUrl)); + assertAllowedEndpoint(endpoint, "The resolved token endpoint"); + + // redirect: "manual" so a 3xx comes back as a response instead of being followed. fetch follows + // redirects by default, and a 307 or 308 replays the method and body, so an allowed host answering + // with a Location on another origin would hand it the client id and secret. The check above cannot + // see that: it runs before the request, and a redirect target only exists afterwards. "manual" + // rather than "error" because it keeps the target readable, where a bare fetch rejection reports + // "fetch failed" and cannot be told apart from the host being down. + const upstream = await fetch(endpoint, { + method: "POST", + redirect: "manual", + headers: { + "Accept": "application/json", + "Content-Type": "application/json" + }, + body: JSON.stringify({ clientId, clientSecret }) + }); + + if (upstream.status >= 300 && upstream.status < 400) { + throw new LocalTokenServerError( + 502, + `Token exchange to ${endpoint.origin} answered HTTP ${upstream.status} redirecting to ` + + `${upstream.headers.get("location") || "an unnamed target"}. The redirect was not followed, ` + + "because the credential would be sent to the target." + ); + } + + const text = await upstream.text(); + let payload; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + payload = { raw: text }; + } + + if (!upstream.ok) { + throw new Error( + `Payabli token exchange failed with HTTP ${upstream.status}: ${safeJson(payload)}` + ); + } + + const token = extractToken(payload, tokenField); + if (!token) { + throw new Error( + `Payabli token exchange response did not include a token field. Response keys: ${Object.keys(payload).join(", ")}` + ); + } + + if (cacheTtlSeconds > 0) { + tokenCache.set(cacheKey, { + token, + expiresAt: Date.now() + cacheTtlSeconds * 1000 + }); + } + + return { token, upstreamStatus: upstream.status }; +} + +// Authenticated call to the Payabli API with the resolved access token. + +function extractToken(payload, configuredField) { + if (configuredField) { + return stringValue(valueAtPath(payload, configuredField)); + } + + for (const field of ["access_token", "accessToken", "token"]) { + const token = stringValue(valueAtPath(payload, field)); + if (token) { + return token; + } + } + + return ""; +} + +function valueAtPath(value, path) { + return path.split(".").reduce((current, key) => { + if (current && typeof current === "object" && key in current) { + return current[key]; + } + return undefined; + }, value); +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} diff --git a/Example/PayabliDemo/LocalTokenServer/lib/upstream.mjs b/Example/PayabliDemo/LocalTokenServer/lib/upstream.mjs new file mode 100644 index 0000000..1495fdf --- /dev/null +++ b/Example/PayabliDemo/LocalTokenServer/lib/upstream.mjs @@ -0,0 +1,50 @@ +// Where a request is allowed to go, and nothing else. +// +// assertAllowedEndpoint is applied to the configured base and to every endpoint resolved from it: a +// path can steer resolution onto another origin, so checking the base alone leaves the credential +// reachable. + +import { LocalTokenServerError } from "./errors.mjs"; +import { allowedApiHosts } from "./settings.mjs"; + +export function ensureTrailingSlash(url) { + return url.endsWith("/") ? url : `${url}/`; +} + +export function normalizeBaseUrl(url) { + const trimmed = url.trim(); + const normalized = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + const parsed = new URL(normalized); + assertAllowedEndpoint(parsed, "PAYABLI_API_BASE_URL"); + return parsed.toString(); +} + +// Checks a URL that is about to receive the credentials. Applied to the configured base and, more +// importantly, to the endpoint actually resolved from base + path: a path can steer that resolution +// onto another origin, so validating the base alone leaves the credential reachable. + +// Checks a URL that is about to receive the credentials. Applied to the configured base and, more +// importantly, to the endpoint actually resolved from base + path: a path can steer that resolution +// onto another origin, so validating the base alone leaves the credential reachable. +export function assertAllowedEndpoint(parsed, label) { + if (parsed.protocol !== "https:" && process.env.PAYABLI_ALLOW_INSECURE_UPSTREAM !== "true") { + throw new LocalTokenServerError(400, `${label} must use https.`); + } + + if (!allowedApiHosts.has(parsed.hostname.toLowerCase())) { + throw new LocalTokenServerError( + 400, + `${label} host is not allowed. Allowed hosts: ${Array.from(allowedApiHosts).join(", ")}` + ); + } + + return parsed.toString(); +} + +export function normalizeTokenPath(path) { + const trimmed = path.trim(); + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { + throw new LocalTokenServerError(400, "PAYABLI_TOKEN_PATH must be a path, not an absolute URL."); + } + return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; +} diff --git a/Example/PayabliDemo/LocalTokenServer/server.mjs b/Example/PayabliDemo/LocalTokenServer/server.mjs index 6cd7c32..596769f 100644 --- a/Example/PayabliDemo/LocalTokenServer/server.mjs +++ b/Example/PayabliDemo/LocalTokenServer/server.mjs @@ -1,43 +1,17 @@ import { createServer } from "node:http"; -import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; -import { dirname, isAbsolute, join } from "node:path"; -import { fileURLToPath } from "node:url"; -const serverDir = dirname(fileURLToPath(import.meta.url)); -// PAYABLI_ENV_FILE picks the file, so a second environment is a second file rather than an edit to -// this one. A relative name resolves beside this server. An explicitly named file that is not there -// is fatal: the alternative is falling back to the sandbox defaults below and reporting nothing. -const envFileName = (process.env.PAYABLI_ENV_FILE || ".env").trim(); -const envFilePath = isAbsolute(envFileName) ? envFileName : join(serverDir, envFileName); -if (process.env.PAYABLI_ENV_FILE && !existsSync(envFilePath)) { - console.error(`PAYABLI_ENV_FILE=${envFileName} does not exist at ${envFilePath}`); - process.exit(1); -} -loadEnv(envFilePath); - -const port = Number.parseInt(process.env.PORT || "8787", 10); -const bindHost = stringValue(process.env.PAYABLI_LOCAL_TOKEN_SERVER_HOST) || "127.0.0.1"; -// Sandbox, matching what the app and .env.example ship. Override with PAYABLI_API_BASE_URL. -const defaultApiBaseUrl = process.env.PAYABLI_API_BASE_URL || "https://api-sandbox.payabli.com/api"; -const defaultTokenPath = process.env.PAYABLI_TOKEN_PATH || "/v2/token/serverside"; -const defaultEntry = (process.env.PAYABLI_ENTRY || "").trim(); -const responseTokenField = (process.env.PAYABLI_RESPONSE_TOKEN_FIELD || "").trim(); -const cacheTtlSeconds = Number.parseInt(process.env.PAYABLI_TOKEN_CACHE_TTL_SECONDS || "300", 10); -const maxRequestBodyBytes = Number.parseInt(process.env.PAYABLI_MAX_REQUEST_BODY_BYTES || "32768", 10); -const allowedApiHosts = parseCsvSet( - process.env.PAYABLI_ALLOWED_API_HOSTS || - "api-sandbox.payabli.com,api-qa.payabli.com,api.payabli.com" -); -const configuredCorsOrigins = parseCsvSet(process.env.PAYABLI_ALLOWED_CORS_ORIGINS || ""); -const tokenCache = new Map(); - -class LocalTokenServerError extends Error { - constructor(statusCode, message) { - super(message); - this.statusCode = statusCode; - } -} +import { listTapToPayDevices, requestActivationCode } from "./lib/card-present.mjs"; +import { LocalTokenServerError, publicErrorMessage, redactSensitiveText } from "./lib/errors.mjs"; +import { readJsonBody, sendJson, setCorsHeaders } from "./lib/http.mjs"; +import { + bindHost, + defaultApiBaseUrl, + defaultEntry, + envFilePath, + port, + stringValue +} from "./lib/settings.mjs"; +import { exchangeCredentials, resolveAccessToken } from "./lib/tokens.mjs"; const server = createServer((req, res) => { handleRequest(req, res).catch((error) => { @@ -130,493 +104,19 @@ server.listen(port, bindHost, () => { } }); -async function resolveAccessToken(options = {}) { - const directToken = stringValue(options.accessToken) || stringValue(process.env.PAYABLI_ACCESS_TOKEN); - if (directToken) { - return directToken; - } - - const exchange = await exchangeCredentials(options); - return exchange.token; -} - -async function exchangeCredentials(options = {}, { forceRefresh = false } = {}) { - const clientId = stringValue(options.clientId) || stringValue(process.env.PAYABLI_CLIENT_ID); - const clientSecret = stringValue(options.clientSecret) || stringValue(process.env.PAYABLI_CLIENT_SECRET); - const apiBaseUrl = normalizeBaseUrl(stringValue(options.apiBaseUrl) || defaultApiBaseUrl); - const tokenPath = normalizeTokenPath(stringValue(options.tokenPath) || defaultTokenPath); - const tokenField = stringValue(options.responseTokenField) || responseTokenField; - - if (!clientId || !clientSecret) { - throw new Error( - "Set PAYABLI_ACCESS_TOKEN, or provide PAYABLI_CLIENT_ID and PAYABLI_CLIENT_SECRET for credential exchange." - ); - } - - const cacheKey = JSON.stringify({ - clientIdHash: sha256(clientId), - clientSecretHash: sha256(clientSecret), - apiBaseUrl, - tokenPath, - tokenField - }); - const cached = tokenCache.get(cacheKey); - if (!forceRefresh && cached && cached.expiresAt > Date.now()) { - return { token: cached.token, upstreamStatus: 200 }; - } - - const endpoint = new URL(tokenPath.replace(/^\/+/, ""), ensureTrailingSlash(apiBaseUrl)); - assertAllowedEndpoint(endpoint, "The resolved token endpoint"); - - // redirect: "manual" so a 3xx comes back as a response instead of being followed. fetch follows - // redirects by default, and a 307 or 308 replays the method and body, so an allowed host answering - // with a Location on another origin would hand it the client id and secret. The check above cannot - // see that: it runs before the request, and a redirect target only exists afterwards. "manual" - // rather than "error" because it keeps the target readable, where a bare fetch rejection reports - // "fetch failed" and cannot be told apart from the host being down. - const upstream = await fetch(endpoint, { - method: "POST", - redirect: "manual", - headers: { - "Accept": "application/json", - "Content-Type": "application/json" - }, - body: JSON.stringify({ clientId, clientSecret }) - }); - - if (upstream.status >= 300 && upstream.status < 400) { - throw new LocalTokenServerError( - 502, - `Token exchange to ${endpoint.origin} answered HTTP ${upstream.status} redirecting to ` + - `${upstream.headers.get("location") || "an unnamed target"}. The redirect was not followed, ` + - "because the credential would be sent to the target." - ); - } - - const text = await upstream.text(); - let payload; - try { - payload = text ? JSON.parse(text) : {}; - } catch { - payload = { raw: text }; - } - - if (!upstream.ok) { - throw new Error( - `Payabli token exchange failed with HTTP ${upstream.status}: ${safeJson(payload)}` - ); - } - - const token = extractToken(payload, tokenField); - if (!token) { - throw new Error( - `Payabli token exchange response did not include a token field. Response keys: ${Object.keys(payload).join(", ")}` - ); - } - - if (cacheTtlSeconds > 0) { - tokenCache.set(cacheKey, { - token, - expiresAt: Date.now() + cacheTtlSeconds * 1000 - }); - } - - return { token, upstreamStatus: upstream.status }; -} - -// Authenticated call to the Payabli API with the resolved access token. -async function payabliApi(path, { method = "GET", body = null, options = {} } = {}) { - const apiBaseUrl = normalizeBaseUrl(stringValue(options.apiBaseUrl) || defaultApiBaseUrl); - const token = await resolveAccessToken(options); - const endpoint = new URL(path.replace(/^\/+/, ""), ensureTrailingSlash(apiBaseUrl)); - assertAllowedEndpoint(endpoint, "The resolved API endpoint"); - - // redirect: "manual", as the credential exchange does and for the same reason: a 307 or 308 replays - // the method, body and Authorization header to whatever origin the Location names. - const upstream = await fetch(endpoint, { - method, - redirect: "manual", - headers: { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": `Bearer ${token}` - }, - body: body === null ? undefined : JSON.stringify(body) - }); - - const text = await upstream.text(); - let payload; - try { - payload = text ? JSON.parse(text) : {}; - } catch { - payload = { raw: text }; - } - - if (!upstream.ok) { - throw new LocalTokenServerError( - upstream.status >= 500 ? 502 : upstream.status, - `Payabli ${path} failed with HTTP ${upstream.status}: ${safeJson(payload)}` - ); - } - - return payload; -} - -// These endpoints report failure as HTTP 200 with `isSuccess: false`, so the -// real outcome is in the envelope rather than the transport status. -function envelopeDecline(payload) { - if (!payload || payload.isSuccess !== false) { - return null; - } - - const data = payload.responseData || {}; - return { - code: Number(data.resultCode) || 0, - text: stringValue(data.resultText) || stringValue(payload.responseText) || "Declined" - }; -} - -// Observed values. Anything else is passed through as its raw number rather -// than guessed at. -const DEVICE_STATUS_ACTIVE = 1; -const DEVICE_STATUS_PENDING = 2; - -function deviceStatusLabel(status) { - if (status === DEVICE_STATUS_ACTIVE) return "active"; - if (status === DEVICE_STATUS_PENDING) return "pending"; - return `status-${status}`; -} - -async function describeDevice(entry, deviceId, options = {}) { - const payload = await payabliApi( - `/Device/get/${encodeURIComponent(entry)}/${encodeURIComponent(deviceId)}`, - { options } - ); - // Reported rather than dropped. Returning null removed the device from the list with nothing - // said, so a lookup declined for provisioning or authorisation looked the same as a device that - // is not there. Which decline codes mean a stale row is documented nowhere this server can read, - // so it names what it skipped instead of deciding. - const decline = envelopeDecline(payload); - return decline ? { deviceId, decline } : { deviceId, device: payload.responseData || null }; -} - -// `/Device/list` omits pending devices, which are the only ones that can be -// activated, so the fuller `/Cloud/list` is the source and each row is then -// described individually to get its status. -async function listTapToPayDevices(entry, options = {}) { - if (!entry) { - throw new LocalTokenServerError( - 400, - `Set PAYABLI_ENTRY in ${envFilePath}, or pass entry in the request.` - ); - } - - const payload = await payabliApi(`/Cloud/list/${encodeURIComponent(entry)}`, { options }); - const decline = envelopeDecline(payload); - if (decline) { - throw new LocalTokenServerError(400, `Device list declined (${decline.code}): ${decline.text}`); - } - - const rows = Array.isArray(payload.responseList) ? payload.responseList : []; - const described = []; - for (let index = 0; index < rows.length; index += 6) { - const batch = await Promise.all( - rows.slice(index, index + 6).map((row) => describeDevice(entry, row.deviceId, options)) - ); - described.push(...batch); - } - - const unavailable = described - .filter((row) => row.decline) - .map((row) => ({ deviceId: row.deviceId, code: row.decline.code, text: row.decline.text })); - - const devices = described - .map((row) => row.device) - .filter((device) => device && stringValue(device.deviceType).toLowerCase() === "softpos") - .map((device) => ({ - deviceId: device.deviceId, - status: deviceStatusLabel(device.deviceStatus), - deviceStatus: device.deviceStatus, - model: device.model, - serialNumber: device.serialNumber, - friendlyName: device.friendlyName, - createdAt: device.createdAt, - updatedAt: device.updatedAt - })) - .sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || ""))); - - return { devices, unavailable }; -} - -// Requests the activation code for a pending device. Idempotent upstream: an -// unexpired code is returned again rather than reissued. -async function requestActivationCode(options = {}) { - const entry = stringValue(options.entry) || defaultEntry; - if (!entry) { - throw new LocalTokenServerError( - 400, - `Set PAYABLI_ENTRY in ${envFilePath}, or pass entry in the request.` - ); - } - - let deviceId = stringValue(options.deviceId); - let resolvedFrom = "request"; - - // A serial number is the app's identifierForVendor and is shared by every - // record a reinstall leaves behind, so it cannot pick one device out. Only a - // deviceId does. Falling back to the newest pending device is a convenience - // for a single-device QA setup, and reports itself as such. - if (!deviceId) { - const { devices } = await listTapToPayDevices(entry, options); - const pending = devices.filter((device) => device.deviceStatus === DEVICE_STATUS_PENDING); - - if (pending.length === 0) { - throw new LocalTokenServerError( - 404, - `No pending Tap to Pay devices on ${entry}. Pass deviceId to target a specific device.` - ); - } - - deviceId = stringValue(pending[0].deviceId); - resolvedFrom = pending.length === 1 ? "onlyPendingDevice" : `newestOf${pending.length}Pending`; - } - - const payload = await payabliApi("/v2/device/taptopay/activate/challenge", { - method: "POST", - body: { entry, deviceId }, - options - }); - - const decline = envelopeDecline(payload); - if (decline) { - throw new LocalTokenServerError( - decline.code === 404 ? 404 : 400, - `Activation challenge declined (${decline.code}): ${decline.text}` - ); - } - - const data = payload.responseData || {}; - // An envelope that reports success and carries no code is an upstream fault, not an activation. - // Returned as 200 with an empty code it reads as issuance, and the device is never activated. - const code = stringValue(data.code); - if (!code) { - throw new LocalTokenServerError( - 502, - `Activation challenge for ${deviceId} on ${entry} reported success and returned no code.` - ); - } - - return { - entry, - deviceId, - resolvedFrom, - code, - expiresAt: stringValue(data.expiresAt), - alreadyIssued: Boolean(data.alreadyIssued) - }; -} - -function sendJson(res, status, body) { - res.writeHead(status, { - "Cache-Control": "no-store", - "Content-Type": "application/json; charset=utf-8", - "X-Content-Type-Options": "nosniff" - }); - res.end(JSON.stringify(body)); -} - -function setCorsHeaders(req, res) { - const origin = req.headers.origin; - res.setHeader("Vary", "Origin"); - res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization"); - if (!origin) { - return true; - } - if (!isAllowedCorsOrigin(origin)) { - return false; - } - res.setHeader("Access-Control-Allow-Origin", origin); - return true; -} - -function loadEnv(path) { - if (!existsSync(path)) { - return; - } - - const lines = readFileSync(path, "utf8").split(/\r?\n/); - for (const rawLine of lines) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) { - continue; - } - - const separatorIndex = line.indexOf("="); - if (separatorIndex === -1) { - continue; - } - - const key = line.slice(0, separatorIndex).trim(); - const value = stripQuotes(line.slice(separatorIndex + 1).trim()); - if (key && process.env[key] === undefined) { - process.env[key] = value; - } - } -} - -function stripQuotes(value) { - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - return value.slice(1, -1); - } - - return value; -} - -function extractToken(payload, configuredField) { - if (configuredField) { - return stringValue(valueAtPath(payload, configuredField)); - } - - for (const field of ["access_token", "accessToken", "token"]) { - const token = stringValue(valueAtPath(payload, field)); - if (token) { - return token; - } - } - - return ""; -} - -function valueAtPath(value, path) { - return path.split(".").reduce((current, key) => { - if (current && typeof current === "object" && key in current) { - return current[key]; - } - return undefined; - }, value); -} - -function stringValue(value) { - return typeof value === "string" ? value.trim() : ""; -} - -function ensureTrailingSlash(url) { - return url.endsWith("/") ? url : `${url}/`; -} - -function normalizeBaseUrl(url) { - const trimmed = url.trim(); - const normalized = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; - const parsed = new URL(normalized); - assertAllowedEndpoint(parsed, "PAYABLI_API_BASE_URL"); - return parsed.toString(); -} - -// Checks a URL that is about to receive the credentials. Applied to the configured base and, more -// importantly, to the endpoint actually resolved from base + path: a path can steer that resolution -// onto another origin, so validating the base alone leaves the credential reachable. -function assertAllowedEndpoint(parsed, label) { - if (parsed.protocol !== "https:" && process.env.PAYABLI_ALLOW_INSECURE_UPSTREAM !== "true") { - throw new LocalTokenServerError(400, `${label} must use https.`); - } - - if (!allowedApiHosts.has(parsed.hostname.toLowerCase())) { - throw new LocalTokenServerError( - 400, - `${label} host is not allowed. Allowed hosts: ${Array.from(allowedApiHosts).join(", ")}` - ); - } - - return parsed.toString(); -} - -function normalizeTokenPath(path) { - const trimmed = path.trim(); - if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { - throw new LocalTokenServerError(400, "PAYABLI_TOKEN_PATH must be a path, not an absolute URL."); - } - return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; -} - -function safeJson(value) { - try { - return redactSensitiveText(JSON.stringify(value)); - } catch { - return redactSensitiveText(String(value)); - } -} - -async function readJsonBody(req) { - const chunks = []; - let totalBytes = 0; - for await (const chunk of req) { - totalBytes += chunk.length; - if (totalBytes > maxRequestBodyBytes) { - throw new LocalTokenServerError(413, `Request body is too large. Maximum is ${maxRequestBodyBytes} bytes.`); - } - chunks.push(chunk); - } - - const raw = Buffer.concat(chunks).toString("utf8").trim(); - if (!raw) { - return {}; - } - - try { - return JSON.parse(raw); - } catch { - throw new LocalTokenServerError(400, "Request body must be valid JSON."); - } -} - -function parseCsvSet(value) { - return new Set( - value - .split(",") - .map((item) => item.trim().toLowerCase()) - .filter(Boolean) - ); -} - -function isAllowedCorsOrigin(origin) { - if (configuredCorsOrigins.has(origin.toLowerCase())) { - return true; - } - - if (configuredCorsOrigins.size > 0) { - return false; +server.listen(port, bindHost, () => { + console.log(`Payabli local token server listening on http://${bindHost}:${port}`); + // The upstream and the file it came from. Without these, two runs on two environments are + // indistinguishable in the log, and a refusal from the wrong one reads as a bad entry point. + console.log(`Upstream: ${defaultApiBaseUrl}`); + console.log(`Env file: ${envFilePath}`); + if (defaultEntry) { + console.log(`Entry point: ${defaultEntry}`); } - - try { - const parsed = new URL(origin); - return ["127.0.0.1", "localhost", "::1", "[::1]"].includes(parsed.hostname.toLowerCase()); - } catch { - return false; + console.log(`Access token endpoint: http://${bindHost}:${port}/payabli/access-token`); + console.log(`Tap to Pay devices: http://${bindHost}:${port}/payabli/devices`); + console.log(`Activation code: http://${bindHost}:${port}/payabli/activation-code`); + if (!defaultEntry) { + console.log("PAYABLI_ENTRY is not set; pass entry in the request body for the Tap to Pay endpoints."); } -} - -function sha256(value) { - return createHash("sha256").update(value).digest("hex"); -} - -function publicErrorMessage(error) { - return redactSensitiveText(error instanceof Error ? error.message : String(error)); -} - -function redactSensitiveText(value) { - return value - .replace(/(bearer\s+)[a-z0-9._~+/-]+=*/gi, "$1[REDACTED]") - .replace( - /("(?:access_token|accessToken|token|clientSecret|client_secret|secret)"\s*:\s*)"[^"]*"/gi, - '$1"[REDACTED]"' - ) - .replace(/("(?:code|activationCode)"\s*:\s*)"[^"]*"/gi, '$1"[REDACTED]"') - .replace( - /((?:access_token|accessToken|token|clientSecret|client_secret|secret)=)[^\s&]+/gi, - "$1[REDACTED]" - ); -} +}); From 11aea5cc253337a578d9aaa5d0d8219d7f79fa4d Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 20:01:06 -0700 Subject: [PATCH 2/4] Start the server once Rebuilding this branch on main appended a listen block the file already had, so the startup banner printed twice. The banner names the upstream, the env file and the entry point, which is the one place a reader checks which environment is being served, and printing it twice is exactly where that stops being trustworthy. Not a crash. Measured: both listen callbacks fire and every route answers, because the second call runs in the same tick as the first, before the server has bound, so ERR_SERVER_ALREADY_LISTEN is never reached. Co-Authored-By: Claude Opus 5 (1M context) --- Example/PayabliDemo/LocalTokenServer/server.mjs | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/Example/PayabliDemo/LocalTokenServer/server.mjs b/Example/PayabliDemo/LocalTokenServer/server.mjs index 596769f..d6c27ae 100644 --- a/Example/PayabliDemo/LocalTokenServer/server.mjs +++ b/Example/PayabliDemo/LocalTokenServer/server.mjs @@ -103,20 +103,3 @@ server.listen(port, bindHost, () => { console.log("PAYABLI_ENTRY is not set; pass entry in the request body for the Tap to Pay endpoints."); } }); - -server.listen(port, bindHost, () => { - console.log(`Payabli local token server listening on http://${bindHost}:${port}`); - // The upstream and the file it came from. Without these, two runs on two environments are - // indistinguishable in the log, and a refusal from the wrong one reads as a bad entry point. - console.log(`Upstream: ${defaultApiBaseUrl}`); - console.log(`Env file: ${envFilePath}`); - if (defaultEntry) { - console.log(`Entry point: ${defaultEntry}`); - } - console.log(`Access token endpoint: http://${bindHost}:${port}/payabli/access-token`); - console.log(`Tap to Pay devices: http://${bindHost}:${port}/payabli/devices`); - console.log(`Activation code: http://${bindHost}:${port}/payabli/activation-code`); - if (!defaultEntry) { - console.log("PAYABLI_ENTRY is not set; pass entry in the request body for the Tap to Pay endpoints."); - } -}); From 250805aec156b39ce5357d7f9f495484390c1e8b Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 20:01:06 -0700 Subject: [PATCH 3/4] Say what settings owns, and move the flag that belongs to it The header claimed nothing else touched process.env, which was false the moment it was written: upstream.mjs read the insecure-upstream flag and tokens.mjs reads the access token and the client credentials. PAYABLI_ALLOW_INSECURE_UPSTREAM is a plain setting, so it moves here and upstream.mjs now reads no environment at all. The credential reads stay in tokens.mjs, because a request can override each of them and exporting them would put the secret in a module every other one imports. The header says that, so it is now a claim a reader can check with one grep. Co-Authored-By: Claude Opus 5 (1M context) --- Example/PayabliDemo/LocalTokenServer/lib/settings.mjs | 11 ++++++++--- Example/PayabliDemo/LocalTokenServer/lib/upstream.mjs | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Example/PayabliDemo/LocalTokenServer/lib/settings.mjs b/Example/PayabliDemo/LocalTokenServer/lib/settings.mjs index 40eccf2..54c8df1 100644 --- a/Example/PayabliDemo/LocalTokenServer/lib/settings.mjs +++ b/Example/PayabliDemo/LocalTokenServer/lib/settings.mjs @@ -1,7 +1,11 @@ -// Everything read from the environment, resolved once, at import. +// Loads the env file, and exports the settings shared across the server. // -// The env file is loaded here rather than in server.mjs so that importing any module below reads the -// same settings whatever the import order. Nothing else in this server touches process.env. +// Loading happens here rather than in server.mjs so that importing any module below reads the same +// values whatever the import order. +// +// Not every read of process.env lives here. tokens.mjs reads the access token and the client +// credentials where it uses them, because a request can override each of them and exporting them +// would put the secret in a module every other one imports. import { existsSync, readFileSync } from "node:fs"; import { dirname, isAbsolute, join } from "node:path"; @@ -47,6 +51,7 @@ export const allowedApiHosts = parseCsvSet( process.env.PAYABLI_ALLOWED_API_HOSTS || "api-sandbox.payabli.com,api-qa.payabli.com,api.payabli.com" ); +export const allowInsecureUpstream = process.env.PAYABLI_ALLOW_INSECURE_UPSTREAM === "true"; export const configuredCorsOrigins = parseCsvSet(process.env.PAYABLI_ALLOWED_CORS_ORIGINS || ""); diff --git a/Example/PayabliDemo/LocalTokenServer/lib/upstream.mjs b/Example/PayabliDemo/LocalTokenServer/lib/upstream.mjs index 1495fdf..5c997d8 100644 --- a/Example/PayabliDemo/LocalTokenServer/lib/upstream.mjs +++ b/Example/PayabliDemo/LocalTokenServer/lib/upstream.mjs @@ -5,7 +5,7 @@ // reachable. import { LocalTokenServerError } from "./errors.mjs"; -import { allowedApiHosts } from "./settings.mjs"; +import { allowInsecureUpstream, allowedApiHosts } from "./settings.mjs"; export function ensureTrailingSlash(url) { return url.endsWith("/") ? url : `${url}/`; @@ -27,7 +27,7 @@ export function normalizeBaseUrl(url) { // importantly, to the endpoint actually resolved from base + path: a path can steer that resolution // onto another origin, so validating the base alone leaves the credential reachable. export function assertAllowedEndpoint(parsed, label) { - if (parsed.protocol !== "https:" && process.env.PAYABLI_ALLOW_INSECURE_UPSTREAM !== "true") { + if (parsed.protocol !== "https:" && !allowInsecureUpstream) { throw new LocalTokenServerError(400, `${label} must use https.`); } From 0f24c1dc29e72dfc72a55825b02a0c1959ba10ff Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Tue, 11 Aug 2026 20:18:01 -0700 Subject: [PATCH 4/4] Delete the comments the split duplicated Extracting blocks carried each one's leading comment with it, so a comment attached to two blocks was written twice. loadEnv ran twice in settings.mjs. The second call reparsed the same file and could change nothing, because loadEnv skips keys already in process.env. Four comment blocks were duplicated: the device-status constants, the pending device note and the activation note in card-present.mjs, the default upstream in settings.mjs, and the endpoint guard in upstream.mjs. Found by sweeping for the shape review reported on the sibling, payabli/sdk-android#38, which named only the repeated loadEnv here. Co-Authored-By: Claude Opus 5 (1M context) --- .../PayabliDemo/LocalTokenServer/lib/card-present.mjs | 11 ----------- Example/PayabliDemo/LocalTokenServer/lib/settings.mjs | 4 ---- Example/PayabliDemo/LocalTokenServer/lib/upstream.mjs | 4 ---- 3 files changed, 19 deletions(-) diff --git a/Example/PayabliDemo/LocalTokenServer/lib/card-present.mjs b/Example/PayabliDemo/LocalTokenServer/lib/card-present.mjs index d4b3af2..ae5ba1c 100644 --- a/Example/PayabliDemo/LocalTokenServer/lib/card-present.mjs +++ b/Example/PayabliDemo/LocalTokenServer/lib/card-present.mjs @@ -7,7 +7,6 @@ import { payabliApi } from "./payabli-api.mjs"; // Observed values. Anything else is passed through as its raw number rather // than guessed at. const DEVICE_STATUS_ACTIVE = 1; - const DEVICE_STATUS_PENDING = 2; // These endpoints report failure as HTTP 200 with `isSuccess: false`, so the @@ -24,9 +23,6 @@ function envelopeDecline(payload) { }; } -// Observed values. Anything else is passed through as its raw number rather -// than guessed at. - function deviceStatusLabel(status) { if (status === DEVICE_STATUS_ACTIVE) return "active"; if (status === DEVICE_STATUS_PENDING) return "pending"; @@ -46,10 +42,6 @@ async function describeDevice(entry, deviceId, options = {}) { return decline ? { deviceId, decline } : { deviceId, device: payload.responseData || null }; } -// `/Device/list` omits pending devices, which are the only ones that can be -// activated, so the fuller `/Cloud/list` is the source and each row is then -// described individually to get its status. - // `/Device/list` omits pending devices, which are the only ones that can be // activated, so the fuller `/Cloud/list` is the source and each row is then // described individually to get its status. @@ -98,9 +90,6 @@ export async function listTapToPayDevices(entry, options = {}) { return { devices, unavailable }; } -// Requests the activation code for a pending device. Idempotent upstream: an -// unexpired code is returned again rather than reissued. - // Requests the activation code for a pending device. Idempotent upstream: an // unexpired code is returned again rather than reissued. export async function requestActivationCode(options = {}) { diff --git a/Example/PayabliDemo/LocalTokenServer/lib/settings.mjs b/Example/PayabliDemo/LocalTokenServer/lib/settings.mjs index 54c8df1..1f61d39 100644 --- a/Example/PayabliDemo/LocalTokenServer/lib/settings.mjs +++ b/Example/PayabliDemo/LocalTokenServer/lib/settings.mjs @@ -25,15 +25,11 @@ if (process.env.PAYABLI_ENV_FILE && !existsSync(envFilePath)) { } loadEnv(envFilePath); -loadEnv(envFilePath); - export { envFilePath }; export const port = Number.parseInt(process.env.PORT || "8787", 10); export const bindHost = stringValue(process.env.PAYABLI_LOCAL_TOKEN_SERVER_HOST) || "127.0.0.1"; -// Sandbox, matching what the app and .env.example ship. Override with PAYABLI_API_BASE_URL. - // Sandbox, matching what the app and .env.example ship. Override with PAYABLI_API_BASE_URL. export const defaultApiBaseUrl = process.env.PAYABLI_API_BASE_URL || "https://api-sandbox.payabli.com/api"; diff --git a/Example/PayabliDemo/LocalTokenServer/lib/upstream.mjs b/Example/PayabliDemo/LocalTokenServer/lib/upstream.mjs index 5c997d8..6ce6333 100644 --- a/Example/PayabliDemo/LocalTokenServer/lib/upstream.mjs +++ b/Example/PayabliDemo/LocalTokenServer/lib/upstream.mjs @@ -19,10 +19,6 @@ export function normalizeBaseUrl(url) { return parsed.toString(); } -// Checks a URL that is about to receive the credentials. Applied to the configured base and, more -// importantly, to the endpoint actually resolved from base + path: a path can steer that resolution -// onto another origin, so validating the base alone leaves the credential reachable. - // Checks a URL that is about to receive the credentials. Applied to the configured base and, more // importantly, to the endpoint actually resolved from base + path: a path can steer that resolution // onto another origin, so validating the base alone leaves the credential reachable.