diff --git a/Example/PayabliDemo/LocalTokenServer/lib/card-present.mjs b/Example/PayabliDemo/LocalTokenServer/lib/card-present.mjs new file mode 100644 index 0000000..ae5ba1c --- /dev/null +++ b/Example/PayabliDemo/LocalTokenServer/lib/card-present.mjs @@ -0,0 +1,159 @@ +// 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" + }; +} + +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. +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. +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..1f61d39 --- /dev/null +++ b/Example/PayabliDemo/LocalTokenServer/lib/settings.mjs @@ -0,0 +1,101 @@ +// Loads the env file, and exports the settings shared across the server. +// +// 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"; +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); + +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. +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 allowInsecureUpstream = process.env.PAYABLI_ALLOW_INSECURE_UPSTREAM === "true"; + +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..6ce6333 --- /dev/null +++ b/Example/PayabliDemo/LocalTokenServer/lib/upstream.mjs @@ -0,0 +1,46 @@ +// 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 { allowInsecureUpstream, 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. +export function assertAllowedEndpoint(parsed, label) { + if (parsed.protocol !== "https:" && !allowInsecureUpstream) { + 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..d6c27ae 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) => { @@ -129,494 +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."); } }); - -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; - } - - try { - const parsed = new URL(origin); - return ["127.0.0.1", "localhost", "::1", "[::1]"].includes(parsed.hostname.toLowerCase()); - } catch { - return false; - } -} - -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]" - ); -}