@smounters/kit 2.2.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.
@@ -0,0 +1,45 @@
1
+ import { Decimal } from "decimal.js";
2
+ /** Money scale: `numeric(20,4)`. Four decimal places cover fiat and keep every posting exact. */
3
+ export declare const MONEY_SCALE = 4;
4
+ /** Ratio scale: `numeric(20,8)` — returns, coverage, shares of a fee. */
5
+ export declare const RATE_SCALE = 8;
6
+ export declare const ZERO_MONEY = "0.0000";
7
+ /**
8
+ * Rounding is never "whatever the library defaults to" — every quantization must round in the direction
9
+ * that protects the party who did NOT initiate the action. Pick explicitly at each call site; these are
10
+ * re-exported so a caller never has to import decimal.js just for a constant.
11
+ */
12
+ export declare const ROUND_DOWN: 1;
13
+ export declare const ROUND_UP: 0;
14
+ export declare const ROUND_HALF_UP: 4;
15
+ /** Parse a possibly empty/absent amount as Decimal; "" and undefined mean zero. */
16
+ export declare function toDecimal(amount: string | undefined | null): Decimal;
17
+ /**
18
+ * Normalize to money scale, or return null when the input carries MORE precision than the ledger can
19
+ * store. The caller must reject rather than persist: the column would round silently, desyncing the
20
+ * stored row from the scale-4 balance math and potentially un-balancing a posting.
21
+ */
22
+ export declare function toMoneyAmount(amount: string): string | null;
23
+ /**
24
+ * Quantize to the decimals of a specific ASSET rather than to the ledger scale.
25
+ *
26
+ * Needed whenever an amount has to be reachable on the other side: a token with 6 decimals cannot
27
+ * transfer a value with 8, so an expected amount quantized to the ledger scale can be impossible to pay
28
+ * exactly — and a strict comparison then reports an underpayment that the payer could not have avoided.
29
+ * `rounding` decides who absorbs the residue: ROUND_UP for what someone must pay (they overpay by dust),
30
+ * ROUND_DOWN for what we pay out.
31
+ */
32
+ export declare function quantizeToDecimals(amount: string, decimals: number, rounding?: Decimal.Rounding): string;
33
+ export declare function sumAmounts(amounts: readonly string[]): Decimal;
34
+ /** Apply a signed delta to a balance, at money scale. */
35
+ export declare function applyDelta(balance: string, amount: string): string;
36
+ export interface PostingLine {
37
+ /** Signed decimal string: debit > 0, credit < 0. */
38
+ amount: string;
39
+ }
40
+ /**
41
+ * Is this a valid double-entry posting? At least two lines, no zero-amount line, and the signed amounts
42
+ * sum to EXACTLY zero. This is the invariant that makes a ledger auditable, so it is checked in code
43
+ * rather than trusted: a posting that does not balance must never reach the database.
44
+ */
45
+ export declare function isBalanced(lines: readonly PostingLine[]): boolean;
@@ -0,0 +1,67 @@
1
+ import { Decimal } from "decimal.js";
2
+ // Money arithmetic. Pure, no infrastructure.
3
+ //
4
+ // Nothing here may use a JS number: a double cannot represent 0.1 exactly, and a ledger that cannot
5
+ // represent its own amounts stops balancing within a day. Amounts travel as decimal STRINGS end to end
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 });
9
+ /** Money scale: `numeric(20,4)`. Four decimal places cover fiat and keep every posting exact. */
10
+ export const MONEY_SCALE = 4;
11
+ /** Ratio scale: `numeric(20,8)` — returns, coverage, shares of a fee. */
12
+ export const RATE_SCALE = 8;
13
+ export const ZERO_MONEY = "0.0000";
14
+ /**
15
+ * Rounding is never "whatever the library defaults to" — every quantization must round in the direction
16
+ * that protects the party who did NOT initiate the action. Pick explicitly at each call site; these are
17
+ * re-exported so a caller never has to import decimal.js just for a constant.
18
+ */
19
+ export const ROUND_DOWN = Decimal.ROUND_DOWN;
20
+ export const ROUND_UP = Decimal.ROUND_UP;
21
+ export const ROUND_HALF_UP = Decimal.ROUND_HALF_UP;
22
+ /** Parse a possibly empty/absent amount as Decimal; "" and undefined mean zero. */
23
+ export function toDecimal(amount) {
24
+ return new Decimal(amount != null && amount.length > 0 ? amount : "0");
25
+ }
26
+ /**
27
+ * Normalize to money scale, or return null when the input carries MORE precision than the ledger can
28
+ * store. The caller must reject rather than persist: the column would round silently, desyncing the
29
+ * stored row from the scale-4 balance math and potentially un-balancing a posting.
30
+ */
31
+ export function toMoneyAmount(amount) {
32
+ const d = toDecimal(amount);
33
+ if (d.decimalPlaces() > MONEY_SCALE)
34
+ return null;
35
+ return d.toFixed(MONEY_SCALE);
36
+ }
37
+ /**
38
+ * Quantize to the decimals of a specific ASSET rather than to the ledger scale.
39
+ *
40
+ * Needed whenever an amount has to be reachable on the other side: a token with 6 decimals cannot
41
+ * transfer a value with 8, so an expected amount quantized to the ledger scale can be impossible to pay
42
+ * exactly — and a strict comparison then reports an underpayment that the payer could not have avoided.
43
+ * `rounding` decides who absorbs the residue: ROUND_UP for what someone must pay (they overpay by dust),
44
+ * ROUND_DOWN for what we pay out.
45
+ */
46
+ export function quantizeToDecimals(amount, decimals, rounding = ROUND_DOWN) {
47
+ return toDecimal(amount).toFixed(decimals, rounding);
48
+ }
49
+ export function sumAmounts(amounts) {
50
+ return amounts.reduce((acc, a) => acc.plus(toDecimal(a)), new Decimal(0));
51
+ }
52
+ /** Apply a signed delta to a balance, at money scale. */
53
+ export function applyDelta(balance, amount) {
54
+ return toDecimal(balance).plus(toDecimal(amount)).toFixed(MONEY_SCALE);
55
+ }
56
+ /**
57
+ * Is this a valid double-entry posting? At least two lines, no zero-amount line, and the signed amounts
58
+ * sum to EXACTLY zero. This is the invariant that makes a ledger auditable, so it is checked in code
59
+ * rather than trusted: a posting that does not balance must never reach the database.
60
+ */
61
+ export function isBalanced(lines) {
62
+ if (lines.length < 2)
63
+ return false;
64
+ if (lines.some((l) => toDecimal(l.amount).isZero()))
65
+ return false;
66
+ return sumAmounts(lines.map((l) => l.amount)).isZero();
67
+ }
@@ -0,0 +1,30 @@
1
+ /** Is this IP literal in a private / loopback / link-local / reserved range? Pure, unit-tested. */
2
+ export declare function isPrivateIp(ip: string): boolean;
3
+ /**
4
+ * Throw unless `rawUrl` is a safe public http(s) target: blocks non-http schemes, internal hostnames,
5
+ * IP literals in private ranges, and hostnames that DNS-resolve into a private range.
6
+ *
7
+ * Known limit: a determined DNS-rebinding attacker can still win the race between this check and the
8
+ * socket connect. Closing that needs connect-time pinning; this blocks the realistic cases —
9
+ * misconfiguration and metadata-endpoint grabs.
10
+ */
11
+ export declare function assertPublicUrl(rawUrl: string): Promise<void>;
12
+ /**
13
+ * A request was refused as an SSRF risk (private/internal target, bad scheme, or a redirect to one).
14
+ * Deliberately distinct from transient network/timeout errors: callers should treat it as a PERMANENT
15
+ * misconfiguration — mark the delivery blocked instead of retrying it forever.
16
+ */
17
+ export declare class SsrfBlockedError extends Error {
18
+ }
19
+ export interface SafeFetchInit extends RequestInit {
20
+ /** Abort after this many ms (default 10s). Ignored when an explicit `signal` is supplied. */
21
+ timeoutMs?: number;
22
+ }
23
+ /**
24
+ * SSRF-safe outbound fetch. `assertPublicUrl` on its own only validates the FIRST url while `fetch`
25
+ * defaults to following redirects — so a public host could answer 302 → a private address and the guard
26
+ * would be bypassed. This follows redirects MANUALLY and re-validates every hop, with a timeout.
27
+ *
28
+ * Route every outbound request built from a user- or tenant-supplied URL through here.
29
+ */
30
+ export declare function safeFetch(url: string, init?: SafeFetchInit): Promise<Response>;
@@ -0,0 +1,122 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { isIP } from "node:net";
3
+ // SSRF guard for outbound requests whose URL is configurable by a user, a tenant admin or a merchant
4
+ // (postback targets, webhook endpoints, avatar imports). Without it such a URL can point at a
5
+ // cluster-internal service or the cloud metadata endpoint (169.254.169.254) and the server will happily
6
+ // fetch it — the request comes from inside the perimeter.
7
+ /** Is this IP literal in a private / loopback / link-local / reserved range? Pure, unit-tested. */
8
+ export function isPrivateIp(ip) {
9
+ const v = isIP(ip);
10
+ if (v === 4)
11
+ return isPrivateV4(ip);
12
+ if (v === 6)
13
+ return isPrivateV6(ip.toLowerCase());
14
+ return false;
15
+ }
16
+ function isPrivateV4(ip) {
17
+ const p = ip.split(".").map(Number);
18
+ if (p.length !== 4 || p.some((n) => Number.isNaN(n) || n < 0 || n > 255))
19
+ return true; // malformed → unsafe
20
+ const [a, b] = p;
21
+ if (a === 0 || a === 10 || a === 127)
22
+ return true; // "this", private, loopback
23
+ if (a === 169 && b === 254)
24
+ return true; // link-local (incl. the 169.254.169.254 metadata address)
25
+ if (a === 172 && b >= 16 && b <= 31)
26
+ return true; // private
27
+ if (a === 192 && b === 168)
28
+ return true; // private
29
+ if (a === 100 && b >= 64 && b <= 127)
30
+ return true; // CGNAT
31
+ if (a === 192 && b === 0)
32
+ return true; // 192.0.0.0/24 (IETF) + 192.0.2.0/24 (documentation)
33
+ if (a === 198 && (b === 18 || b === 19))
34
+ return true; // benchmarking
35
+ if (a >= 224)
36
+ return true; // multicast + reserved + 255.255.255.255
37
+ return false;
38
+ }
39
+ function isPrivateV6(ip) {
40
+ if (ip === "::1" || ip === "::")
41
+ return true; // loopback / unspecified
42
+ const mapped = /::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(ip); // IPv4-mapped → check the embedded IPv4
43
+ if (mapped?.[1])
44
+ return isPrivateV4(mapped[1]);
45
+ if (ip.startsWith("fc") || ip.startsWith("fd"))
46
+ return true; // ULA fc00::/7
47
+ if (ip.startsWith("fe8") || ip.startsWith("fe9") || ip.startsWith("fea") || ip.startsWith("feb"))
48
+ return true; // link-local
49
+ return false;
50
+ }
51
+ /**
52
+ * Throw unless `rawUrl` is a safe public http(s) target: blocks non-http schemes, internal hostnames,
53
+ * IP literals in private ranges, and hostnames that DNS-resolve into a private range.
54
+ *
55
+ * Known limit: a determined DNS-rebinding attacker can still win the race between this check and the
56
+ * socket connect. Closing that needs connect-time pinning; this blocks the realistic cases —
57
+ * misconfiguration and metadata-endpoint grabs.
58
+ */
59
+ export async function assertPublicUrl(rawUrl) {
60
+ let u;
61
+ try {
62
+ u = new URL(rawUrl);
63
+ }
64
+ catch {
65
+ throw new Error("invalid URL");
66
+ }
67
+ if (u.protocol !== "http:" && u.protocol !== "https:")
68
+ throw new Error(`scheme ${u.protocol} not allowed`);
69
+ const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, "");
70
+ if (host === "localhost" || /\.(local|internal|localhost)$/.test(host))
71
+ throw new Error(`internal host ${host}`);
72
+ if (isIP(host) !== 0) {
73
+ if (isPrivateIp(host))
74
+ throw new Error(`private address ${host}`);
75
+ return;
76
+ }
77
+ const addrs = await lookup(host, { all: true });
78
+ for (const a of addrs)
79
+ if (isPrivateIp(a.address))
80
+ throw new Error(`${host} resolves to private ${a.address}`);
81
+ }
82
+ const MAX_REDIRECTS = 3;
83
+ const DEFAULT_TIMEOUT_MS = 10_000;
84
+ /**
85
+ * A request was refused as an SSRF risk (private/internal target, bad scheme, or a redirect to one).
86
+ * Deliberately distinct from transient network/timeout errors: callers should treat it as a PERMANENT
87
+ * misconfiguration — mark the delivery blocked instead of retrying it forever.
88
+ */
89
+ export class SsrfBlockedError extends Error {
90
+ }
91
+ /**
92
+ * SSRF-safe outbound fetch. `assertPublicUrl` on its own only validates the FIRST url while `fetch`
93
+ * defaults to following redirects — so a public host could answer 302 → a private address and the guard
94
+ * would be bypassed. This follows redirects MANUALLY and re-validates every hop, with a timeout.
95
+ *
96
+ * Route every outbound request built from a user- or tenant-supplied URL through here.
97
+ */
98
+ export async function safeFetch(url, init = {}) {
99
+ const { timeoutMs = DEFAULT_TIMEOUT_MS, signal, ...rest } = init;
100
+ let current = url;
101
+ for (let hop = 0;; hop++) {
102
+ try {
103
+ await assertPublicUrl(current); // the initial URL AND every redirect target
104
+ }
105
+ catch (err) {
106
+ throw new SsrfBlockedError(err.message);
107
+ }
108
+ const res = await fetch(current, {
109
+ ...rest,
110
+ redirect: "manual",
111
+ signal: signal ?? AbortSignal.timeout(timeoutMs),
112
+ });
113
+ if (res.status < 300 || res.status >= 400)
114
+ return res;
115
+ const location = res.headers.get("location");
116
+ if (!location)
117
+ return res; // 3xx without Location — nothing to follow
118
+ if (hop >= MAX_REDIRECTS)
119
+ throw new SsrfBlockedError("too many redirects");
120
+ current = new URL(location, current).toString(); // relative → absolute; the loop re-validates
121
+ }
122
+ }
@@ -0,0 +1,66 @@
1
+ import type { OnModuleDestroy, OnModuleInit } from "@smounters/core/core";
2
+ import { LoggerService } from "@smounters/core/services";
3
+ import Redis, { type RedisOptions } from "ioredis";
4
+ export declare const REDIS_CONNECTION: unique symbol;
5
+ export interface RedisConnectionOptions {
6
+ /** Connection string, e.g. `redis://host:6379/0`. */
7
+ url: string;
8
+ /** Passed through to ioredis; the defaults below are chosen for a request-serving process. */
9
+ options?: RedisOptions;
10
+ /**
11
+ * Wait for the connection before the module finishes starting, and FAIL the startup if it errors.
12
+ *
13
+ * Two defensible stances, so it is a choice rather than a default: an API that also serves pages
14
+ * should come up and warn (a cache outage is not an outage of the product), while a worker whose whole
15
+ * job is queue-driven is better off refusing to start than pretending to work. Default is not to wait.
16
+ */
17
+ awaitReady?: boolean;
18
+ }
19
+ /**
20
+ * Register in the application with the connection supplied as a value provider — the package has no
21
+ * opinion on where configuration comes from:
22
+ *
23
+ * ```ts
24
+ * @Module({
25
+ * providers: [{ provide: REDIS_CONNECTION, useValue: { url: appConfig.REDIS_URL } }, RedisService],
26
+ * exports: [RedisService],
27
+ * global: true,
28
+ * })
29
+ * export class RedisModule {}
30
+ * ```
31
+ */
32
+ export declare class RedisService implements OnModuleInit, OnModuleDestroy {
33
+ private readonly connection;
34
+ private readonly logger;
35
+ private client;
36
+ constructor(connection: RedisConnectionOptions, logger: LoggerService);
37
+ onModuleInit(): Promise<void>;
38
+ onModuleDestroy(): Promise<void>;
39
+ getClient(): Redis;
40
+ /** Publish to a pub/sub channel. Returns the number of subscribers that received it. */
41
+ publish(channel: string, message: string): Promise<number>;
42
+ /**
43
+ * A dedicated connection for SUBSCRIBE mode. A subscribing connection cannot run ordinary commands,
44
+ * which is why this is a separate socket rather than the shared client.
45
+ */
46
+ createSubscriber(): Redis;
47
+ /**
48
+ * Read-through JSON cache: return the cached value, otherwise run the loader, cache it and return.
49
+ *
50
+ * A loader that throws is NOT cached — the next call retries. A null/undefined result is returned but
51
+ * also NOT cached, so a miss (an unknown token, a deleted row) cannot poison the cache. Invalidate
52
+ * explicitly from whatever writes the source.
53
+ */
54
+ cacheJson<T>(key: string, ttlSec: number, loader: () => Promise<T>): Promise<T>;
55
+ invalidate(...keys: string[]): Promise<number>;
56
+ /**
57
+ * Run `fn` while holding a distributed lock (SET NX EX). Returns null WITHOUT running it when another
58
+ * instance holds the lock — that is the point: a periodic tick must do its work once per cluster, not
59
+ * once per replica.
60
+ *
61
+ * Release is a Lua compare-and-delete against a random token, so a lock that expired mid-run and was
62
+ * re-acquired elsewhere is never freed by the previous owner. `ttlSec` must exceed the worst-case
63
+ * runtime of `fn`, otherwise the lock can expire while the work is still going.
64
+ */
65
+ withLock<T>(key: string, ttlSec: number, fn: () => Promise<T>): Promise<T | null>;
66
+ }
@@ -0,0 +1,127 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
11
+ return function (target, key) { decorator(target, key, paramIndex); }
12
+ };
13
+ import { randomUUID } from "node:crypto";
14
+ import { Inject, Injectable } from "@smounters/core/decorators";
15
+ import { LoggerService } from "@smounters/core/services";
16
+ import Redis from "ioredis";
17
+ // Redis as one injectable service: connection lifecycle, pub/sub, a read-through cache and a distributed
18
+ // lock. Deliberately free of domain methods — anything that knows what an entity IS belongs to that
19
+ // feature, not here, or every product ends up carrying the others' keys.
20
+ export const REDIS_CONNECTION = Symbol("kit:redis-connection");
21
+ const DEFAULTS = {
22
+ // A request should fail fast rather than hang while a command is retried forever.
23
+ maxRetriesPerRequest: 2,
24
+ enableOfflineQueue: true,
25
+ };
26
+ /**
27
+ * Register in the application with the connection supplied as a value provider — the package has no
28
+ * opinion on where configuration comes from:
29
+ *
30
+ * ```ts
31
+ * @Module({
32
+ * providers: [{ provide: REDIS_CONNECTION, useValue: { url: appConfig.REDIS_URL } }, RedisService],
33
+ * exports: [RedisService],
34
+ * global: true,
35
+ * })
36
+ * export class RedisModule {}
37
+ * ```
38
+ */
39
+ let RedisService = class RedisService {
40
+ constructor(connection, logger) {
41
+ this.connection = connection;
42
+ this.logger = logger;
43
+ }
44
+ async onModuleInit() {
45
+ this.client = new Redis(this.connection.url, { ...DEFAULTS, ...this.connection.options });
46
+ this.client.on("ready", () => this.logger.info({ type: "redis", event: "ready" }));
47
+ this.client.on("error", (err) => this.logger.warn({ type: "redis", event: "error", error: err.message }));
48
+ if (this.connection.awaitReady) {
49
+ await new Promise((resolve, reject) => {
50
+ this.client.once("ready", resolve);
51
+ this.client.once("error", reject);
52
+ });
53
+ }
54
+ }
55
+ async onModuleDestroy() {
56
+ try {
57
+ await this.client.quit();
58
+ }
59
+ catch {
60
+ this.client.disconnect();
61
+ }
62
+ }
63
+ getClient() {
64
+ return this.client;
65
+ }
66
+ /** Publish to a pub/sub channel. Returns the number of subscribers that received it. */
67
+ publish(channel, message) {
68
+ return this.client.publish(channel, message);
69
+ }
70
+ /**
71
+ * A dedicated connection for SUBSCRIBE mode. A subscribing connection cannot run ordinary commands,
72
+ * which is why this is a separate socket rather than the shared client.
73
+ */
74
+ createSubscriber() {
75
+ return this.client.duplicate();
76
+ }
77
+ /**
78
+ * Read-through JSON cache: return the cached value, otherwise run the loader, cache it and return.
79
+ *
80
+ * A loader that throws is NOT cached — the next call retries. A null/undefined result is returned but
81
+ * also NOT cached, so a miss (an unknown token, a deleted row) cannot poison the cache. Invalidate
82
+ * explicitly from whatever writes the source.
83
+ */
84
+ async cacheJson(key, ttlSec, loader) {
85
+ const hit = await this.client.get(key);
86
+ if (hit !== null)
87
+ return JSON.parse(hit);
88
+ const value = await loader();
89
+ if (value != null)
90
+ await this.client.set(key, JSON.stringify(value), "EX", ttlSec);
91
+ return value;
92
+ }
93
+ invalidate(...keys) {
94
+ return keys.length > 0 ? this.client.del(...keys) : Promise.resolve(0);
95
+ }
96
+ /**
97
+ * Run `fn` while holding a distributed lock (SET NX EX). Returns null WITHOUT running it when another
98
+ * instance holds the lock — that is the point: a periodic tick must do its work once per cluster, not
99
+ * once per replica.
100
+ *
101
+ * Release is a Lua compare-and-delete against a random token, so a lock that expired mid-run and was
102
+ * re-acquired elsewhere is never freed by the previous owner. `ttlSec` must exceed the worst-case
103
+ * runtime of `fn`, otherwise the lock can expire while the work is still going.
104
+ */
105
+ async withLock(key, ttlSec, fn) {
106
+ const token = randomUUID();
107
+ const acquired = await this.client.set(key, token, "EX", ttlSec, "NX");
108
+ if (acquired !== "OK")
109
+ return null;
110
+ try {
111
+ return await fn();
112
+ }
113
+ finally {
114
+ await this.client
115
+ .eval("if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", 1, key, token)
116
+ // Releasing is best-effort: the lock expires on its own, and a failure here must not mask
117
+ // whatever fn returned or threw.
118
+ .catch(() => undefined);
119
+ }
120
+ }
121
+ };
122
+ RedisService = __decorate([
123
+ Injectable(),
124
+ __param(0, Inject(REDIS_CONNECTION)),
125
+ __metadata("design:paramtypes", [Object, LoggerService])
126
+ ], RedisService);
127
+ export { RedisService };
@@ -0,0 +1,15 @@
1
+ import type { BaseContext, Interceptor, NextFn } from "@smounters/core/core";
2
+ /**
3
+ * Global protovalidate enforcement: every RPC request is checked against the `buf.validate` rules
4
+ * declared in its own proto BEFORE the handler runs; a violation becomes `InvalidArgument`.
5
+ *
6
+ * Register once as a global interceptor. The point is that validation rules live in the contract and are
7
+ * enforced by the transport — a handler cannot forget to check, and a rule added to the proto starts
8
+ * being enforced without touching any service. The Connect handler context carries the method
9
+ * descriptor, so the input schema is available generically, with no per-service wiring. Compiled rules
10
+ * are cached inside the validator, which is created once.
11
+ */
12
+ export declare class ProtoValidateInterceptor implements Interceptor {
13
+ private readonly validator;
14
+ intercept(ctx: BaseContext, next: NextFn): Promise<unknown>;
15
+ }
@@ -0,0 +1,47 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { createValidator } from "@bufbuild/protovalidate";
8
+ import { Code, ConnectError } from "@connectrpc/connect";
9
+ import { Injectable } from "@smounters/core/decorators";
10
+ /**
11
+ * Global protovalidate enforcement: every RPC request is checked against the `buf.validate` rules
12
+ * declared in its own proto BEFORE the handler runs; a violation becomes `InvalidArgument`.
13
+ *
14
+ * Register once as a global interceptor. The point is that validation rules live in the contract and are
15
+ * enforced by the transport — a handler cannot forget to check, and a rule added to the proto starts
16
+ * being enforced without touching any service. The Connect handler context carries the method
17
+ * descriptor, so the input schema is available generically, with no per-service wiring. Compiled rules
18
+ * are cached inside the validator, which is created once.
19
+ */
20
+ let ProtoValidateInterceptor = class ProtoValidateInterceptor {
21
+ constructor() {
22
+ this.validator = createValidator();
23
+ }
24
+ async intercept(ctx, next) {
25
+ if (ctx.getType() === "rpc") {
26
+ const rpc = ctx.switchToRpc();
27
+ const message = rpc.getData();
28
+ const handlerCtx = rpc.getContext();
29
+ const schema = handlerCtx?.method?.input;
30
+ if (schema && message) {
31
+ const result = this.validator.validate(schema, message);
32
+ if (result.kind === "invalid") {
33
+ throw new ConnectError(`Validation failed: ${result.violations.map((v) => v.message).join("; ")}`, Code.InvalidArgument);
34
+ }
35
+ if (result.kind === "error") {
36
+ // A rule failed to compile or evaluate: a server-side contract problem, not the caller's fault.
37
+ throw new ConnectError(`Validation rule error: ${result.error.message}`, Code.Internal);
38
+ }
39
+ }
40
+ }
41
+ return next();
42
+ }
43
+ };
44
+ ProtoValidateInterceptor = __decorate([
45
+ Injectable()
46
+ ], ProtoValidateInterceptor);
47
+ export { ProtoValidateInterceptor };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * ULID (Universally Unique Lexicographically Sortable Identifier), spec-compliant: 26 chars of
3
+ * Crockford base32 = 48-bit millisecond timestamp (10 chars) + 80-bit randomness (16 chars).
4
+ *
5
+ * Time-ordered like UUIDv7 but a compact, URL-safe, opaque string — good for ids that travel to third
6
+ * parties (a webhook id a consumer dedups on, a public reference in a receipt). A fresh random suffix
7
+ * per call is enough at our volumes; there is no monotonicity guarantee inside one millisecond.
8
+ */
9
+ export declare function ulid(nowMs?: number): string;
10
+ /**
11
+ * Strip credentials before a payload is written somewhere long-lived (a delivery log row, an audit
12
+ * record, a support attachment). Two passes: token-like URL query params inside any string (a verify
13
+ * link in a mail body, a callback URL carrying a signing token) and object KEYS that name a credential.
14
+ *
15
+ * Structure is preserved so a stored payload can still be re-sent or diffed — only the secret VALUE is
16
+ * replaced. Returns a copy; the input is not mutated.
17
+ */
18
+ export declare function redactSecrets<T>(value: T): T;
@@ -0,0 +1,57 @@
1
+ import { randomBytes } from "node:crypto";
2
+ // Dependency-free helpers with no infrastructure of their own.
3
+ const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // excludes I, L, O, U
4
+ function encodeTime(nowMs, len) {
5
+ let out = "";
6
+ let n = nowMs;
7
+ for (let i = len - 1; i >= 0; i--) {
8
+ const mod = n % 32;
9
+ out = CROCKFORD[mod] + out;
10
+ n = (n - mod) / 32;
11
+ }
12
+ return out;
13
+ }
14
+ function encodeRandom(len) {
15
+ let out = "";
16
+ for (const b of randomBytes(len))
17
+ out += CROCKFORD[b % 32];
18
+ return out;
19
+ }
20
+ /**
21
+ * ULID (Universally Unique Lexicographically Sortable Identifier), spec-compliant: 26 chars of
22
+ * Crockford base32 = 48-bit millisecond timestamp (10 chars) + 80-bit randomness (16 chars).
23
+ *
24
+ * Time-ordered like UUIDv7 but a compact, URL-safe, opaque string — good for ids that travel to third
25
+ * parties (a webhook id a consumer dedups on, a public reference in a receipt). A fresh random suffix
26
+ * per call is enough at our volumes; there is no monotonicity guarantee inside one millisecond.
27
+ */
28
+ export function ulid(nowMs = Date.now()) {
29
+ return encodeTime(nowMs, 10) + encodeRandom(16);
30
+ }
31
+ const SECRET_QUERY_PARAM = /([?&](?:token|code|secret|key|otp|hmac|signature|password|apikey|api_key)=)[^&"'\s)]+/gi;
32
+ const SECRET_KEY = /^(?:token|secret|password|apikey|api_key|authorization|hmac|secret_token|x-api-key|sm-api-key|sm-signature|sm-internal-token)$/i;
33
+ const REDACTED = "[redacted]";
34
+ /**
35
+ * Strip credentials before a payload is written somewhere long-lived (a delivery log row, an audit
36
+ * record, a support attachment). Two passes: token-like URL query params inside any string (a verify
37
+ * link in a mail body, a callback URL carrying a signing token) and object KEYS that name a credential.
38
+ *
39
+ * Structure is preserved so a stored payload can still be re-sent or diffed — only the secret VALUE is
40
+ * replaced. Returns a copy; the input is not mutated.
41
+ */
42
+ export function redactSecrets(value) {
43
+ if (value == null)
44
+ return value;
45
+ if (typeof value === "string")
46
+ return value.replace(SECRET_QUERY_PARAM, `$1${REDACTED}`);
47
+ if (Array.isArray(value))
48
+ return value.map((v) => redactSecrets(v));
49
+ if (typeof value === "object") {
50
+ const out = {};
51
+ for (const [k, v] of Object.entries(value)) {
52
+ out[k] = SECRET_KEY.test(k) ? REDACTED : redactSecrets(v);
53
+ }
54
+ return out;
55
+ }
56
+ return value;
57
+ }