@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 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
@@ -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;
@@ -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 (a 20-digit amount divided by an 18-dp rate).
8
- Decimal.set({ precision: 50, toExpNeg: -50, toExpPos: 50 });
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, "&amp;")
18
+ .replace(/</g, "&lt;")
19
+ .replace(/>/g, "&gt;")
20
+ .replace(/"/g, "&quot;")
21
+ .replace(/'/g, "&#39;");
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.23.0",
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": [