import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; // Server-side example. Never put a Shop secret in browser code. function bytes(value) { if (typeof value === 'string') return Buffer.from(value, 'utf8'); if (Buffer.isBuffer(value) || value instanceof Uint8Array) return Buffer.from(value); throw new TypeError('The raw body must be a UTF-8 string or bytes.'); } function timestampText(value) { if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/.test(value)) { throw new TypeError('Timestamp must be canonical Unix seconds as a string.'); } return value; } function secretBytes(secret) { if (typeof secret !== 'string' || !secret) throw new TypeError('A literal Shop secret is required.'); return Buffer.from(secret, 'utf8'); // Do not base64-decode the issued secret. } export function signRequest(timestamp, nonce, method, target, rawBody, secret) { timestampText(timestamp); if (typeof nonce !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(nonce)) { throw new TypeError('Nonce must be UUID v4.'); } if (typeof method !== 'string' || !/^[A-Za-z]+$/.test(method)) throw new TypeError('Invalid HTTP method.'); if (typeof target !== 'string' || !/^\/(?!\/)[^\r\n#]*$/.test(target)) throw new TypeError('Use the exact path and encoded query.'); const hmac = createHmac('sha512', secretBytes(secret)); hmac.update(`${timestamp}\n${nonce}\n${method.toUpperCase()}\n${target}\n`, 'utf8'); hmac.update(bytes(rawBody)); return hmac.digest('hex'); } export function verifyWebhook(rawBody, timestamp, signature, secret) { if (typeof timestamp !== 'string' || !/^(0|[1-9][0-9]*)$/.test(timestamp)) return false; if (typeof signature !== 'string' || !/^[0-9a-f]{128}$/.test(signature)) return false; const expected = createHmac('sha512', secretBytes(secret)) .update(`${timestamp}.`, 'utf8').update(bytes(rawBody)).digest(); return timingSafeEqual(expected, Buffer.from(signature, 'hex')); } // Signature verification alone does not check freshness or prevent repeated fulfillment. // The receiver must check the timestamp against its clock (recommended ±300 seconds), // then durably accept payin id + status_id, or payout event_id + revision and its business effect in one transaction. // Success is never downgraded by a delayed overdue notification. export function samplePayin() { return { merchant_order_id: 'YOUR_UNIQUE_ORDER_ID', amount: '15000.00', currency: 'RUB', method_id: 1, mode: 'h2h', traffic_type: 'primary', payer: { id: 'YOUR_CUSTOMER_ID', ip: '192.0.2.10' }, webhook_url: 'https://merchant.example.invalid/webhooks/absolut-pay', }; } export function normalizeApiBase(value) { let parsed; try { parsed = new URL(value); } catch { throw new Error('ABSOLUT_API_BASE_URL must be an API origin.'); } const local = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname); if ((parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && local)) || parsed.username || parsed.password || parsed.search || parsed.hash || parsed.pathname !== '/') { throw new Error('Use an HTTPS API origin; HTTP is allowed only for loopback development.'); } return parsed.origin; } export function prepareRequest({ baseUrl, token, secret, method, target, rawBody = Buffer.alloc(0), timestamp, nonce }) { const base = normalizeApiBase(baseUrl); if (typeof token !== 'string' || !token) throw new Error('ABSOLUT_API_TOKEN is required.'); const body = bytes(rawBody); const verb = method.toUpperCase(); if (verb === 'GET' && body.length) throw new Error('GET must have an empty body.'); const signature = signRequest(timestamp, nonce, verb, target, body, secret); const url = new URL(target, `${base}/`); if (url.origin !== base || `${url.pathname}${url.search}` !== target) { throw new Error('URL normalization would change the signed target.'); } return { url: url.href, options: { method: verb, redirect: 'manual', headers: { 'X-API-Token': token, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Signature': signature, ...(verb === 'POST' ? { 'Content-Type': 'application/json' } : {}) }, ...(verb === 'GET' ? {} : { body }), }, }; } const usage = `Server-side Merchant API v2 example (Node.js 20+) node examples/client.mjs sample node examples/client.mjs balance node examples/client.mjs payout-create --body request.json node examples/client.mjs payout-result 'YOUR_MERCHANT_PAYOUT_ID' node examples/client.mjs payout-get 'UUID_V4' node examples/client.mjs auth-check node examples/client.mjs banks node examples/client.mjs banks --after 'bank_100' node examples/client.mjs create --body request.json node examples/client.mjs result 'YOUR_MERCHANT_ORDER_ID' node examples/client.mjs get 'ord_...' Network commands require ABSOLUT_API_BASE_URL, ABSOLUT_API_TOKEN and ABSOLUT_API_SECRET. The API origin is supplied during onboarding. No production host is assumed. Create reads and signs the exact bytes of an explicitly supplied UTF-8 file. There are no automatic retries. After an ambiguous Create, query result with the same merchant_order_id and fresh authentication; do not blindly create again.`; export async function runCli(args, dependencies = {}) { const env = dependencies.env ?? process.env; const output = dependencies.output ?? ((value) => process.stdout.write(`${value}\n`)); const diagnostic = dependencies.diagnostic ?? ((value) => process.stderr.write(`${value}\n`)); const readBody = dependencies.readBody ?? readFile; const fetchRequest = dependencies.fetchRequest ?? globalThis.fetch; const now = dependencies.now ?? (() => Math.floor(Date.now() / 1000)); const nonce = dependencies.nonce ?? randomUUID; if (args.length === 0 || (args.length === 1 && ['--help', '-h'].includes(args[0]))) { output(usage); return 0; } if (args.length === 1 && args[0] === 'sample') { output(JSON.stringify(samplePayin(), null, 2)); return 0; } let target, method = 'GET', rawBody = Buffer.alloc(0); if (args.length === 1 && args[0] === 'auth-check') target = '/api/v1/auth/check'; else if (args.length === 1 && args[0] === 'balance') target='/api/v2/balance'; else if(args.length===2&&args[0]==='payout-result'&&args[1]) target=`/api/v2/payouts/result?merchant_order_id=${encodeURIComponent(args[1])}`; else if(args.length===2&&args[0]==='payout-get'&&/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(args[1])) target=`/api/v2/payouts/${args[1]}`; else if (args.length === 1 && args[0] === 'banks') target = '/api/v2/banks'; else if (args.length === 3 && args[0] === 'banks' && args[1] === '--after' && /^bank_[1-9][0-9]*$/.test(args[2])) { target = `/api/v2/banks?after=${encodeURIComponent(args[2])}`; } else if (args.length === 2 && args[0] === 'result' && args[1]) { target = `/api/v2/payins/result?merchant_order_id=${encodeURIComponent(args[1])}`; } else if (args.length === 2 && args[0] === 'get' && args[1]) { target = `/api/v2/payins/${encodeURIComponent(args[1])}`; } else if (args.length === 3 && ['create','payout-create'].includes(args[0]) && args[1] === '--body' && args[2]) { method = 'POST'; target = args[0]==='payout-create'?'/api/v2/payouts':'/api/v2/payins'; try { rawBody = Buffer.from(await readBody(args[2])); const body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(rawBody)); if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.merchant_order_id !== 'string' || !body.merchant_order_id || typeof body.amount !== 'string') throw new Error('invalid request'); } catch { throw new Error('The explicit body file must contain valid UTF-8 JSON with merchant_order_id and a string amount.'); } } else { throw new Error('Invalid command. Run with --help. Create requires --body request.json.'); } const prepared = prepareRequest({ baseUrl: env.ABSOLUT_API_BASE_URL, token: env.ABSOLUT_API_TOKEN, secret: env.ABSOLUT_API_SECRET, method, target, rawBody, timestamp: String(now()), nonce: nonce() }); let response; try { response = await fetchRequest(prepared.url, { ...prepared.options, signal: AbortSignal.timeout(35000) }); } catch { diagnostic(method === 'POST' ? 'Create response is unknown. Use signed result lookup with the same merchant_order_id; no request was retried.' : 'Request failed. No automatic retry was performed.'); return 1; } output(`HTTP ${response.status}`); let responseText; try { responseText = await response.text(); } catch { diagnostic(method === 'POST' ? 'Create response is incomplete. Use signed result lookup with the same merchant_order_id; no request was retried.' : 'Response is incomplete. No automatic retry was performed.'); return 1; } if (responseText) output(responseText); if (method === 'POST' && (response.status === 409 || response.status >= 500)) { diagnostic('Recover using result with the same merchant_order_id. Do not blindly repeat POST or allocate a new order ID.'); // Deliberately do not log credentials, request headers or payer data. } return response.ok ? 0 : 1; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { runCli(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => { // These fixed validation errors exclude secrets and raw transport exceptions. process.stderr.write(`${error.message}\n`); process.exitCode = 1; }); }