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 +296 -0
- package/demo/index.html +168 -0
- package/dist/engine.d.ts +47 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +125 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +348 -0
- package/dist/risk.d.ts +9 -0
- package/dist/risk.d.ts.map +1 -0
- package/dist/risk.js +17 -0
- package/dist/server.d.ts +37 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +42 -0
- package/package.json +45 -0
- package/src/engine.ts +193 -0
- package/src/index.ts +507 -0
- package/src/risk.ts +36 -0
- package/src/server.ts +80 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAoC,KAAK,UAAU,EAAE,MAAM,WAAW,CAAC;AAE9E,YAAY,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAiBnE,MAAM,MAAM,oBAAoB,GAC5B,QAAQ,GACR,UAAU,GACV,cAAc,GACd,UAAU,GACV,cAAc,GACd,eAAe,GACf,QAAQ,CAAC;AAEb,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,oBAAoB,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,iDAAiD;IACjD,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,+DAA+D;IAC/D,MAAM,CAAC,EAAE,oBAAoB,CAAC;IAC9B,sDAAsD;IACtD,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,0CAA0C;IAC1C,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;CAC7C,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,4CAA4C;IAC5C,YAAY,EAAE,MAAM,CAAC;IACrB,kDAAkD;IAClD,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,8CAA8C;IAC9C,cAAc,EAAE,MAAM,CAAC;IACvB,kDAAkD;IAClD,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,iEAAiE;IACjE,MAAM,EAAE,MAAM,CAAC;IACf,iDAAiD;IACjD,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,kDAAkD;IAClD,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;CAC/C,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,UAAU,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,4CAA4C;IAC5C,YAAY,EAAE,MAAM,CAAC;IACrB,kDAAkD;IAClD,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB,CAAC;AA8CF,wBAAgB,YAAY,CAAC,OAAO,GAAE,IAAI,CAAC,iBAAiB,EAAE,QAAQ,CAAM,GAAG,oBAAoB,CAElG;AAED,wBAAgB,UAAU,CAAC,OAAO,GAAE,iBAAsB,GAAG,oBAAoB,CAuChF;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,mBAAmB,GAAG,sBAAsB,CAiDjF;AAED,wBAAgB,YAAY,CAAC,IAAI,GAAE,UAAqB,GAAG,sBAAsB,CAmDhF"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { createEngine } from "./engine.js";
|
|
2
|
+
import { buildRiskResult } from "./risk.js";
|
|
3
|
+
const DISPOSABLE_EMAIL_DOMAINS = new Set([
|
|
4
|
+
"10minutemail.com",
|
|
5
|
+
"anonaddy.com",
|
|
6
|
+
"dispostable.com",
|
|
7
|
+
"guerrillamail.com",
|
|
8
|
+
"mailinator.com",
|
|
9
|
+
"maildrop.cc",
|
|
10
|
+
"sharklasers.com",
|
|
11
|
+
"temp-mail.org",
|
|
12
|
+
"tempmail.com",
|
|
13
|
+
"throwawaymail.com",
|
|
14
|
+
"yopmail.com"
|
|
15
|
+
]);
|
|
16
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/u;
|
|
17
|
+
const SEQUENTIAL_PHONE_PATTERN = /(?:012345|123456|234567|345678|456789|987654|876543|765432|654321|543210)/u;
|
|
18
|
+
const EMAIL_FIELD_SELECTOR = [
|
|
19
|
+
"input[type='email']",
|
|
20
|
+
"input[name*='email' i]",
|
|
21
|
+
"input[id*='email' i]",
|
|
22
|
+
"input[autocomplete='email' i]"
|
|
23
|
+
].join(", ");
|
|
24
|
+
const PHONE_FIELD_SELECTOR = [
|
|
25
|
+
"input[type='tel']",
|
|
26
|
+
"input[name*='phone' i]",
|
|
27
|
+
"input[id*='phone' i]",
|
|
28
|
+
"input[name*='mobile' i]",
|
|
29
|
+
"input[id*='mobile' i]",
|
|
30
|
+
"input[name*='telephone' i]",
|
|
31
|
+
"input[id*='telephone' i]",
|
|
32
|
+
"input[name*='tel' i]",
|
|
33
|
+
"input[id*='tel' i]",
|
|
34
|
+
"input[autocomplete='tel' i]"
|
|
35
|
+
].join(", ");
|
|
36
|
+
const PHONE_FIELD_WORDS = ["phone", "mobile", "telephone", "tel"];
|
|
37
|
+
const PHONE_FIELD_PHRASES = ["contact number"];
|
|
38
|
+
const FORM_LISTENER_OPTIONS = { capture: true };
|
|
39
|
+
const SIGNUP_GUARD_FORM_ATTRIBUTE = "data-signup-guard-form";
|
|
40
|
+
const SIGNUP_GUARD_IGNORE_ATTRIBUTE = "data-signup-guard-ignore";
|
|
41
|
+
const protectedFormListeners = new WeakMap();
|
|
42
|
+
const watchedFormListeners = new WeakMap();
|
|
43
|
+
const watchedActionListeners = new WeakMap();
|
|
44
|
+
export function watchSignups(options = {}) {
|
|
45
|
+
return watchForms({ ...options, intent: "signup" });
|
|
46
|
+
}
|
|
47
|
+
export function watchForms(options = {}) {
|
|
48
|
+
const root = options.root ?? document;
|
|
49
|
+
const intent = options.intent ?? "signup";
|
|
50
|
+
const attachedForms = [];
|
|
51
|
+
for (const form of Array.from(root.querySelectorAll("form"))) {
|
|
52
|
+
if (watchedFormListeners.has(form) || !shouldWatchForm(form, intent)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const onSubmit = () => {
|
|
56
|
+
const detail = {
|
|
57
|
+
intent,
|
|
58
|
+
timestamp: new Date().toISOString(),
|
|
59
|
+
metadata: options.metadata ?? {}
|
|
60
|
+
};
|
|
61
|
+
options.onSubmit?.(detail);
|
|
62
|
+
form.dispatchEvent(new CustomEvent("signupguard:submit", { detail }));
|
|
63
|
+
};
|
|
64
|
+
form.addEventListener("submit", onSubmit, FORM_LISTENER_OPTIONS);
|
|
65
|
+
watchedFormListeners.set(form, onSubmit);
|
|
66
|
+
attachedForms.push(form);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
watchedForms: attachedForms.length,
|
|
70
|
+
destroy: () => {
|
|
71
|
+
for (const form of attachedForms) {
|
|
72
|
+
const listener = watchedFormListeners.get(form);
|
|
73
|
+
if (listener) {
|
|
74
|
+
form.removeEventListener("submit", listener, FORM_LISTENER_OPTIONS);
|
|
75
|
+
watchedFormListeners.delete(form);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function watchActions(options) {
|
|
82
|
+
const root = options.root ?? document;
|
|
83
|
+
const { action } = options;
|
|
84
|
+
const attachTime = Date.now();
|
|
85
|
+
const attachedForms = [];
|
|
86
|
+
// One engine per watchActions() call — isolated state, O(1) counters per form key.
|
|
87
|
+
const engine = createEngine({ velocityThreshold: 3, velocityWindowMs: 60_000 });
|
|
88
|
+
// Stable string key per form so the engine can track per-form state.
|
|
89
|
+
const formKeys = new Map();
|
|
90
|
+
let nextKey = 0;
|
|
91
|
+
for (const form of Array.from(root.querySelectorAll("form"))) {
|
|
92
|
+
if (watchedActionListeners.has(form) || !shouldWatchActionForm(form, action)) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const key = String(nextKey++);
|
|
96
|
+
formKeys.set(form, key);
|
|
97
|
+
const onSubmit = () => {
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
const reasons = engine.track(key, { now, elapsed: now - attachTime });
|
|
100
|
+
const risk = buildRiskResult(reasons);
|
|
101
|
+
injectRiskField(form, risk);
|
|
102
|
+
const detail = { action, risk, timestamp: new Date().toISOString() };
|
|
103
|
+
options.onAction?.(detail);
|
|
104
|
+
form.dispatchEvent(new CustomEvent("signupguard:action", { detail }));
|
|
105
|
+
};
|
|
106
|
+
form.addEventListener("submit", onSubmit, FORM_LISTENER_OPTIONS);
|
|
107
|
+
watchedActionListeners.set(form, onSubmit);
|
|
108
|
+
attachedForms.push(form);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
watchedForms: attachedForms.length,
|
|
112
|
+
destroy: () => {
|
|
113
|
+
for (const form of attachedForms) {
|
|
114
|
+
const listener = watchedActionListeners.get(form);
|
|
115
|
+
if (listener) {
|
|
116
|
+
form.removeEventListener("submit", listener, FORM_LISTENER_OPTIONS);
|
|
117
|
+
watchedActionListeners.delete(form);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export function protectForms(root = document) {
|
|
124
|
+
const forms = Array.from(root.querySelectorAll("form"));
|
|
125
|
+
const attachedForms = [];
|
|
126
|
+
for (const form of forms) {
|
|
127
|
+
if (protectedFormListeners.has(form) || !shouldProtectForm(form)) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const onSubmit = (event) => {
|
|
131
|
+
const fields = detectSignupFields(form);
|
|
132
|
+
if (!fields) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
clearSignupGuardMessage(fields.email);
|
|
136
|
+
clearSignupGuardMessage(fields.phone);
|
|
137
|
+
const blocked = getBlockedReason({
|
|
138
|
+
email: fields.email?.value ?? null,
|
|
139
|
+
phone: fields.phone?.value ?? null
|
|
140
|
+
});
|
|
141
|
+
if (!blocked) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
event.preventDefault();
|
|
145
|
+
event.stopImmediatePropagation();
|
|
146
|
+
showSignupGuardMessage(form, fields, blocked);
|
|
147
|
+
};
|
|
148
|
+
form.addEventListener("submit", onSubmit, FORM_LISTENER_OPTIONS);
|
|
149
|
+
protectedFormListeners.set(form, onSubmit);
|
|
150
|
+
attachedForms.push(form);
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
protectedForms: attachedForms.length,
|
|
154
|
+
destroy: () => {
|
|
155
|
+
for (const form of attachedForms) {
|
|
156
|
+
const listener = protectedFormListeners.get(form);
|
|
157
|
+
if (listener) {
|
|
158
|
+
form.removeEventListener("submit", listener, FORM_LISTENER_OPTIONS);
|
|
159
|
+
protectedFormListeners.delete(form);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function shouldProtectForm(form) {
|
|
166
|
+
return !hasFormAttribute(form, SIGNUP_GUARD_IGNORE_ATTRIBUTE) &&
|
|
167
|
+
(hasFormAttribute(form, SIGNUP_GUARD_FORM_ATTRIBUTE) || Boolean(detectSignupFields(form)));
|
|
168
|
+
}
|
|
169
|
+
function shouldWatchForm(form, intent) {
|
|
170
|
+
if (hasFormAttribute(form, SIGNUP_GUARD_IGNORE_ATTRIBUTE)) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
const explicitIntent = getFormAttribute(form, SIGNUP_GUARD_FORM_ATTRIBUTE);
|
|
174
|
+
if (explicitIntent !== null) {
|
|
175
|
+
return explicitIntent === "" || explicitIntent === "true" || explicitIntent === intent;
|
|
176
|
+
}
|
|
177
|
+
if (intent === "signup") {
|
|
178
|
+
return Boolean(detectSignupFields(form));
|
|
179
|
+
}
|
|
180
|
+
return formText(form).includes(intent.replace("_", " ")) ||
|
|
181
|
+
intentKeywords(intent).some((keyword) => formText(form).includes(keyword));
|
|
182
|
+
}
|
|
183
|
+
function shouldWatchActionForm(form, action) {
|
|
184
|
+
if (hasFormAttribute(form, SIGNUP_GUARD_IGNORE_ATTRIBUTE)) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
const explicitIntent = getFormAttribute(form, SIGNUP_GUARD_FORM_ATTRIBUTE);
|
|
188
|
+
if (explicitIntent !== null) {
|
|
189
|
+
return explicitIntent === "" || explicitIntent === "true" || explicitIntent === action;
|
|
190
|
+
}
|
|
191
|
+
const normalized = action.replace(/_/g, " ").toLowerCase();
|
|
192
|
+
return formText(form).includes(normalized) ||
|
|
193
|
+
intentKeywords(action).some((kw) => formText(form).includes(kw));
|
|
194
|
+
}
|
|
195
|
+
function hasFormAttribute(form, name) {
|
|
196
|
+
return typeof form.hasAttribute === "function" && form.hasAttribute(name);
|
|
197
|
+
}
|
|
198
|
+
function getFormAttribute(form, name) {
|
|
199
|
+
if (!hasFormAttribute(form, name) || typeof form.getAttribute !== "function") {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return form.getAttribute(name);
|
|
203
|
+
}
|
|
204
|
+
function formText(form) {
|
|
205
|
+
return [
|
|
206
|
+
form.getAttribute?.("id"),
|
|
207
|
+
form.getAttribute?.("name"),
|
|
208
|
+
form.getAttribute?.("aria-label"),
|
|
209
|
+
form.getAttribute?.(SIGNUP_GUARD_FORM_ATTRIBUTE),
|
|
210
|
+
form.textContent
|
|
211
|
+
]
|
|
212
|
+
.filter(Boolean)
|
|
213
|
+
.join(" ")
|
|
214
|
+
.toLowerCase();
|
|
215
|
+
}
|
|
216
|
+
function intentKeywords(intent) {
|
|
217
|
+
if (intent === "checkout")
|
|
218
|
+
return ["checkout", "payment", "billing", "card"];
|
|
219
|
+
if (intent === "demo_request")
|
|
220
|
+
return ["demo", "book a demo", "request demo"];
|
|
221
|
+
if (intent === "waitlist")
|
|
222
|
+
return ["waitlist", "join waitlist"];
|
|
223
|
+
if (intent === "lead_capture")
|
|
224
|
+
return ["lead", "contact", "work email"];
|
|
225
|
+
if (intent === "contact_sales")
|
|
226
|
+
return ["contact sales", "sales"];
|
|
227
|
+
if (intent === "invite")
|
|
228
|
+
return ["invite", "invitation"];
|
|
229
|
+
return ["signup", "sign up", "create account"];
|
|
230
|
+
}
|
|
231
|
+
function injectRiskField(form, risk) {
|
|
232
|
+
const doc = form.ownerDocument;
|
|
233
|
+
if (!doc)
|
|
234
|
+
return;
|
|
235
|
+
const FIELD_NAME = "signupGuardRisk";
|
|
236
|
+
let field = form.querySelector(`input[name="${FIELD_NAME}"]`);
|
|
237
|
+
if (!field) {
|
|
238
|
+
field = doc.createElement("input");
|
|
239
|
+
field.type = "hidden";
|
|
240
|
+
field.name = FIELD_NAME;
|
|
241
|
+
form.appendChild(field);
|
|
242
|
+
}
|
|
243
|
+
field.value = JSON.stringify(risk);
|
|
244
|
+
}
|
|
245
|
+
function detectSignupFields(form) {
|
|
246
|
+
const email = findEmailField(form);
|
|
247
|
+
const phone = findPhoneField(form);
|
|
248
|
+
if (!email && !phone) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
return { email, phone };
|
|
252
|
+
}
|
|
253
|
+
function findEmailField(form) {
|
|
254
|
+
return form.querySelector(EMAIL_FIELD_SELECTOR) ?? undefined;
|
|
255
|
+
}
|
|
256
|
+
function findPhoneField(form) {
|
|
257
|
+
const directMatch = form.querySelector(PHONE_FIELD_SELECTOR);
|
|
258
|
+
if (directMatch) {
|
|
259
|
+
return directMatch;
|
|
260
|
+
}
|
|
261
|
+
return Array.from(form.querySelectorAll("input")).find((field) => isPhoneField(field));
|
|
262
|
+
}
|
|
263
|
+
function isPhoneField(field) {
|
|
264
|
+
const text = fieldText(field);
|
|
265
|
+
return PHONE_FIELD_WORDS.some((word) => text.includes(word)) ||
|
|
266
|
+
PHONE_FIELD_PHRASES.some((phrase) => text.includes(phrase));
|
|
267
|
+
}
|
|
268
|
+
function fieldText(field) {
|
|
269
|
+
const labels = Array.from(field.labels ?? [], (label) => label.textContent ?? "");
|
|
270
|
+
return [
|
|
271
|
+
field.name,
|
|
272
|
+
field.id,
|
|
273
|
+
field.type,
|
|
274
|
+
field.autocomplete,
|
|
275
|
+
field.placeholder,
|
|
276
|
+
field.getAttribute("aria-label"),
|
|
277
|
+
...labels
|
|
278
|
+
]
|
|
279
|
+
.filter(Boolean)
|
|
280
|
+
.join(" ")
|
|
281
|
+
.toLowerCase();
|
|
282
|
+
}
|
|
283
|
+
function getBlockedReason(input) {
|
|
284
|
+
const email = String(input.email ?? "").trim().toLowerCase();
|
|
285
|
+
const phone = String(input.phone ?? "").trim();
|
|
286
|
+
if (email) {
|
|
287
|
+
if (!EMAIL_PATTERN.test(email)) {
|
|
288
|
+
return "invalid_email_format";
|
|
289
|
+
}
|
|
290
|
+
if (isDisposableEmail(email)) {
|
|
291
|
+
return "disposable_email";
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (!phone) {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
const digits = phone.replace(/\D/gu, "");
|
|
298
|
+
const phoneFormatReason = getPhoneFormatReason(phone, digits);
|
|
299
|
+
if (phoneFormatReason) {
|
|
300
|
+
return phoneFormatReason;
|
|
301
|
+
}
|
|
302
|
+
return getFakePhoneReason(digits);
|
|
303
|
+
}
|
|
304
|
+
function isDisposableEmail(email) {
|
|
305
|
+
const domain = String(email).trim().toLowerCase().split("@").pop();
|
|
306
|
+
return Boolean(domain && DISPOSABLE_EMAIL_DOMAINS.has(domain));
|
|
307
|
+
}
|
|
308
|
+
function getPhoneFormatReason(phone, digits) {
|
|
309
|
+
const trimmed = phone.trim();
|
|
310
|
+
if (!/^[+()\-.\s\d]+$/u.test(trimmed))
|
|
311
|
+
return "invalid_phone_format";
|
|
312
|
+
if (digits.length < 7)
|
|
313
|
+
return "phone_too_short";
|
|
314
|
+
if (digits.length > 15)
|
|
315
|
+
return "phone_too_long";
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
function getFakePhoneReason(digits) {
|
|
319
|
+
if (/^(\d)\1+$/u.test(digits))
|
|
320
|
+
return "repeated_digit_phone";
|
|
321
|
+
if (SEQUENTIAL_PHONE_PATTERN.test(digits))
|
|
322
|
+
return "sequential_phone";
|
|
323
|
+
if (/^55501\d{2}$/u.test(digits.slice(-7)) || /^555010\d$/u.test(digits.slice(-7)))
|
|
324
|
+
return "reserved_test_phone";
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
function showSignupGuardMessage(form, fields, reason) {
|
|
328
|
+
const field = reason.includes("email") ? fields.email : fields.phone;
|
|
329
|
+
const message = messageForReason(reason);
|
|
330
|
+
if (field && "setCustomValidity" in field && "reportValidity" in field) {
|
|
331
|
+
field.setCustomValidity(message);
|
|
332
|
+
field.reportValidity();
|
|
333
|
+
field.addEventListener("input", () => clearSignupGuardMessage(field), { once: true });
|
|
334
|
+
}
|
|
335
|
+
form.dispatchEvent(new CustomEvent("signupguard:block", { detail: { reason, message } }));
|
|
336
|
+
}
|
|
337
|
+
function clearSignupGuardMessage(field) {
|
|
338
|
+
field?.setCustomValidity("");
|
|
339
|
+
}
|
|
340
|
+
function messageForReason(reason) {
|
|
341
|
+
if (reason === "invalid_email_format") {
|
|
342
|
+
return "Please enter a valid email address.";
|
|
343
|
+
}
|
|
344
|
+
if (reason === "disposable_email") {
|
|
345
|
+
return "This email provider is not supported.";
|
|
346
|
+
}
|
|
347
|
+
return "Please enter a valid phone number.";
|
|
348
|
+
}
|
package/dist/risk.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type RiskReason = "velocity" | "spike" | "fast_action" | "retry" | "enumeration" | "endpoint_concentration" | "probing" | "flow_anomaly";
|
|
2
|
+
export type RiskLevel = "low" | "medium" | "high";
|
|
3
|
+
export type RiskResult = {
|
|
4
|
+
riskScore: number;
|
|
5
|
+
riskLevel: RiskLevel;
|
|
6
|
+
reasons: RiskReason[];
|
|
7
|
+
};
|
|
8
|
+
export declare function buildRiskResult(reasons: RiskReason[]): RiskResult;
|
|
9
|
+
//# sourceMappingURL=risk.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"risk.d.ts","sourceRoot":"","sources":["../src/risk.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,UAAU,GAClB,UAAU,GACV,OAAO,GACP,aAAa,GACb,OAAO,GACP,aAAa,GACb,wBAAwB,GACxB,SAAS,GACT,cAAc,CAAC;AAEnB,MAAM,MAAM,SAAS,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;AAElD,MAAM,MAAM,UAAU,GAAG;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,SAAS,CAAC;IACrB,OAAO,EAAE,UAAU,EAAE,CAAC;CACvB,CAAC;AAcF,wBAAgB,eAAe,CAAC,OAAO,EAAE,UAAU,EAAE,GAAG,UAAU,CAKjE"}
|
package/dist/risk.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Weights per spec: velocity +30, retry +25, fast_action +20
|
|
2
|
+
const REASON_SCORES = {
|
|
3
|
+
velocity: 30,
|
|
4
|
+
spike: 40,
|
|
5
|
+
fast_action: 20,
|
|
6
|
+
retry: 25,
|
|
7
|
+
enumeration: 50,
|
|
8
|
+
endpoint_concentration: 35,
|
|
9
|
+
probing: 40,
|
|
10
|
+
flow_anomaly: 30
|
|
11
|
+
};
|
|
12
|
+
export function buildRiskResult(reasons) {
|
|
13
|
+
const unique = [...new Set(reasons)];
|
|
14
|
+
const riskScore = Math.min(100, unique.reduce((sum, r) => sum + REASON_SCORES[r], 0));
|
|
15
|
+
const riskLevel = riskScore >= 70 ? "high" : riskScore >= 40 ? "medium" : "low";
|
|
16
|
+
return { riskScore, riskLevel, reasons: unique };
|
|
17
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type EngineOptions } from "./engine.js";
|
|
2
|
+
export type { RiskReason, RiskLevel, RiskResult } from "./risk.js";
|
|
3
|
+
type IncomingHeaders = Record<string, string | string[] | undefined>;
|
|
4
|
+
/** Request shape — compatible with Express, Next.js, and plain Node IncomingMessage. */
|
|
5
|
+
export type SignupGuardRequest = {
|
|
6
|
+
method?: string;
|
|
7
|
+
url?: string;
|
|
8
|
+
ip?: string;
|
|
9
|
+
headers?: IncomingHeaders;
|
|
10
|
+
signupGuardRisk?: import("./risk.js").RiskResult;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
};
|
|
13
|
+
export type WatchAccessOptions = EngineOptions & {
|
|
14
|
+
/** Max requests per velocityWindowMs before "velocity" fires. Default: 60. */
|
|
15
|
+
velocityThreshold?: number;
|
|
16
|
+
/** Max requests per 10 s before "spike" fires. Default: 20. */
|
|
17
|
+
spikeThreshold?: number;
|
|
18
|
+
/** Max requests to same normalised endpoint before "endpoint_concentration" fires. Default: 20. */
|
|
19
|
+
concThreshold?: number;
|
|
20
|
+
};
|
|
21
|
+
export type WatchAccessMiddleware = (req: SignupGuardRequest, res: unknown, next: () => void) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Express / Node middleware.
|
|
24
|
+
* Attaches req.signupGuardRisk = { riskScore, riskLevel, reasons } to every request.
|
|
25
|
+
*
|
|
26
|
+
* Detected signals (all O(1), in-memory, zero external calls):
|
|
27
|
+
* velocity — too many requests from this IP in the window
|
|
28
|
+
* spike — burst of requests in 10 s
|
|
29
|
+
* enumeration — sequential numeric IDs in URL path
|
|
30
|
+
* endpoint_concentration — same endpoint hit repeatedly (scraping)
|
|
31
|
+
* probing — high 4xx rate (directory / param probing)
|
|
32
|
+
* flow_anomaly — write request with no prior read (bot shortcut)
|
|
33
|
+
*
|
|
34
|
+
* Mount it narrowly: app.use("/api/checkout", watchAccess()) rather than globally.
|
|
35
|
+
*/
|
|
36
|
+
export declare function watchAccess(options?: WatchAccessOptions): WatchAccessMiddleware;
|
|
37
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAG/D,YAAY,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAEnE,KAAK,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;AAErE,wFAAwF;AACxF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,eAAe,CAAC;IAC1B,eAAe,CAAC,EAAE,OAAO,WAAW,EAAE,UAAU,CAAC;IACjD,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG,aAAa,GAAG;IAC/C,8EAA8E;IAC9E,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,+DAA+D;IAC/D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mGAAmG;IACnG,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG,CAClC,GAAG,EAAE,kBAAkB,EACvB,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,MAAM,IAAI,KACb,IAAI,CAAC;AAEV;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,OAAO,GAAE,kBAAuB,GAAG,qBAAqB,CAuBnF"}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createEngine } from "./engine.js";
|
|
2
|
+
import { buildRiskResult } from "./risk.js";
|
|
3
|
+
/**
|
|
4
|
+
* Express / Node middleware.
|
|
5
|
+
* Attaches req.signupGuardRisk = { riskScore, riskLevel, reasons } to every request.
|
|
6
|
+
*
|
|
7
|
+
* Detected signals (all O(1), in-memory, zero external calls):
|
|
8
|
+
* velocity — too many requests from this IP in the window
|
|
9
|
+
* spike — burst of requests in 10 s
|
|
10
|
+
* enumeration — sequential numeric IDs in URL path
|
|
11
|
+
* endpoint_concentration — same endpoint hit repeatedly (scraping)
|
|
12
|
+
* probing — high 4xx rate (directory / param probing)
|
|
13
|
+
* flow_anomaly — write request with no prior read (bot shortcut)
|
|
14
|
+
*
|
|
15
|
+
* Mount it narrowly: app.use("/api/checkout", watchAccess()) rather than globally.
|
|
16
|
+
*/
|
|
17
|
+
export function watchAccess(options = {}) {
|
|
18
|
+
const engine = createEngine(options);
|
|
19
|
+
return function watchAccessMiddleware(req, res, next) {
|
|
20
|
+
const ip = resolveIp(req);
|
|
21
|
+
const url = req.url ?? "";
|
|
22
|
+
const method = req.method ?? "GET";
|
|
23
|
+
const reasons = engine.track(ip, { url, method });
|
|
24
|
+
// Capture response status asynchronously for probing detection.
|
|
25
|
+
// Safe cast: a no-op when res has no 'on' method (plain object, tests).
|
|
26
|
+
const resObj = res;
|
|
27
|
+
resObj.on?.("finish", () => {
|
|
28
|
+
if (typeof resObj.statusCode === "number" && resObj.statusCode >= 400) {
|
|
29
|
+
engine.reportError(ip);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
req.signupGuardRisk = buildRiskResult(reasons);
|
|
33
|
+
next();
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function resolveIp(req) {
|
|
37
|
+
const forwarded = req.headers?.["x-forwarded-for"];
|
|
38
|
+
if (forwarded) {
|
|
39
|
+
return Array.isArray(forwarded) ? forwarded[0] : forwarded.split(",")[0].trim();
|
|
40
|
+
}
|
|
41
|
+
return typeof req.ip === "string" ? req.ip : "unknown";
|
|
42
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "apira-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Catches bots, abuse, and broken flows after Cloudflare, before PostHog.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./server": {
|
|
14
|
+
"types": "./dist/server.d.ts",
|
|
15
|
+
"import": "./dist/server.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"demo",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.json",
|
|
26
|
+
"test": "npm run build && node --test"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"apira-guard",
|
|
30
|
+
"signup",
|
|
31
|
+
"guard",
|
|
32
|
+
"forms",
|
|
33
|
+
"spam-prevention",
|
|
34
|
+
"email-validation",
|
|
35
|
+
"phone-validation",
|
|
36
|
+
"disposable-email"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"typescript": "^6.0.3"
|
|
44
|
+
}
|
|
45
|
+
}
|