@arcis/node 1.2.0 → 1.4.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.
Files changed (122) hide show
  1. package/dist/core/{index.d.mts → constants.d.ts} +21 -70
  2. package/dist/core/constants.d.ts.map +1 -0
  3. package/dist/core/errors.d.ts +53 -0
  4. package/dist/core/errors.d.ts.map +1 -0
  5. package/dist/core/index.d.ts +6 -168
  6. package/dist/core/index.d.ts.map +1 -0
  7. package/dist/{types-CsOFHoD9.d.mts → core/types.d.ts} +38 -31
  8. package/dist/core/types.d.ts.map +1 -0
  9. package/dist/index.d.ts +71 -166
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +151 -4
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +145 -5
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/logging/index.d.ts +4 -36
  16. package/dist/logging/index.d.ts.map +1 -0
  17. package/dist/logging/{index.d.mts → redactor.d.ts} +5 -9
  18. package/dist/logging/redactor.d.ts.map +1 -0
  19. package/dist/middleware/bot-detection.d.ts +86 -0
  20. package/dist/middleware/bot-detection.d.ts.map +1 -0
  21. package/dist/middleware/cookies.d.ts +48 -0
  22. package/dist/middleware/cookies.d.ts.map +1 -0
  23. package/dist/middleware/cors.d.ts +65 -0
  24. package/dist/middleware/cors.d.ts.map +1 -0
  25. package/dist/middleware/csrf.d.ts +109 -0
  26. package/dist/middleware/csrf.d.ts.map +1 -0
  27. package/dist/middleware/error-handler.d.ts +43 -0
  28. package/dist/middleware/error-handler.d.ts.map +1 -0
  29. package/dist/middleware/headers.d.ts +29 -0
  30. package/dist/middleware/headers.d.ts.map +1 -0
  31. package/dist/middleware/hpp.d.ts +56 -0
  32. package/dist/middleware/hpp.d.ts.map +1 -0
  33. package/dist/middleware/index.d.ts +16 -3
  34. package/dist/middleware/index.d.ts.map +1 -0
  35. package/dist/middleware/index.js +28 -3
  36. package/dist/middleware/index.js.map +1 -1
  37. package/dist/middleware/index.mjs +28 -3
  38. package/dist/middleware/index.mjs.map +1 -1
  39. package/dist/middleware/main.d.ts +40 -0
  40. package/dist/middleware/main.d.ts.map +1 -0
  41. package/dist/middleware/rate-limit-sliding.d.ts +46 -0
  42. package/dist/middleware/rate-limit-sliding.d.ts.map +1 -0
  43. package/dist/middleware/rate-limit-token.d.ts +51 -0
  44. package/dist/middleware/rate-limit-token.d.ts.map +1 -0
  45. package/dist/middleware/rate-limit.d.ts +34 -0
  46. package/dist/middleware/rate-limit.d.ts.map +1 -0
  47. package/dist/sanitizers/command.d.ts +28 -0
  48. package/dist/sanitizers/command.d.ts.map +1 -0
  49. package/dist/sanitizers/encode.d.ts +46 -0
  50. package/dist/sanitizers/encode.d.ts.map +1 -0
  51. package/dist/sanitizers/headers.d.ts +46 -0
  52. package/dist/sanitizers/headers.d.ts.map +1 -0
  53. package/dist/sanitizers/index.d.ts +17 -22
  54. package/dist/sanitizers/index.d.ts.map +1 -0
  55. package/dist/sanitizers/index.js +72 -0
  56. package/dist/sanitizers/index.js.map +1 -1
  57. package/dist/sanitizers/index.mjs +68 -1
  58. package/dist/sanitizers/index.mjs.map +1 -1
  59. package/dist/sanitizers/jsonp.d.ts +34 -0
  60. package/dist/sanitizers/jsonp.d.ts.map +1 -0
  61. package/dist/sanitizers/nosql.d.ts +31 -0
  62. package/dist/sanitizers/nosql.d.ts.map +1 -0
  63. package/dist/sanitizers/path.d.ts +28 -0
  64. package/dist/sanitizers/path.d.ts.map +1 -0
  65. package/dist/sanitizers/pii.d.ts +80 -0
  66. package/dist/sanitizers/pii.d.ts.map +1 -0
  67. package/dist/sanitizers/prototype.d.ts +34 -0
  68. package/dist/sanitizers/prototype.d.ts.map +1 -0
  69. package/dist/sanitizers/sanitize.d.ts +51 -0
  70. package/dist/sanitizers/sanitize.d.ts.map +1 -0
  71. package/dist/sanitizers/sql.d.ts +28 -0
  72. package/dist/sanitizers/sql.d.ts.map +1 -0
  73. package/dist/sanitizers/ssti.d.ts +20 -0
  74. package/dist/sanitizers/ssti.d.ts.map +1 -0
  75. package/dist/sanitizers/utils.d.ts +19 -0
  76. package/dist/sanitizers/utils.d.ts.map +1 -0
  77. package/dist/sanitizers/xss.d.ts +35 -0
  78. package/dist/sanitizers/xss.d.ts.map +1 -0
  79. package/dist/sanitizers/xxe.d.ts +20 -0
  80. package/dist/sanitizers/xxe.d.ts.map +1 -0
  81. package/dist/stores/index.d.ts +6 -104
  82. package/dist/stores/index.d.ts.map +1 -0
  83. package/dist/stores/memory.d.ts +35 -0
  84. package/dist/stores/memory.d.ts.map +1 -0
  85. package/dist/stores/{index.d.mts → redis.d.ts} +6 -45
  86. package/dist/stores/redis.d.ts.map +1 -0
  87. package/dist/utils/duration.d.ts +34 -0
  88. package/dist/utils/duration.d.ts.map +1 -0
  89. package/dist/utils/fingerprint.d.ts +64 -0
  90. package/dist/utils/fingerprint.d.ts.map +1 -0
  91. package/dist/utils/index.d.ts +10 -0
  92. package/dist/utils/index.d.ts.map +1 -0
  93. package/dist/utils/index.js +188 -0
  94. package/dist/utils/index.js.map +1 -0
  95. package/dist/utils/index.mjs +182 -0
  96. package/dist/utils/index.mjs.map +1 -0
  97. package/dist/utils/ip.d.ts +70 -0
  98. package/dist/utils/ip.d.ts.map +1 -0
  99. package/dist/validation/email.d.ts +82 -0
  100. package/dist/validation/email.d.ts.map +1 -0
  101. package/dist/validation/file.d.ts +90 -0
  102. package/dist/validation/file.d.ts.map +1 -0
  103. package/dist/validation/index.d.ts +10 -3
  104. package/dist/validation/index.d.ts.map +1 -0
  105. package/dist/validation/redirect.d.ts +64 -0
  106. package/dist/validation/redirect.d.ts.map +1 -0
  107. package/dist/validation/schema.d.ts +36 -0
  108. package/dist/validation/schema.d.ts.map +1 -0
  109. package/dist/validation/url.d.ts +65 -0
  110. package/dist/validation/url.d.ts.map +1 -0
  111. package/package.json +8 -6
  112. package/dist/index-A-m-pPeW.d.mts +0 -340
  113. package/dist/index-CgK94hY_.d.mts +0 -532
  114. package/dist/index-Co5kPRZz.d.ts +0 -340
  115. package/dist/index-D_bdJcF0.d.ts +0 -532
  116. package/dist/index.d.mts +0 -175
  117. package/dist/middleware/index.d.mts +0 -3
  118. package/dist/pii-CXcHMlnX.d.mts +0 -438
  119. package/dist/pii-DhNpl7M3.d.ts +0 -438
  120. package/dist/sanitizers/index.d.mts +0 -24
  121. package/dist/types-CsOFHoD9.d.ts +0 -269
  122. package/dist/validation/index.d.mts +0 -3
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @module @arcis/node/utils/fingerprint
3
+ * Deterministic request fingerprinting via SHA-256.
4
+ *
5
+ * Generates a stable hash from request characteristics for
6
+ * rate limiting keys, abuse detection, and analytics.
7
+ *
8
+ * @example
9
+ * const fp = await fingerprint(req);
10
+ * // "a3f2b8c1d4e5..."
11
+ */
12
+ import type { DetectIpOptions } from './ip';
13
+ export interface FingerprintOptions {
14
+ /** Include IP address in fingerprint. Default: true */
15
+ ip?: boolean;
16
+ /** Include User-Agent header. Default: true */
17
+ userAgent?: boolean;
18
+ /** Include Accept header. Default: true */
19
+ accept?: boolean;
20
+ /** Include Accept-Language header. Default: true */
21
+ acceptLanguage?: boolean;
22
+ /** Include Accept-Encoding header. Default: true */
23
+ acceptEncoding?: boolean;
24
+ /** Additional custom components to include */
25
+ custom?: string[];
26
+ /** IP detection options */
27
+ ipOptions?: DetectIpOptions;
28
+ }
29
+ interface RequestLike {
30
+ headers: Record<string, string | string[] | undefined>;
31
+ socket?: {
32
+ remoteAddress?: string;
33
+ };
34
+ connection?: {
35
+ remoteAddress?: string;
36
+ };
37
+ ip?: string;
38
+ }
39
+ /**
40
+ * Generate a deterministic fingerprint for a request.
41
+ *
42
+ * Creates a SHA-256 hash from configurable request components.
43
+ * The fingerprint is stable across requests from the same client
44
+ * (same IP, browser, language settings).
45
+ *
46
+ * @param req - HTTP request object
47
+ * @param options - Fingerprint configuration
48
+ * @returns Hex-encoded SHA-256 hash (64 characters)
49
+ *
50
+ * @example
51
+ * // Default fingerprint (IP + UA + Accept headers)
52
+ * const fp = fingerprint(req);
53
+ *
54
+ * @example
55
+ * // IP-only fingerprint (for simple rate limiting)
56
+ * const fp = fingerprint(req, { userAgent: false, accept: false, acceptLanguage: false, acceptEncoding: false });
57
+ *
58
+ * @example
59
+ * // With custom components
60
+ * const fp = fingerprint(req, { custom: [req.body?.userId] });
61
+ */
62
+ export declare function fingerprint(req: RequestLike, options?: FingerprintOptions): string;
63
+ export {};
64
+ //# sourceMappingURL=fingerprint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fingerprint.d.ts","sourceRoot":"","sources":["../../src/utils/fingerprint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,MAAM,CAAC;AAE5C,MAAM,WAAW,kBAAkB;IACjC,uDAAuD;IACvD,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,+CAA+C;IAC/C,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,2CAA2C;IAC3C,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,oDAAoD;IACpD,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,oDAAoD;IACpD,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,8CAA8C;IAC9C,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,2BAA2B;IAC3B,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAED,UAAU,WAAW;IACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;IACvD,MAAM,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACpC,UAAU,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,EAAE,CAAC,EAAE,MAAM,CAAC;CACb;AAQD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,WAAW,EAAE,OAAO,GAAE,kBAAuB,GAAG,MAAM,CAuCtF"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @module @arcis/node/utils
3
+ * Utility functions for Arcis
4
+ */
5
+ export { parseDuration, formatDuration } from './duration';
6
+ export { detectClientIp, isPrivateIp } from './ip';
7
+ export { fingerprint } from './fingerprint';
8
+ export type { Platform, DetectIpOptions } from './ip';
9
+ export type { FingerprintOptions } from './fingerprint';
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,MAAM,CAAC;AACnD,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,YAAY,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,MAAM,CAAC;AACtD,YAAY,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC"}
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ var crypto = require('crypto');
4
+
5
+ // src/utils/duration.ts
6
+ var MAX_DURATION_MS = 4294967295;
7
+ var DURATION_REGEX = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/i;
8
+ var UNIT_TO_MS = {
9
+ ms: 1,
10
+ s: 1e3,
11
+ m: 6e4,
12
+ h: 36e5,
13
+ d: 864e5
14
+ };
15
+ function parseDuration(value) {
16
+ if (typeof value === "number") {
17
+ if (!Number.isFinite(value) || value < 0) {
18
+ throw new Error(`Invalid duration: ${value}. Must be a non-negative finite number.`);
19
+ }
20
+ return Math.min(Math.floor(value), MAX_DURATION_MS);
21
+ }
22
+ if (typeof value !== "string" || value.trim() === "") {
23
+ throw new Error(`Invalid duration: "${value}". Expected a duration string (e.g. "5m", "2h") or number.`);
24
+ }
25
+ const match = value.trim().match(DURATION_REGEX);
26
+ if (!match) {
27
+ throw new Error(
28
+ `Invalid duration: "${value}". Expected format: <number><unit> where unit is ms, s, m, h, or d.`
29
+ );
30
+ }
31
+ const amount = parseFloat(match[1]);
32
+ const unit = match[2].toLowerCase();
33
+ const ms = Math.floor(amount * UNIT_TO_MS[unit]);
34
+ if (ms < 0 || ms > MAX_DURATION_MS) {
35
+ throw new Error(`Duration "${value}" exceeds maximum allowed (${MAX_DURATION_MS}ms / ~49.7 days).`);
36
+ }
37
+ return ms;
38
+ }
39
+ function formatDuration(ms) {
40
+ if (!Number.isFinite(ms) || ms < 0) return "0ms";
41
+ if (ms < 1e3) return `${ms}ms`;
42
+ const days = Math.floor(ms / 864e5);
43
+ const hours = Math.floor(ms % 864e5 / 36e5);
44
+ const minutes = Math.floor(ms % 36e5 / 6e4);
45
+ const seconds = Math.floor(ms % 6e4 / 1e3);
46
+ const parts = [];
47
+ if (days > 0) parts.push(`${days}d`);
48
+ if (hours > 0) parts.push(`${hours}h`);
49
+ if (minutes > 0) parts.push(`${minutes}m`);
50
+ if (seconds > 0) parts.push(`${seconds}s`);
51
+ return parts.join(" ") || "0ms";
52
+ }
53
+
54
+ // src/utils/ip.ts
55
+ var PLATFORM_HEADERS = {
56
+ cloudflare: "cf-connecting-ip",
57
+ vercel: "x-real-ip",
58
+ flyio: "fly-client-ip",
59
+ render: "x-render-client-ip",
60
+ firebase: "x-appengine-user-ip",
61
+ "aws-alb": "x-forwarded-for"
62
+ };
63
+ function detectPlatform() {
64
+ const env = typeof process !== "undefined" ? process.env : {};
65
+ if (env.CF_PAGES || env.CF_WORKERS) return "cloudflare";
66
+ if (env.VERCEL) return "vercel";
67
+ if (env.FLY_APP_NAME) return "flyio";
68
+ if (env.RENDER) return "render";
69
+ if (env.FIREBASE_CONFIG || env.GCLOUD_PROJECT) return "firebase";
70
+ if (env.AWS_EXECUTION_ENV || env.AWS_LAMBDA_FUNCTION_NAME) return "aws-alb";
71
+ return "generic";
72
+ }
73
+ var _cachedPlatform = null;
74
+ function getCachedPlatform() {
75
+ if (_cachedPlatform === null) {
76
+ _cachedPlatform = detectPlatform();
77
+ }
78
+ return _cachedPlatform;
79
+ }
80
+ var MAX_IP_LENGTH = 45;
81
+ function sanitizeIp(ip) {
82
+ const trimmed = ip.trim();
83
+ if (trimmed.length > MAX_IP_LENGTH) return trimmed.slice(0, MAX_IP_LENGTH);
84
+ return trimmed;
85
+ }
86
+ function getHeader(req, name) {
87
+ const val = req.headers[name];
88
+ if (Array.isArray(val)) return val[0];
89
+ return val;
90
+ }
91
+ function parseForwardedFor(header, trustedProxyCount) {
92
+ const ips = header.split(",").map((ip) => ip.trim()).filter(Boolean);
93
+ if (ips.length === 0) return void 0;
94
+ const clientIndex = Math.max(0, ips.length - trustedProxyCount);
95
+ return ips[clientIndex] || void 0;
96
+ }
97
+ function detectClientIp(req, options = {}) {
98
+ const { platform = "auto", trustedProxyCount = 1 } = options;
99
+ const r = req;
100
+ const resolvedPlatform = platform === "auto" ? getCachedPlatform() : platform;
101
+ if (resolvedPlatform !== "generic" && resolvedPlatform in PLATFORM_HEADERS) {
102
+ const headerName = PLATFORM_HEADERS[resolvedPlatform];
103
+ if (headerName) {
104
+ if (resolvedPlatform === "aws-alb") {
105
+ const xff2 = getHeader(r, "x-forwarded-for");
106
+ if (xff2) {
107
+ const ip = parseForwardedFor(xff2, trustedProxyCount);
108
+ if (ip) return sanitizeIp(ip);
109
+ }
110
+ } else {
111
+ const ip = getHeader(r, headerName);
112
+ if (ip) return sanitizeIp(ip);
113
+ }
114
+ }
115
+ }
116
+ if (r.ip) return sanitizeIp(r.ip);
117
+ const xff = getHeader(r, "x-forwarded-for");
118
+ if (xff) {
119
+ const ip = parseForwardedFor(xff, trustedProxyCount);
120
+ if (ip) return sanitizeIp(ip);
121
+ }
122
+ const realIp = getHeader(r, "x-real-ip");
123
+ if (realIp) return sanitizeIp(realIp);
124
+ const socketIp = r.socket?.remoteAddress ?? r.connection?.remoteAddress;
125
+ if (socketIp) return sanitizeIp(socketIp);
126
+ return "unknown";
127
+ }
128
+ function isPrivateIp(ip) {
129
+ const normalized = ip.startsWith("::ffff:") ? ip.slice(7) : ip;
130
+ if (/^127\./.test(normalized)) return true;
131
+ if (/^10\./.test(normalized)) return true;
132
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(normalized)) return true;
133
+ if (/^192\.168\./.test(normalized)) return true;
134
+ if (/^169\.254\./.test(normalized)) return true;
135
+ if (/^0\./.test(normalized)) return true;
136
+ if (ip === "::1") return true;
137
+ if (/^fe80:/i.test(ip)) return true;
138
+ if (/^fc00:/i.test(ip)) return true;
139
+ if (/^fd/i.test(ip)) return true;
140
+ return false;
141
+ }
142
+ function getHeader2(req, name) {
143
+ const val = req.headers[name];
144
+ if (Array.isArray(val)) return val[0] ?? "";
145
+ return val ?? "";
146
+ }
147
+ function fingerprint(req, options = {}) {
148
+ const {
149
+ ip = true,
150
+ userAgent = true,
151
+ accept = true,
152
+ acceptLanguage = true,
153
+ acceptEncoding = true,
154
+ custom = [],
155
+ ipOptions
156
+ } = options;
157
+ const components = [];
158
+ if (ip) {
159
+ components.push(`ip:${detectClientIp(req, ipOptions)}`);
160
+ }
161
+ if (userAgent) {
162
+ components.push(`ua:${getHeader2(req, "user-agent")}`);
163
+ }
164
+ if (accept) {
165
+ components.push(`accept:${getHeader2(req, "accept")}`);
166
+ }
167
+ if (acceptLanguage) {
168
+ components.push(`lang:${getHeader2(req, "accept-language")}`);
169
+ }
170
+ if (acceptEncoding) {
171
+ components.push(`enc:${getHeader2(req, "accept-encoding")}`);
172
+ }
173
+ for (const c of custom) {
174
+ if (c !== null && c !== void 0) components.push(`custom:${c}`);
175
+ }
176
+ components.sort();
177
+ const hash = crypto.createHash("sha256");
178
+ hash.update(components.join("|"));
179
+ return hash.digest("hex");
180
+ }
181
+
182
+ exports.detectClientIp = detectClientIp;
183
+ exports.fingerprint = fingerprint;
184
+ exports.formatDuration = formatDuration;
185
+ exports.isPrivateIp = isPrivateIp;
186
+ exports.parseDuration = parseDuration;
187
+ //# sourceMappingURL=index.js.map
188
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/duration.ts","../../src/utils/ip.ts","../../src/utils/fingerprint.ts"],"names":["xff","getHeader","createHash"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @module @arcis/node/utils/duration\n * Parse human-readable duration strings into milliseconds.\n *\n * Supports: ms, s, m, h, d\n *\n * @example\n * parseDuration('5m') // 300000\n * parseDuration('2h') // 7200000\n * parseDuration(60000) // 60000 (passthrough)\n * parseDuration('500ms') // 500\n */\n\n/** Maximum duration: ~49.7 days (uint32 max in ms) */\nconst MAX_DURATION_MS = 4_294_967_295;\n\nconst DURATION_REGEX = /^(\\d+(?:\\.\\d+)?)\\s*(ms|s|m|h|d)$/i;\n\nconst UNIT_TO_MS: Record<string, number> = {\n ms: 1,\n s: 1_000,\n m: 60_000,\n h: 3_600_000,\n d: 86_400_000,\n};\n\n/**\n * Parse a duration string or number into milliseconds.\n *\n * @param value - Duration string (e.g. \"5m\", \"2h\", \"30s\") or number (ms)\n * @returns Duration in milliseconds\n * @throws {Error} If the value is not a valid duration\n *\n * @example\n * parseDuration('15m') // 900000\n * parseDuration('1d') // 86400000\n * parseDuration('500ms') // 500\n * parseDuration(60000) // 60000\n */\nexport function parseDuration(value: string | number): number {\n if (typeof value === 'number') {\n if (!Number.isFinite(value) || value < 0) {\n throw new Error(`Invalid duration: ${value}. Must be a non-negative finite number.`);\n }\n return Math.min(Math.floor(value), MAX_DURATION_MS);\n }\n\n if (typeof value !== 'string' || value.trim() === '') {\n throw new Error(`Invalid duration: \"${value}\". Expected a duration string (e.g. \"5m\", \"2h\") or number.`);\n }\n\n const match = value.trim().match(DURATION_REGEX);\n if (!match) {\n throw new Error(\n `Invalid duration: \"${value}\". Expected format: <number><unit> where unit is ms, s, m, h, or d.`\n );\n }\n\n const amount = parseFloat(match[1]);\n const unit = match[2].toLowerCase();\n const ms = Math.floor(amount * UNIT_TO_MS[unit]);\n\n if (ms < 0 || ms > MAX_DURATION_MS) {\n throw new Error(`Duration \"${value}\" exceeds maximum allowed (${MAX_DURATION_MS}ms / ~49.7 days).`);\n }\n\n return ms;\n}\n\n/**\n * Format milliseconds into a human-readable duration string.\n *\n * @param ms - Duration in milliseconds\n * @returns Human-readable string (e.g. \"5m\", \"2h 30m\")\n */\nexport function formatDuration(ms: number): string {\n if (!Number.isFinite(ms) || ms < 0) return '0ms';\n\n if (ms < 1000) return `${ms}ms`;\n\n const days = Math.floor(ms / 86_400_000);\n const hours = Math.floor((ms % 86_400_000) / 3_600_000);\n const minutes = Math.floor((ms % 3_600_000) / 60_000);\n const seconds = Math.floor((ms % 60_000) / 1_000);\n\n const parts: string[] = [];\n if (days > 0) parts.push(`${days}d`);\n if (hours > 0) parts.push(`${hours}h`);\n if (minutes > 0) parts.push(`${minutes}m`);\n if (seconds > 0) parts.push(`${seconds}s`);\n\n return parts.join(' ') || '0ms';\n}\n","/**\n * @module @arcis/node/utils/ip\n * Platform-aware client IP detection.\n *\n * Prevents IP spoofing by reading platform-specific headers\n * instead of blindly trusting X-Forwarded-For.\n *\n * @example\n * // Auto-detect platform from environment\n * const ip = detectClientIp(req);\n *\n * // Explicit platform\n * const ip = detectClientIp(req, { platform: 'cloudflare' });\n */\n\nimport type { IncomingMessage } from 'http';\n\nexport type Platform =\n | 'auto'\n | 'cloudflare'\n | 'vercel'\n | 'flyio'\n | 'render'\n | 'firebase'\n | 'aws-alb'\n | 'generic';\n\nexport interface DetectIpOptions {\n /** Platform to use for header selection. Default: 'auto' */\n platform?: Platform;\n /** Number of trusted proxies (for X-Forwarded-For parsing). Default: 1 */\n trustedProxyCount?: number;\n}\n\ninterface RequestLike {\n headers: Record<string, string | string[] | undefined>;\n socket?: { remoteAddress?: string };\n connection?: { remoteAddress?: string };\n ip?: string;\n}\n\n/**\n * Platform-specific header configurations.\n * Each platform sets a trusted header that cannot be spoofed by the client.\n */\nconst PLATFORM_HEADERS: Record<Exclude<Platform, 'auto' | 'generic'>, string> = {\n cloudflare: 'cf-connecting-ip',\n vercel: 'x-real-ip',\n flyio: 'fly-client-ip',\n render: 'x-render-client-ip',\n firebase: 'x-appengine-user-ip',\n 'aws-alb': 'x-forwarded-for',\n};\n\n/**\n * Auto-detect the platform from environment variables.\n */\nfunction detectPlatform(): Platform {\n const env = typeof process !== 'undefined' ? process.env : {};\n\n if (env.CF_PAGES || env.CF_WORKERS) return 'cloudflare';\n if (env.VERCEL) return 'vercel';\n if (env.FLY_APP_NAME) return 'flyio';\n if (env.RENDER) return 'render';\n if (env.FIREBASE_CONFIG || env.GCLOUD_PROJECT) return 'firebase';\n if (env.AWS_EXECUTION_ENV || env.AWS_LAMBDA_FUNCTION_NAME) return 'aws-alb';\n\n return 'generic';\n}\n\n// Cache the detected platform — it won't change during process lifetime\nlet _cachedPlatform: Platform | null = null;\n\nfunction getCachedPlatform(): Platform {\n if (_cachedPlatform === null) {\n _cachedPlatform = detectPlatform();\n }\n return _cachedPlatform;\n}\n\n/** Max IP string length (IPv6 max = 45 chars) */\nconst MAX_IP_LENGTH = 45;\n\n/**\n * Sanitize an IP string: trim, truncate, strip control characters.\n * Prevents unbounded strings from being used as map keys.\n */\nfunction sanitizeIp(ip: string): string {\n const trimmed = ip.trim();\n if (trimmed.length > MAX_IP_LENGTH) return trimmed.slice(0, MAX_IP_LENGTH);\n return trimmed;\n}\n\n/**\n * Get a header value from the request, handling string arrays.\n */\nfunction getHeader(req: RequestLike, name: string): string | undefined {\n const val = req.headers[name];\n if (Array.isArray(val)) return val[0];\n return val;\n}\n\n/**\n * Parse the rightmost trusted IP from X-Forwarded-For.\n * Reading from the right prevents client spoofing — the rightmost entry\n * is the one added by the closest trusted proxy.\n */\nfunction parseForwardedFor(header: string, trustedProxyCount: number): string | undefined {\n const ips = header.split(',').map(ip => ip.trim()).filter(Boolean);\n if (ips.length === 0) return undefined;\n\n // The client IP is at position (length - trustedProxyCount)\n const clientIndex = Math.max(0, ips.length - trustedProxyCount);\n return ips[clientIndex] || undefined;\n}\n\n/**\n * Detect the real client IP address from a request.\n *\n * Uses platform-specific headers when available to prevent IP spoofing.\n * Falls back to X-Forwarded-For (parsed from the right) and then\n * the socket remote address.\n *\n * @param req - HTTP request object (Express, raw http, etc.)\n * @param options - Detection options\n * @returns Client IP address, or 'unknown' if unresolvable\n *\n * @example\n * // Auto-detect platform\n * app.use((req, res, next) => {\n * const clientIp = detectClientIp(req);\n * console.log('Client IP:', clientIp);\n * next();\n * });\n *\n * @example\n * // Behind Cloudflare\n * const ip = detectClientIp(req, { platform: 'cloudflare' });\n *\n * @example\n * // Behind 2 proxies (e.g. CDN + load balancer)\n * const ip = detectClientIp(req, { trustedProxyCount: 2 });\n */\nexport function detectClientIp(\n req: RequestLike | IncomingMessage,\n options: DetectIpOptions = {}\n): string {\n const { platform = 'auto', trustedProxyCount = 1 } = options;\n const r = req as RequestLike;\n\n const resolvedPlatform = platform === 'auto' ? getCachedPlatform() : platform;\n\n // 1. Try platform-specific header (most trusted)\n if (resolvedPlatform !== 'generic' && resolvedPlatform in PLATFORM_HEADERS) {\n const headerName = PLATFORM_HEADERS[resolvedPlatform as keyof typeof PLATFORM_HEADERS];\n if (headerName) {\n if (resolvedPlatform === 'aws-alb') {\n // AWS ALB: parse X-Forwarded-For from the right\n const xff = getHeader(r, 'x-forwarded-for');\n if (xff) {\n const ip = parseForwardedFor(xff, trustedProxyCount);\n if (ip) return sanitizeIp(ip);\n }\n } else {\n const ip = getHeader(r, headerName);\n if (ip) return sanitizeIp(ip);\n }\n }\n }\n\n // 2. Try Express req.ip (respects trust proxy setting)\n if (r.ip) return sanitizeIp(r.ip);\n\n // 3. Try X-Forwarded-For (parsed from the right for safety)\n const xff = getHeader(r, 'x-forwarded-for');\n if (xff) {\n const ip = parseForwardedFor(xff, trustedProxyCount);\n if (ip) return sanitizeIp(ip);\n }\n\n // 4. Try X-Real-IP\n const realIp = getHeader(r, 'x-real-ip');\n if (realIp) return sanitizeIp(realIp);\n\n // 5. Socket remote address\n const socketIp = r.socket?.remoteAddress ?? r.connection?.remoteAddress;\n if (socketIp) return sanitizeIp(socketIp);\n\n return 'unknown';\n}\n\n/**\n * Check if an IP address is a private/internal address.\n *\n * Detects: loopback, private ranges (RFC 1918), link-local, IPv6 equivalents.\n */\nexport function isPrivateIp(ip: string): boolean {\n // Strip IPv4-mapped IPv6 prefix (::ffff:127.0.0.1 -> 127.0.0.1)\n const normalized = ip.startsWith('::ffff:') ? ip.slice(7) : ip;\n\n // IPv4 private ranges\n if (/^127\\./.test(normalized)) return true; // Loopback\n if (/^10\\./.test(normalized)) return true; // Class A private\n if (/^172\\.(1[6-9]|2\\d|3[01])\\./.test(normalized)) return true; // Class B private\n if (/^192\\.168\\./.test(normalized)) return true; // Class C private\n if (/^169\\.254\\./.test(normalized)) return true; // Link-local\n if (/^0\\./.test(normalized)) return true; // Current network\n\n // IPv6\n if (ip === '::1') return true; // Loopback\n if (/^fe80:/i.test(ip)) return true; // Link-local\n if (/^fc00:/i.test(ip)) return true; // Unique local\n if (/^fd/i.test(ip)) return true; // Unique local\n\n return false;\n}\n\n/** Reset cached platform (for testing). */\nexport function _resetPlatformCache(): void {\n _cachedPlatform = null;\n}\n","/**\n * @module @arcis/node/utils/fingerprint\n * Deterministic request fingerprinting via SHA-256.\n *\n * Generates a stable hash from request characteristics for\n * rate limiting keys, abuse detection, and analytics.\n *\n * @example\n * const fp = await fingerprint(req);\n * // \"a3f2b8c1d4e5...\"\n */\n\nimport { createHash } from 'crypto';\nimport { detectClientIp } from './ip';\nimport type { DetectIpOptions } from './ip';\n\nexport interface FingerprintOptions {\n /** Include IP address in fingerprint. Default: true */\n ip?: boolean;\n /** Include User-Agent header. Default: true */\n userAgent?: boolean;\n /** Include Accept header. Default: true */\n accept?: boolean;\n /** Include Accept-Language header. Default: true */\n acceptLanguage?: boolean;\n /** Include Accept-Encoding header. Default: true */\n acceptEncoding?: boolean;\n /** Additional custom components to include */\n custom?: string[];\n /** IP detection options */\n ipOptions?: DetectIpOptions;\n}\n\ninterface RequestLike {\n headers: Record<string, string | string[] | undefined>;\n socket?: { remoteAddress?: string };\n connection?: { remoteAddress?: string };\n ip?: string;\n}\n\nfunction getHeader(req: RequestLike, name: string): string {\n const val = req.headers[name];\n if (Array.isArray(val)) return val[0] ?? '';\n return val ?? '';\n}\n\n/**\n * Generate a deterministic fingerprint for a request.\n *\n * Creates a SHA-256 hash from configurable request components.\n * The fingerprint is stable across requests from the same client\n * (same IP, browser, language settings).\n *\n * @param req - HTTP request object\n * @param options - Fingerprint configuration\n * @returns Hex-encoded SHA-256 hash (64 characters)\n *\n * @example\n * // Default fingerprint (IP + UA + Accept headers)\n * const fp = fingerprint(req);\n *\n * @example\n * // IP-only fingerprint (for simple rate limiting)\n * const fp = fingerprint(req, { userAgent: false, accept: false, acceptLanguage: false, acceptEncoding: false });\n *\n * @example\n * // With custom components\n * const fp = fingerprint(req, { custom: [req.body?.userId] });\n */\nexport function fingerprint(req: RequestLike, options: FingerprintOptions = {}): string {\n const {\n ip = true,\n userAgent = true,\n accept = true,\n acceptLanguage = true,\n acceptEncoding = true,\n custom = [],\n ipOptions,\n } = options;\n\n const components: string[] = [];\n\n if (ip) {\n components.push(`ip:${detectClientIp(req, ipOptions)}`);\n }\n if (userAgent) {\n components.push(`ua:${getHeader(req, 'user-agent')}`);\n }\n if (accept) {\n components.push(`accept:${getHeader(req, 'accept')}`);\n }\n if (acceptLanguage) {\n components.push(`lang:${getHeader(req, 'accept-language')}`);\n }\n if (acceptEncoding) {\n components.push(`enc:${getHeader(req, 'accept-encoding')}`);\n }\n\n for (const c of custom) {\n if (c !== null && c !== undefined) components.push(`custom:${c}`);\n }\n\n // Sort for deterministic ordering\n components.sort();\n\n const hash = createHash('sha256');\n hash.update(components.join('|'));\n return hash.digest('hex');\n}\n"]}
@@ -0,0 +1,182 @@
1
+ import { createHash } from 'crypto';
2
+
3
+ // src/utils/duration.ts
4
+ var MAX_DURATION_MS = 4294967295;
5
+ var DURATION_REGEX = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/i;
6
+ var UNIT_TO_MS = {
7
+ ms: 1,
8
+ s: 1e3,
9
+ m: 6e4,
10
+ h: 36e5,
11
+ d: 864e5
12
+ };
13
+ function parseDuration(value) {
14
+ if (typeof value === "number") {
15
+ if (!Number.isFinite(value) || value < 0) {
16
+ throw new Error(`Invalid duration: ${value}. Must be a non-negative finite number.`);
17
+ }
18
+ return Math.min(Math.floor(value), MAX_DURATION_MS);
19
+ }
20
+ if (typeof value !== "string" || value.trim() === "") {
21
+ throw new Error(`Invalid duration: "${value}". Expected a duration string (e.g. "5m", "2h") or number.`);
22
+ }
23
+ const match = value.trim().match(DURATION_REGEX);
24
+ if (!match) {
25
+ throw new Error(
26
+ `Invalid duration: "${value}". Expected format: <number><unit> where unit is ms, s, m, h, or d.`
27
+ );
28
+ }
29
+ const amount = parseFloat(match[1]);
30
+ const unit = match[2].toLowerCase();
31
+ const ms = Math.floor(amount * UNIT_TO_MS[unit]);
32
+ if (ms < 0 || ms > MAX_DURATION_MS) {
33
+ throw new Error(`Duration "${value}" exceeds maximum allowed (${MAX_DURATION_MS}ms / ~49.7 days).`);
34
+ }
35
+ return ms;
36
+ }
37
+ function formatDuration(ms) {
38
+ if (!Number.isFinite(ms) || ms < 0) return "0ms";
39
+ if (ms < 1e3) return `${ms}ms`;
40
+ const days = Math.floor(ms / 864e5);
41
+ const hours = Math.floor(ms % 864e5 / 36e5);
42
+ const minutes = Math.floor(ms % 36e5 / 6e4);
43
+ const seconds = Math.floor(ms % 6e4 / 1e3);
44
+ const parts = [];
45
+ if (days > 0) parts.push(`${days}d`);
46
+ if (hours > 0) parts.push(`${hours}h`);
47
+ if (minutes > 0) parts.push(`${minutes}m`);
48
+ if (seconds > 0) parts.push(`${seconds}s`);
49
+ return parts.join(" ") || "0ms";
50
+ }
51
+
52
+ // src/utils/ip.ts
53
+ var PLATFORM_HEADERS = {
54
+ cloudflare: "cf-connecting-ip",
55
+ vercel: "x-real-ip",
56
+ flyio: "fly-client-ip",
57
+ render: "x-render-client-ip",
58
+ firebase: "x-appengine-user-ip",
59
+ "aws-alb": "x-forwarded-for"
60
+ };
61
+ function detectPlatform() {
62
+ const env = typeof process !== "undefined" ? process.env : {};
63
+ if (env.CF_PAGES || env.CF_WORKERS) return "cloudflare";
64
+ if (env.VERCEL) return "vercel";
65
+ if (env.FLY_APP_NAME) return "flyio";
66
+ if (env.RENDER) return "render";
67
+ if (env.FIREBASE_CONFIG || env.GCLOUD_PROJECT) return "firebase";
68
+ if (env.AWS_EXECUTION_ENV || env.AWS_LAMBDA_FUNCTION_NAME) return "aws-alb";
69
+ return "generic";
70
+ }
71
+ var _cachedPlatform = null;
72
+ function getCachedPlatform() {
73
+ if (_cachedPlatform === null) {
74
+ _cachedPlatform = detectPlatform();
75
+ }
76
+ return _cachedPlatform;
77
+ }
78
+ var MAX_IP_LENGTH = 45;
79
+ function sanitizeIp(ip) {
80
+ const trimmed = ip.trim();
81
+ if (trimmed.length > MAX_IP_LENGTH) return trimmed.slice(0, MAX_IP_LENGTH);
82
+ return trimmed;
83
+ }
84
+ function getHeader(req, name) {
85
+ const val = req.headers[name];
86
+ if (Array.isArray(val)) return val[0];
87
+ return val;
88
+ }
89
+ function parseForwardedFor(header, trustedProxyCount) {
90
+ const ips = header.split(",").map((ip) => ip.trim()).filter(Boolean);
91
+ if (ips.length === 0) return void 0;
92
+ const clientIndex = Math.max(0, ips.length - trustedProxyCount);
93
+ return ips[clientIndex] || void 0;
94
+ }
95
+ function detectClientIp(req, options = {}) {
96
+ const { platform = "auto", trustedProxyCount = 1 } = options;
97
+ const r = req;
98
+ const resolvedPlatform = platform === "auto" ? getCachedPlatform() : platform;
99
+ if (resolvedPlatform !== "generic" && resolvedPlatform in PLATFORM_HEADERS) {
100
+ const headerName = PLATFORM_HEADERS[resolvedPlatform];
101
+ if (headerName) {
102
+ if (resolvedPlatform === "aws-alb") {
103
+ const xff2 = getHeader(r, "x-forwarded-for");
104
+ if (xff2) {
105
+ const ip = parseForwardedFor(xff2, trustedProxyCount);
106
+ if (ip) return sanitizeIp(ip);
107
+ }
108
+ } else {
109
+ const ip = getHeader(r, headerName);
110
+ if (ip) return sanitizeIp(ip);
111
+ }
112
+ }
113
+ }
114
+ if (r.ip) return sanitizeIp(r.ip);
115
+ const xff = getHeader(r, "x-forwarded-for");
116
+ if (xff) {
117
+ const ip = parseForwardedFor(xff, trustedProxyCount);
118
+ if (ip) return sanitizeIp(ip);
119
+ }
120
+ const realIp = getHeader(r, "x-real-ip");
121
+ if (realIp) return sanitizeIp(realIp);
122
+ const socketIp = r.socket?.remoteAddress ?? r.connection?.remoteAddress;
123
+ if (socketIp) return sanitizeIp(socketIp);
124
+ return "unknown";
125
+ }
126
+ function isPrivateIp(ip) {
127
+ const normalized = ip.startsWith("::ffff:") ? ip.slice(7) : ip;
128
+ if (/^127\./.test(normalized)) return true;
129
+ if (/^10\./.test(normalized)) return true;
130
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(normalized)) return true;
131
+ if (/^192\.168\./.test(normalized)) return true;
132
+ if (/^169\.254\./.test(normalized)) return true;
133
+ if (/^0\./.test(normalized)) return true;
134
+ if (ip === "::1") return true;
135
+ if (/^fe80:/i.test(ip)) return true;
136
+ if (/^fc00:/i.test(ip)) return true;
137
+ if (/^fd/i.test(ip)) return true;
138
+ return false;
139
+ }
140
+ function getHeader2(req, name) {
141
+ const val = req.headers[name];
142
+ if (Array.isArray(val)) return val[0] ?? "";
143
+ return val ?? "";
144
+ }
145
+ function fingerprint(req, options = {}) {
146
+ const {
147
+ ip = true,
148
+ userAgent = true,
149
+ accept = true,
150
+ acceptLanguage = true,
151
+ acceptEncoding = true,
152
+ custom = [],
153
+ ipOptions
154
+ } = options;
155
+ const components = [];
156
+ if (ip) {
157
+ components.push(`ip:${detectClientIp(req, ipOptions)}`);
158
+ }
159
+ if (userAgent) {
160
+ components.push(`ua:${getHeader2(req, "user-agent")}`);
161
+ }
162
+ if (accept) {
163
+ components.push(`accept:${getHeader2(req, "accept")}`);
164
+ }
165
+ if (acceptLanguage) {
166
+ components.push(`lang:${getHeader2(req, "accept-language")}`);
167
+ }
168
+ if (acceptEncoding) {
169
+ components.push(`enc:${getHeader2(req, "accept-encoding")}`);
170
+ }
171
+ for (const c of custom) {
172
+ if (c !== null && c !== void 0) components.push(`custom:${c}`);
173
+ }
174
+ components.sort();
175
+ const hash = createHash("sha256");
176
+ hash.update(components.join("|"));
177
+ return hash.digest("hex");
178
+ }
179
+
180
+ export { detectClientIp, fingerprint, formatDuration, isPrivateIp, parseDuration };
181
+ //# sourceMappingURL=index.mjs.map
182
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/duration.ts","../../src/utils/ip.ts","../../src/utils/fingerprint.ts"],"names":["xff","getHeader"],"mappings":"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","file":"index.mjs","sourcesContent":["/**\n * @module @arcis/node/utils/duration\n * Parse human-readable duration strings into milliseconds.\n *\n * Supports: ms, s, m, h, d\n *\n * @example\n * parseDuration('5m') // 300000\n * parseDuration('2h') // 7200000\n * parseDuration(60000) // 60000 (passthrough)\n * parseDuration('500ms') // 500\n */\n\n/** Maximum duration: ~49.7 days (uint32 max in ms) */\nconst MAX_DURATION_MS = 4_294_967_295;\n\nconst DURATION_REGEX = /^(\\d+(?:\\.\\d+)?)\\s*(ms|s|m|h|d)$/i;\n\nconst UNIT_TO_MS: Record<string, number> = {\n ms: 1,\n s: 1_000,\n m: 60_000,\n h: 3_600_000,\n d: 86_400_000,\n};\n\n/**\n * Parse a duration string or number into milliseconds.\n *\n * @param value - Duration string (e.g. \"5m\", \"2h\", \"30s\") or number (ms)\n * @returns Duration in milliseconds\n * @throws {Error} If the value is not a valid duration\n *\n * @example\n * parseDuration('15m') // 900000\n * parseDuration('1d') // 86400000\n * parseDuration('500ms') // 500\n * parseDuration(60000) // 60000\n */\nexport function parseDuration(value: string | number): number {\n if (typeof value === 'number') {\n if (!Number.isFinite(value) || value < 0) {\n throw new Error(`Invalid duration: ${value}. Must be a non-negative finite number.`);\n }\n return Math.min(Math.floor(value), MAX_DURATION_MS);\n }\n\n if (typeof value !== 'string' || value.trim() === '') {\n throw new Error(`Invalid duration: \"${value}\". Expected a duration string (e.g. \"5m\", \"2h\") or number.`);\n }\n\n const match = value.trim().match(DURATION_REGEX);\n if (!match) {\n throw new Error(\n `Invalid duration: \"${value}\". Expected format: <number><unit> where unit is ms, s, m, h, or d.`\n );\n }\n\n const amount = parseFloat(match[1]);\n const unit = match[2].toLowerCase();\n const ms = Math.floor(amount * UNIT_TO_MS[unit]);\n\n if (ms < 0 || ms > MAX_DURATION_MS) {\n throw new Error(`Duration \"${value}\" exceeds maximum allowed (${MAX_DURATION_MS}ms / ~49.7 days).`);\n }\n\n return ms;\n}\n\n/**\n * Format milliseconds into a human-readable duration string.\n *\n * @param ms - Duration in milliseconds\n * @returns Human-readable string (e.g. \"5m\", \"2h 30m\")\n */\nexport function formatDuration(ms: number): string {\n if (!Number.isFinite(ms) || ms < 0) return '0ms';\n\n if (ms < 1000) return `${ms}ms`;\n\n const days = Math.floor(ms / 86_400_000);\n const hours = Math.floor((ms % 86_400_000) / 3_600_000);\n const minutes = Math.floor((ms % 3_600_000) / 60_000);\n const seconds = Math.floor((ms % 60_000) / 1_000);\n\n const parts: string[] = [];\n if (days > 0) parts.push(`${days}d`);\n if (hours > 0) parts.push(`${hours}h`);\n if (minutes > 0) parts.push(`${minutes}m`);\n if (seconds > 0) parts.push(`${seconds}s`);\n\n return parts.join(' ') || '0ms';\n}\n","/**\n * @module @arcis/node/utils/ip\n * Platform-aware client IP detection.\n *\n * Prevents IP spoofing by reading platform-specific headers\n * instead of blindly trusting X-Forwarded-For.\n *\n * @example\n * // Auto-detect platform from environment\n * const ip = detectClientIp(req);\n *\n * // Explicit platform\n * const ip = detectClientIp(req, { platform: 'cloudflare' });\n */\n\nimport type { IncomingMessage } from 'http';\n\nexport type Platform =\n | 'auto'\n | 'cloudflare'\n | 'vercel'\n | 'flyio'\n | 'render'\n | 'firebase'\n | 'aws-alb'\n | 'generic';\n\nexport interface DetectIpOptions {\n /** Platform to use for header selection. Default: 'auto' */\n platform?: Platform;\n /** Number of trusted proxies (for X-Forwarded-For parsing). Default: 1 */\n trustedProxyCount?: number;\n}\n\ninterface RequestLike {\n headers: Record<string, string | string[] | undefined>;\n socket?: { remoteAddress?: string };\n connection?: { remoteAddress?: string };\n ip?: string;\n}\n\n/**\n * Platform-specific header configurations.\n * Each platform sets a trusted header that cannot be spoofed by the client.\n */\nconst PLATFORM_HEADERS: Record<Exclude<Platform, 'auto' | 'generic'>, string> = {\n cloudflare: 'cf-connecting-ip',\n vercel: 'x-real-ip',\n flyio: 'fly-client-ip',\n render: 'x-render-client-ip',\n firebase: 'x-appengine-user-ip',\n 'aws-alb': 'x-forwarded-for',\n};\n\n/**\n * Auto-detect the platform from environment variables.\n */\nfunction detectPlatform(): Platform {\n const env = typeof process !== 'undefined' ? process.env : {};\n\n if (env.CF_PAGES || env.CF_WORKERS) return 'cloudflare';\n if (env.VERCEL) return 'vercel';\n if (env.FLY_APP_NAME) return 'flyio';\n if (env.RENDER) return 'render';\n if (env.FIREBASE_CONFIG || env.GCLOUD_PROJECT) return 'firebase';\n if (env.AWS_EXECUTION_ENV || env.AWS_LAMBDA_FUNCTION_NAME) return 'aws-alb';\n\n return 'generic';\n}\n\n// Cache the detected platform — it won't change during process lifetime\nlet _cachedPlatform: Platform | null = null;\n\nfunction getCachedPlatform(): Platform {\n if (_cachedPlatform === null) {\n _cachedPlatform = detectPlatform();\n }\n return _cachedPlatform;\n}\n\n/** Max IP string length (IPv6 max = 45 chars) */\nconst MAX_IP_LENGTH = 45;\n\n/**\n * Sanitize an IP string: trim, truncate, strip control characters.\n * Prevents unbounded strings from being used as map keys.\n */\nfunction sanitizeIp(ip: string): string {\n const trimmed = ip.trim();\n if (trimmed.length > MAX_IP_LENGTH) return trimmed.slice(0, MAX_IP_LENGTH);\n return trimmed;\n}\n\n/**\n * Get a header value from the request, handling string arrays.\n */\nfunction getHeader(req: RequestLike, name: string): string | undefined {\n const val = req.headers[name];\n if (Array.isArray(val)) return val[0];\n return val;\n}\n\n/**\n * Parse the rightmost trusted IP from X-Forwarded-For.\n * Reading from the right prevents client spoofing — the rightmost entry\n * is the one added by the closest trusted proxy.\n */\nfunction parseForwardedFor(header: string, trustedProxyCount: number): string | undefined {\n const ips = header.split(',').map(ip => ip.trim()).filter(Boolean);\n if (ips.length === 0) return undefined;\n\n // The client IP is at position (length - trustedProxyCount)\n const clientIndex = Math.max(0, ips.length - trustedProxyCount);\n return ips[clientIndex] || undefined;\n}\n\n/**\n * Detect the real client IP address from a request.\n *\n * Uses platform-specific headers when available to prevent IP spoofing.\n * Falls back to X-Forwarded-For (parsed from the right) and then\n * the socket remote address.\n *\n * @param req - HTTP request object (Express, raw http, etc.)\n * @param options - Detection options\n * @returns Client IP address, or 'unknown' if unresolvable\n *\n * @example\n * // Auto-detect platform\n * app.use((req, res, next) => {\n * const clientIp = detectClientIp(req);\n * console.log('Client IP:', clientIp);\n * next();\n * });\n *\n * @example\n * // Behind Cloudflare\n * const ip = detectClientIp(req, { platform: 'cloudflare' });\n *\n * @example\n * // Behind 2 proxies (e.g. CDN + load balancer)\n * const ip = detectClientIp(req, { trustedProxyCount: 2 });\n */\nexport function detectClientIp(\n req: RequestLike | IncomingMessage,\n options: DetectIpOptions = {}\n): string {\n const { platform = 'auto', trustedProxyCount = 1 } = options;\n const r = req as RequestLike;\n\n const resolvedPlatform = platform === 'auto' ? getCachedPlatform() : platform;\n\n // 1. Try platform-specific header (most trusted)\n if (resolvedPlatform !== 'generic' && resolvedPlatform in PLATFORM_HEADERS) {\n const headerName = PLATFORM_HEADERS[resolvedPlatform as keyof typeof PLATFORM_HEADERS];\n if (headerName) {\n if (resolvedPlatform === 'aws-alb') {\n // AWS ALB: parse X-Forwarded-For from the right\n const xff = getHeader(r, 'x-forwarded-for');\n if (xff) {\n const ip = parseForwardedFor(xff, trustedProxyCount);\n if (ip) return sanitizeIp(ip);\n }\n } else {\n const ip = getHeader(r, headerName);\n if (ip) return sanitizeIp(ip);\n }\n }\n }\n\n // 2. Try Express req.ip (respects trust proxy setting)\n if (r.ip) return sanitizeIp(r.ip);\n\n // 3. Try X-Forwarded-For (parsed from the right for safety)\n const xff = getHeader(r, 'x-forwarded-for');\n if (xff) {\n const ip = parseForwardedFor(xff, trustedProxyCount);\n if (ip) return sanitizeIp(ip);\n }\n\n // 4. Try X-Real-IP\n const realIp = getHeader(r, 'x-real-ip');\n if (realIp) return sanitizeIp(realIp);\n\n // 5. Socket remote address\n const socketIp = r.socket?.remoteAddress ?? r.connection?.remoteAddress;\n if (socketIp) return sanitizeIp(socketIp);\n\n return 'unknown';\n}\n\n/**\n * Check if an IP address is a private/internal address.\n *\n * Detects: loopback, private ranges (RFC 1918), link-local, IPv6 equivalents.\n */\nexport function isPrivateIp(ip: string): boolean {\n // Strip IPv4-mapped IPv6 prefix (::ffff:127.0.0.1 -> 127.0.0.1)\n const normalized = ip.startsWith('::ffff:') ? ip.slice(7) : ip;\n\n // IPv4 private ranges\n if (/^127\\./.test(normalized)) return true; // Loopback\n if (/^10\\./.test(normalized)) return true; // Class A private\n if (/^172\\.(1[6-9]|2\\d|3[01])\\./.test(normalized)) return true; // Class B private\n if (/^192\\.168\\./.test(normalized)) return true; // Class C private\n if (/^169\\.254\\./.test(normalized)) return true; // Link-local\n if (/^0\\./.test(normalized)) return true; // Current network\n\n // IPv6\n if (ip === '::1') return true; // Loopback\n if (/^fe80:/i.test(ip)) return true; // Link-local\n if (/^fc00:/i.test(ip)) return true; // Unique local\n if (/^fd/i.test(ip)) return true; // Unique local\n\n return false;\n}\n\n/** Reset cached platform (for testing). */\nexport function _resetPlatformCache(): void {\n _cachedPlatform = null;\n}\n","/**\n * @module @arcis/node/utils/fingerprint\n * Deterministic request fingerprinting via SHA-256.\n *\n * Generates a stable hash from request characteristics for\n * rate limiting keys, abuse detection, and analytics.\n *\n * @example\n * const fp = await fingerprint(req);\n * // \"a3f2b8c1d4e5...\"\n */\n\nimport { createHash } from 'crypto';\nimport { detectClientIp } from './ip';\nimport type { DetectIpOptions } from './ip';\n\nexport interface FingerprintOptions {\n /** Include IP address in fingerprint. Default: true */\n ip?: boolean;\n /** Include User-Agent header. Default: true */\n userAgent?: boolean;\n /** Include Accept header. Default: true */\n accept?: boolean;\n /** Include Accept-Language header. Default: true */\n acceptLanguage?: boolean;\n /** Include Accept-Encoding header. Default: true */\n acceptEncoding?: boolean;\n /** Additional custom components to include */\n custom?: string[];\n /** IP detection options */\n ipOptions?: DetectIpOptions;\n}\n\ninterface RequestLike {\n headers: Record<string, string | string[] | undefined>;\n socket?: { remoteAddress?: string };\n connection?: { remoteAddress?: string };\n ip?: string;\n}\n\nfunction getHeader(req: RequestLike, name: string): string {\n const val = req.headers[name];\n if (Array.isArray(val)) return val[0] ?? '';\n return val ?? '';\n}\n\n/**\n * Generate a deterministic fingerprint for a request.\n *\n * Creates a SHA-256 hash from configurable request components.\n * The fingerprint is stable across requests from the same client\n * (same IP, browser, language settings).\n *\n * @param req - HTTP request object\n * @param options - Fingerprint configuration\n * @returns Hex-encoded SHA-256 hash (64 characters)\n *\n * @example\n * // Default fingerprint (IP + UA + Accept headers)\n * const fp = fingerprint(req);\n *\n * @example\n * // IP-only fingerprint (for simple rate limiting)\n * const fp = fingerprint(req, { userAgent: false, accept: false, acceptLanguage: false, acceptEncoding: false });\n *\n * @example\n * // With custom components\n * const fp = fingerprint(req, { custom: [req.body?.userId] });\n */\nexport function fingerprint(req: RequestLike, options: FingerprintOptions = {}): string {\n const {\n ip = true,\n userAgent = true,\n accept = true,\n acceptLanguage = true,\n acceptEncoding = true,\n custom = [],\n ipOptions,\n } = options;\n\n const components: string[] = [];\n\n if (ip) {\n components.push(`ip:${detectClientIp(req, ipOptions)}`);\n }\n if (userAgent) {\n components.push(`ua:${getHeader(req, 'user-agent')}`);\n }\n if (accept) {\n components.push(`accept:${getHeader(req, 'accept')}`);\n }\n if (acceptLanguage) {\n components.push(`lang:${getHeader(req, 'accept-language')}`);\n }\n if (acceptEncoding) {\n components.push(`enc:${getHeader(req, 'accept-encoding')}`);\n }\n\n for (const c of custom) {\n if (c !== null && c !== undefined) components.push(`custom:${c}`);\n }\n\n // Sort for deterministic ordering\n components.sort();\n\n const hash = createHash('sha256');\n hash.update(components.join('|'));\n return hash.digest('hex');\n}\n"]}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @module @arcis/node/utils/ip
3
+ * Platform-aware client IP detection.
4
+ *
5
+ * Prevents IP spoofing by reading platform-specific headers
6
+ * instead of blindly trusting X-Forwarded-For.
7
+ *
8
+ * @example
9
+ * // Auto-detect platform from environment
10
+ * const ip = detectClientIp(req);
11
+ *
12
+ * // Explicit platform
13
+ * const ip = detectClientIp(req, { platform: 'cloudflare' });
14
+ */
15
+ import type { IncomingMessage } from 'http';
16
+ export type Platform = 'auto' | 'cloudflare' | 'vercel' | 'flyio' | 'render' | 'firebase' | 'aws-alb' | 'generic';
17
+ export interface DetectIpOptions {
18
+ /** Platform to use for header selection. Default: 'auto' */
19
+ platform?: Platform;
20
+ /** Number of trusted proxies (for X-Forwarded-For parsing). Default: 1 */
21
+ trustedProxyCount?: number;
22
+ }
23
+ interface RequestLike {
24
+ headers: Record<string, string | string[] | undefined>;
25
+ socket?: {
26
+ remoteAddress?: string;
27
+ };
28
+ connection?: {
29
+ remoteAddress?: string;
30
+ };
31
+ ip?: string;
32
+ }
33
+ /**
34
+ * Detect the real client IP address from a request.
35
+ *
36
+ * Uses platform-specific headers when available to prevent IP spoofing.
37
+ * Falls back to X-Forwarded-For (parsed from the right) and then
38
+ * the socket remote address.
39
+ *
40
+ * @param req - HTTP request object (Express, raw http, etc.)
41
+ * @param options - Detection options
42
+ * @returns Client IP address, or 'unknown' if unresolvable
43
+ *
44
+ * @example
45
+ * // Auto-detect platform
46
+ * app.use((req, res, next) => {
47
+ * const clientIp = detectClientIp(req);
48
+ * console.log('Client IP:', clientIp);
49
+ * next();
50
+ * });
51
+ *
52
+ * @example
53
+ * // Behind Cloudflare
54
+ * const ip = detectClientIp(req, { platform: 'cloudflare' });
55
+ *
56
+ * @example
57
+ * // Behind 2 proxies (e.g. CDN + load balancer)
58
+ * const ip = detectClientIp(req, { trustedProxyCount: 2 });
59
+ */
60
+ export declare function detectClientIp(req: RequestLike | IncomingMessage, options?: DetectIpOptions): string;
61
+ /**
62
+ * Check if an IP address is a private/internal address.
63
+ *
64
+ * Detects: loopback, private ranges (RFC 1918), link-local, IPv6 equivalents.
65
+ */
66
+ export declare function isPrivateIp(ip: string): boolean;
67
+ /** Reset cached platform (for testing). */
68
+ export declare function _resetPlatformCache(): void;
69
+ export {};
70
+ //# sourceMappingURL=ip.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ip.d.ts","sourceRoot":"","sources":["../../src/utils/ip.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,MAAM,CAAC;AAE5C,MAAM,MAAM,QAAQ,GAChB,MAAM,GACN,YAAY,GACZ,QAAQ,GACR,OAAO,GACP,QAAQ,GACR,UAAU,GACV,SAAS,GACT,SAAS,CAAC;AAEd,MAAM,WAAW,eAAe;IAC9B,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,0EAA0E;IAC1E,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,UAAU,WAAW;IACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;IACvD,MAAM,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACpC,UAAU,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,EAAE,CAAC,EAAE,MAAM,CAAC;CACb;AA6ED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,cAAc,CAC5B,GAAG,EAAE,WAAW,GAAG,eAAe,EAClC,OAAO,GAAE,eAAoB,GAC5B,MAAM,CA2CR;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAmB/C;AAED,2CAA2C;AAC3C,wBAAgB,mBAAmB,IAAI,IAAI,CAE1C"}