Files
tehran_taj/apps/api/src/modules/accounting-sync/accounting-http.ts
T
s.esmaeiliandClaude Opus 4.6 19d490e790 feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps
- Add Tahesab/Zarrin accounting sync module with admin panel UI
- Add SMS relay driver as default provider, refactor SMS into driver pattern
- Update customer services, orders, products, and catalog queries
- Rebuild user-app and user-app-v2 dist assets
- Add Prisma migration for accounting tables
- Update docker-compose and env examples
- Update parity docs (DECISIONS, ISSUES, PROGRESS)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 16:48:36 +03:30

282 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { X509Certificate } from 'crypto';
import * as http from 'http';
import * as https from 'https';
import * as net from 'net';
import * as tls from 'tls';
/**
* The one HTTP client both accounting drivers use.
*
* Built on node:http(s) rather than global fetch because a self-hosted
* Tahesab/Zarrin often serves a self-signed certificate, and fetch cannot be
* given a CA without an extra dependency. The reference "solved" that with
* `Http::withoutVerifying()` (SEC-17); here verification is ALWAYS on and the
* admin can paste the installation's certificate instead.
*
* A pasted certificate is trusted two ways: as a CA (the normal chain and
* hostname checks), and as a pin — when the server presents exactly that
* certificate it is accepted whatever name it was issued for. Tahesab's own
* certificate is self-signed for `localhost`, so a remote install reached by
* IP can only be verified by the pin.
*
* Errors say whether the request may have reached the server. That decides
* what the outbox may do next: a refused connection is safe to retry, a
* timeout after the request went out is not (Zarrin has no idempotency key).
*/
export interface HttpRequest {
method: 'GET' | 'POST';
url: string;
headers?: Record<string, string>;
body?: string;
timeoutMs: number;
/** PEM CA certificate(s) to trust in addition to the system store. */
ca?: string | null;
}
export interface HttpResponse {
status: number;
body: string;
}
export class AccountingHttpError extends Error {
constructor(
message: string,
/** True when the request may have been received and acted on. */
readonly ambiguous: boolean,
readonly code?: string,
) {
super(message);
this.name = 'AccountingHttpError';
}
}
const MAX_BODY_BYTES = 20 * 1024 * 1024;
const TLS_CERT_ERRORS = /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/;
/** The PEM blocks in a pasted bundle, parsed; unparsable blocks are skipped. */
export function parsePemCertificates(pem: string | null | undefined): X509Certificate[] {
const blocks = (pem ?? '').match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? [];
const out: X509Certificate[] = [];
for (const block of blocks) {
try {
out.push(new X509Certificate(block));
} catch {
// not a certificate — the CA list below will reject it too
}
}
return out;
}
/** SHA-256 fingerprints, as Node prints them (`AB:CD:…`), of the pasted certificates. */
export function pinnedFingerprints(pem: string | null | undefined): Set<string> {
return new Set(parsePemCertificates(pem).map((c) => c.fingerprint256));
}
function tlsErrorMessage(err: NodeJS.ErrnoException, pinned: boolean): string {
if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
return pinned
? `گواهی سرور برای این آدرس صادر نشده و با گواهی ثبت‌شده در تنظیمات هم یکی نیست (${err.code}) — اگر گواهی سرور عوض شده، در بخش «گواهی امنیتی سرور» «بررسی دوباره» را بزنید`
: `گواهی TLS سرور برای این آدرس صادر نشده (${err.code}) — در تب «اتصال»، بخش «گواهی امنیتی سرور»، «بررسی گواهی سرور» را بزنید و پس از تطبیق اثرانگشت تأیید و ذخیره کنید`;
}
return pinned
? `گواهی فعلی سرور با گواهی ثبت‌شده در تنظیمات یکی نیست (${err.code}) — اگر گواهی سرور عوض شده، در بخش «گواهی امنیتی سرور» «بررسی دوباره» را بزنید`
: `گواهی TLS سرور قابل‌اعتماد نیست (${err.code}) — در تب «اتصال»، بخش «گواهی امنیتی سرور»، «بررسی گواهی سرور» را بزنید و پس از تطبیق اثرانگشت تأیید و ذخیره کنید`;
}
export function httpRequest(req: HttpRequest): Promise<HttpResponse> {
let url: URL;
try {
url = new URL(req.url);
} catch {
return Promise.reject(new AccountingHttpError('آدرس سرور حسابداری نامعتبر است', false, 'BAD_URL'));
}
const isHttps = url.protocol === 'https:';
if (!isHttps && url.protocol !== 'http:') {
return Promise.reject(new AccountingHttpError('آدرس باید با http یا https شروع شود', false, 'BAD_URL'));
}
const headers: Record<string, string | number> = { ...(req.headers ?? {}) };
if (req.body !== undefined) headers['Content-Length'] = Buffer.byteLength(req.body);
const options: https.RequestOptions = {
method: req.method,
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: `${url.pathname}${url.search}`,
headers,
};
const pins = isHttps ? pinnedFingerprints(req.ca) : new Set<string>();
if (isHttps) {
options.rejectUnauthorized = true;
if (req.ca && req.ca.trim()) {
// Extra CA on top of the system store, never instead of verification.
options.ca = [...tls.rootCertificates, req.ca];
// Runs only after the chain verified. The exact pasted certificate is
// accepted under any name; anything else gets the normal name check.
// (Pooled sockets are keyed by `ca`, so a reused one had the same pins.)
options.checkServerIdentity = (host, cert) =>
pins.has(cert.fingerprint256) ? undefined : tls.checkServerIdentity(host, cert);
}
}
return new Promise<HttpResponse>((resolve, reject) => {
let connected = false;
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn();
};
const client = isHttps ? https : http;
const request = client.request(options, (res) => {
const chunks: Buffer[] = [];
let size = 0;
res.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
request.destroy();
finish(() => reject(new AccountingHttpError('پاسخ سرور حسابداری بیش از حد بزرگ است', true, 'TOO_LARGE')));
return;
}
chunks.push(chunk);
});
res.on('end', () =>
finish(() => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })),
);
res.on('error', (err) => finish(() => reject(new AccountingHttpError(err.message, true, 'RESPONSE'))));
});
request.on('socket', (socket) => {
// A kept-alive socket is already connected; listening on it again would
// pile up a listener per request.
if (!(socket as { connecting?: boolean }).connecting) {
connected = true;
return;
}
socket.once(isHttps ? 'secureConnect' : 'connect', () => {
connected = true;
});
});
const timer = setTimeout(() => {
request.destroy();
finish(() =>
reject(
new AccountingHttpError(
connected ? 'پاسخ سرور حسابداری در زمان مقرر نرسید' : 'اتصال به سرور حسابداری برقرار نشد (timeout)',
connected,
'TIMEOUT',
),
),
);
}, req.timeoutMs);
request.on('error', (err: NodeJS.ErrnoException) => {
// Node's own text for these suggests flags that do not apply here.
const text = isHttps && err.code && TLS_CERT_ERRORS.test(err.code) ? tlsErrorMessage(err, pins.size > 0) : err.message;
finish(() => reject(new AccountingHttpError(text, connected, err.code)));
});
if (req.body !== undefined) request.write(req.body);
request.end();
});
}
export interface ServerCertificate {
pem: string;
subject: string;
issuer: string;
subjectAltName: string | null;
validFrom: string;
validTo: string;
expired: boolean;
fingerprint256: string;
selfSigned: boolean;
/** Valid chain to a system CA and issued for this host: no pin needed. */
trustedBySystem: boolean;
}
/**
* Reads the certificate an https server presents, WITHOUT trusting it, so the
* admin can compare its fingerprint with the one on the accounting server and
* pin it. Nothing but the TLS handshake is sent.
*/
export function fetchServerCertificate(baseUrl: string, timeoutMs: number): Promise<ServerCertificate> {
let url: URL;
try {
url = new URL(baseUrl);
} catch {
return Promise.reject(new AccountingHttpError('آدرس سرور حسابداری نامعتبر است', false, 'BAD_URL'));
}
if (url.protocol !== 'https:') {
return Promise.reject(new AccountingHttpError('این آدرس https نیست؛ گواهی ندارد', false, 'NOT_HTTPS'));
}
const host = url.hostname.replace(/^\[|\]$/g, '');
return new Promise<ServerCertificate>((resolve, reject) => {
const socket = tls.connect({
host,
port: Number(url.port || 443),
servername: net.isIP(host) ? undefined : host,
rejectUnauthorized: false,
});
const timer = setTimeout(() => {
socket.destroy();
reject(new AccountingHttpError('اتصال به سرور حسابداری برقرار نشد (timeout)', false, 'TIMEOUT'));
}, timeoutMs);
socket.once('secureConnect', () => {
clearTimeout(timer);
const peer = socket.getPeerCertificate();
const chainOk = socket.authorized;
socket.end();
if (!peer?.raw) {
reject(new AccountingHttpError('سرور گواهی ارسال نکرد', false, 'NO_CERT'));
return;
}
const cert = new X509Certificate(peer.raw);
const nameOk = net.isIP(host) ? !!cert.checkIP(host) : !!cert.checkHost(host);
const b64 = peer.raw.toString('base64').match(/.{1,64}/g)!.join('\n');
resolve({
pem: `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----`,
subject: cert.subject,
issuer: cert.issuer,
subjectAltName: cert.subjectAltName ?? null,
validFrom: cert.validFrom,
validTo: cert.validTo,
expired: new Date(cert.validTo).getTime() < Date.now(),
fingerprint256: cert.fingerprint256,
// checkIssued() also wants keyCertSign, which Tahesab's certificate lacks.
selfSigned: cert.subject === cert.issuer && cert.verify(cert.publicKey),
trustedBySystem: chainOk && nameOk,
});
});
socket.once('error', (err: NodeJS.ErrnoException) => {
clearTimeout(timer);
reject(new AccountingHttpError(err.message, false, err.code));
});
});
}
/**
* Parses a JSON body that may carry junk around it. Tahesab replies have been
* seen with a BOM, raw control characters and unescaped backslashes inside
* strings (henza_server TahesabController::test had to scrub them the same
* way before json_decode would accept the body).
*/
export function parseLooseJson(body: string): unknown {
try {
return JSON.parse(body);
} catch {
// fall through to the scrubbed attempt
}
let raw = body.replace(/^/, '');
raw = raw.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
raw = raw.replace(/\\(?!["\\/bfnrtu])/g, '-');
const start = raw.search(/[{[]/);
if (start > 0) raw = raw.slice(start);
return JSON.parse(raw);
}