@arcis/node 1.4.0 → 1.4.3

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.
Files changed (65) hide show
  1. package/README.md +1 -1
  2. package/dist/core/constants.d.ts +2 -2
  3. package/dist/core/constants.d.ts.map +1 -1
  4. package/dist/core/index.js +11 -3
  5. package/dist/core/index.js.map +1 -1
  6. package/dist/core/index.mjs +11 -3
  7. package/dist/core/index.mjs.map +1 -1
  8. package/dist/core/types.d.ts +6 -0
  9. package/dist/core/types.d.ts.map +1 -1
  10. package/dist/index.d.ts +4 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +527 -63
  13. package/dist/index.js.map +1 -1
  14. package/dist/index.mjs +525 -65
  15. package/dist/index.mjs.map +1 -1
  16. package/dist/logging/index.js.map +1 -1
  17. package/dist/logging/index.mjs.map +1 -1
  18. package/dist/middleware/bot-detection.d.ts.map +1 -1
  19. package/dist/middleware/cookies.d.ts.map +1 -1
  20. package/dist/middleware/csrf.d.ts +10 -0
  21. package/dist/middleware/csrf.d.ts.map +1 -1
  22. package/dist/middleware/hpp.d.ts.map +1 -1
  23. package/dist/middleware/index.d.ts +2 -0
  24. package/dist/middleware/index.d.ts.map +1 -1
  25. package/dist/middleware/index.js +671 -39
  26. package/dist/middleware/index.js.map +1 -1
  27. package/dist/middleware/index.mjs +671 -41
  28. package/dist/middleware/index.mjs.map +1 -1
  29. package/dist/middleware/main.d.ts.map +1 -1
  30. package/dist/middleware/rate-limit.d.ts.map +1 -1
  31. package/dist/middleware/signup-protection.d.ts +65 -0
  32. package/dist/middleware/signup-protection.d.ts.map +1 -0
  33. package/dist/middleware/telemetry.d.ts +36 -0
  34. package/dist/middleware/telemetry.d.ts.map +1 -0
  35. package/dist/sanitizers/encode.d.ts.map +1 -1
  36. package/dist/sanitizers/index.d.ts +1 -0
  37. package/dist/sanitizers/index.d.ts.map +1 -1
  38. package/dist/sanitizers/index.js +113 -37
  39. package/dist/sanitizers/index.js.map +1 -1
  40. package/dist/sanitizers/index.mjs +111 -38
  41. package/dist/sanitizers/index.mjs.map +1 -1
  42. package/dist/sanitizers/ldap.d.ts +42 -0
  43. package/dist/sanitizers/ldap.d.ts.map +1 -0
  44. package/dist/sanitizers/path.d.ts.map +1 -1
  45. package/dist/sanitizers/pii.d.ts.map +1 -1
  46. package/dist/sanitizers/sanitize.d.ts.map +1 -1
  47. package/dist/sanitizers/ssti.d.ts.map +1 -1
  48. package/dist/sanitizers/xxe.d.ts.map +1 -1
  49. package/dist/stores/index.js +21 -1
  50. package/dist/stores/index.js.map +1 -1
  51. package/dist/stores/index.mjs +21 -1
  52. package/dist/stores/index.mjs.map +1 -1
  53. package/dist/stores/memory.d.ts +4 -10
  54. package/dist/stores/memory.d.ts.map +1 -1
  55. package/dist/telemetry/client.d.ts +60 -0
  56. package/dist/telemetry/client.d.ts.map +1 -0
  57. package/dist/telemetry/index.d.ts +3 -0
  58. package/dist/telemetry/index.d.ts.map +1 -0
  59. package/dist/telemetry/types.d.ts +59 -0
  60. package/dist/telemetry/types.d.ts.map +1 -0
  61. package/dist/validation/index.js +41 -21
  62. package/dist/validation/index.js.map +1 -1
  63. package/dist/validation/index.mjs +41 -21
  64. package/dist/validation/index.mjs.map +1 -1
  65. package/package.json +8 -2
