@fonderie/risk 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fonderie, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # @fonderie/risk
2
+
3
+ A generic **signal → meaning → decision** engine. Assess an action — a trial
4
+ start, a login, a registration, a promo redemption — against weak signals and a
5
+ tunable ruleset, and get back a graded **verdict with reasons**. What to *do*
6
+ with a verdict is your application's call.
7
+
8
+ > **Decides, never enforces.** The engine is pure and synchronous: it reads
9
+ > signals and returns a verdict. It never calls Stripe, sends an email, or
10
+ > cancels anything. Every side effect — deny, challenge, step-up MFA, revoke,
11
+ > alert — lives in your app, which has the context to do it right. This is what
12
+ > keeps the brick sound and reusable across subjects.
13
+
14
+ Status: **experimental** (0.x — API may move).
15
+
16
+ ## Use it
17
+
18
+ ```ts
19
+ import { RiskEngine, DEFAULT_RULESETS } from '@fonderie/risk';
20
+ import { getMigrationsPath } from '@fonderie/risk/migrations';
21
+ // run getMigrationsPath()'s SQL with your store's migration runner first.
22
+
23
+ const risk = new RiskEngine(store, {
24
+ rulesets: DEFAULT_RULESETS, // ships a 'trial.start' ruleset; add your own
25
+ pepper: process.env.RISK_PEPPER, // required in production
26
+ });
27
+
28
+ const verdict = await risk.assess('trial.start', {
29
+ actorId: user.id,
30
+ identifiers: [ // hashed by the engine — raw values never stored
31
+ { kind: 'card', value: cardFingerprint },
32
+ { kind: 'device', value: deviceId },
33
+ { kind: 'ip', value: ip },
34
+ { kind: 'email-domain', value: emailDomain },
35
+ ],
36
+ attributes: { accountAgeMinutes, disposableEmail },
37
+ });
38
+
39
+ // The app maps the verdict to an action — the engine never does.
40
+ if (verdict.tier === 'high') return deny(verdict.reasons);
41
+ if (verdict.tier === 'medium') return challenge();
42
+ await risk.record(verdict.assessmentId, 'allowed'); // makes the identifiers "seen" next time
43
+ ```
44
+
45
+ ## Rulesets are data
46
+
47
+ A ruleset binds a subject to weighted signals and tier thresholds — move
48
+ weights without a deploy. Signals are `reuse` (identifier seen before),
49
+ `velocity` (count in a window), or `attr` (a fact from the context). See the
50
+ shipped `TRIAL_START_RULESET`.
51
+
52
+ ## What it stores
53
+
54
+ One `risk_events` table of **hashed** identifiers (sha256 + pepper — never raw),
55
+ with short retention and a hard wall against the analytics pipeline. `assess()`
56
+ writes provisional rows; `record()` promotes them to the real outcome;
57
+ `purgeExpired()` (call on a timer) enforces retention.
58
+
59
+ Peer-depends on `@fonderie/core` and `@fonderie/store`; imports no other brick.
60
+ See `docs/RISK-BRICK-DESIGN.md` for the full design.
@@ -0,0 +1,27 @@
1
+ <!-- GENERATED — do not edit. Regenerate with: npm run docs:signatures -->
2
+
3
+ # @fonderie/risk — outcomes
4
+
5
+ What this package does to a running app: tables its migrations create,
6
+ rows it seeds, routes it registers. Generated from the migration SQL and
7
+ route tables in source — trust this file instead of reading `dist/` or
8
+ downloading tarballs.
9
+
10
+ ## Database tables (after all migrations)
11
+
12
+ ### `risk_events`
13
+
14
+ ```sql
15
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid()
16
+ assessment_id UUID NOT NULL
17
+ subject TEXT NOT NULL
18
+ actor_id UUID
19
+ signal_kind TEXT NOT NULL
20
+ value_hash TEXT NOT NULL
21
+ outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending', 'allowed', 'challenged', 'blocked'))
22
+ score INTEGER
23
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
24
+ expires_at TIMESTAMPTZ NOT NULL
25
+ ```
26
+
27
+ Raw SQL ships in `node_modules/@fonderie/risk/dist/migrations/sql/` — read it there if you must; never download tarballs.
@@ -0,0 +1,103 @@
1
+ <!-- GENERATED — do not edit. Regenerate with: npm run docs:signatures -->
2
+
3
+ # @fonderie/risk — signatures
4
+
5
+ ## @fonderie/risk
6
+
7
+ Subpath exports: `@fonderie/risk/migrations`
8
+
9
+ ```ts
10
+ new RiskEngine(store: Queryable, opts: RiskEngineOptions): RiskEngine
11
+ .assess(subject: string, ctx: RiskContext): Promise<RiskVerdict>
12
+ .record(assessmentId: string, outcome: RiskOutcome): Promise<void>
13
+ .purgeExpired(): Promise<void>
14
+
15
+ function scoreFromFired(rs: RuleSet, fired: Iterable<string>): { score: number; reasons: RiskReason[]; }
16
+
17
+ interface Queryable {
18
+ query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;
19
+ }
20
+
21
+ const DEFAULT_RULESETS: { [x: string]: RuleSet; }
22
+
23
+ const TRIAL_START_RULESET: RuleSet
24
+
25
+ function tierFor(score: number, tiers: { medium: number; high: number; }): RiskTier
26
+
27
+ function attributeFires(sig: AttributeSignal, attributes: Record<string, string | number | boolean> | undefined): boolean
28
+
29
+ function validateRuleset(rs: RuleSet): void
30
+
31
+ function hashValue(pepper: string, kind: string, value: string): string
32
+
33
+ function ipBucket(ip: string): string
34
+
35
+ function normalizeForKind(kind: string, value: string): string
36
+
37
+ function resolvePepper(supplied?: string | undefined): string
38
+
39
+ interface Identifier {
40
+ kind: string;
41
+ value: string;
42
+ }
43
+
44
+ interface RiskContext {
45
+ actorId?: string | null;
46
+ identifiers: Identifier[];
47
+ attributes?: Record<string, string | number | boolean>;
48
+ }
49
+
50
+ type RiskTier = 'low' | 'medium' | 'high';
51
+
52
+ interface RiskReason {
53
+ signal: string;
54
+ weight: number;
55
+ }
56
+
57
+ interface RiskVerdict {
58
+ score: number;
59
+ tier: RiskTier;
60
+ reasons: RiskReason[];
61
+ assessmentId: string;
62
+ }
63
+
64
+ type RiskOutcome = 'allowed' | 'challenged' | 'blocked';
65
+
66
+ interface ReuseSignal {
67
+ weight: number;
68
+ reuse: string;
69
+ }
70
+
71
+ interface VelocitySignal {
72
+ weight: number;
73
+ velocity: string;
74
+ window: string;
75
+ over: number;
76
+ }
77
+
78
+ interface AttributeSignal {
79
+ weight: number;
80
+ attr: string;
81
+ under?: number;
82
+ over?: number;
83
+ equals?: string | number | boolean;
84
+ }
85
+
86
+ type RuleSignal = ReuseSignal | VelocitySignal | AttributeSignal;
87
+
88
+ interface RuleSet {
89
+ subject: string;
90
+ signals: Record<string, RuleSignal>;
91
+ tiers: {
92
+ medium: number;
93
+ high: number;
94
+ };
95
+ }
96
+
97
+ interface RiskEngineOptions {
98
+ rulesets: Record<string, RuleSet>;
99
+ pepper?: string;
100
+ pendingTtl?: string;
101
+ recordTtl?: string;
102
+ }
103
+ ```
package/dist/index.cjs ADDED
@@ -0,0 +1,241 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ DEFAULT_RULESETS: () => DEFAULT_RULESETS,
24
+ RiskEngine: () => RiskEngine,
25
+ TRIAL_START_RULESET: () => TRIAL_START_RULESET,
26
+ attributeFires: () => attributeFires,
27
+ hashValue: () => hashValue,
28
+ ipBucket: () => ipBucket,
29
+ normalizeForKind: () => normalizeForKind,
30
+ resolvePepper: () => resolvePepper,
31
+ scoreFromFired: () => scoreFromFired,
32
+ tierFor: () => tierFor,
33
+ validateRuleset: () => validateRuleset
34
+ });
35
+ module.exports = __toCommonJS(index_exports);
36
+
37
+ // src/engine.ts
38
+ var import_node_crypto2 = require("crypto");
39
+
40
+ // src/hashing.ts
41
+ var import_node_crypto = require("crypto");
42
+ var PLACEHOLDER_PEPPERS = /* @__PURE__ */ new Set(["change-me", "dev-pepper", "change-me-long-random-string"]);
43
+ function resolvePepper(supplied) {
44
+ const p = supplied ?? process.env.RISK_PEPPER;
45
+ if (p && p.length >= 32 && !PLACEHOLDER_PEPPERS.has(p)) return p;
46
+ if (process.env.NODE_ENV === "production") {
47
+ throw new Error(
48
+ "@fonderie/risk: a unique pepper of >=32 chars is required in production (pass RiskEngine({ pepper }) or set RISK_PEPPER) \u2014 without it the hashed risk_events store is dictionary-attackable offline."
49
+ );
50
+ }
51
+ return "fonderie-risk-dev-pepper-not-for-production";
52
+ }
53
+ function hashValue(pepper, kind, value) {
54
+ return (0, import_node_crypto.createHash)("sha256").update(`${pepper}\0${kind}\0${value}`).digest("hex");
55
+ }
56
+ function ipBucket(ip) {
57
+ if (!ip.includes(":")) return ip;
58
+ const [head] = ip.split("%");
59
+ const v4Mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(head ?? "");
60
+ if (v4Mapped) return v4Mapped[1];
61
+ const groups = (head ?? "").split("::");
62
+ let left = groups[0] ? groups[0].split(":") : [];
63
+ const right = groups[1] ? groups[1].split(":") : [];
64
+ if (groups.length === 2) {
65
+ const fill = 8 - left.length - right.length;
66
+ left = [...left, ...Array(Math.max(0, fill)).fill("0"), ...right];
67
+ }
68
+ return `${left.slice(0, 4).join(":")}::/64`;
69
+ }
70
+ function normalizeForKind(kind, value) {
71
+ if (kind === "ip") return ipBucket(value.trim());
72
+ return value.trim().toLowerCase();
73
+ }
74
+
75
+ // src/rulesets.ts
76
+ var TRIAL_START_RULESET = {
77
+ subject: "trial.start",
78
+ signals: {
79
+ cardReuse: { weight: 75, reuse: "card" },
80
+ deviceReuse: { weight: 25, reuse: "device" },
81
+ signupVelocity: { weight: 30, velocity: "device", window: "1 hour", over: 3 },
82
+ ipTrials: { weight: 10, velocity: "ip", window: "24 hours", over: 2 },
83
+ disposableEmail: { weight: 20, attr: "disposableEmail" },
84
+ freshAccount: { weight: 10, attr: "accountAgeMinutes", under: 10 }
85
+ },
86
+ tiers: { medium: 30, high: 70 }
87
+ };
88
+ var DEFAULT_RULESETS = {
89
+ "trial.start": TRIAL_START_RULESET
90
+ };
91
+ function tierFor(score, tiers) {
92
+ if (score < tiers.medium) return "low";
93
+ if (score <= tiers.high) return "medium";
94
+ return "high";
95
+ }
96
+ function isReuse(s) {
97
+ return "reuse" in s;
98
+ }
99
+ function isVelocity(s) {
100
+ return "velocity" in s;
101
+ }
102
+ function isAttr(s) {
103
+ return "attr" in s;
104
+ }
105
+ var WINDOW_RE = /^\d+\s+(second|minute|hour|day|week|month)s?$/;
106
+ function validateRuleset(rs) {
107
+ if (!rs.subject) throw new Error("ruleset: missing subject");
108
+ if (!rs.tiers || typeof rs.tiers.medium !== "number" || typeof rs.tiers.high !== "number")
109
+ throw new Error(`ruleset ${rs.subject}: tiers.medium and tiers.high are required numbers`);
110
+ for (const [name, sig] of Object.entries(rs.signals)) {
111
+ if (typeof sig.weight !== "number" || sig.weight <= 0)
112
+ throw new Error(`ruleset ${rs.subject}.${name}: weight must be a positive number`);
113
+ const kinds = [isReuse(sig), isVelocity(sig), isAttr(sig)].filter(Boolean).length;
114
+ if (kinds !== 1)
115
+ throw new Error(`ruleset ${rs.subject}.${name}: must be exactly one of reuse/velocity/attr`);
116
+ if (isVelocity(sig)) {
117
+ if (!WINDOW_RE.test(sig.window))
118
+ throw new Error(`ruleset ${rs.subject}.${name}: window "${sig.window}" must be a Postgres interval like "1 hour"`);
119
+ if (typeof sig.over !== "number")
120
+ throw new Error(`ruleset ${rs.subject}.${name}: velocity signal needs a numeric "over"`);
121
+ }
122
+ }
123
+ }
124
+ function attributeFires(sig, attributes) {
125
+ const v = attributes?.[sig.attr];
126
+ if (v === void 0) return false;
127
+ if (sig.equals !== void 0) return v === sig.equals;
128
+ if (sig.under !== void 0) return typeof v === "number" && v < sig.under;
129
+ if (sig.over !== void 0) return typeof v === "number" && v > sig.over;
130
+ return v === true;
131
+ }
132
+
133
+ // src/engine.ts
134
+ function scoreFromFired(rs, fired) {
135
+ let score = 0;
136
+ const reasons = [];
137
+ for (const name of fired) {
138
+ const sig = rs.signals[name];
139
+ if (!sig) continue;
140
+ score += sig.weight;
141
+ reasons.push({ signal: name, weight: sig.weight });
142
+ }
143
+ return { score, reasons };
144
+ }
145
+ var RiskEngine = class {
146
+ constructor(store, opts) {
147
+ this.store = store;
148
+ this.rulesets = opts.rulesets;
149
+ for (const rs of Object.values(this.rulesets)) validateRuleset(rs);
150
+ this.pepper = resolvePepper(opts.pepper);
151
+ this.pendingTtl = opts.pendingTtl ?? "24 hours";
152
+ this.recordTtl = opts.recordTtl ?? "90 days";
153
+ }
154
+ store;
155
+ rulesets;
156
+ pepper;
157
+ pendingTtl;
158
+ recordTtl;
159
+ async assess(subject, ctx) {
160
+ const rs = this.rulesets[subject];
161
+ if (!rs) throw new Error(`@fonderie/risk: no ruleset for subject "${subject}"`);
162
+ const actorId = ctx.actorId ?? null;
163
+ const hashes = /* @__PURE__ */ new Map();
164
+ for (const id of ctx.identifiers) {
165
+ hashes.set(id.kind, hashValue(this.pepper, id.kind, normalizeForKind(id.kind, id.value)));
166
+ }
167
+ const fired = /* @__PURE__ */ new Set();
168
+ for (const [name, sig] of Object.entries(rs.signals)) {
169
+ if (isReuse(sig)) {
170
+ const h = hashes.get(sig.reuse);
171
+ if (h && await this.reuseSeen(subject, sig.reuse, h, actorId)) fired.add(name);
172
+ } else if (isVelocity(sig)) {
173
+ const h = hashes.get(sig.velocity);
174
+ if (h && await this.velocityCount(subject, sig.velocity, h, sig.window) > sig.over)
175
+ fired.add(name);
176
+ } else if (isAttr(sig)) {
177
+ if (attributeFires(sig, ctx.attributes)) fired.add(name);
178
+ }
179
+ }
180
+ const { score, reasons } = scoreFromFired(rs, fired);
181
+ const tier = tierFor(score, rs.tiers);
182
+ const assessmentId = (0, import_node_crypto2.randomUUID)();
183
+ for (const [kind, h] of hashes) {
184
+ await this.store.query(
185
+ `INSERT INTO risk_events
186
+ (assessment_id, subject, actor_id, signal_kind, value_hash, outcome, score, expires_at)
187
+ VALUES ($1, $2, $3, $4, $5, 'pending', $6, now() + $7::interval)`,
188
+ [assessmentId, subject, actorId, kind, h, score, this.pendingTtl]
189
+ );
190
+ }
191
+ return { score, tier, reasons, assessmentId };
192
+ }
193
+ /** Report what the app actually did; flips this assessment's provisional
194
+ * rows to the terminal outcome and extends their retention. */
195
+ async record(assessmentId, outcome) {
196
+ await this.store.query(
197
+ `UPDATE risk_events SET outcome = $2, expires_at = now() + $3::interval
198
+ WHERE assessment_id = $1 AND outcome = 'pending'`,
199
+ [assessmentId, outcome, this.recordTtl]
200
+ );
201
+ }
202
+ /** Retention purge — call on a timer, not the request path. */
203
+ async purgeExpired() {
204
+ await this.store.query("DELETE FROM risk_events WHERE expires_at < now()");
205
+ }
206
+ async reuseSeen(subject, kind, valueHash, actorId) {
207
+ const rows = await this.store.query(
208
+ `SELECT 1 FROM risk_events
209
+ WHERE subject = $1 AND signal_kind = $2 AND value_hash = $3
210
+ AND outcome IN ('pending', 'allowed', 'challenged')
211
+ AND ($4::uuid IS NULL OR actor_id IS DISTINCT FROM $4::uuid)
212
+ LIMIT 1`,
213
+ [subject, kind, valueHash, actorId]
214
+ );
215
+ return rows.length > 0;
216
+ }
217
+ async velocityCount(subject, kind, valueHash, window) {
218
+ const rows = await this.store.query(
219
+ `SELECT count(DISTINCT assessment_id)::int AS n FROM risk_events
220
+ WHERE subject = $1 AND signal_kind = $2 AND value_hash = $3
221
+ AND created_at > now() - $4::interval`,
222
+ [subject, kind, valueHash, window]
223
+ );
224
+ return Number(rows[0]?.n ?? 0);
225
+ }
226
+ };
227
+ // Annotate the CommonJS export names for ESM import in node:
228
+ 0 && (module.exports = {
229
+ DEFAULT_RULESETS,
230
+ RiskEngine,
231
+ TRIAL_START_RULESET,
232
+ attributeFires,
233
+ hashValue,
234
+ ipBucket,
235
+ normalizeForKind,
236
+ resolvePepper,
237
+ scoreFromFired,
238
+ tierFor,
239
+ validateRuleset
240
+ });
241
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/engine.ts","../src/hashing.ts","../src/rulesets.ts"],"sourcesContent":["// @fonderie/risk — a generic signal → meaning → decision engine.\n//\n// It DECIDES, it never ENFORCES: assess() reads signals and returns a graded\n// verdict with reasons; the application maps that verdict to an action (deny,\n// challenge, step-up MFA, cancel, alert) and owns every external side effect.\n// See docs/RISK-BRICK-DESIGN.md.\n\nexport { RiskEngine, scoreFromFired } from './engine.js';\nexport type { Queryable } from './engine.js';\nexport {\n\tDEFAULT_RULESETS,\n\tTRIAL_START_RULESET,\n\ttierFor,\n\tattributeFires,\n\tvalidateRuleset,\n} from './rulesets.js';\nexport {\n\thashValue,\n\tipBucket,\n\tnormalizeForKind,\n\tresolvePepper,\n} from './hashing.js';\nexport type {\n\tIdentifier,\n\tRiskContext,\n\tRiskTier,\n\tRiskReason,\n\tRiskVerdict,\n\tRiskOutcome,\n\tReuseSignal,\n\tVelocitySignal,\n\tAttributeSignal,\n\tRuleSignal,\n\tRuleSet,\n\tRiskEngineOptions,\n} from './types.js';\n","import { randomUUID } from 'node:crypto';\nimport type {\n\tRiskContext,\n\tRiskEngineOptions,\n\tRiskOutcome,\n\tRiskReason,\n\tRiskVerdict,\n\tRuleSet,\n} from './types.js';\nimport { hashValue, normalizeForKind, resolvePepper } from './hashing.js';\nimport {\n\tattributeFires,\n\tisAttr,\n\tisReuse,\n\tisVelocity,\n\ttierFor,\n\tvalidateRuleset,\n} from './rulesets.js';\n\n/** The engine only ever queries — accept the store adapter or a transaction\n * handle, so it composes into an app's own transaction if wanted. */\nexport interface Queryable {\n\tquery<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;\n}\n\n/** Sum the weights of the fired signals into a score + explainable reasons.\n * Pure — the store-backed gathering happens in assess(); this is unit-testable. */\nexport function scoreFromFired(rs: RuleSet, fired: Iterable<string>): { score: number; reasons: RiskReason[] } {\n\tlet score = 0;\n\tconst reasons: RiskReason[] = [];\n\tfor (const name of fired) {\n\t\tconst sig = rs.signals[name];\n\t\tif (!sig) continue;\n\t\tscore += sig.weight;\n\t\treasons.push({ signal: name, weight: sig.weight });\n\t}\n\treturn { score, reasons };\n}\n\n/**\n * The generic risk engine. `assess()` reads the signal store, scores against a\n * subject's ruleset, and returns a graded verdict — it performs NO external\n * side effect (its only write is provisional 'pending' rows in its own\n * risk_events store, so identifiers become \"seen\" for later assessments).\n * `record()` flips those rows to the real outcome the app carried out.\n */\nexport class RiskEngine {\n\tprivate readonly rulesets: Record<string, RuleSet>;\n\tprivate readonly pepper: string;\n\tprivate readonly pendingTtl: string;\n\tprivate readonly recordTtl: string;\n\n\tconstructor(\n\t\tprivate readonly store: Queryable,\n\t\topts: RiskEngineOptions,\n\t) {\n\t\tthis.rulesets = opts.rulesets;\n\t\tfor (const rs of Object.values(this.rulesets)) validateRuleset(rs);\n\t\tthis.pepper = resolvePepper(opts.pepper);\n\t\tthis.pendingTtl = opts.pendingTtl ?? '24 hours';\n\t\tthis.recordTtl = opts.recordTtl ?? '90 days';\n\t}\n\n\tasync assess(subject: string, ctx: RiskContext): Promise<RiskVerdict> {\n\t\tconst rs = this.rulesets[subject];\n\t\tif (!rs) throw new Error(`@fonderie/risk: no ruleset for subject \"${subject}\"`);\n\n\t\tconst actorId = ctx.actorId ?? null;\n\t\t// Hash every identifier once; last value wins per kind.\n\t\tconst hashes = new Map<string, string>();\n\t\tfor (const id of ctx.identifiers) {\n\t\t\thashes.set(id.kind, hashValue(this.pepper, id.kind, normalizeForKind(id.kind, id.value)));\n\t\t}\n\n\t\tconst fired = new Set<string>();\n\t\tfor (const [name, sig] of Object.entries(rs.signals)) {\n\t\t\tif (isReuse(sig)) {\n\t\t\t\tconst h = hashes.get(sig.reuse);\n\t\t\t\tif (h && (await this.reuseSeen(subject, sig.reuse, h, actorId))) fired.add(name);\n\t\t\t} else if (isVelocity(sig)) {\n\t\t\t\tconst h = hashes.get(sig.velocity);\n\t\t\t\tif (h && (await this.velocityCount(subject, sig.velocity, h, sig.window)) > sig.over)\n\t\t\t\t\tfired.add(name);\n\t\t\t} else if (isAttr(sig)) {\n\t\t\t\tif (attributeFires(sig, ctx.attributes)) fired.add(name);\n\t\t\t}\n\t\t}\n\n\t\tconst { score, reasons } = scoreFromFired(rs, fired);\n\t\tconst tier = tierFor(score, rs.tiers);\n\t\tconst assessmentId = randomUUID();\n\n\t\t// Persist the identifiers as provisional rows so this assessment counts\n\t\t// toward future reuse/velocity. record() promotes them to the real outcome.\n\t\tfor (const [kind, h] of hashes) {\n\t\t\tawait this.store.query(\n\t\t\t\t`INSERT INTO risk_events\n\t\t\t\t (assessment_id, subject, actor_id, signal_kind, value_hash, outcome, score, expires_at)\n\t\t\t\t VALUES ($1, $2, $3, $4, $5, 'pending', $6, now() + $7::interval)`,\n\t\t\t\t[assessmentId, subject, actorId, kind, h, score, this.pendingTtl],\n\t\t\t);\n\t\t}\n\n\t\treturn { score, tier, reasons, assessmentId };\n\t}\n\n\t/** Report what the app actually did; flips this assessment's provisional\n\t * rows to the terminal outcome and extends their retention. */\n\tasync record(assessmentId: string, outcome: RiskOutcome): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE risk_events SET outcome = $2, expires_at = now() + $3::interval\n\t\t\t WHERE assessment_id = $1 AND outcome = 'pending'`,\n\t\t\t[assessmentId, outcome, this.recordTtl],\n\t\t);\n\t}\n\n\t/** Retention purge — call on a timer, not the request path. */\n\tasync purgeExpired(): Promise<void> {\n\t\tawait this.store.query('DELETE FROM risk_events WHERE expires_at < now()');\n\t}\n\n\tprivate async reuseSeen(\n\t\tsubject: string,\n\t\tkind: string,\n\t\tvalueHash: string,\n\t\tactorId: string | null,\n\t): Promise<boolean> {\n\t\tconst rows = await this.store.query(\n\t\t\t`SELECT 1 FROM risk_events\n\t\t\t WHERE subject = $1 AND signal_kind = $2 AND value_hash = $3\n\t\t\t AND outcome IN ('pending', 'allowed', 'challenged')\n\t\t\t AND ($4::uuid IS NULL OR actor_id IS DISTINCT FROM $4::uuid)\n\t\t\t LIMIT 1`,\n\t\t\t[subject, kind, valueHash, actorId],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\tprivate async velocityCount(\n\t\tsubject: string,\n\t\tkind: string,\n\t\tvalueHash: string,\n\t\twindow: string,\n\t): Promise<number> {\n\t\tconst rows = await this.store.query<{ n: number | string }>(\n\t\t\t`SELECT count(DISTINCT assessment_id)::int AS n FROM risk_events\n\t\t\t WHERE subject = $1 AND signal_kind = $2 AND value_hash = $3\n\t\t\t AND created_at > now() - $4::interval`,\n\t\t\t[subject, kind, valueHash, window],\n\t\t);\n\t\treturn Number(rows[0]?.n ?? 0);\n\t}\n}\n","// The PII firewall in code: every correlating value is peppered-hashed before\n// it touches the store, and IPs are bucketed so per-address rotation can't\n// evade velocity. Nothing here is ever exported to analytics.\nimport { createHash } from 'node:crypto';\n\nconst PLACEHOLDER_PEPPERS = new Set(['change-me', 'dev-pepper', 'change-me-long-random-string']);\n\n/** Resolve the pepper: a caller-supplied value wins; otherwise fall back to a\n * dev pepper OUTSIDE production and throw INSIDE it (a leaked store must not be\n * dictionary-attackable). */\nexport function resolvePepper(supplied?: string): string {\n\tconst p = supplied ?? process.env.RISK_PEPPER;\n\tif (p && p.length >= 32 && !PLACEHOLDER_PEPPERS.has(p)) return p;\n\tif (process.env.NODE_ENV === 'production') {\n\t\tthrow new Error(\n\t\t\t'@fonderie/risk: a unique pepper of >=32 chars is required in production ' +\n\t\t\t\t'(pass RiskEngine({ pepper }) or set RISK_PEPPER) — without it the hashed ' +\n\t\t\t\t'risk_events store is dictionary-attackable offline.',\n\t\t);\n\t}\n\treturn 'fonderie-risk-dev-pepper-not-for-production';\n}\n\n/** sha256(pepper ‖ kind ‖ value) — domain-separated so a card hash can never\n * collide with an ip hash, peppered so a leaked table can't be reversed by\n * hashing candidate values. */\nexport function hashValue(pepper: string, kind: string, value: string): string {\n\treturn createHash('sha256').update(`${pepper}\\0${kind}\\0${value}`).digest('hex');\n}\n\n/** IPv6 collapses to its /64 (the customary end-site prefix — the low 64 bits\n * rotate freely and would defeat per-address velocity); IPv4-mapped IPv6\n * resolves to its embedded IPv4; IPv4 passes through whole. */\nexport function ipBucket(ip: string): string {\n\tif (!ip.includes(':')) return ip;\n\tconst [head] = ip.split('%');\n\tconst v4Mapped = /^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/i.exec(head ?? '');\n\tif (v4Mapped) return v4Mapped[1] as string;\n\tconst groups = (head ?? '').split('::');\n\tlet left = groups[0] ? groups[0].split(':') : [];\n\tconst right = groups[1] ? groups[1].split(':') : [];\n\tif (groups.length === 2) {\n\t\tconst fill = 8 - left.length - right.length;\n\t\tleft = [...left, ...Array<string>(Math.max(0, fill)).fill('0'), ...right];\n\t}\n\treturn `${left.slice(0, 4).join(':')}::/64`;\n}\n\n/** Normalize a value for hashing by kind: IPs are bucketed, everything else is\n * lower-cased/trimmed so trivial spelling differences collapse. */\nexport function normalizeForKind(kind: string, value: string): string {\n\tif (kind === 'ip') return ipBucket(value.trim());\n\treturn value.trim().toLowerCase();\n}\n","// Rulesets are DATA: weights, windows, and thresholds move without a deploy.\n// The launch ruleset (trial.start) is the trial-abuse scorer, generalized —\n// the app that consumes it maps tiers to actions (the engine never does).\nimport type { RuleSet, RuleSignal, RiskTier } from './types.js';\n\n/** The reference trial-abuse ruleset. Card reuse alone is 'high' (75 > 70):\n * one free trial per physical card. Weights are illustrative and tunable. */\nexport const TRIAL_START_RULESET: RuleSet = {\n\tsubject: 'trial.start',\n\tsignals: {\n\t\tcardReuse: { weight: 75, reuse: 'card' },\n\t\tdeviceReuse: { weight: 25, reuse: 'device' },\n\t\tsignupVelocity: { weight: 30, velocity: 'device', window: '1 hour', over: 3 },\n\t\tipTrials: { weight: 10, velocity: 'ip', window: '24 hours', over: 2 },\n\t\tdisposableEmail: { weight: 20, attr: 'disposableEmail' },\n\t\tfreshAccount: { weight: 10, attr: 'accountAgeMinutes', under: 10 },\n\t},\n\ttiers: { medium: 30, high: 70 },\n};\n\n/** The default ruleset map. Apps pass their own (merged) map to RiskEngine. */\nexport const DEFAULT_RULESETS: Record<string, RuleSet> = {\n\t'trial.start': TRIAL_START_RULESET,\n};\n\n/** `< medium` → low, `<= high` → medium, else high. */\nexport function tierFor(score: number, tiers: RuleSet['tiers']): RiskTier {\n\tif (score < tiers.medium) return 'low';\n\tif (score <= tiers.high) return 'medium';\n\treturn 'high';\n}\n\nfunction isReuse(s: RuleSignal): s is Extract<RuleSignal, { reuse: string }> {\n\treturn 'reuse' in s;\n}\nfunction isVelocity(s: RuleSignal): s is Extract<RuleSignal, { velocity: string }> {\n\treturn 'velocity' in s;\n}\nfunction isAttr(s: RuleSignal): s is Extract<RuleSignal, { attr: string }> {\n\treturn 'attr' in s;\n}\n\nconst WINDOW_RE = /^\\d+\\s+(second|minute|hour|day|week|month)s?$/;\n\n/** Fail fast on a malformed ruleset — a bad window would otherwise reach SQL,\n * and a non-positive weight is almost always a typo. */\nexport function validateRuleset(rs: RuleSet): void {\n\tif (!rs.subject) throw new Error('ruleset: missing subject');\n\tif (!rs.tiers || typeof rs.tiers.medium !== 'number' || typeof rs.tiers.high !== 'number')\n\t\tthrow new Error(`ruleset ${rs.subject}: tiers.medium and tiers.high are required numbers`);\n\tfor (const [name, sig] of Object.entries(rs.signals)) {\n\t\tif (typeof sig.weight !== 'number' || sig.weight <= 0)\n\t\t\tthrow new Error(`ruleset ${rs.subject}.${name}: weight must be a positive number`);\n\t\tconst kinds = [isReuse(sig), isVelocity(sig), isAttr(sig)].filter(Boolean).length;\n\t\tif (kinds !== 1)\n\t\t\tthrow new Error(`ruleset ${rs.subject}.${name}: must be exactly one of reuse/velocity/attr`);\n\t\tif (isVelocity(sig)) {\n\t\t\tif (!WINDOW_RE.test(sig.window))\n\t\t\t\tthrow new Error(`ruleset ${rs.subject}.${name}: window \"${sig.window}\" must be a Postgres interval like \"1 hour\"`);\n\t\t\tif (typeof sig.over !== 'number')\n\t\t\t\tthrow new Error(`ruleset ${rs.subject}.${name}: velocity signal needs a numeric \"over\"`);\n\t\t}\n\t}\n}\n\n/** Does an attribute signal fire against this context's attributes? Pure. */\nexport function attributeFires(\n\tsig: Extract<RuleSignal, { attr: string }>,\n\tattributes: Record<string, string | number | boolean> | undefined,\n): boolean {\n\tconst v = attributes?.[sig.attr];\n\tif (v === undefined) return false;\n\tif (sig.equals !== undefined) return v === sig.equals;\n\tif (sig.under !== undefined) return typeof v === 'number' && v < sig.under;\n\tif (sig.over !== undefined) return typeof v === 'number' && v > sig.over;\n\treturn v === true; // bare boolean attribute\n}\n\nexport { isReuse, isVelocity, isAttr };\n"],"mappings":"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","names":["import_node_crypto"]}
@@ -0,0 +1,146 @@
1
+ /** A correlating value the engine may reason about. Hashed on the way in;
2
+ * the raw value never touches the store. `kind` is an open set — 'card', 'ip',
3
+ * 'device', 'email-domain', or anything an app defines. */
4
+ interface Identifier {
5
+ kind: string;
6
+ value: string;
7
+ }
8
+ /** The evidence bag for one assessment. */
9
+ interface RiskContext {
10
+ /** The assessed principal, when known (a user id at login/trial). Used to
11
+ * exclude an actor's own prior events from "reuse". */
12
+ actorId?: string | null;
13
+ /** Correlating identifiers; hashed by the engine before storage/lookup. */
14
+ identifiers: Identifier[];
15
+ /** Non-correlating facts a ruleset's attribute signals read (e.g.
16
+ * accountAgeMinutes, disposableEmail). */
17
+ attributes?: Record<string, string | number | boolean>;
18
+ }
19
+ type RiskTier = 'low' | 'medium' | 'high';
20
+ /** One weighted reason a verdict scored as it did — explainable, for logs and
21
+ * appeals. */
22
+ interface RiskReason {
23
+ signal: string;
24
+ weight: number;
25
+ }
26
+ interface RiskVerdict {
27
+ score: number;
28
+ tier: RiskTier;
29
+ reasons: RiskReason[];
30
+ /** Correlates the later record() call to this assessment's stored rows. */
31
+ assessmentId: string;
32
+ }
33
+ /** The outcome an app reports back via record() — what it actually did. */
34
+ type RiskOutcome = 'allowed' | 'challenged' | 'blocked';
35
+ /** Fires when this identifier kind was seen in a prior (non-blocked)
36
+ * assessment of the same subject by a different actor. */
37
+ interface ReuseSignal {
38
+ weight: number;
39
+ reuse: string;
40
+ }
41
+ /** Fires when the count of distinct assessments for this identifier kind within
42
+ * `window` exceeds `over`. `window` is a Postgres interval string ('1 hour'). */
43
+ interface VelocitySignal {
44
+ weight: number;
45
+ velocity: string;
46
+ window: string;
47
+ over: number;
48
+ }
49
+ /** Fires from a ctx attribute: `under`/`over` compare a number; `equals`
50
+ * compares any value; with none, a truthy attribute fires. */
51
+ interface AttributeSignal {
52
+ weight: number;
53
+ attr: string;
54
+ under?: number;
55
+ over?: number;
56
+ equals?: string | number | boolean;
57
+ }
58
+ type RuleSignal = ReuseSignal | VelocitySignal | AttributeSignal;
59
+ interface RuleSet {
60
+ subject: string;
61
+ signals: Record<string, RuleSignal>;
62
+ /** Score thresholds: `< medium` → low, `<= high` → medium, else high. */
63
+ tiers: {
64
+ medium: number;
65
+ high: number;
66
+ };
67
+ }
68
+ interface RiskEngineOptions {
69
+ /** Ruleset per subject. Data, not code — tune weights without a deploy. */
70
+ rulesets: Record<string, RuleSet>;
71
+ /** HMAC pepper for identifier hashes. REQUIRED in production; a leaked
72
+ * store is dictionary-attackable without it. */
73
+ pepper?: string;
74
+ /** TTL for provisional ('pending') assessment rows (Postgres interval). */
75
+ pendingTtl?: string;
76
+ /** TTL for recorded (terminal-outcome) rows (Postgres interval). */
77
+ recordTtl?: string;
78
+ }
79
+
80
+ /** The engine only ever queries — accept the store adapter or a transaction
81
+ * handle, so it composes into an app's own transaction if wanted. */
82
+ interface Queryable {
83
+ query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;
84
+ }
85
+ /** Sum the weights of the fired signals into a score + explainable reasons.
86
+ * Pure — the store-backed gathering happens in assess(); this is unit-testable. */
87
+ declare function scoreFromFired(rs: RuleSet, fired: Iterable<string>): {
88
+ score: number;
89
+ reasons: RiskReason[];
90
+ };
91
+ /**
92
+ * The generic risk engine. `assess()` reads the signal store, scores against a
93
+ * subject's ruleset, and returns a graded verdict — it performs NO external
94
+ * side effect (its only write is provisional 'pending' rows in its own
95
+ * risk_events store, so identifiers become "seen" for later assessments).
96
+ * `record()` flips those rows to the real outcome the app carried out.
97
+ */
98
+ declare class RiskEngine {
99
+ private readonly store;
100
+ private readonly rulesets;
101
+ private readonly pepper;
102
+ private readonly pendingTtl;
103
+ private readonly recordTtl;
104
+ constructor(store: Queryable, opts: RiskEngineOptions);
105
+ assess(subject: string, ctx: RiskContext): Promise<RiskVerdict>;
106
+ /** Report what the app actually did; flips this assessment's provisional
107
+ * rows to the terminal outcome and extends their retention. */
108
+ record(assessmentId: string, outcome: RiskOutcome): Promise<void>;
109
+ /** Retention purge — call on a timer, not the request path. */
110
+ purgeExpired(): Promise<void>;
111
+ private reuseSeen;
112
+ private velocityCount;
113
+ }
114
+
115
+ /** The reference trial-abuse ruleset. Card reuse alone is 'high' (75 > 70):
116
+ * one free trial per physical card. Weights are illustrative and tunable. */
117
+ declare const TRIAL_START_RULESET: RuleSet;
118
+ /** The default ruleset map. Apps pass their own (merged) map to RiskEngine. */
119
+ declare const DEFAULT_RULESETS: Record<string, RuleSet>;
120
+ /** `< medium` → low, `<= high` → medium, else high. */
121
+ declare function tierFor(score: number, tiers: RuleSet['tiers']): RiskTier;
122
+ /** Fail fast on a malformed ruleset — a bad window would otherwise reach SQL,
123
+ * and a non-positive weight is almost always a typo. */
124
+ declare function validateRuleset(rs: RuleSet): void;
125
+ /** Does an attribute signal fire against this context's attributes? Pure. */
126
+ declare function attributeFires(sig: Extract<RuleSignal, {
127
+ attr: string;
128
+ }>, attributes: Record<string, string | number | boolean> | undefined): boolean;
129
+
130
+ /** Resolve the pepper: a caller-supplied value wins; otherwise fall back to a
131
+ * dev pepper OUTSIDE production and throw INSIDE it (a leaked store must not be
132
+ * dictionary-attackable). */
133
+ declare function resolvePepper(supplied?: string): string;
134
+ /** sha256(pepper ‖ kind ‖ value) — domain-separated so a card hash can never
135
+ * collide with an ip hash, peppered so a leaked table can't be reversed by
136
+ * hashing candidate values. */
137
+ declare function hashValue(pepper: string, kind: string, value: string): string;
138
+ /** IPv6 collapses to its /64 (the customary end-site prefix — the low 64 bits
139
+ * rotate freely and would defeat per-address velocity); IPv4-mapped IPv6
140
+ * resolves to its embedded IPv4; IPv4 passes through whole. */
141
+ declare function ipBucket(ip: string): string;
142
+ /** Normalize a value for hashing by kind: IPs are bucketed, everything else is
143
+ * lower-cased/trimmed so trivial spelling differences collapse. */
144
+ declare function normalizeForKind(kind: string, value: string): string;
145
+
146
+ export { type AttributeSignal, DEFAULT_RULESETS, type Identifier, type Queryable, type ReuseSignal, type RiskContext, RiskEngine, type RiskEngineOptions, type RiskOutcome, type RiskReason, type RiskTier, type RiskVerdict, type RuleSet, type RuleSignal, TRIAL_START_RULESET, type VelocitySignal, attributeFires, hashValue, ipBucket, normalizeForKind, resolvePepper, scoreFromFired, tierFor, validateRuleset };
@@ -0,0 +1,146 @@
1
+ /** A correlating value the engine may reason about. Hashed on the way in;
2
+ * the raw value never touches the store. `kind` is an open set — 'card', 'ip',
3
+ * 'device', 'email-domain', or anything an app defines. */
4
+ interface Identifier {
5
+ kind: string;
6
+ value: string;
7
+ }
8
+ /** The evidence bag for one assessment. */
9
+ interface RiskContext {
10
+ /** The assessed principal, when known (a user id at login/trial). Used to
11
+ * exclude an actor's own prior events from "reuse". */
12
+ actorId?: string | null;
13
+ /** Correlating identifiers; hashed by the engine before storage/lookup. */
14
+ identifiers: Identifier[];
15
+ /** Non-correlating facts a ruleset's attribute signals read (e.g.
16
+ * accountAgeMinutes, disposableEmail). */
17
+ attributes?: Record<string, string | number | boolean>;
18
+ }
19
+ type RiskTier = 'low' | 'medium' | 'high';
20
+ /** One weighted reason a verdict scored as it did — explainable, for logs and
21
+ * appeals. */
22
+ interface RiskReason {
23
+ signal: string;
24
+ weight: number;
25
+ }
26
+ interface RiskVerdict {
27
+ score: number;
28
+ tier: RiskTier;
29
+ reasons: RiskReason[];
30
+ /** Correlates the later record() call to this assessment's stored rows. */
31
+ assessmentId: string;
32
+ }
33
+ /** The outcome an app reports back via record() — what it actually did. */
34
+ type RiskOutcome = 'allowed' | 'challenged' | 'blocked';
35
+ /** Fires when this identifier kind was seen in a prior (non-blocked)
36
+ * assessment of the same subject by a different actor. */
37
+ interface ReuseSignal {
38
+ weight: number;
39
+ reuse: string;
40
+ }
41
+ /** Fires when the count of distinct assessments for this identifier kind within
42
+ * `window` exceeds `over`. `window` is a Postgres interval string ('1 hour'). */
43
+ interface VelocitySignal {
44
+ weight: number;
45
+ velocity: string;
46
+ window: string;
47
+ over: number;
48
+ }
49
+ /** Fires from a ctx attribute: `under`/`over` compare a number; `equals`
50
+ * compares any value; with none, a truthy attribute fires. */
51
+ interface AttributeSignal {
52
+ weight: number;
53
+ attr: string;
54
+ under?: number;
55
+ over?: number;
56
+ equals?: string | number | boolean;
57
+ }
58
+ type RuleSignal = ReuseSignal | VelocitySignal | AttributeSignal;
59
+ interface RuleSet {
60
+ subject: string;
61
+ signals: Record<string, RuleSignal>;
62
+ /** Score thresholds: `< medium` → low, `<= high` → medium, else high. */
63
+ tiers: {
64
+ medium: number;
65
+ high: number;
66
+ };
67
+ }
68
+ interface RiskEngineOptions {
69
+ /** Ruleset per subject. Data, not code — tune weights without a deploy. */
70
+ rulesets: Record<string, RuleSet>;
71
+ /** HMAC pepper for identifier hashes. REQUIRED in production; a leaked
72
+ * store is dictionary-attackable without it. */
73
+ pepper?: string;
74
+ /** TTL for provisional ('pending') assessment rows (Postgres interval). */
75
+ pendingTtl?: string;
76
+ /** TTL for recorded (terminal-outcome) rows (Postgres interval). */
77
+ recordTtl?: string;
78
+ }
79
+
80
+ /** The engine only ever queries — accept the store adapter or a transaction
81
+ * handle, so it composes into an app's own transaction if wanted. */
82
+ interface Queryable {
83
+ query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;
84
+ }
85
+ /** Sum the weights of the fired signals into a score + explainable reasons.
86
+ * Pure — the store-backed gathering happens in assess(); this is unit-testable. */
87
+ declare function scoreFromFired(rs: RuleSet, fired: Iterable<string>): {
88
+ score: number;
89
+ reasons: RiskReason[];
90
+ };
91
+ /**
92
+ * The generic risk engine. `assess()` reads the signal store, scores against a
93
+ * subject's ruleset, and returns a graded verdict — it performs NO external
94
+ * side effect (its only write is provisional 'pending' rows in its own
95
+ * risk_events store, so identifiers become "seen" for later assessments).
96
+ * `record()` flips those rows to the real outcome the app carried out.
97
+ */
98
+ declare class RiskEngine {
99
+ private readonly store;
100
+ private readonly rulesets;
101
+ private readonly pepper;
102
+ private readonly pendingTtl;
103
+ private readonly recordTtl;
104
+ constructor(store: Queryable, opts: RiskEngineOptions);
105
+ assess(subject: string, ctx: RiskContext): Promise<RiskVerdict>;
106
+ /** Report what the app actually did; flips this assessment's provisional
107
+ * rows to the terminal outcome and extends their retention. */
108
+ record(assessmentId: string, outcome: RiskOutcome): Promise<void>;
109
+ /** Retention purge — call on a timer, not the request path. */
110
+ purgeExpired(): Promise<void>;
111
+ private reuseSeen;
112
+ private velocityCount;
113
+ }
114
+
115
+ /** The reference trial-abuse ruleset. Card reuse alone is 'high' (75 > 70):
116
+ * one free trial per physical card. Weights are illustrative and tunable. */
117
+ declare const TRIAL_START_RULESET: RuleSet;
118
+ /** The default ruleset map. Apps pass their own (merged) map to RiskEngine. */
119
+ declare const DEFAULT_RULESETS: Record<string, RuleSet>;
120
+ /** `< medium` → low, `<= high` → medium, else high. */
121
+ declare function tierFor(score: number, tiers: RuleSet['tiers']): RiskTier;
122
+ /** Fail fast on a malformed ruleset — a bad window would otherwise reach SQL,
123
+ * and a non-positive weight is almost always a typo. */
124
+ declare function validateRuleset(rs: RuleSet): void;
125
+ /** Does an attribute signal fire against this context's attributes? Pure. */
126
+ declare function attributeFires(sig: Extract<RuleSignal, {
127
+ attr: string;
128
+ }>, attributes: Record<string, string | number | boolean> | undefined): boolean;
129
+
130
+ /** Resolve the pepper: a caller-supplied value wins; otherwise fall back to a
131
+ * dev pepper OUTSIDE production and throw INSIDE it (a leaked store must not be
132
+ * dictionary-attackable). */
133
+ declare function resolvePepper(supplied?: string): string;
134
+ /** sha256(pepper ‖ kind ‖ value) — domain-separated so a card hash can never
135
+ * collide with an ip hash, peppered so a leaked table can't be reversed by
136
+ * hashing candidate values. */
137
+ declare function hashValue(pepper: string, kind: string, value: string): string;
138
+ /** IPv6 collapses to its /64 (the customary end-site prefix — the low 64 bits
139
+ * rotate freely and would defeat per-address velocity); IPv4-mapped IPv6
140
+ * resolves to its embedded IPv4; IPv4 passes through whole. */
141
+ declare function ipBucket(ip: string): string;
142
+ /** Normalize a value for hashing by kind: IPs are bucketed, everything else is
143
+ * lower-cased/trimmed so trivial spelling differences collapse. */
144
+ declare function normalizeForKind(kind: string, value: string): string;
145
+
146
+ export { type AttributeSignal, DEFAULT_RULESETS, type Identifier, type Queryable, type ReuseSignal, type RiskContext, RiskEngine, type RiskEngineOptions, type RiskOutcome, type RiskReason, type RiskTier, type RiskVerdict, type RuleSet, type RuleSignal, TRIAL_START_RULESET, type VelocitySignal, attributeFires, hashValue, ipBucket, normalizeForKind, resolvePepper, scoreFromFired, tierFor, validateRuleset };
package/dist/index.js ADDED
@@ -0,0 +1,204 @@
1
+ // src/engine.ts
2
+ import { randomUUID } from "crypto";
3
+
4
+ // src/hashing.ts
5
+ import { createHash } from "crypto";
6
+ var PLACEHOLDER_PEPPERS = /* @__PURE__ */ new Set(["change-me", "dev-pepper", "change-me-long-random-string"]);
7
+ function resolvePepper(supplied) {
8
+ const p = supplied ?? process.env.RISK_PEPPER;
9
+ if (p && p.length >= 32 && !PLACEHOLDER_PEPPERS.has(p)) return p;
10
+ if (process.env.NODE_ENV === "production") {
11
+ throw new Error(
12
+ "@fonderie/risk: a unique pepper of >=32 chars is required in production (pass RiskEngine({ pepper }) or set RISK_PEPPER) \u2014 without it the hashed risk_events store is dictionary-attackable offline."
13
+ );
14
+ }
15
+ return "fonderie-risk-dev-pepper-not-for-production";
16
+ }
17
+ function hashValue(pepper, kind, value) {
18
+ return createHash("sha256").update(`${pepper}\0${kind}\0${value}`).digest("hex");
19
+ }
20
+ function ipBucket(ip) {
21
+ if (!ip.includes(":")) return ip;
22
+ const [head] = ip.split("%");
23
+ const v4Mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(head ?? "");
24
+ if (v4Mapped) return v4Mapped[1];
25
+ const groups = (head ?? "").split("::");
26
+ let left = groups[0] ? groups[0].split(":") : [];
27
+ const right = groups[1] ? groups[1].split(":") : [];
28
+ if (groups.length === 2) {
29
+ const fill = 8 - left.length - right.length;
30
+ left = [...left, ...Array(Math.max(0, fill)).fill("0"), ...right];
31
+ }
32
+ return `${left.slice(0, 4).join(":")}::/64`;
33
+ }
34
+ function normalizeForKind(kind, value) {
35
+ if (kind === "ip") return ipBucket(value.trim());
36
+ return value.trim().toLowerCase();
37
+ }
38
+
39
+ // src/rulesets.ts
40
+ var TRIAL_START_RULESET = {
41
+ subject: "trial.start",
42
+ signals: {
43
+ cardReuse: { weight: 75, reuse: "card" },
44
+ deviceReuse: { weight: 25, reuse: "device" },
45
+ signupVelocity: { weight: 30, velocity: "device", window: "1 hour", over: 3 },
46
+ ipTrials: { weight: 10, velocity: "ip", window: "24 hours", over: 2 },
47
+ disposableEmail: { weight: 20, attr: "disposableEmail" },
48
+ freshAccount: { weight: 10, attr: "accountAgeMinutes", under: 10 }
49
+ },
50
+ tiers: { medium: 30, high: 70 }
51
+ };
52
+ var DEFAULT_RULESETS = {
53
+ "trial.start": TRIAL_START_RULESET
54
+ };
55
+ function tierFor(score, tiers) {
56
+ if (score < tiers.medium) return "low";
57
+ if (score <= tiers.high) return "medium";
58
+ return "high";
59
+ }
60
+ function isReuse(s) {
61
+ return "reuse" in s;
62
+ }
63
+ function isVelocity(s) {
64
+ return "velocity" in s;
65
+ }
66
+ function isAttr(s) {
67
+ return "attr" in s;
68
+ }
69
+ var WINDOW_RE = /^\d+\s+(second|minute|hour|day|week|month)s?$/;
70
+ function validateRuleset(rs) {
71
+ if (!rs.subject) throw new Error("ruleset: missing subject");
72
+ if (!rs.tiers || typeof rs.tiers.medium !== "number" || typeof rs.tiers.high !== "number")
73
+ throw new Error(`ruleset ${rs.subject}: tiers.medium and tiers.high are required numbers`);
74
+ for (const [name, sig] of Object.entries(rs.signals)) {
75
+ if (typeof sig.weight !== "number" || sig.weight <= 0)
76
+ throw new Error(`ruleset ${rs.subject}.${name}: weight must be a positive number`);
77
+ const kinds = [isReuse(sig), isVelocity(sig), isAttr(sig)].filter(Boolean).length;
78
+ if (kinds !== 1)
79
+ throw new Error(`ruleset ${rs.subject}.${name}: must be exactly one of reuse/velocity/attr`);
80
+ if (isVelocity(sig)) {
81
+ if (!WINDOW_RE.test(sig.window))
82
+ throw new Error(`ruleset ${rs.subject}.${name}: window "${sig.window}" must be a Postgres interval like "1 hour"`);
83
+ if (typeof sig.over !== "number")
84
+ throw new Error(`ruleset ${rs.subject}.${name}: velocity signal needs a numeric "over"`);
85
+ }
86
+ }
87
+ }
88
+ function attributeFires(sig, attributes) {
89
+ const v = attributes?.[sig.attr];
90
+ if (v === void 0) return false;
91
+ if (sig.equals !== void 0) return v === sig.equals;
92
+ if (sig.under !== void 0) return typeof v === "number" && v < sig.under;
93
+ if (sig.over !== void 0) return typeof v === "number" && v > sig.over;
94
+ return v === true;
95
+ }
96
+
97
+ // src/engine.ts
98
+ function scoreFromFired(rs, fired) {
99
+ let score = 0;
100
+ const reasons = [];
101
+ for (const name of fired) {
102
+ const sig = rs.signals[name];
103
+ if (!sig) continue;
104
+ score += sig.weight;
105
+ reasons.push({ signal: name, weight: sig.weight });
106
+ }
107
+ return { score, reasons };
108
+ }
109
+ var RiskEngine = class {
110
+ constructor(store, opts) {
111
+ this.store = store;
112
+ this.rulesets = opts.rulesets;
113
+ for (const rs of Object.values(this.rulesets)) validateRuleset(rs);
114
+ this.pepper = resolvePepper(opts.pepper);
115
+ this.pendingTtl = opts.pendingTtl ?? "24 hours";
116
+ this.recordTtl = opts.recordTtl ?? "90 days";
117
+ }
118
+ store;
119
+ rulesets;
120
+ pepper;
121
+ pendingTtl;
122
+ recordTtl;
123
+ async assess(subject, ctx) {
124
+ const rs = this.rulesets[subject];
125
+ if (!rs) throw new Error(`@fonderie/risk: no ruleset for subject "${subject}"`);
126
+ const actorId = ctx.actorId ?? null;
127
+ const hashes = /* @__PURE__ */ new Map();
128
+ for (const id of ctx.identifiers) {
129
+ hashes.set(id.kind, hashValue(this.pepper, id.kind, normalizeForKind(id.kind, id.value)));
130
+ }
131
+ const fired = /* @__PURE__ */ new Set();
132
+ for (const [name, sig] of Object.entries(rs.signals)) {
133
+ if (isReuse(sig)) {
134
+ const h = hashes.get(sig.reuse);
135
+ if (h && await this.reuseSeen(subject, sig.reuse, h, actorId)) fired.add(name);
136
+ } else if (isVelocity(sig)) {
137
+ const h = hashes.get(sig.velocity);
138
+ if (h && await this.velocityCount(subject, sig.velocity, h, sig.window) > sig.over)
139
+ fired.add(name);
140
+ } else if (isAttr(sig)) {
141
+ if (attributeFires(sig, ctx.attributes)) fired.add(name);
142
+ }
143
+ }
144
+ const { score, reasons } = scoreFromFired(rs, fired);
145
+ const tier = tierFor(score, rs.tiers);
146
+ const assessmentId = randomUUID();
147
+ for (const [kind, h] of hashes) {
148
+ await this.store.query(
149
+ `INSERT INTO risk_events
150
+ (assessment_id, subject, actor_id, signal_kind, value_hash, outcome, score, expires_at)
151
+ VALUES ($1, $2, $3, $4, $5, 'pending', $6, now() + $7::interval)`,
152
+ [assessmentId, subject, actorId, kind, h, score, this.pendingTtl]
153
+ );
154
+ }
155
+ return { score, tier, reasons, assessmentId };
156
+ }
157
+ /** Report what the app actually did; flips this assessment's provisional
158
+ * rows to the terminal outcome and extends their retention. */
159
+ async record(assessmentId, outcome) {
160
+ await this.store.query(
161
+ `UPDATE risk_events SET outcome = $2, expires_at = now() + $3::interval
162
+ WHERE assessment_id = $1 AND outcome = 'pending'`,
163
+ [assessmentId, outcome, this.recordTtl]
164
+ );
165
+ }
166
+ /** Retention purge — call on a timer, not the request path. */
167
+ async purgeExpired() {
168
+ await this.store.query("DELETE FROM risk_events WHERE expires_at < now()");
169
+ }
170
+ async reuseSeen(subject, kind, valueHash, actorId) {
171
+ const rows = await this.store.query(
172
+ `SELECT 1 FROM risk_events
173
+ WHERE subject = $1 AND signal_kind = $2 AND value_hash = $3
174
+ AND outcome IN ('pending', 'allowed', 'challenged')
175
+ AND ($4::uuid IS NULL OR actor_id IS DISTINCT FROM $4::uuid)
176
+ LIMIT 1`,
177
+ [subject, kind, valueHash, actorId]
178
+ );
179
+ return rows.length > 0;
180
+ }
181
+ async velocityCount(subject, kind, valueHash, window) {
182
+ const rows = await this.store.query(
183
+ `SELECT count(DISTINCT assessment_id)::int AS n FROM risk_events
184
+ WHERE subject = $1 AND signal_kind = $2 AND value_hash = $3
185
+ AND created_at > now() - $4::interval`,
186
+ [subject, kind, valueHash, window]
187
+ );
188
+ return Number(rows[0]?.n ?? 0);
189
+ }
190
+ };
191
+ export {
192
+ DEFAULT_RULESETS,
193
+ RiskEngine,
194
+ TRIAL_START_RULESET,
195
+ attributeFires,
196
+ hashValue,
197
+ ipBucket,
198
+ normalizeForKind,
199
+ resolvePepper,
200
+ scoreFromFired,
201
+ tierFor,
202
+ validateRuleset
203
+ };
204
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/engine.ts","../src/hashing.ts","../src/rulesets.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport type {\n\tRiskContext,\n\tRiskEngineOptions,\n\tRiskOutcome,\n\tRiskReason,\n\tRiskVerdict,\n\tRuleSet,\n} from './types.js';\nimport { hashValue, normalizeForKind, resolvePepper } from './hashing.js';\nimport {\n\tattributeFires,\n\tisAttr,\n\tisReuse,\n\tisVelocity,\n\ttierFor,\n\tvalidateRuleset,\n} from './rulesets.js';\n\n/** The engine only ever queries — accept the store adapter or a transaction\n * handle, so it composes into an app's own transaction if wanted. */\nexport interface Queryable {\n\tquery<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;\n}\n\n/** Sum the weights of the fired signals into a score + explainable reasons.\n * Pure — the store-backed gathering happens in assess(); this is unit-testable. */\nexport function scoreFromFired(rs: RuleSet, fired: Iterable<string>): { score: number; reasons: RiskReason[] } {\n\tlet score = 0;\n\tconst reasons: RiskReason[] = [];\n\tfor (const name of fired) {\n\t\tconst sig = rs.signals[name];\n\t\tif (!sig) continue;\n\t\tscore += sig.weight;\n\t\treasons.push({ signal: name, weight: sig.weight });\n\t}\n\treturn { score, reasons };\n}\n\n/**\n * The generic risk engine. `assess()` reads the signal store, scores against a\n * subject's ruleset, and returns a graded verdict — it performs NO external\n * side effect (its only write is provisional 'pending' rows in its own\n * risk_events store, so identifiers become \"seen\" for later assessments).\n * `record()` flips those rows to the real outcome the app carried out.\n */\nexport class RiskEngine {\n\tprivate readonly rulesets: Record<string, RuleSet>;\n\tprivate readonly pepper: string;\n\tprivate readonly pendingTtl: string;\n\tprivate readonly recordTtl: string;\n\n\tconstructor(\n\t\tprivate readonly store: Queryable,\n\t\topts: RiskEngineOptions,\n\t) {\n\t\tthis.rulesets = opts.rulesets;\n\t\tfor (const rs of Object.values(this.rulesets)) validateRuleset(rs);\n\t\tthis.pepper = resolvePepper(opts.pepper);\n\t\tthis.pendingTtl = opts.pendingTtl ?? '24 hours';\n\t\tthis.recordTtl = opts.recordTtl ?? '90 days';\n\t}\n\n\tasync assess(subject: string, ctx: RiskContext): Promise<RiskVerdict> {\n\t\tconst rs = this.rulesets[subject];\n\t\tif (!rs) throw new Error(`@fonderie/risk: no ruleset for subject \"${subject}\"`);\n\n\t\tconst actorId = ctx.actorId ?? null;\n\t\t// Hash every identifier once; last value wins per kind.\n\t\tconst hashes = new Map<string, string>();\n\t\tfor (const id of ctx.identifiers) {\n\t\t\thashes.set(id.kind, hashValue(this.pepper, id.kind, normalizeForKind(id.kind, id.value)));\n\t\t}\n\n\t\tconst fired = new Set<string>();\n\t\tfor (const [name, sig] of Object.entries(rs.signals)) {\n\t\t\tif (isReuse(sig)) {\n\t\t\t\tconst h = hashes.get(sig.reuse);\n\t\t\t\tif (h && (await this.reuseSeen(subject, sig.reuse, h, actorId))) fired.add(name);\n\t\t\t} else if (isVelocity(sig)) {\n\t\t\t\tconst h = hashes.get(sig.velocity);\n\t\t\t\tif (h && (await this.velocityCount(subject, sig.velocity, h, sig.window)) > sig.over)\n\t\t\t\t\tfired.add(name);\n\t\t\t} else if (isAttr(sig)) {\n\t\t\t\tif (attributeFires(sig, ctx.attributes)) fired.add(name);\n\t\t\t}\n\t\t}\n\n\t\tconst { score, reasons } = scoreFromFired(rs, fired);\n\t\tconst tier = tierFor(score, rs.tiers);\n\t\tconst assessmentId = randomUUID();\n\n\t\t// Persist the identifiers as provisional rows so this assessment counts\n\t\t// toward future reuse/velocity. record() promotes them to the real outcome.\n\t\tfor (const [kind, h] of hashes) {\n\t\t\tawait this.store.query(\n\t\t\t\t`INSERT INTO risk_events\n\t\t\t\t (assessment_id, subject, actor_id, signal_kind, value_hash, outcome, score, expires_at)\n\t\t\t\t VALUES ($1, $2, $3, $4, $5, 'pending', $6, now() + $7::interval)`,\n\t\t\t\t[assessmentId, subject, actorId, kind, h, score, this.pendingTtl],\n\t\t\t);\n\t\t}\n\n\t\treturn { score, tier, reasons, assessmentId };\n\t}\n\n\t/** Report what the app actually did; flips this assessment's provisional\n\t * rows to the terminal outcome and extends their retention. */\n\tasync record(assessmentId: string, outcome: RiskOutcome): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE risk_events SET outcome = $2, expires_at = now() + $3::interval\n\t\t\t WHERE assessment_id = $1 AND outcome = 'pending'`,\n\t\t\t[assessmentId, outcome, this.recordTtl],\n\t\t);\n\t}\n\n\t/** Retention purge — call on a timer, not the request path. */\n\tasync purgeExpired(): Promise<void> {\n\t\tawait this.store.query('DELETE FROM risk_events WHERE expires_at < now()');\n\t}\n\n\tprivate async reuseSeen(\n\t\tsubject: string,\n\t\tkind: string,\n\t\tvalueHash: string,\n\t\tactorId: string | null,\n\t): Promise<boolean> {\n\t\tconst rows = await this.store.query(\n\t\t\t`SELECT 1 FROM risk_events\n\t\t\t WHERE subject = $1 AND signal_kind = $2 AND value_hash = $3\n\t\t\t AND outcome IN ('pending', 'allowed', 'challenged')\n\t\t\t AND ($4::uuid IS NULL OR actor_id IS DISTINCT FROM $4::uuid)\n\t\t\t LIMIT 1`,\n\t\t\t[subject, kind, valueHash, actorId],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\tprivate async velocityCount(\n\t\tsubject: string,\n\t\tkind: string,\n\t\tvalueHash: string,\n\t\twindow: string,\n\t): Promise<number> {\n\t\tconst rows = await this.store.query<{ n: number | string }>(\n\t\t\t`SELECT count(DISTINCT assessment_id)::int AS n FROM risk_events\n\t\t\t WHERE subject = $1 AND signal_kind = $2 AND value_hash = $3\n\t\t\t AND created_at > now() - $4::interval`,\n\t\t\t[subject, kind, valueHash, window],\n\t\t);\n\t\treturn Number(rows[0]?.n ?? 0);\n\t}\n}\n","// The PII firewall in code: every correlating value is peppered-hashed before\n// it touches the store, and IPs are bucketed so per-address rotation can't\n// evade velocity. Nothing here is ever exported to analytics.\nimport { createHash } from 'node:crypto';\n\nconst PLACEHOLDER_PEPPERS = new Set(['change-me', 'dev-pepper', 'change-me-long-random-string']);\n\n/** Resolve the pepper: a caller-supplied value wins; otherwise fall back to a\n * dev pepper OUTSIDE production and throw INSIDE it (a leaked store must not be\n * dictionary-attackable). */\nexport function resolvePepper(supplied?: string): string {\n\tconst p = supplied ?? process.env.RISK_PEPPER;\n\tif (p && p.length >= 32 && !PLACEHOLDER_PEPPERS.has(p)) return p;\n\tif (process.env.NODE_ENV === 'production') {\n\t\tthrow new Error(\n\t\t\t'@fonderie/risk: a unique pepper of >=32 chars is required in production ' +\n\t\t\t\t'(pass RiskEngine({ pepper }) or set RISK_PEPPER) — without it the hashed ' +\n\t\t\t\t'risk_events store is dictionary-attackable offline.',\n\t\t);\n\t}\n\treturn 'fonderie-risk-dev-pepper-not-for-production';\n}\n\n/** sha256(pepper ‖ kind ‖ value) — domain-separated so a card hash can never\n * collide with an ip hash, peppered so a leaked table can't be reversed by\n * hashing candidate values. */\nexport function hashValue(pepper: string, kind: string, value: string): string {\n\treturn createHash('sha256').update(`${pepper}\\0${kind}\\0${value}`).digest('hex');\n}\n\n/** IPv6 collapses to its /64 (the customary end-site prefix — the low 64 bits\n * rotate freely and would defeat per-address velocity); IPv4-mapped IPv6\n * resolves to its embedded IPv4; IPv4 passes through whole. */\nexport function ipBucket(ip: string): string {\n\tif (!ip.includes(':')) return ip;\n\tconst [head] = ip.split('%');\n\tconst v4Mapped = /^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/i.exec(head ?? '');\n\tif (v4Mapped) return v4Mapped[1] as string;\n\tconst groups = (head ?? '').split('::');\n\tlet left = groups[0] ? groups[0].split(':') : [];\n\tconst right = groups[1] ? groups[1].split(':') : [];\n\tif (groups.length === 2) {\n\t\tconst fill = 8 - left.length - right.length;\n\t\tleft = [...left, ...Array<string>(Math.max(0, fill)).fill('0'), ...right];\n\t}\n\treturn `${left.slice(0, 4).join(':')}::/64`;\n}\n\n/** Normalize a value for hashing by kind: IPs are bucketed, everything else is\n * lower-cased/trimmed so trivial spelling differences collapse. */\nexport function normalizeForKind(kind: string, value: string): string {\n\tif (kind === 'ip') return ipBucket(value.trim());\n\treturn value.trim().toLowerCase();\n}\n","// Rulesets are DATA: weights, windows, and thresholds move without a deploy.\n// The launch ruleset (trial.start) is the trial-abuse scorer, generalized —\n// the app that consumes it maps tiers to actions (the engine never does).\nimport type { RuleSet, RuleSignal, RiskTier } from './types.js';\n\n/** The reference trial-abuse ruleset. Card reuse alone is 'high' (75 > 70):\n * one free trial per physical card. Weights are illustrative and tunable. */\nexport const TRIAL_START_RULESET: RuleSet = {\n\tsubject: 'trial.start',\n\tsignals: {\n\t\tcardReuse: { weight: 75, reuse: 'card' },\n\t\tdeviceReuse: { weight: 25, reuse: 'device' },\n\t\tsignupVelocity: { weight: 30, velocity: 'device', window: '1 hour', over: 3 },\n\t\tipTrials: { weight: 10, velocity: 'ip', window: '24 hours', over: 2 },\n\t\tdisposableEmail: { weight: 20, attr: 'disposableEmail' },\n\t\tfreshAccount: { weight: 10, attr: 'accountAgeMinutes', under: 10 },\n\t},\n\ttiers: { medium: 30, high: 70 },\n};\n\n/** The default ruleset map. Apps pass their own (merged) map to RiskEngine. */\nexport const DEFAULT_RULESETS: Record<string, RuleSet> = {\n\t'trial.start': TRIAL_START_RULESET,\n};\n\n/** `< medium` → low, `<= high` → medium, else high. */\nexport function tierFor(score: number, tiers: RuleSet['tiers']): RiskTier {\n\tif (score < tiers.medium) return 'low';\n\tif (score <= tiers.high) return 'medium';\n\treturn 'high';\n}\n\nfunction isReuse(s: RuleSignal): s is Extract<RuleSignal, { reuse: string }> {\n\treturn 'reuse' in s;\n}\nfunction isVelocity(s: RuleSignal): s is Extract<RuleSignal, { velocity: string }> {\n\treturn 'velocity' in s;\n}\nfunction isAttr(s: RuleSignal): s is Extract<RuleSignal, { attr: string }> {\n\treturn 'attr' in s;\n}\n\nconst WINDOW_RE = /^\\d+\\s+(second|minute|hour|day|week|month)s?$/;\n\n/** Fail fast on a malformed ruleset — a bad window would otherwise reach SQL,\n * and a non-positive weight is almost always a typo. */\nexport function validateRuleset(rs: RuleSet): void {\n\tif (!rs.subject) throw new Error('ruleset: missing subject');\n\tif (!rs.tiers || typeof rs.tiers.medium !== 'number' || typeof rs.tiers.high !== 'number')\n\t\tthrow new Error(`ruleset ${rs.subject}: tiers.medium and tiers.high are required numbers`);\n\tfor (const [name, sig] of Object.entries(rs.signals)) {\n\t\tif (typeof sig.weight !== 'number' || sig.weight <= 0)\n\t\t\tthrow new Error(`ruleset ${rs.subject}.${name}: weight must be a positive number`);\n\t\tconst kinds = [isReuse(sig), isVelocity(sig), isAttr(sig)].filter(Boolean).length;\n\t\tif (kinds !== 1)\n\t\t\tthrow new Error(`ruleset ${rs.subject}.${name}: must be exactly one of reuse/velocity/attr`);\n\t\tif (isVelocity(sig)) {\n\t\t\tif (!WINDOW_RE.test(sig.window))\n\t\t\t\tthrow new Error(`ruleset ${rs.subject}.${name}: window \"${sig.window}\" must be a Postgres interval like \"1 hour\"`);\n\t\t\tif (typeof sig.over !== 'number')\n\t\t\t\tthrow new Error(`ruleset ${rs.subject}.${name}: velocity signal needs a numeric \"over\"`);\n\t\t}\n\t}\n}\n\n/** Does an attribute signal fire against this context's attributes? Pure. */\nexport function attributeFires(\n\tsig: Extract<RuleSignal, { attr: string }>,\n\tattributes: Record<string, string | number | boolean> | undefined,\n): boolean {\n\tconst v = attributes?.[sig.attr];\n\tif (v === undefined) return false;\n\tif (sig.equals !== undefined) return v === sig.equals;\n\tif (sig.under !== undefined) return typeof v === 'number' && v < sig.under;\n\tif (sig.over !== undefined) return typeof v === 'number' && v > sig.over;\n\treturn v === true; // bare boolean attribute\n}\n\nexport { isReuse, isVelocity, isAttr };\n"],"mappings":"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","names":[]}
@@ -0,0 +1,3 @@
1
+ declare const getMigrationsPath: () => string;
2
+
3
+ export { getMigrationsPath };
@@ -0,0 +1,7 @@
1
+ // src/migrations/index.ts
2
+ import { createMigrationsPath } from "@fonderie/store";
3
+ var getMigrationsPath = () => createMigrationsPath(import.meta.url);
4
+ export {
5
+ getMigrationsPath
6
+ };
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/migrations/index.ts"],"sourcesContent":["import { createMigrationsPath } from '@fonderie/store';\n\nexport const getMigrationsPath = (): string => createMigrationsPath(import.meta.url);\n"],"mappings":";AAAA,SAAS,4BAA4B;AAE9B,IAAM,oBAAoB,MAAc,qBAAqB,YAAY,GAAG;","names":[]}
@@ -0,0 +1,38 @@
1
+ -- ----------------------------------------------------------------------------
2
+ -- 001_risk_events
3
+ -- ----------------------------------------------------------------------------
4
+ -- The engine's hashed-identity memory. One row per (assessment, signal
5
+ -- identifier): an assess() call writes a 'pending' row for each identifier it
6
+ -- was given; record() flips that assessment's rows to the real outcome. Reuse
7
+ -- and velocity signals are answered by querying (subject, signal_kind,
8
+ -- value_hash) against these rows.
9
+ --
10
+ -- PII firewall: every correlating value is stored as sha256(pepper ‖ kind ‖
11
+ -- value) — NEVER raw. Rows expire (expires_at, purged on a timer). Legal basis
12
+ -- is legitimate interest (fraud prevention); nothing here may flow into the
13
+ -- pseudonymous analytics/telemetry pipeline.
14
+ -- ----------------------------------------------------------------------------
15
+
16
+ CREATE TABLE IF NOT EXISTS risk_events (
17
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
18
+ assessment_id UUID NOT NULL, -- groups one assess() call's identifiers
19
+ subject TEXT NOT NULL, -- 'trial.start' | 'auth.login' | …
20
+ actor_id UUID, -- the assessed principal, when known
21
+ signal_kind TEXT NOT NULL, -- 'card' | 'ip' | 'device' | 'email-domain' | …
22
+ value_hash TEXT NOT NULL, -- sha256(pepper ‖ kind ‖ value) — never raw
23
+ outcome TEXT NOT NULL DEFAULT 'pending'
24
+ CHECK (outcome IN ('pending', 'allowed', 'challenged', 'blocked')),
25
+ score INTEGER,
26
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
27
+ expires_at TIMESTAMPTZ NOT NULL
28
+ );
29
+
30
+ -- The reuse/velocity lookup: "seen this identifier in this subject before?"
31
+ CREATE INDEX IF NOT EXISTS idx_risk_events_lookup
32
+ ON risk_events (subject, signal_kind, value_hash);
33
+ -- record() resolves an assessment's rows by id.
34
+ CREATE INDEX IF NOT EXISTS idx_risk_events_assessment
35
+ ON risk_events (assessment_id);
36
+ -- Retention purge.
37
+ CREATE INDEX IF NOT EXISTS idx_risk_events_expires
38
+ ON risk_events (expires_at);
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@fonderie/risk",
3
+ "version": "0.1.0",
4
+ "fonderie": { "stability": "experimental" },
5
+ "description": "Generic signal → meaning → decision engine — assess an action (trial start, login, registration, promo) against weak signals and a tunable ruleset, and return a graded verdict with reasons. Decides, never enforces: the app owns every side effect.",
6
+ "keywords": [
7
+ "fonderiejs",
8
+ "risk",
9
+ "fraud",
10
+ "abuse",
11
+ "trial-abuse",
12
+ "signals",
13
+ "scoring",
14
+ "saas",
15
+ "typescript"
16
+ ],
17
+ "license": "MIT",
18
+ "type": "module",
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js",
26
+ "require": "./dist/index.cjs"
27
+ },
28
+ "./migrations": {
29
+ "types": "./dist/migrations/index.d.ts",
30
+ "import": "./dist/migrations/index.js"
31
+ }
32
+ },
33
+ "main": "./dist/index.cjs",
34
+ "module": "./dist/index.js",
35
+ "types": "./dist/index.d.ts",
36
+ "scripts": {
37
+ "build": "tsup && tsup --config tsup.migrations.ts",
38
+ "dev": "tsup --watch",
39
+ "typecheck": "tsc --noEmit",
40
+ "test": "tsx --test src/__tests__/*.test.ts",
41
+ "lint": "biome lint src",
42
+ "format": "biome format --write src",
43
+ "check": "biome check --write src"
44
+ },
45
+ "peerDependencies": {
46
+ "@fonderie/core": "^0.10.0",
47
+ "@fonderie/store": "^0.3.0"
48
+ },
49
+ "devDependencies": {
50
+ "@fonderie/core": "../core",
51
+ "@fonderie/store": "../store",
52
+ "@types/node": "^26.4.1",
53
+ "tsup": "^8.5.1",
54
+ "tsx": "^4.23.13",
55
+ "typescript": "^6.0.3"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "files": [
61
+ "dist",
62
+ "brain",
63
+ "LICENSE",
64
+ "README.md"
65
+ ],
66
+ "repository": {
67
+ "type": "git",
68
+ "url": "git+https://github.com/fonderiejs/fonderie.git",
69
+ "directory": "packages/risk"
70
+ },
71
+ "homepage": "https://github.com/fonderiejs/fonderie/tree/main/packages/risk#readme",
72
+ "bugs": {
73
+ "url": "https://github.com/fonderiejs/fonderie/issues"
74
+ }
75
+ }