@smounters/kit 2.23.0 → 2.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +21 -0
- package/dist/money/index.d.ts +26 -0
- package/dist/money/index.js +30 -2
- package/dist/text/index.d.ts +32 -0
- package/dist/text/index.js +73 -0
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.25.0 - 2026-09-11
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `kit/text` — подстановка `{{ path.to.value }}` с экранированием ПО ЗНАЧЕНИЮ под получателя
|
|
7
|
+
(`html`, `url`, `markdownv2`), экранирование для SQL `LIKE` (поиск по «100%» не должен находить
|
|
8
|
+
всё подряд) и наивный разбор ФИО. Подпуть был в плане пакета с самого начала и закрывает третью
|
|
9
|
+
переписанную копию этих же функций.
|
|
10
|
+
Словаря предметной области здесь нет: канал отправки в режим экранирования переводит потребитель.
|
|
11
|
+
|
|
12
|
+
## 2.24.0 - 2026-09-11
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- `kit/money`: `createMoney({ scale, pad })` — арифметика, привязанная к масштабу хранения, который
|
|
16
|
+
выбирает ПРИЛОЖЕНИЕ. Прежние `MONEY_SCALE`/`toMoneyAmount`/`applyDelta` остались как масштаб-4 по
|
|
17
|
+
умолчанию и ничего не меняют для тех, кто выбора не делал. Понадобилось потребителю, расширившему
|
|
18
|
+
денежные колонки до `numeric(40,20)`: на четырёх знаках срезалась точность токенов.
|
|
19
|
+
Добивки нулями у новой формы по умолчанию нет — при широком масштабе она отдаёт стену нулей, а
|
|
20
|
+
`"0.00000000000000000000" !== "0"` ломает сравнения ниже по течению.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- `kit/money`: `Decimal.set` поднят с `precision: 50` до `80`. У самой decimal.js умолчание — 20
|
|
24
|
+
ЗНАЧАЩИХ цифр, и она молча округляет до них даже обычное сложение; на 18–20 знаках после запятой
|
|
25
|
+
это теряет хвост каждой суммы. Самая широкая колонка потребителя — 40 значащих цифр, и две такие
|
|
26
|
+
встречаются в одном умножении, поэтому запас взят двойной.
|
|
27
|
+
|
|
3
28
|
## 2.2.0 - 2026-07-30
|
|
4
29
|
|
|
5
30
|
### Added
|
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ npm i @smounters/kit @smounters/core
|
|
|
19
19
|
| `kit/config` | zod-препроцессоры для разбора env | `zod` |
|
|
20
20
|
| `kit/money` | decimal-арифметика, масштабы, округление, проверка сбалансированности проводки | — |
|
|
21
21
|
| `kit/rpc` | `ProtoValidateInterceptor` — правила `buf.validate` из контракта, enforced транспортом | `@smounters/core`, `@connectrpc/connect`, `@bufbuild/*` |
|
|
22
|
+
| `kit/text` | подстановка `{{ }}` с экранированием под получателя, экранирование для SQL `LIKE`, разбор ФИО | — |
|
|
22
23
|
| `kit/util` | `ulid`, `redactSecrets` | — |
|
|
23
24
|
|
|
24
25
|
## Что здесь НЕ лежит и почему
|
|
@@ -104,6 +105,26 @@ quantizeToDecimals(due, 6, ROUND_UP); // сумма, достижимая у т
|
|
|
104
105
|
знаками не переведёт значение с восемью, и «ожидаемая» сумма, скруглённая до масштаба книги, окажется
|
|
105
106
|
неоплатной точно — а строгое сравнение назовёт это недоплатой, которой плательщик не мог избежать.
|
|
106
107
|
|
|
108
|
+
### Свой масштаб хранения
|
|
109
|
+
|
|
110
|
+
Функции выше привязаны к масштабу 4 — это фиат. Приложению, которое держит в той же книге токены,
|
|
111
|
+
четырёх знаков мало, а масштаб — **политика**, поэтому его задаёт потребитель:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { createMoney } from "@smounters/kit/money";
|
|
115
|
+
|
|
116
|
+
const money = createMoney({ scale: 20 }); // numeric(40,20)
|
|
117
|
+
|
|
118
|
+
money.toAmount("10.123456"); // "10.123456" — точность токена сохранена
|
|
119
|
+
money.toAmount("0.123456789012345678901") // null — точнее, чем хранит колонка
|
|
120
|
+
money.applyDelta("0", "100"); // "100", а не "100.00000000000000000000"
|
|
121
|
+
money.quantize("10.126", ROUND_HALF_UP); // "10.13" — округление, когда его ПОПРОСИЛИ
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Добивки нулями по умолчанию нет намеренно: при широком масштабе она возвращала бы стену нулей, а
|
|
125
|
+
`"0.00000000000000000000" !== "0"` ломает любое наивное сравнение ниже по течению. Нужна старая
|
|
126
|
+
форма — `createMoney({ scale: 4, pad: true })`.
|
|
127
|
+
|
|
107
128
|
## Защита от SSRF
|
|
108
129
|
|
|
109
130
|
```ts
|
package/dist/money/index.d.ts
CHANGED
|
@@ -33,6 +33,32 @@ export declare function quantizeToDecimals(amount: string, decimals: number, rou
|
|
|
33
33
|
export declare function sumAmounts(amounts: readonly string[]): Decimal;
|
|
34
34
|
/** Apply a signed delta to a balance, at money scale. */
|
|
35
35
|
export declare function applyDelta(balance: string, amount: string): string;
|
|
36
|
+
export interface MoneyOptions {
|
|
37
|
+
/** Decimal places the storage column keeps. Anything more precise is rejected, never rounded away. */
|
|
38
|
+
scale: number;
|
|
39
|
+
/**
|
|
40
|
+
* Pad results to `scale` (`"100"` → `"100.0000"`). Off by default: a wide column (scale 20) would
|
|
41
|
+
* otherwise hand every caller a string of trailing zeros, and `"0.00000000000000000000" !== "0"`
|
|
42
|
+
* breaks every naive comparison downstream.
|
|
43
|
+
*/
|
|
44
|
+
pad?: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface MoneyOps {
|
|
47
|
+
readonly scale: number;
|
|
48
|
+
readonly zero: string;
|
|
49
|
+
fits(amount: string): boolean;
|
|
50
|
+
toAmount(amount: string): string | null;
|
|
51
|
+
applyDelta(balance: string, amount: string): string;
|
|
52
|
+
quantize(amount: string, rounding?: Decimal.Rounding): string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Money arithmetic bound to one storage scale.
|
|
56
|
+
*
|
|
57
|
+
* The scale is POLICY — a fiat-only ledger settles at 4 decimals, one that also holds tokens needs 18
|
|
58
|
+
* or more — so it belongs to the application, not to this package. The module-level `MONEY_SCALE`
|
|
59
|
+
* helpers remain as the scale-4 default for callers that never had to choose.
|
|
60
|
+
*/
|
|
61
|
+
export declare function createMoney({ scale, pad }: MoneyOptions): MoneyOps;
|
|
36
62
|
export interface PostingLine {
|
|
37
63
|
/** Signed decimal string: debit > 0, credit < 0. */
|
|
38
64
|
amount: string;
|
package/dist/money/index.js
CHANGED
|
@@ -4,8 +4,12 @@ import { Decimal } from "decimal.js";
|
|
|
4
4
|
// Nothing here may use a JS number: a double cannot represent 0.1 exactly, and a ledger that cannot
|
|
5
5
|
// represent its own amounts stops balancing within a day. Amounts travel as decimal STRINGS end to end
|
|
6
6
|
// (numeric in the database, string on the wire) and are only turned into Decimal for the arithmetic.
|
|
7
|
-
// Precision must exceed the widest intermediate we produce
|
|
8
|
-
|
|
7
|
+
// Precision must exceed the widest intermediate we produce. The widest column a consumer stores is
|
|
8
|
+
// numeric(40,20) — 40 significant digits — and two of those can meet in one multiplication, so the
|
|
9
|
+
// library must carry twice that before it starts rounding. decimal.js defaults to 20 SIGNIFICANT
|
|
10
|
+
// digits and rounds even a plain addition down to them, silently: at 18 decimal places that loses
|
|
11
|
+
// the tail of every sum. Importing this module is what fixes it for the whole process.
|
|
12
|
+
Decimal.set({ precision: 80, toExpNeg: -80, toExpPos: 80 });
|
|
9
13
|
/** Money scale: `numeric(20,4)`. Four decimal places cover fiat and keep every posting exact. */
|
|
10
14
|
export const MONEY_SCALE = 4;
|
|
11
15
|
/** Ratio scale: `numeric(20,8)` — returns, coverage, shares of a fee. */
|
|
@@ -53,6 +57,30 @@ export function sumAmounts(amounts) {
|
|
|
53
57
|
export function applyDelta(balance, amount) {
|
|
54
58
|
return toDecimal(balance).plus(toDecimal(amount)).toFixed(MONEY_SCALE);
|
|
55
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Money arithmetic bound to one storage scale.
|
|
62
|
+
*
|
|
63
|
+
* The scale is POLICY — a fiat-only ledger settles at 4 decimals, one that also holds tokens needs 18
|
|
64
|
+
* or more — so it belongs to the application, not to this package. The module-level `MONEY_SCALE`
|
|
65
|
+
* helpers remain as the scale-4 default for callers that never had to choose.
|
|
66
|
+
*/
|
|
67
|
+
export function createMoney({ scale, pad = false }) {
|
|
68
|
+
if (!Number.isInteger(scale) || scale < 0 || scale > 40) {
|
|
69
|
+
throw new RangeError(`Money scale must be an integer in 0..40, got ${scale}`);
|
|
70
|
+
}
|
|
71
|
+
const render = (d) => (pad ? d.toFixed(scale) : d.toFixed());
|
|
72
|
+
return {
|
|
73
|
+
scale,
|
|
74
|
+
zero: render(new Decimal(0)),
|
|
75
|
+
fits: (amount) => toDecimal(amount).decimalPlaces() <= scale,
|
|
76
|
+
toAmount: (amount) => {
|
|
77
|
+
const d = toDecimal(amount);
|
|
78
|
+
return d.decimalPlaces() > scale ? null : render(d);
|
|
79
|
+
},
|
|
80
|
+
applyDelta: (balance, amount) => render(toDecimal(balance).plus(toDecimal(amount))),
|
|
81
|
+
quantize: (amount, rounding = ROUND_HALF_UP) => toDecimal(amount).toFixed(scale, rounding),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
56
84
|
/**
|
|
57
85
|
* Is this a valid double-entry posting? At least two lines, no zero-amount line, and the signed amounts
|
|
58
86
|
* sum to EXACTLY zero. This is the invariant that makes a ledger auditable, so it is checked in code
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Where the rendered value is about to land. Picking the wrong one is an injection, not a typo. */
|
|
2
|
+
export type EscapeMode = "none" | "url" | "html" | "markdownv2" | "plain";
|
|
3
|
+
/**
|
|
4
|
+
* Escape a value for a SQL `LIKE` pattern: a user searching for "100%" must not match everything.
|
|
5
|
+
*
|
|
6
|
+
* The backslash goes first — escaping it after `%`/`_` would double-escape the escapes. Postgres needs
|
|
7
|
+
* no `ESCAPE` clause with this, since backslash is the default.
|
|
8
|
+
*/
|
|
9
|
+
export declare function escapeLike(value: string): string;
|
|
10
|
+
export declare function escapeHtml(str: string): string;
|
|
11
|
+
/** Telegram MarkdownV2 reserves this whole set; one unescaped character fails the whole send. */
|
|
12
|
+
export declare function escapeMarkdownV2(str: string): string;
|
|
13
|
+
export declare function escapeValue(str: string, mode: EscapeMode): string;
|
|
14
|
+
/** Read `a.b.c` out of a plain object, returning undefined instead of throwing on a missing branch. */
|
|
15
|
+
export declare function resolvePath(context: Record<string, unknown>, path: string): unknown;
|
|
16
|
+
/**
|
|
17
|
+
* Substitute `{{ path.to.value }}` placeholders, escaping each substituted value for the target.
|
|
18
|
+
*
|
|
19
|
+
* Escaping is per-VALUE, never over the finished string: the template itself is authored by us and may
|
|
20
|
+
* legitimately contain markup, while the values come from data and may contain anything. A missing key
|
|
21
|
+
* renders as an empty string rather than leaving the placeholder visible to a customer.
|
|
22
|
+
*/
|
|
23
|
+
export declare function renderTemplate(template: string, context: Record<string, unknown>, escape?: boolean | EscapeMode): string;
|
|
24
|
+
/**
|
|
25
|
+
* Split a full name into first + rest. Deliberately naive: one word is a first name, everything after
|
|
26
|
+
* the first space is the last name. Anything smarter guesses wrong on the half of the world that writes
|
|
27
|
+
* the family name first.
|
|
28
|
+
*/
|
|
29
|
+
export declare function splitPersonName(full: string | undefined): {
|
|
30
|
+
firstName?: string;
|
|
31
|
+
lastName?: string;
|
|
32
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Text mechanics that every service rewrites: placeholder substitution with the right escaping for the
|
|
2
|
+
// channel it is going out on, escaping for a SQL LIKE pattern, splitting a person's full name.
|
|
3
|
+
//
|
|
4
|
+
// No product vocabulary lives here — no channel enum, no header names, no field names. The caller says
|
|
5
|
+
// which escape mode it needs; the mapping from its own channel enum to a mode is the caller's policy.
|
|
6
|
+
/**
|
|
7
|
+
* Escape a value for a SQL `LIKE` pattern: a user searching for "100%" must not match everything.
|
|
8
|
+
*
|
|
9
|
+
* The backslash goes first — escaping it after `%`/`_` would double-escape the escapes. Postgres needs
|
|
10
|
+
* no `ESCAPE` clause with this, since backslash is the default.
|
|
11
|
+
*/
|
|
12
|
+
export function escapeLike(value) {
|
|
13
|
+
return value.replace(/\\/g, "\\\\").replace(/[%_]/g, "\\$&");
|
|
14
|
+
}
|
|
15
|
+
export function escapeHtml(str) {
|
|
16
|
+
return str
|
|
17
|
+
.replace(/&/g, "&")
|
|
18
|
+
.replace(/</g, "<")
|
|
19
|
+
.replace(/>/g, ">")
|
|
20
|
+
.replace(/"/g, """)
|
|
21
|
+
.replace(/'/g, "'");
|
|
22
|
+
}
|
|
23
|
+
/** Telegram MarkdownV2 reserves this whole set; one unescaped character fails the whole send. */
|
|
24
|
+
export function escapeMarkdownV2(str) {
|
|
25
|
+
return str.replace(/[\\_*[\]()~`>#+\-=|{}.!]/g, (m) => `\\${m}`);
|
|
26
|
+
}
|
|
27
|
+
export function escapeValue(str, mode) {
|
|
28
|
+
switch (mode) {
|
|
29
|
+
case "url":
|
|
30
|
+
return encodeURIComponent(str);
|
|
31
|
+
case "html":
|
|
32
|
+
return escapeHtml(str);
|
|
33
|
+
case "markdownv2":
|
|
34
|
+
return escapeMarkdownV2(str);
|
|
35
|
+
default:
|
|
36
|
+
return str;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Read `a.b.c` out of a plain object, returning undefined instead of throwing on a missing branch. */
|
|
40
|
+
export function resolvePath(context, path) {
|
|
41
|
+
let cur = context;
|
|
42
|
+
for (const key of path.split(".")) {
|
|
43
|
+
if (cur == null || typeof cur !== "object")
|
|
44
|
+
return undefined;
|
|
45
|
+
cur = cur[key];
|
|
46
|
+
}
|
|
47
|
+
return cur;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Substitute `{{ path.to.value }}` placeholders, escaping each substituted value for the target.
|
|
51
|
+
*
|
|
52
|
+
* Escaping is per-VALUE, never over the finished string: the template itself is authored by us and may
|
|
53
|
+
* legitimately contain markup, while the values come from data and may contain anything. A missing key
|
|
54
|
+
* renders as an empty string rather than leaving the placeholder visible to a customer.
|
|
55
|
+
*/
|
|
56
|
+
export function renderTemplate(template, context, escape = false) {
|
|
57
|
+
const mode = escape === true ? "url" : escape === false ? "none" : escape;
|
|
58
|
+
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_match, path) => {
|
|
59
|
+
const value = resolvePath(context, path);
|
|
60
|
+
return escapeValue(value == null ? "" : String(value), mode);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Split a full name into first + rest. Deliberately naive: one word is a first name, everything after
|
|
65
|
+
* the first space is the last name. Anything smarter guesses wrong on the half of the world that writes
|
|
66
|
+
* the family name first.
|
|
67
|
+
*/
|
|
68
|
+
export function splitPersonName(full) {
|
|
69
|
+
const words = (full ?? "").trim().split(/\s+/).filter(Boolean);
|
|
70
|
+
if (!words.length)
|
|
71
|
+
return {};
|
|
72
|
+
return { firstName: words[0], lastName: words.slice(1).join(" ") || undefined };
|
|
73
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smounters/kit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.25.0",
|
|
4
4
|
"description": "Batteries for Imperium services: structured logging, Redis module with a distributed lock, SSRF-safe fetch, decimal money, request context, env schemas",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"imperium",
|
|
@@ -45,6 +45,10 @@
|
|
|
45
45
|
"./util": {
|
|
46
46
|
"types": "./dist/util/index.d.ts",
|
|
47
47
|
"import": "./dist/util/index.js"
|
|
48
|
+
},
|
|
49
|
+
"./text": {
|
|
50
|
+
"types": "./dist/text/index.d.ts",
|
|
51
|
+
"import": "./dist/text/index.js"
|
|
48
52
|
}
|
|
49
53
|
},
|
|
50
54
|
"files": [
|