@@ -49,6 +49,9 @@ var XSS_REMOVE_PATTERNS = [
49
49
  /<script[^>]*>[\s\S]*?<\/script>/gi,
50
50
  /** Standalone/unclosed script tags */
51
51
  /<script[^>]*>/gi,
52
+ /** style — CSS expression() and behavior: attacks (IE-era but still relevant) */
53
+ /<style[^>]*>[\s\S]*?<\/style>/gi,
54
+ /<style[^>]*/gi,
52
55
  /** iframe — full block and partial/unclosed */
53
56
  /<iframe[^>]*>[\s\S]*?<\/iframe>/gi,
54
57
  /<iframe[^>]*/gi,
@@ -69,7 +72,15 @@ var XSS_REMOVE_PATTERNS = [
69
72
  /javascript\s*:/gi,
70
73
  /vbscript\s*:/gi,
71
74
  /** data: URIs with HTML/script content */
72
- /data\s*:\s*text\/html[^>\s]*/gi
75
+ /data\s*:\s*text\/html[^>\s]*/gi,
76
+ /** form tag injection — phishing via action= redirection */
77
+ /<form[\s>][^>]*/gi,
78
+ /** meta tag injection — http-equiv refresh or CSP bypass */
79
+ /<meta[\s>][^>]*/gi,
80
+ /** base href hijacking */
81
+ /<base[\s>][^>]*/gi,
82
+ /** link tag injection — stylesheet or preload attacks */
83
+ /<link[\s>][^>]*/gi
73
84
  ];
74
85
  var SQL_PATTERNS = [
75
86
  /** SQL keywords */
@@ -133,8 +144,8 @@ var COMMAND_PATTERNS = [
133
144
  /[;&|`]/g,
134
145
  /** Command substitution: $( ... ) — matched as a pair to reduce false positives */
135
146
  /\$\(/g,
136
- /** URL-encoded newline/carriage-return injection (%0a, %0d) */
137
- /%0[ad]/gi
147
+ /** URL-encoded control characters (%00-%0F): null, tab, vtab, formfeed, LF, CR */
148
+ /%0[0-9a-f]/gi
138
149
  ];
139
150
  var DANGEROUS_PROTO_KEYS = /* @__PURE__ */ new Set([
140
151
  "__proto__",
@@ -353,13 +364,13 @@ function createRateLimiter(options = {}) {
353
364
  statusCode = RATE_LIMIT.DEFAULT_STATUS_CODE,
354
365
  keyGenerator = (req) => {
355
366
  const ip = req.ip ?? req.socket?.remoteAddress;
356
- if (!ip) {
357
- console.warn(
358
- "[arcis] Rate limiter: cannot resolve client IP. All unresolvable clients share one counter. Set Express trust proxy if behind a reverse proxy."
359
- );
360
- return "unknown";
361
- }
362
- return ip;
367
+ if (ip) return ip;
368
+ const ua = req.headers["user-agent"] ?? "";
369
+ const lang = req.headers["accept-language"] ?? "";
370
+ const fp = `${ua}|${lang}`;
371
+ let hash = 0;
372
+ for (let i = 0; i < fp.length; i++) hash = (hash << 5) - hash + fp.charCodeAt(i) | 0;
373
+ return `unknown:${hash.toString(36)}`;
363
374
  },
364
375
  skip,
365
376
  store: externalStore
@@ -422,7 +433,24 @@ function createRateLimiter(options = {}) {
422
433
  }
423
434
  next();
424
435
  } catch (error) {
425
- console.error("[arcis] Rate limiter error:", error);
436
+ console.error("[arcis] Rate limiter store error, using in-memory fallback:", error);
437
+ try {
438
+ const key = keyGenerator(req);
439
+ const now = Date.now();
440
+ if (!inMemoryStore[key] || inMemoryStore[key].resetTime < now) {
441
+ inMemoryStore[key] = { count: 1, resetTime: now + windowMs };
442
+ } else {
443
+ inMemoryStore[key].count++;
444
+ }
445
+ const count = inMemoryStore[key].count;
446
+ if (count > max) {
447
+ const resetSeconds = Math.ceil((inMemoryStore[key].resetTime - now) / 1e3);
448
+ res.setHeader("Retry-After", resetSeconds.toString());
449
+ res.status(statusCode).json({ error: message, retryAfter: resetSeconds });
450
+ return;
451
+ }
452
+ } catch {
453
+ }
426
454
  next();
427
455
  }
428
456
  };
@@ -534,6 +562,80 @@ var SecurityThreatError = class extends ArcisError {
534
562
  }
535
563
  };
536
564
 
565
+ // src/middleware/telemetry.ts
566
+ var THREAT_TO_VECTOR = {
567
+ xss: "xss",
568
+ sql_injection: "sql",
569
+ nosql_injection: "nosql",
570
+ path_traversal: "path",
571
+ command_injection: "command",
572
+ prototype_pollution: "prototype",
573
+ header_injection: "header",
574
+ ssti: "ssti",
575
+ xxe: "xxe"
576
+ };
577
+ function createTelemetryEmitter(client) {
578
+ return (req, res, next) => {
579
+ const start = performance.now();
580
+ res.on("finish", () => {
581
+ try {
582
+ const event = buildEvent(req, res.statusCode, performance.now() - start);
583
+ client.record(event);
584
+ } catch {
585
+ }
586
+ });
587
+ next();
588
+ };
589
+ }
590
+ function tapSanitizerThreats(handler) {
591
+ return (req, res, next) => {
592
+ handler(req, res, (err) => {
593
+ if (err instanceof SecurityThreatError) {
594
+ const vector = THREAT_TO_VECTOR[err.threatType] ?? err.threatType;
595
+ req.__arcis = {
596
+ vector,
597
+ rule: `${vector}/match`,
598
+ severity: "high",
599
+ matchedPattern: err.pattern,
600
+ reason: err.message,
601
+ decision: "deny"
602
+ };
603
+ }
604
+ next(err);
605
+ });
606
+ };
607
+ }
608
+ function buildEvent(req, status, latencyMs) {
609
+ const marker = req.__arcis;
610
+ const decision = marker?.decision ?? inferDecision(status);
611
+ return {
612
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
613
+ ip: extractIp(req),
614
+ method: (req.method ?? "GET").toUpperCase(),
615
+ path: req.path ?? req.url ?? "/",
616
+ decision,
617
+ vector: marker?.vector ?? (status === 429 ? "rate-limit" : void 0),
618
+ rule: marker?.rule ?? (status === 429 ? "rate-limit/exceeded" : void 0),
619
+ severity: marker?.severity ?? (status === 429 ? "medium" : void 0),
620
+ userAgent: typeof req.headers?.["user-agent"] === "string" ? req.headers["user-agent"] : "",
621
+ reason: marker?.reason,
622
+ status,
623
+ matchedPattern: marker?.matchedPattern,
624
+ latencyMs: Math.max(0, latencyMs)
625
+ };
626
+ }
627
+ function inferDecision(status) {
628
+ if (status === 429) return "deny";
629
+ if (status === 400) return "deny";
630
+ if (status === 403) return "deny";
631
+ return "allow";
632
+ }
633
+ function extractIp(req) {
634
+ if (typeof req.ip === "string" && req.ip.length > 0) return req.ip;
635
+ const remote = req.socket?.remoteAddress;
636
+ return typeof remote === "string" ? remote : "0.0.0.0";
637
+ }
638
+
537
639
  // src/sanitizers/utils.ts
538
640
  function encodeHtmlEntities(str) {
539
641
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
@@ -632,26 +734,31 @@ function sanitizePath(input, collectThreats = false) {
632
734
  const threats = [];
633
735
  let value = input;
634
736
  let wasSanitized = false;
635
- for (const pattern of PATH_PATTERNS) {
636
- pattern.lastIndex = 0;
637
- if (pattern.test(value)) {
737
+ value = value.normalize("NFKC");
738
+ let prev;
739
+ do {
740
+ prev = value;
741
+ for (const pattern of PATH_PATTERNS) {
638
742
  pattern.lastIndex = 0;
639
- if (collectThreats) {
640
- const matches = value.match(pattern);
641
- if (matches) {
642
- for (const match of matches) {
643
- threats.push({
644
- type: "path_traversal",
645
- pattern: pattern.source,
646
- original: match
647
- });
743
+ if (pattern.test(value)) {
744
+ pattern.lastIndex = 0;
745
+ if (collectThreats) {
746
+ const matches = value.match(pattern);
747
+ if (matches) {
748
+ for (const match of matches) {
749
+ threats.push({
750
+ type: "path_traversal",
751
+ pattern: pattern.source,
752
+ original: match
753
+ });
754
+ }
648
755
  }
649
756
  }
757
+ value = value.replace(pattern, "");
758
+ wasSanitized = true;
650
759
  }
651
- value = value.replace(pattern, "");
652
- wasSanitized = true;
653
760
  }
654
- }
761
+ } while (value !== prev);
655
762
  if (collectThreats) {
656
763
  return { value, wasSanitized, threats };
657
764
  }
@@ -709,7 +816,7 @@ function sanitizeString(value, options = {}) {
709
816
  if (value.length > maxSize) {
710
817
  throw new InputTooLargeError(maxSize, value.length);
711
818
  }
712
- const reject = options.mode !== "sanitize";
819
+ const reject = options.mode === "reject";
713
820
  let result = value;
714
821
  if (options.sql !== false) {
715
822
  if (reject) {
@@ -963,6 +1070,238 @@ function validateField(field, value, rules) {
963
1070
  // Audio/Video
964
1071
  "audio/mpeg": [Buffer.from([255, 251]), Buffer.from([255, 243]), Buffer.from([73, 68, 51])]});
965
1072
 
1073
+ // src/validation/email.ts
1074
+ var MAX_EMAIL_LENGTH = 254;
1075
+ var MAX_LOCAL_LENGTH = 64;
1076
+ var MAX_DOMAIN_LENGTH = 255;
1077
+ var EMAIL_SYNTAX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/;
1078
+ var FREE_PROVIDERS = /* @__PURE__ */ new Set([
1079
+ "gmail.com",
1080
+ "yahoo.com",
1081
+ "hotmail.com",
1082
+ "outlook.com",
1083
+ "aol.com",
1084
+ "protonmail.com",
1085
+ "proton.me",
1086
+ "icloud.com",
1087
+ "mail.com",
1088
+ "zoho.com",
1089
+ "yandex.com",
1090
+ "gmx.com",
1091
+ "gmx.net",
1092
+ "live.com",
1093
+ "msn.com",
1094
+ "me.com",
1095
+ "mac.com",
1096
+ "fastmail.com",
1097
+ "tutanota.com",
1098
+ "hey.com"
1099
+ ]);
1100
+ var DISPOSABLE_DOMAINS = /* @__PURE__ */ new Set([
1101
+ // Popular disposable services
1102
+ "guerrillamail.com",
1103
+ "guerrillamail.net",
1104
+ "guerrillamail.org",
1105
+ "tempmail.com",
1106
+ "temp-mail.org",
1107
+ "temp-mail.io",
1108
+ "throwaway.email",
1109
+ "throwaway.com",
1110
+ "mailinator.com",
1111
+ "mailinator.net",
1112
+ "yopmail.com",
1113
+ "yopmail.fr",
1114
+ "yopmail.net",
1115
+ "sharklasers.com",
1116
+ "grr.la",
1117
+ "guerrillamail.info",
1118
+ "guerrillamail.biz",
1119
+ "guerrillamail.de",
1120
+ "trashmail.com",
1121
+ "trashmail.me",
1122
+ "trashmail.net",
1123
+ "dispostable.com",
1124
+ "maildrop.cc",
1125
+ "mailnesia.com",
1126
+ "tempail.com",
1127
+ "mohmal.com",
1128
+ "getnada.com",
1129
+ "emailondeck.com",
1130
+ "discard.email",
1131
+ "fakeinbox.com",
1132
+ "mailcatch.com",
1133
+ "mintemail.com",
1134
+ "tempr.email",
1135
+ "tempinbox.com",
1136
+ "burnermail.io",
1137
+ "mailsac.com",
1138
+ "harakirimail.com",
1139
+ "tempmailo.com",
1140
+ "emailfake.com",
1141
+ "crazymailing.com",
1142
+ "armyspy.com",
1143
+ "dayrep.com",
1144
+ "einrot.com",
1145
+ "fleckens.hu",
1146
+ "gustr.com",
1147
+ "jourrapide.com",
1148
+ "rhyta.com",
1149
+ "superrito.com",
1150
+ "teleworm.us",
1151
+ "10minutemail.com",
1152
+ "10minutemail.net",
1153
+ "minutemail.com",
1154
+ "tempsky.com",
1155
+ "spamgourmet.com",
1156
+ "mytrashmail.com",
1157
+ "mailexpire.com",
1158
+ "safetymail.info",
1159
+ "filzmail.com",
1160
+ "trashymail.com",
1161
+ "sharkmail.com",
1162
+ "jetable.org",
1163
+ "nospam.ze.tc",
1164
+ "trash-me.com",
1165
+ "dodgit.com",
1166
+ "mailmoat.com",
1167
+ "spamfree24.org",
1168
+ "incognitomail.org",
1169
+ "tempomail.fr",
1170
+ "ephemail.net",
1171
+ "hidemail.de",
1172
+ "spaml.de",
1173
+ "uggsrock.com",
1174
+ "binkmail.com",
1175
+ "suremail.info",
1176
+ "bugmenot.com"
1177
+ ]);
1178
+ var DOMAIN_TYPOS = {
1179
+ "gmial.com": "gmail.com",
1180
+ "gmaill.com": "gmail.com",
1181
+ "gmai.com": "gmail.com",
1182
+ "gamil.com": "gmail.com",
1183
+ "gnail.com": "gmail.com",
1184
+ "gmal.com": "gmail.com",
1185
+ "gmil.com": "gmail.com",
1186
+ "gmail.co": "gmail.com",
1187
+ "gmail.cm": "gmail.com",
1188
+ "gmail.om": "gmail.com",
1189
+ "gmail.con": "gmail.com",
1190
+ "gmail.cim": "gmail.com",
1191
+ "gmail.comm": "gmail.com",
1192
+ "yahooo.com": "yahoo.com",
1193
+ "yaho.com": "yahoo.com",
1194
+ "yahoo.co": "yahoo.com",
1195
+ "yahoo.cm": "yahoo.com",
1196
+ "yahoo.con": "yahoo.com",
1197
+ "yahho.com": "yahoo.com",
1198
+ "hotmial.com": "hotmail.com",
1199
+ "hotmal.com": "hotmail.com",
1200
+ "hotmai.com": "hotmail.com",
1201
+ "hotmil.com": "hotmail.com",
1202
+ "hotmail.co": "hotmail.com",
1203
+ "hotmail.cm": "hotmail.com",
1204
+ "hotmail.con": "hotmail.com",
1205
+ "outlok.com": "outlook.com",
1206
+ "outloo.com": "outlook.com",
1207
+ "outlook.co": "outlook.com",
1208
+ "outlook.cm": "outlook.com",
1209
+ "protonmal.com": "protonmail.com",
1210
+ "protonmail.co": "protonmail.com",
1211
+ "icloud.co": "icloud.com",
1212
+ "icloud.cm": "icloud.com",
1213
+ "icoud.com": "icloud.com"
1214
+ };
1215
+ function invalidResult(reason, email) {
1216
+ return {
1217
+ valid: false,
1218
+ reason,
1219
+ suggestion: null,
1220
+ isFree: false,
1221
+ isDisposable: false,
1222
+ normalized: email
1223
+ };
1224
+ }
1225
+ function validateEmail(email, options = {}) {
1226
+ const {
1227
+ checkDisposable = true,
1228
+ suggestTypoFix = true,
1229
+ blockedDomains = [],
1230
+ allowedDomains = []
1231
+ } = options;
1232
+ const normalized = email.trim().toLowerCase();
1233
+ if (!normalized || normalized.length > MAX_EMAIL_LENGTH) {
1234
+ return invalidResult("invalid_syntax", normalized);
1235
+ }
1236
+ const atIndex = normalized.lastIndexOf("@");
1237
+ if (atIndex === -1) {
1238
+ return invalidResult("invalid_syntax", normalized);
1239
+ }
1240
+ const localPart = normalized.slice(0, atIndex);
1241
+ const domain = normalized.slice(atIndex + 1);
1242
+ if (localPart.length === 0 || localPart.length > MAX_LOCAL_LENGTH) {
1243
+ return invalidResult("invalid_syntax", normalized);
1244
+ }
1245
+ if (domain.length === 0 || domain.length > MAX_DOMAIN_LENGTH) {
1246
+ return invalidResult("invalid_syntax", normalized);
1247
+ }
1248
+ if (localPart.includes("..")) {
1249
+ return invalidResult("invalid_syntax", normalized);
1250
+ }
1251
+ if (localPart.startsWith(".") || localPart.endsWith(".")) {
1252
+ return invalidResult("invalid_syntax", normalized);
1253
+ }
1254
+ if (!EMAIL_SYNTAX.test(normalized)) {
1255
+ return invalidResult("invalid_syntax", normalized);
1256
+ }
1257
+ const allowedSet = new Set(allowedDomains.map((d) => d.toLowerCase()));
1258
+ if (allowedSet.has(domain)) {
1259
+ return {
1260
+ valid: true,
1261
+ reason: "valid",
1262
+ suggestion: null,
1263
+ isFree: FREE_PROVIDERS.has(domain),
1264
+ isDisposable: false,
1265
+ normalized
1266
+ };
1267
+ }
1268
+ const blockedSet = new Set(blockedDomains.map((d) => d.toLowerCase()));
1269
+ if (blockedSet.has(domain)) {
1270
+ return invalidResult("blocked", normalized);
1271
+ }
1272
+ const isDisposable = DISPOSABLE_DOMAINS.has(domain);
1273
+ if (checkDisposable && isDisposable) {
1274
+ return {
1275
+ valid: false,
1276
+ reason: "disposable",
1277
+ suggestion: null,
1278
+ isFree: false,
1279
+ isDisposable: true,
1280
+ normalized
1281
+ };
1282
+ }
1283
+ const isFree = FREE_PROVIDERS.has(domain);
1284
+ if (suggestTypoFix && DOMAIN_TYPOS[domain]) {
1285
+ const corrected = `${localPart}@${DOMAIN_TYPOS[domain]}`;
1286
+ return {
1287
+ valid: true,
1288
+ reason: "typo",
1289
+ suggestion: corrected,
1290
+ isFree: FREE_PROVIDERS.has(DOMAIN_TYPOS[domain]),
1291
+ isDisposable: false,
1292
+ normalized
1293
+ };
1294
+ }
1295
+ return {
1296
+ valid: true,
1297
+ reason: "valid",
1298
+ suggestion: null,
1299
+ isFree,
1300
+ isDisposable,
1301
+ normalized
1302
+ };
1303
+ }
1304
+
966
1305
  // src/logging/redactor.ts
967
1306
  var LOG_LEVELS = {
968
1307
  debug: 0,
@@ -1035,10 +1374,192 @@ function redactString(str, maxLength, patterns) {
1035
1374
  return safe;
1036
1375
  }
1037
1376
 
1377
+ // src/telemetry/client.ts
1378
+ var DEFAULT_BATCH_SIZE = 50;
1379
+ var MAX_BATCH_SIZE = 500;
1380
+ var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
1381
+ var MIN_FLUSH_INTERVAL_MS = 500;
1382
+ var FLUSH_TIMEOUT_MS = 1e4;
1383
+ var TelemetryClient = class {
1384
+ constructor(options) {
1385
+ this.queue = [];
1386
+ this.flushing = false;
1387
+ this.closed = false;
1388
+ if (!options.endpoint || typeof options.endpoint !== "string") {
1389
+ throw new TypeError("TelemetryClient: `endpoint` is required");
1390
+ }
1391
+ this.endpoint = options.endpoint;
1392
+ this.apiKey = options.apiKey;
1393
+ this.workspaceId = options.workspaceId;
1394
+ this.batchSize = clamp(
1395
+ options.batchSize ?? DEFAULT_BATCH_SIZE,
1396
+ 1,
1397
+ MAX_BATCH_SIZE
1398
+ );
1399
+ this.flushIntervalMs = Math.max(
1400
+ options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
1401
+ MIN_FLUSH_INTERVAL_MS
1402
+ );
1403
+ this.onError = options.onError ?? (() => {
1404
+ });
1405
+ this.startTimer();
1406
+ }
1407
+ /**
1408
+ * Enqueue an event. Fast, synchronous, cannot throw.
1409
+ * Triggers a flush if the queue has reached `batchSize`.
1410
+ */
1411
+ record(event) {
1412
+ if (this.closed) return;
1413
+ this.queue.push(event);
1414
+ if (this.queue.length >= this.batchSize) {
1415
+ void this.flush();
1416
+ }
1417
+ }
1418
+ /**
1419
+ * Manually flush the queue. Pulls up to `batchSize` events into a batch and
1420
+ * POSTs them. Returns a resolved promise on success OR on handled failure.
1421
+ * Never throws.
1422
+ */
1423
+ async flush() {
1424
+ if (this.flushing) return;
1425
+ if (this.queue.length === 0) return;
1426
+ this.flushing = true;
1427
+ try {
1428
+ const batch = this.queue.splice(0, this.batchSize);
1429
+ await this.send(batch);
1430
+ } catch (err) {
1431
+ this.safeNotify(err);
1432
+ } finally {
1433
+ this.flushing = false;
1434
+ }
1435
+ if (!this.closed && this.queue.length > 0) {
1436
+ void this.flush();
1437
+ }
1438
+ }
1439
+ /**
1440
+ * Shut down: stop the interval timer and attempt one final flush.
1441
+ * Safe to call multiple times.
1442
+ */
1443
+ async close() {
1444
+ if (this.closed) return;
1445
+ this.closed = true;
1446
+ if (this.timer !== void 0) {
1447
+ clearInterval(this.timer);
1448
+ this.timer = void 0;
1449
+ }
1450
+ if (this.signalHandler !== void 0) {
1451
+ process.off("SIGTERM", this.signalHandler);
1452
+ process.off("SIGINT", this.signalHandler);
1453
+ this.signalHandler = void 0;
1454
+ }
1455
+ try {
1456
+ await this.flush();
1457
+ } catch {
1458
+ }
1459
+ }
1460
+ /**
1461
+ * Register `SIGTERM` / `SIGINT` handlers that call `close()` to drain
1462
+ * the queue on graceful shutdown. Opt-in — libraries should not silently
1463
+ * attach global signal handlers. Safe to call multiple times.
1464
+ */
1465
+ installShutdownHooks() {
1466
+ if (this.signalHandler !== void 0 || this.closed) return;
1467
+ const handler = () => {
1468
+ void this.close();
1469
+ };
1470
+ this.signalHandler = handler;
1471
+ process.once("SIGTERM", handler);
1472
+ process.once("SIGINT", handler);
1473
+ }
1474
+ /** Count of events currently waiting to be sent. Useful for tests. */
1475
+ get pendingCount() {
1476
+ return this.queue.length;
1477
+ }
1478
+ // ── internals ─────────────────────────────────────────────────────────
1479
+ async send(batch) {
1480
+ const headers = {
1481
+ "content-type": "application/json"
1482
+ };
1483
+ if (this.apiKey) headers["authorization"] = `Bearer ${this.apiKey}`;
1484
+ if (this.workspaceId) headers["x-workspace-id"] = this.workspaceId;
1485
+ const controller = new AbortController();
1486
+ const abortTimer = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
1487
+ try {
1488
+ const res = await fetch(this.endpoint, {
1489
+ method: "POST",
1490
+ headers,
1491
+ body: JSON.stringify({ events: batch }),
1492
+ signal: controller.signal
1493
+ });
1494
+ if (!res.ok) {
1495
+ const text = await safeReadBody(res);
1496
+ throw new TelemetryHttpError(res.status, text);
1497
+ }
1498
+ } finally {
1499
+ clearTimeout(abortTimer);
1500
+ }
1501
+ }
1502
+ startTimer() {
1503
+ this.timer = setInterval(() => {
1504
+ void this.flush();
1505
+ }, this.flushIntervalMs);
1506
+ this.timer.unref?.();
1507
+ }
1508
+ safeNotify(err) {
1509
+ try {
1510
+ this.onError(err instanceof Error ? err : new Error(String(err)));
1511
+ } catch {
1512
+ }
1513
+ }
1514
+ };
1515
+ var TelemetryHttpError = class extends Error {
1516
+ constructor(status, responseBody) {
1517
+ super(`Telemetry ingest returned HTTP ${status}`);
1518
+ this.status = status;
1519
+ this.responseBody = responseBody;
1520
+ this.name = "TelemetryHttpError";
1521
+ }
1522
+ };
1523
+ function clamp(value, min, max) {
1524
+ if (!Number.isFinite(value)) return min;
1525
+ return Math.max(min, Math.min(max, Math.trunc(value)));
1526
+ }
1527
+ async function safeReadBody(res) {
1528
+ try {
1529
+ const text = await res.text();
1530
+ return text.slice(0, 500);
1531
+ } catch {
1532
+ return "";
1533
+ }
1534
+ }
1535
+
1038
1536
  // src/middleware/main.ts
1537
+ function buildTelemetryFromEnv() {
1538
+ const env = typeof process !== "undefined" ? process.env : void 0;
1539
+ const endpoint = env?.ARCIS_ENDPOINT;
1540
+ if (!endpoint) return void 0;
1541
+ const opts = { endpoint };
1542
+ if (env?.ARCIS_WORKSPACE_ID) opts.workspaceId = env.ARCIS_WORKSPACE_ID;
1543
+ if (env?.ARCIS_KEY) opts.apiKey = env.ARCIS_KEY;
1544
+ const batch = env?.ARCIS_BATCH_SIZE ? parseInt(env.ARCIS_BATCH_SIZE, 10) : NaN;
1545
+ if (!Number.isNaN(batch)) opts.batchSize = batch;
1546
+ const flush = env?.ARCIS_FLUSH_INTERVAL_MS ? parseInt(env.ARCIS_FLUSH_INTERVAL_MS, 10) : NaN;
1547
+ if (!Number.isNaN(flush)) opts.flushIntervalMs = flush;
1548
+ return opts;
1549
+ }
1039
1550
  function arcis(options = {}) {
1040
1551
  const middlewares = [];
1041
1552
  const cleanupFns = [];
1553
+ let telemetryClient;
1554
+ const telemetryOpts = options.telemetry?.endpoint ? options.telemetry : buildTelemetryFromEnv();
1555
+ if (telemetryOpts) {
1556
+ const client = new TelemetryClient(telemetryOpts);
1557
+ telemetryClient = client;
1558
+ middlewares.push(createTelemetryEmitter(client));
1559
+ cleanupFns.push(() => {
1560
+ void client.close();
1561
+ });
1562
+ }
1042
1563
  if (options.headers !== false) {
1043
1564
  const headerOpts = typeof options.headers === "object" ? options.headers : {};
1044
1565
  middlewares.push(createHeaders(headerOpts));
@@ -1051,7 +1572,8 @@ function arcis(options = {}) {
1051
1572
  }
1052
1573
  if (options.sanitize !== false) {
1053
1574
  const sanitizeOpts = typeof options.sanitize === "object" ? options.sanitize : {};
1054
- middlewares.push(createSanitizer(sanitizeOpts));
1575
+ const sanitizer = createSanitizer(sanitizeOpts);
1576
+ middlewares.push(telemetryClient ? tapSanitizerThreats(sanitizer) : sanitizer);
1055
1577
  }
1056
1578
  const result = middlewares;
1057
1579
  result.close = () => {
@@ -1364,6 +1886,16 @@ function secureCookieDefaults(options = {}) {
1364
1886
  sameSite: options.sameSite ?? "Lax",
1365
1887
  path: options.path
1366
1888
  };
1889
+ if (resolved.sameSite === "None" && resolved.secure === false) {
1890
+ throw new Error(
1891
+ "[arcis] secureCookieDefaults: sameSite=None requires secure=true (modern browsers reject the cookie otherwise)"
1892
+ );
1893
+ }
1894
+ if (resolved.httpOnly === false && resolved.secure === false && isProduction) {
1895
+ console.warn(
1896
+ "[arcis] secureCookieDefaults: httpOnly and secure are both disabled in production \u2014 cookies will be readable by JS and sent over HTTP"
1897
+ );
1898
+ }
1367
1899
  return (_req, res, next) => {
1368
1900
  const originalSetHeader = res.setHeader.bind(res);
1369
1901
  res.setHeader = function patchedSetHeader(name, value) {
@@ -1496,7 +2028,16 @@ function detectBehavioralSignals(req) {
1496
2028
  }
1497
2029
  function detectBot(req) {
1498
2030
  const rawUa = req.headers["user-agent"] ?? "";
1499
- const ua = rawUa.length > 2048 ? rawUa.slice(0, 2048) : rawUa;
2031
+ if (rawUa.length > 2048) {
2032
+ return {
2033
+ isBot: true,
2034
+ category: "UNKNOWN",
2035
+ name: null,
2036
+ confidence: 0.9,
2037
+ signals: detectBehavioralSignals(req)
2038
+ };
2039
+ }
2040
+ const ua = rawUa;
1500
2041
  const signals = detectBehavioralSignals(req);
1501
2042
  if (!ua) {
1502
2043
  return {
@@ -1586,11 +2127,9 @@ function generateCsrfToken(length = 32) {
1586
2127
  function validateCsrfToken(cookieToken, requestToken) {
1587
2128
  if (!cookieToken || !requestToken) return false;
1588
2129
  if (cookieToken.length !== requestToken.length) return false;
1589
- let result = 0;
1590
- for (let i = 0; i < cookieToken.length; i++) {
1591
- result |= cookieToken.charCodeAt(i) ^ requestToken.charCodeAt(i);
1592
- }
1593
- return result === 0;
2130
+ const a = Buffer.from(cookieToken);
2131
+ const b = Buffer.from(requestToken);
2132
+ return crypto.timingSafeEqual(a, b);
1594
2133
  }
1595
2134
  function getRequestToken(req, headerName, fieldName) {
1596
2135
  const headerToken = req.headers[headerName.toLowerCase()];
@@ -1599,10 +2138,6 @@ function getRequestToken(req, headerName, fieldName) {
1599
2138
  const bodyToken = req.body[fieldName];
1600
2139
  if (typeof bodyToken === "string" && bodyToken) return bodyToken;
1601
2140
  }
1602
- if (req.query && fieldName in req.query) {
1603
- const queryToken = req.query[fieldName];
1604
- if (typeof queryToken === "string" && queryToken) return queryToken;
1605
- }
1606
2141
  return void 0;
1607
2142
  }
1608
2143
  function csrfProtection(options = {}) {
@@ -1666,6 +2201,10 @@ function csrfProtection(options = {}) {
1666
2201
  if (!validateCsrfToken(cookieToken, requestToken)) {
1667
2202
  return onError(req, res, next);
1668
2203
  }
2204
+ if (options.rotateOnUse) {
2205
+ const freshToken = generateCsrfToken(tokenLength);
2206
+ setCsrfCookie(res, cookieName, freshToken, cookieOpts);
2207
+ }
1669
2208
  next();
1670
2209
  };
1671
2210
  }
@@ -1685,16 +2224,108 @@ function setCsrfCookie(res, name, token, opts) {
1685
2224
  if (opts.secure) parts.push("Secure");
1686
2225
  parts.push(`SameSite=${opts.sameSite}`);
1687
2226
  if (opts.domain) parts.push(`Domain=${opts.domain}`);
1688
- res.setHeader("Set-Cookie", parts.join("; "));
2227
+ const newCookie = parts.join("; ");
2228
+ const existing = res.getHeader("Set-Cookie");
2229
+ if (existing === void 0) {
2230
+ res.setHeader("Set-Cookie", newCookie);
2231
+ } else if (Array.isArray(existing)) {
2232
+ res.setHeader("Set-Cookie", [...existing, newCookie]);
2233
+ } else {
2234
+ res.setHeader("Set-Cookie", [existing, newCookie]);
2235
+ }
1689
2236
  }
1690
2237
  function escapeRegex(str) {
1691
2238
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1692
2239
  }
1693
2240
  var createCsrf = csrfProtection;
1694
2241
 
2242
+ // src/middleware/signup-protection.ts
2243
+ function checkSignup(req, options = {}) {
2244
+ const {
2245
+ emailField = "email",
2246
+ checkEmail = true,
2247
+ blockDisposable = true,
2248
+ checkBot = true,
2249
+ allowedBotCategories = [],
2250
+ allowedEmailDomains = [],
2251
+ blockedEmailDomains = []
2252
+ } = options;
2253
+ if (checkBot) {
2254
+ const bot = detectBot(req);
2255
+ if (bot.isBot && !allowedBotCategories.includes(bot.category)) {
2256
+ return {
2257
+ allowed: false,
2258
+ reason: "bot",
2259
+ details: { category: bot.category, name: bot.name, confidence: bot.confidence }
2260
+ };
2261
+ }
2262
+ }
2263
+ if (checkEmail) {
2264
+ const email = req.body?.[emailField];
2265
+ if (typeof email !== "string" || email.length === 0) {
2266
+ return { allowed: false, reason: "missing_email" };
2267
+ }
2268
+ const result = validateEmail(email, {
2269
+ checkDisposable: blockDisposable,
2270
+ allowedDomains: allowedEmailDomains,
2271
+ blockedDomains: blockedEmailDomains
2272
+ });
2273
+ if (!result.valid) {
2274
+ const reason = result.reason === "disposable" ? "disposable_email" : "invalid_email";
2275
+ return { allowed: false, reason, details: { emailReason: result.reason } };
2276
+ }
2277
+ }
2278
+ return { allowed: true, reason: "ok" };
2279
+ }
2280
+ function signupProtection(options = {}) {
2281
+ const rateLimitCfg = options.rateLimit;
2282
+ const limiter = rateLimitCfg === false ? null : createRateLimiter({
2283
+ max: rateLimitCfg?.max ?? 5,
2284
+ windowMs: rateLimitCfg?.windowMs ?? 6e4,
2285
+ message: "Too many signup attempts"
2286
+ });
2287
+ const handler = (req, res, next) => {
2288
+ const result = checkSignup(req, options);
2289
+ if (!result.allowed) {
2290
+ options.onBlocked?.(req, result);
2291
+ const status = result.reason === "bot" ? 403 : 400;
2292
+ res.status(status).json({ error: "signup_blocked", reason: result.reason });
2293
+ return;
2294
+ }
2295
+ if (limiter) {
2296
+ let rateLimited = false;
2297
+ const rateLimitNext = (err) => {
2298
+ if (err) return next(err);
2299
+ if (!rateLimited) next();
2300
+ };
2301
+ const patchedRes = new Proxy(res, {
2302
+ get(target, prop) {
2303
+ if (prop === "status") {
2304
+ return (code) => {
2305
+ if (code === 429) {
2306
+ rateLimited = true;
2307
+ options.onBlocked?.(req, { allowed: false, reason: "rate_limited" });
2308
+ }
2309
+ return target.status.call(target, code);
2310
+ };
2311
+ }
2312
+ return Reflect.get(target, prop);
2313
+ }
2314
+ });
2315
+ limiter(req, patchedRes, rateLimitNext);
2316
+ return;
2317
+ }
2318
+ next();
2319
+ };
2320
+ const middleware = handler;
2321
+ middleware.close = () => limiter?.close();
2322
+ return middleware;
2323
+ }
2324
+
1695
2325
  exports.arcis = arcis;
1696
2326
  exports.arcisFunction = arcisWithMethods;
1697
2327
  exports.botProtection = botProtection;
2328
+ exports.checkSignup = checkSignup;
1698
2329
  exports.createCors = createCors;
1699
2330
  exports.createCsrf = createCsrf;
1700
2331
  exports.createErrorHandler = createErrorHandler;
@@ -1713,6 +2344,7 @@ exports.rateLimit = rateLimit;
1713
2344
  exports.safeCors = safeCors;
1714
2345
  exports.secureCookieDefaults = secureCookieDefaults;
1715
2346
  exports.securityHeaders = securityHeaders;
2347
+ exports.signupProtection = signupProtection;
1716
2348
  exports.validateCsrfToken = validateCsrfToken;
1717
2349
  //# sourceMappingURL=index.js.map
1718
2350
  //# sourceMappingURL=index.js.map