apira-guard 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/README.md ADDED
@@ -0,0 +1,296 @@
1
+ # Apira
2
+
3
+ Catches bots, abuse, and broken flows after Cloudflare, before PostHog.
4
+
5
+ Detect fake signups, risky actions, API abuse, and silent backend failures — locally, with no API key.
6
+
7
+ ```bash
8
+ npm install apira-guard
9
+ ```
10
+
11
+ Apira is a local, no-network layer. **`watchActions()`** and **`watchAccess()`** attach `riskScore`, `riskLevel`, and `reasons` to matched form submits and HTTP requests. **`watchSignups()`** / **`watchForms()`** listen for conversion submits and fire `signupguard:submit` with intent metadata (no risk payload). **`protectForms()`** blocks obvious bad email and phone values before submit.
12
+
13
+ No account. No dashboard. No outbound calls.
14
+
15
+ ---
16
+
17
+ ## Common use cases
18
+
19
+ Use Apira to:
20
+
21
+ * block or flag fake or junk signups (`protectForms`, then optional `watchActions` risk)
22
+ * detect disposable emails and fake-looking phones at submit time (`protectForms`)
23
+ * score checkout, password reset, account update, invite, waitlist, demo request, and contact sales forms (`watchActions` — match by form copy or `data-signup-guard-form`)
24
+ * detect forms submitted unrealistically fast (`watchActions`)
25
+ * detect repeated form submissions (`watchActions`)
26
+ * add request-risk scoring to Express, Next.js, or Node APIs
27
+ * detect request velocity
28
+ * detect sudden traffic spikes
29
+ * detect simple API enumeration like `/api/users/1`, `/api/users/2`, `/api/users/3`
30
+ * send risk events to your own logs, database, Sentry, PostHog, or analytics stack
31
+
32
+ ---
33
+
34
+ ## Add one line
35
+
36
+ ### Signup forms
37
+
38
+ ```ts
39
+ import { watchSignups } from "apira-guard";
40
+
41
+ watchSignups();
42
+ ```
43
+
44
+ Auto-detects signup forms with email or phone fields and fires `signupguard:submit` on submit (detail: `intent`, `timestamp`, `metadata`). It does **not** inject `signupGuardRisk` — use `watchActions({ action: "signup" })` (plus matching form text or `data-signup-guard-form="signup"`) when you want the same risk JSON on signup flows.
45
+
46
+ Use `protectForms()` if you want to block obvious bad submissions (invalid / disposable email, bad phone shapes).
47
+
48
+ ---
49
+
50
+ ### Important actions
51
+
52
+ Protect conversion-critical forms like checkout, password reset, account update, invite, waitlist, demo request, and contact sales.
53
+
54
+ ```ts
55
+ import { watchActions } from "apira-guard";
56
+
57
+ watchActions({ action: "checkout" });
58
+ ```
59
+
60
+ Apira watches the matching form, computes risk on submit, injects `signupGuardRisk` into the form body, and fires `signupguard:action`.
61
+
62
+ ```ts
63
+ watchActions({
64
+ action: "checkout",
65
+ onAction(event) {
66
+ console.log(event.action, event.risk);
67
+ }
68
+ });
69
+ ```
70
+
71
+ Target a form explicitly:
72
+
73
+ ```html
74
+ <form data-signup-guard-form="checkout">
75
+ ...
76
+ </form>
77
+ ```
78
+
79
+ ---
80
+
81
+ ### API routes
82
+
83
+ Watch server-side requests for lightweight API abuse signals.
84
+
85
+ ```ts
86
+ import { watchAccess } from "apira-guard/server";
87
+
88
+ app.use(watchAccess());
89
+ ```
90
+
91
+ Apira attaches risk directly to the request:
92
+
93
+ ```ts
94
+ app.get("/api/users/:id", (req, res) => {
95
+ if (req.signupGuardRisk?.riskLevel === "high") {
96
+ return res.status(429).json({ error: "rate limited" });
97
+ }
98
+
99
+ res.json({ ok: true });
100
+ });
101
+ ```
102
+
103
+ Configure velocity and spike detection:
104
+
105
+ ```ts
106
+ watchAccess({
107
+ velocityThreshold: 60,
108
+ spikeThreshold: 20,
109
+ velocityWindowMs: 60_000
110
+ });
111
+ ```
112
+
113
+ ---
114
+
115
+ ## Risk output
116
+
117
+ **`watchActions()`** and **`watchAccess()`** both use the same payload shape:
118
+
119
+ ```ts
120
+ {
121
+ riskScore: 85,
122
+ riskLevel: "high",
123
+ reasons: ["velocity", "fast_action", "enumeration"]
124
+ }
125
+ ```
126
+
127
+ | Reason | Where | What it means |
128
+ | ------------------------ | -------------- | ------------- |
129
+ | `velocity` | forms (`watchActions`), APIs | Too many submits or requests in the sliding window (default 60 s on the server; client `watchActions` uses a stricter per-form threshold) |
130
+ | `fast_action` | forms (`watchActions`) | Submitted faster than the fast-action threshold after the watcher attached |
131
+ | `retry` | forms (`watchActions`) | Same form submitted again |
132
+ | `enumeration` | APIs | Sequential numeric IDs in the URL path (run ≥ 3) |
133
+ | `spike` | APIs | Burst of requests in a 10 s window |
134
+ | `endpoint_concentration` | APIs | Same normalised endpoint hit repeatedly in the velocity window |
135
+ | `probing` | APIs | High 4xx rate from this client (after enough samples) |
136
+ | `flow_anomaly` | APIs | Write request with no prior read on the same key (bot-style shortcut) |
137
+
138
+ Score thresholds: **low** < 40 · **medium** 40–69 · **high** ≥ 70.
139
+
140
+ ---
141
+
142
+ ## Can Apira block risk?
143
+
144
+ Yes. Apira can run as a signal layer or a blocking layer.
145
+
146
+ Signal mode: **`watchActions()`** injects a hidden `signupGuardRisk` JSON field on submit; **`watchAccess()`** sets `req.signupGuardRisk` on each request.
147
+
148
+ ```ts
149
+ const risk = JSON.parse(req.body.signupGuardRisk ?? "{}");
150
+ ```
151
+
152
+ Block mode stops obvious bad activity before it continues.
153
+
154
+ ```ts
155
+ import { protectForms } from "apira-guard";
156
+
157
+ protectForms();
158
+ ```
159
+
160
+ For APIs, block high-risk requests in middleware:
161
+
162
+ ```ts
163
+ app.use(watchAccess());
164
+
165
+ app.use((req, res, next) => {
166
+ if (req.signupGuardRisk?.riskLevel === "high") {
167
+ return res.status(429).json({ error: "Too many suspicious requests" });
168
+ }
169
+
170
+ next();
171
+ });
172
+ ```
173
+
174
+ Apira can reduce obvious abuse. It does not guarantee fraud prevention or stop sophisticated attackers.
175
+
176
+ ---
177
+
178
+ ## Reading risk on the server
179
+
180
+ **`watchActions()`** injects a hidden field on matched forms:
181
+
182
+ ```ts
183
+ app.post("/checkout", (req, res) => {
184
+ const risk = JSON.parse(req.body.signupGuardRisk ?? "{}");
185
+
186
+ if (risk.riskLevel === "high") {
187
+ return res.status(422).json({ error: "Suspicious request" });
188
+ }
189
+
190
+ res.json({ ok: true });
191
+ });
192
+ ```
193
+
194
+ API routes get risk directly:
195
+
196
+ ```ts
197
+ req.signupGuardRisk;
198
+ ```
199
+
200
+ For TypeScript Express apps:
201
+
202
+ ```ts
203
+ declare global {
204
+ namespace Express {
205
+ interface Request {
206
+ signupGuardRisk?: import("apira-guard/server").RiskResult;
207
+ }
208
+ }
209
+ }
210
+ ```
211
+
212
+ ---
213
+
214
+ ## Signup-first, conversion-ready
215
+
216
+ `watchSignups()` is shorthand for:
217
+
218
+ ```ts
219
+ watchForms({ intent: "signup" });
220
+ ```
221
+
222
+ For any conversion-critical form, use `watchForms()`:
223
+
224
+ ```ts
225
+ import { watchForms } from "apira-guard";
226
+
227
+ watchForms({
228
+ intent: "demo_request",
229
+ metadata: { page: "pricing" },
230
+ onSubmit(event) {
231
+ console.log(event.intent, event.timestamp, event.metadata);
232
+ }
233
+ });
234
+ ```
235
+
236
+ Opt a form in or out:
237
+
238
+ ```html
239
+ <form data-signup-guard-form="checkout">...</form>
240
+ <form data-signup-guard-ignore>...</form>
241
+ ```
242
+
243
+ ---
244
+
245
+ ## What protectForms() blocks
246
+
247
+ ```ts
248
+ import { protectForms } from "apira-guard";
249
+
250
+ protectForms();
251
+ ```
252
+
253
+ Blocks obvious garbage at submit time using browser validation messages:
254
+
255
+ * invalid email formats
256
+ * disposable email domains
257
+ * invalid phone formats
258
+ * fake-looking phones like `0000000000`, `1234567890`, and `555-01xx`
259
+
260
+ ---
261
+
262
+ ## What ships in npm
263
+
264
+ * `watchSignups()`
265
+ * `watchForms()`
266
+ * `watchActions()`
267
+ * `protectForms()`
268
+ * `watchAccess()`
269
+ * shared risk payloads
270
+ * local scoring rules
271
+ * reason codes
272
+ * TypeScript types
273
+
274
+ Everything runs inside your app. Apira does not call external services.
275
+
276
+ ---
277
+
278
+ ## What is not included
279
+
280
+ Not included today:
281
+
282
+ * hosted dashboard
283
+ * shared reputation network
284
+ * external IP reputation checks
285
+ * external email verification APIs
286
+ * ML fraud models
287
+
288
+ These may become optional later. The npm package works without them.
289
+
290
+ ---
291
+
292
+ ## What Apira is not
293
+
294
+ Apira is not a CAPTCHA, identity verification system, hosted fraud platform, or replacement for server-side validation.
295
+
296
+ It is a local first line of defense for forms, actions, and API routes — the layer between edge protection and your analytics/logging stack.
@@ -0,0 +1,168 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Apira Demo</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; }
9
+ body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 44rem; }
10
+ h2 { margin-top: 2.5rem; }
11
+ label { display: block; margin-block: 0.75rem 0.2rem; font-size: 0.9rem; color: #555; }
12
+ input { padding: 0.5rem 0.6rem; width: 100%; border: 1px solid #ccc; border-radius: 4px; font: inherit; }
13
+ button { margin-top: 0.9rem; padding: 0.55rem 1rem; font: inherit; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #fff; }
14
+ button:hover { background: #f3f4f6; }
15
+ pre {
16
+ background: #111827; color: #f9fafb; border-radius: 6px;
17
+ overflow: auto; padding: 1rem; font-size: 0.85rem; min-height: 3rem;
18
+ }
19
+ .badge {
20
+ display: inline-block; padding: 0.2rem 0.6rem; border-radius: 999px;
21
+ font-size: 0.75rem; font-weight: 600; text-transform: uppercase; margin-left: 0.5rem;
22
+ vertical-align: middle;
23
+ }
24
+ .low { background: #dcfce7; color: #166534; }
25
+ .medium { background: #fef9c3; color: #713f12; }
26
+ .high { background: #fee2e2; color: #991b1b; }
27
+ hr { margin-block: 2.5rem; border: none; border-top: 1px solid #e5e7eb; }
28
+ .buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; }
29
+ </style>
30
+ </head>
31
+ <body>
32
+ <h1>Apira Demo</h1>
33
+ <p>Local behaviour engine — no network calls, no API keys.</p>
34
+
35
+ <!-- ── A) protectForms ──────────────────────────────────────────── -->
36
+ <h2>A) <code>protectForms()</code></h2>
37
+ <p>Blocks fake inputs at the browser level. Try submitting as-is.</p>
38
+ <form id="signup-form">
39
+ <label for="email">Email</label>
40
+ <input id="email" name="email" value="test@mailinator.com" />
41
+ <label for="phone">Phone</label>
42
+ <input id="phone" name="phone" value="1111111111" />
43
+ <button type="submit">Create account</button>
44
+ </form>
45
+ <pre id="protect-result">→ submit to see what gets blocked</pre>
46
+
47
+ <hr />
48
+
49
+ <!-- ── B) watchActions ──────────────────────────────────────────── -->
50
+ <h2>
51
+ B) <code>watchActions({ action: "checkout" })</code>
52
+ <span id="risk-badge" class="badge" style="display:none"></span>
53
+ </h2>
54
+ <p>
55
+ Click <strong>Checkout</strong> several times in a row.
56
+ Watch the risk score climb: <em>fast_action</em> → <em>retry</em> → <em>velocity</em>.
57
+ </p>
58
+ <form id="checkout-form" data-signup-guard-form="checkout">
59
+ <label for="card">Card number</label>
60
+ <input id="card" name="card" placeholder="4242 4242 4242 4242" />
61
+ <button type="submit">Checkout</button>
62
+ </form>
63
+ <pre id="actions-result">→ submit the form to see risk signals</pre>
64
+
65
+ <hr />
66
+
67
+ <!-- ── C) watchAccess simulation ───────────────────────────────── -->
68
+ <h2>C) <code>watchAccess()</code> — API abuse patterns</h2>
69
+ <p>
70
+ In production this runs server-side as <code>app.use("/api", watchAccess())</code>.
71
+ Here we run the same engine in-browser to show the signals.
72
+ </p>
73
+ <div class="buttons">
74
+ <button id="btn-normal">Normal user</button>
75
+ <button id="btn-enum">Enumerator</button>
76
+ <button id="btn-scraper">Scraper</button>
77
+ <button id="btn-bot">Bot (no browse)</button>
78
+ <button id="btn-brute">Brute force</button>
79
+ </div>
80
+ <pre id="access-result">→ pick a pattern above</pre>
81
+
82
+ <!-- ── scripts ──────────────────────────────────────────────────── -->
83
+ <script type="module">
84
+ import { protectForms, watchActions } from "../dist/index.js";
85
+
86
+ // A) protect forms
87
+ const signupForm = document.querySelector("#signup-form");
88
+ const protectOut = document.querySelector("#protect-result");
89
+
90
+ protectForms();
91
+ signupForm.addEventListener("signupguard:block", (e) => {
92
+ protectOut.textContent = JSON.stringify(e.detail, null, 2);
93
+ });
94
+ signupForm.addEventListener("submit", (e) => {
95
+ e.preventDefault();
96
+ protectOut.textContent = "✓ valid — submission would continue";
97
+ });
98
+
99
+ // B) watch actions
100
+ const actionsOut = document.querySelector("#actions-result");
101
+ const badge = document.querySelector("#risk-badge");
102
+
103
+ watchActions({
104
+ action: "checkout",
105
+ onAction({ risk }) {
106
+ actionsOut.textContent = JSON.stringify(risk, null, 2);
107
+ badge.textContent = risk.riskLevel;
108
+ badge.className = `badge ${risk.riskLevel}`;
109
+ badge.style.display = "inline-block";
110
+ }
111
+ });
112
+
113
+ document.querySelector("#checkout-form").addEventListener("submit", (e) => {
114
+ e.preventDefault();
115
+ });
116
+ </script>
117
+
118
+ <script type="module">
119
+ // C) watchAccess simulation — same engine the server uses
120
+ import { createEngine } from "../dist/engine.js";
121
+ import { buildRiskResult } from "../dist/risk.js";
122
+
123
+ const accessOut = document.querySelector("#access-result");
124
+
125
+ function simulate(label, requests) {
126
+ // One engine per simulation (mirrors one watchAccess() mount per route)
127
+ const engine = createEngine({ velocityThreshold: 5, spikeThreshold: 5, concThreshold: 5 });
128
+ const log = [];
129
+
130
+ for (const [ip, method, url] of requests) {
131
+ const reasons = engine.track(ip, { url, method });
132
+ log.push({ url, method, ...buildRiskResult(reasons) });
133
+ }
134
+
135
+ const last = log.at(-1);
136
+ const signals = [...new Set(log.flatMap(r => r.reasons))];
137
+
138
+ accessOut.textContent =
139
+ `Pattern : ${label}\n` +
140
+ `Requests: ${requests.length}\n\n` +
141
+ `Last request:\n${JSON.stringify(last, null, 2)}\n\n` +
142
+ `All signals fired: ${signals.length ? signals.join(", ") : "none (clean traffic)"}`;
143
+ }
144
+
145
+ const ip = "demo-ip";
146
+
147
+ document.querySelector("#btn-normal").onclick = () =>
148
+ simulate("Normal user", [
149
+ [ip, "GET", "/api/products"],
150
+ [ip, "GET", "/api/product/42"],
151
+ [ip, "GET", "/api/checkout"],
152
+ [ip, "POST", "/api/checkout"],
153
+ ]);
154
+
155
+ document.querySelector("#btn-enum").onclick = () =>
156
+ simulate("Enumerator", [1,2,3,4,5,6,7].map(n => [ip, "GET", `/api/user/${n}`]));
157
+
158
+ document.querySelector("#btn-scraper").onclick = () =>
159
+ simulate("Scraper", Array.from({ length: 8 }, () => [ip, "GET", "/api/products"]));
160
+
161
+ document.querySelector("#btn-bot").onclick = () =>
162
+ simulate("Bot (no browse)", Array.from({ length: 4 }, () => [ip, "POST", "/api/checkout"]));
163
+
164
+ document.querySelector("#btn-brute").onclick = () =>
165
+ simulate("Brute force", Array.from({ length: 8 }, () => [ip, "POST", "/api/login"]));
166
+ </script>
167
+ </body>
168
+ </html>
@@ -0,0 +1,47 @@
1
+ /**
2
+ * O(1) behaviour detection engine — shared by all surfaces.
3
+ *
4
+ * All counters use fixed-window arithmetic so every operation is constant time:
5
+ * no timestamp arrays, no filter/scan, no history queries.
6
+ */
7
+ import type { RiskReason } from "./risk.js";
8
+ export type TrackEvent = {
9
+ now?: number;
10
+ /**
11
+ * Milliseconds since the watcher was attached (submit surface).
12
+ * Presence of this field marks the event as a submit-surface event:
13
+ * - enables fast_action and retry signals
14
+ * - disables enumeration / endpoint_concentration / flow_anomaly
15
+ */
16
+ elapsed?: number;
17
+ /**
18
+ * Request URL (access surface).
19
+ * Presence of this field marks the event as an access-surface event:
20
+ * - enables enumeration, endpoint_concentration, flow_anomaly
21
+ * - disables retry
22
+ */
23
+ url?: string;
24
+ /** HTTP method — required for flow_anomaly (access surface only). */
25
+ method?: string;
26
+ };
27
+ export type EngineOptions = {
28
+ /** Requests per velocityWindowMs before "velocity" fires. Default: 60. */
29
+ velocityThreshold?: number;
30
+ /** Sliding window for velocity. Default: 60 000 ms. */
31
+ velocityWindowMs?: number;
32
+ /** Requests per 10 s before "spike" fires. Default: 20. */
33
+ spikeThreshold?: number;
34
+ /** Requests to the same normalised endpoint before "endpoint_concentration" fires. Default: 20. */
35
+ concThreshold?: number;
36
+ /** Minimum error rate (0–1) before "probing" fires (needs ≥ 5 requests). Default: 0.5. */
37
+ errorRateThreshold?: number;
38
+ /** Elapsed ms threshold for "fast_action". Default: 3 000. */
39
+ fastActionMs?: number;
40
+ };
41
+ export type Engine = {
42
+ track(key: string, event: TrackEvent): RiskReason[];
43
+ /** Call after a 4xx / 5xx response to feed the probing detector. */
44
+ reportError(key: string): void;
45
+ };
46
+ export declare function createEngine(opts?: EngineOptions): Engine;
47
+ //# sourceMappingURL=engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAI5C,MAAM,MAAM,UAAU,GAAG;IACvB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,0EAA0E;IAC1E,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,uDAAuD;IACvD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,2DAA2D;IAC3D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mGAAmG;IACnG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,0FAA0F;IAC1F,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,8DAA8D;IAC9D,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,MAAM,GAAG;IACnB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,UAAU,EAAE,CAAC;IACpD,oEAAoE;IACpE,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC,CAAC;AA0CF,wBAAgB,YAAY,CAAC,IAAI,GAAE,aAAkB,GAAG,MAAM,CA6F7D"}
package/dist/engine.js ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * O(1) behaviour detection engine — shared by all surfaces.
3
+ *
4
+ * All counters use fixed-window arithmetic so every operation is constant time:
5
+ * no timestamp arrays, no filter/scan, no history queries.
6
+ */
7
+ const NUMERIC_ID_RE = /\/(\d+)(?:\/|$|\?)/u;
8
+ function makeState(now) {
9
+ return {
10
+ winStart: now, winCount: 0,
11
+ spikeStart: now, spikeCount: 0,
12
+ totalCount: 0,
13
+ lastPathNum: null, seqRun: 0,
14
+ epWinStart: now, epCounts: new Map(),
15
+ reqCount: 0, errCount: 0,
16
+ hasGet: false
17
+ };
18
+ }
19
+ // ─── factory ─────────────────────────────────────────────────────────────────
20
+ export function createEngine(opts = {}) {
21
+ const velocityThreshold = opts.velocityThreshold ?? 60;
22
+ const velocityWindowMs = opts.velocityWindowMs ?? 60_000;
23
+ const spikeThreshold = opts.spikeThreshold ?? 20;
24
+ const concThreshold = opts.concThreshold ?? 20;
25
+ const errorRateThreshold = opts.errorRateThreshold ?? 0.5;
26
+ const fastActionMs = opts.fastActionMs ?? 3_000;
27
+ const store = new Map();
28
+ function state(key, now) {
29
+ let s = store.get(key);
30
+ if (!s) {
31
+ s = makeState(now);
32
+ store.set(key, s);
33
+ }
34
+ return s;
35
+ }
36
+ return {
37
+ track(key, event) {
38
+ const now = event.now ?? Date.now();
39
+ const s = state(key, now);
40
+ const reasons = [];
41
+ // ── Velocity (O(1) fixed window) ───────────────────────────────────────
42
+ if (now - s.winStart >= velocityWindowMs) {
43
+ s.winStart = now;
44
+ s.winCount = 0;
45
+ }
46
+ s.winCount++;
47
+ if (s.winCount > velocityThreshold)
48
+ reasons.push("velocity");
49
+ // ── Spike (O(1) fixed 10 s window) ────────────────────────────────────
50
+ if (now - s.spikeStart >= 10_000) {
51
+ s.spikeStart = now;
52
+ s.spikeCount = 0;
53
+ }
54
+ s.spikeCount++;
55
+ if (s.spikeCount > spikeThreshold)
56
+ reasons.push("spike");
57
+ s.totalCount++;
58
+ // ── Fast action (submit surface) ───────────────────────────────────────
59
+ if (event.elapsed !== undefined && event.elapsed < fastActionMs) {
60
+ reasons.push("fast_action");
61
+ }
62
+ if (event.url !== undefined) {
63
+ // ── Access-surface signals ─────────────────────────────────────────
64
+ // Enumeration (O(1) sequential-run counter)
65
+ const m = NUMERIC_ID_RE.exec(event.url);
66
+ if (m) {
67
+ const num = parseInt(m[1], 10);
68
+ s.seqRun = (s.lastPathNum !== null && Math.abs(num - s.lastPathNum) <= 5)
69
+ ? s.seqRun + 1
70
+ : 1;
71
+ s.lastPathNum = num;
72
+ }
73
+ else {
74
+ s.lastPathNum = null;
75
+ s.seqRun = 0;
76
+ }
77
+ if (s.seqRun >= 3)
78
+ reasons.push("enumeration");
79
+ // Endpoint concentration (O(1) per-endpoint window counter)
80
+ if (now - s.epWinStart >= velocityWindowMs) {
81
+ s.epCounts.clear();
82
+ s.epWinStart = now;
83
+ }
84
+ const ep = normalizeEndpoint(event.url);
85
+ const epCount = (s.epCounts.get(ep) ?? 0) + 1;
86
+ s.epCounts.set(ep, epCount);
87
+ if (epCount > concThreshold)
88
+ reasons.push("endpoint_concentration");
89
+ // Flow anomaly (O(1) method tracker — POST/PUT/DELETE with no prior GET)
90
+ if (event.method) {
91
+ const method = event.method.toUpperCase();
92
+ if (method === "GET") {
93
+ s.hasGet = true;
94
+ }
95
+ else if (!s.hasGet && s.totalCount > 2) {
96
+ reasons.push("flow_anomaly");
97
+ }
98
+ }
99
+ // Probing (O(1) error rate — evaluated on next request after reportError calls)
100
+ s.reqCount++;
101
+ if (s.reqCount >= 5 && s.errCount / s.reqCount > errorRateThreshold) {
102
+ reasons.push("probing");
103
+ }
104
+ }
105
+ else {
106
+ // ── Submit-surface signals ─────────────────────────────────────────
107
+ // Retry (second or later submit to the same form)
108
+ if (s.totalCount > 1)
109
+ reasons.push("retry");
110
+ }
111
+ return reasons;
112
+ },
113
+ reportError(key) {
114
+ const s = store.get(key);
115
+ if (s) {
116
+ s.errCount++;
117
+ }
118
+ }
119
+ };
120
+ }
121
+ // ─── helpers ─────────────────────────────────────────────────────────────────
122
+ function normalizeEndpoint(url) {
123
+ // Strip query string, collapse numeric path segments to :id
124
+ return url.split("?")[0].replace(/\/\d+/gu, "/:id");
125
+ }
@@ -0,0 +1,54 @@
1
+ import { type RiskResult } from "./risk.js";
2
+ export type { RiskReason, RiskLevel, RiskResult } from "./risk.js";
3
+ export type ConversionFormIntent = "signup" | "checkout" | "demo_request" | "waitlist" | "lead_capture" | "contact_sales" | "invite";
4
+ export type WatchFormsEvent = {
5
+ intent: ConversionFormIntent;
6
+ timestamp: string;
7
+ metadata: Record<string, unknown>;
8
+ };
9
+ export type WatchFormsOptions = {
10
+ /** Root node to search. Defaults to document. */
11
+ root?: ParentNode;
12
+ /** Revenue-critical flow being watched. Defaults to signup. */
13
+ intent?: ConversionFormIntent;
14
+ /** Optional metadata copied into the submit event. */
15
+ metadata?: Record<string, unknown>;
16
+ /** Called when a watched form submits. */
17
+ onSubmit?: (event: WatchFormsEvent) => void;
18
+ };
19
+ export type WatchFormsController = {
20
+ /** Number of forms watched by this call. */
21
+ watchedForms: number;
22
+ /** Remove submit listeners added by this call. */
23
+ destroy: () => void;
24
+ };
25
+ export type ProtectFormsController = {
26
+ /** Number of forms protected by this call. */
27
+ protectedForms: number;
28
+ /** Remove submit listeners added by this call. */
29
+ destroy: () => void;
30
+ };
31
+ export type WatchActionsOptions = {
32
+ /** Action being watched — e.g. "checkout", "reset", "update". */
33
+ action: string;
34
+ /** Root node to search. Defaults to document. */
35
+ root?: ParentNode;
36
+ /** Called on each action with the risk result. */
37
+ onAction?: (event: WatchActionsEvent) => void;
38
+ };
39
+ export type WatchActionsEvent = {
40
+ action: string;
41
+ risk: RiskResult;
42
+ timestamp: string;
43
+ };
44
+ export type WatchActionsController = {
45
+ /** Number of forms watched by this call. */
46
+ watchedForms: number;
47
+ /** Remove submit listeners added by this call. */
48
+ destroy: () => void;
49
+ };
50
+ export declare function watchSignups(options?: Omit<WatchFormsOptions, "intent">): WatchFormsController;
51
+ export declare function watchForms(options?: WatchFormsOptions): WatchFormsController;
52
+ export declare function watchActions(options: WatchActionsOptions): WatchActionsController;
53
+ export declare function protectForms(root?: ParentNode): ProtectFormsController;
54
+ //# sourceMappingURL=index.d.ts.map