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