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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a72fa607a5
commit
19d490e790
@@ -0,0 +1,56 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
|
||||
import { envOrNull } from '../../common/env';
|
||||
|
||||
/**
|
||||
* Encrypts the accounting-software token at rest (D27).
|
||||
*
|
||||
* The token sits in the Setting table, so a database dump or a backup would
|
||||
* otherwise hand out a live credential to the shop's books. AES-256-GCM with a
|
||||
* key derived from `ACCOUNTING_SECRET_KEY`.
|
||||
*
|
||||
* When that variable is unset the key falls back to one derived from
|
||||
* `JWT_SECRET`, with a loud warning at boot — the same "warn, don't refuse"
|
||||
* trade-off D23 made — so an admin can configure the integration before ops
|
||||
* add the variable. The cost: rotating JWT_SECRET then makes the stored token
|
||||
* unreadable and the admin has to enter it again (the screen says so).
|
||||
*/
|
||||
|
||||
const PREFIX = 'enc:v1:';
|
||||
|
||||
export function secretKeySource(): 'ACCOUNTING_SECRET_KEY' | 'JWT_SECRET' | null {
|
||||
if (envOrNull('ACCOUNTING_SECRET_KEY')) return 'ACCOUNTING_SECRET_KEY';
|
||||
if (envOrNull('JWT_SECRET')) return 'JWT_SECRET';
|
||||
return null;
|
||||
}
|
||||
|
||||
function key(): Buffer {
|
||||
const material = envOrNull('ACCOUNTING_SECRET_KEY') ?? envOrNull('JWT_SECRET');
|
||||
if (!material) {
|
||||
throw new Error('ACCOUNTING_SECRET_KEY تنظیم نشده است؛ ذخیرهی توکن حسابداری ممکن نیست.');
|
||||
}
|
||||
return createHash('sha256').update(`goldbank-accounting:${material}`).digest();
|
||||
}
|
||||
|
||||
export function sealSecret(plain: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key(), iv);
|
||||
const body = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return PREFIX + Buffer.concat([iv, tag, body]).toString('base64');
|
||||
}
|
||||
|
||||
/** Null when the value cannot be decrypted (wrong key, tampered, malformed). */
|
||||
export function openSecret(sealed: string | null | undefined): string | null {
|
||||
if (!sealed || !sealed.startsWith(PREFIX)) return null;
|
||||
try {
|
||||
const raw = Buffer.from(sealed.slice(PREFIX.length), 'base64');
|
||||
const iv = raw.subarray(0, 12);
|
||||
const tag = raw.subarray(12, 28);
|
||||
const body = raw.subarray(28);
|
||||
const decipher = createDecipheriv('aes-256-gcm', key(), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user