@el7ven/cookie-kit 0.3.1 → 0.3.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.
@@ -0,0 +1,536 @@
1
+ /**
2
+ * GeoDetector — detect user's geographic region
3
+ * @module GeoDetector
4
+ */
5
+
6
+ import { getRegion, requiresConsentBanner } from './RegionRules.js'
7
+
8
+ export class GeoDetector {
9
+ constructor(config = {}) {
10
+ this.config = {
11
+ mode: 'auto', // 'auto' | 'eu-only' | 'always' | 'never'
12
+
13
+ // Detection methods (in order of preference)
14
+ useEdgeHeaders: true, // CF-IPCountry, X-Vercel-IP-Country, etc
15
+ useIPAPI: true, // ipapi.co, ipinfo.io (requires API call)
16
+ useTimezoneFallback: true, // Browser timezone (approximate)
17
+
18
+ // IP API settings
19
+ ipApiUrl: 'https://ipinfo.io/json',
20
+ ipApiTimeout: 3000,
21
+ ipApiToken: null, // Add an API key to increase limits
22
+
23
+ // Backup IP APIs
24
+ backupIpApiUrl: 'https://ipinfo.io/json',
25
+ freeGeoIpUrl: 'https://freegeoip.app/json/',
26
+ ipApiComUrl: 'https://ip-api.com/json/',
27
+
28
+ // Cache settings
29
+ cacheRegion: true,
30
+ cacheKey: 'cmp_user_region',
31
+ cacheDuration: 24 * 60 * 60 * 1000, // 24h for production
32
+
33
+ debug: false,
34
+ ...config
35
+ }
36
+
37
+ this.detectedRegion = null
38
+ this.detectionMethod = null
39
+ }
40
+
41
+ /**
42
+ * Detect user's region
43
+ * @returns {Promise<string>} Region code (EU, UK, US_CA, ROW, etc)
44
+ */
45
+ async detect() {
46
+ this._debug('Starting geo detection...')
47
+
48
+ // Check cache first
49
+ if (this.config.cacheRegion) {
50
+ const cached = this._getCachedRegion()
51
+ if (cached) {
52
+ this._debug('Using cached region:', cached)
53
+ this.detectedRegion = cached.region
54
+ this.detectionMethod = 'cache'
55
+ return cached.region
56
+ }
57
+ }
58
+
59
+ let countryCode = null
60
+ let stateCode = null
61
+
62
+ // 1. Try edge headers (fastest, most accurate)
63
+ if (this.config.useEdgeHeaders) {
64
+ const edgeResult = this._detectFromEdgeHeaders()
65
+ if (edgeResult) {
66
+ countryCode = edgeResult.country
67
+ stateCode = edgeResult.state
68
+ this.detectionMethod = 'edge-headers'
69
+ this._debug('Detected from edge headers:', countryCode)
70
+ }
71
+ }
72
+
73
+ // 2. Try IP API (requires network request)
74
+ if (!countryCode && this.config.useIPAPI) {
75
+ // Try primary API (ipinfo.io)
76
+ try {
77
+ const ipResult = await this._detectFromIPAPI()
78
+ if (ipResult) {
79
+ countryCode = ipResult.country
80
+ stateCode = ipResult.state
81
+ this.detectionMethod = 'ip-api'
82
+ this._debug('Detected from IP API:', countryCode)
83
+ }
84
+ } catch (error) {
85
+ this._debug('Primary IP API failed:', error)
86
+
87
+ // Try freegeoip.app
88
+ try {
89
+ const freeResult = await this._detectFromFreeGeoIp()
90
+ if (freeResult) {
91
+ countryCode = freeResult.country
92
+ stateCode = freeResult.state
93
+ this.detectionMethod = 'free-geo-ip'
94
+ this._debug('Detected from freegeoip.app:', countryCode)
95
+ }
96
+ } catch (freeError) {
97
+ this._debug('freegeoip.app failed:', freeError)
98
+
99
+ // Try ip-api.com
100
+ try {
101
+ const comResult = await this._detectFromIpApiCom()
102
+ if (comResult) {
103
+ countryCode = comResult.country
104
+ stateCode = comResult.state
105
+ this.detectionMethod = 'ip-api-com'
106
+ this._debug('Detected from ip-api.com:', countryCode)
107
+ }
108
+ } catch (comError) {
109
+ this._debug('ip-api.com also failed:', comError)
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ // 3. Fallback to timezone (approximate)
116
+ if (!countryCode && this.config.useTimezoneFallback) {
117
+ countryCode = this._detectFromTimezone()
118
+ this.detectionMethod = 'timezone-fallback'
119
+ this._debug('Detected from timezone (approximate):', countryCode)
120
+ }
121
+
122
+ // Get region from country code
123
+ const region = countryCode ? getRegion(countryCode, stateCode) : 'ROW'
124
+ this.detectedRegion = region
125
+
126
+ this._debug(`Country: ${countryCode}, State: ${stateCode}, Region: ${region}`)
127
+
128
+ // Cache result
129
+ if (this.config.cacheRegion) {
130
+ this._cacheRegion(region)
131
+ }
132
+
133
+ this._debug(`Region detected: ${region} (method: ${this.detectionMethod})`)
134
+ return region
135
+ }
136
+
137
+ /**
138
+ * Check if consent banner should be shown
139
+ * @returns {Promise<boolean>}
140
+ */
141
+ async shouldShowBanner() {
142
+ const mode = this.config.mode
143
+
144
+ // Mode overrides
145
+ if (mode === 'always') return true
146
+ if (mode === 'never') return false
147
+
148
+ // Detect region
149
+ const region = await this.detect()
150
+
151
+ if (mode === 'eu-only') {
152
+ // Show only for EU/EEA/UK/CH
153
+ return ['EU', 'EEA', 'UK', 'CH'].includes(region)
154
+ }
155
+
156
+ // Auto mode: show if region requires consent
157
+ return requiresConsentBanner(region)
158
+ }
159
+
160
+ /**
161
+ * Get detected region (without re-detecting)
162
+ * @returns {string|null}
163
+ */
164
+ getRegion() {
165
+ return this.detectedRegion
166
+ }
167
+
168
+ /**
169
+ * Get detection method used
170
+ * @returns {string|null}
171
+ */
172
+ getDetectionMethod() {
173
+ return this.detectionMethod
174
+ }
175
+
176
+ /**
177
+ * Clear cached region
178
+ */
179
+ clearCache() {
180
+ if (typeof localStorage !== 'undefined') {
181
+ localStorage.removeItem(this.config.cacheKey)
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Force refresh region (bypass cache)
187
+ * @returns {Promise<string>}
188
+ */
189
+ async forceRefresh() {
190
+ this.clearCache()
191
+ this.detectedRegion = null
192
+ this.detectionMethod = null
193
+ return this.detect()
194
+ }
195
+
196
+ // --- Detection Methods ---
197
+
198
+ /**
199
+ * Detect from edge/CDN headers
200
+ * @private
201
+ * @returns {Object|null} { country, state }
202
+ */
203
+ _detectFromEdgeHeaders() {
204
+ // This only works server-side or with edge functions
205
+ // In browser, we can't access these headers directly
206
+ // This is a placeholder for SSR/Edge implementation
207
+
208
+ if (typeof window === 'undefined') {
209
+ // Server-side: check request headers
210
+ // Example: req.headers['cf-ipcountry']
211
+ return null
212
+ }
213
+
214
+ // Browser: not available
215
+ return null
216
+ }
217
+
218
+ /**
219
+ * Detect from IP API
220
+ * @private
221
+ * @returns {Promise<Object|null>} { country, state }
222
+ */
223
+ async _detectFromIPAPI() {
224
+ if (typeof fetch === 'undefined') {
225
+ return null
226
+ }
227
+
228
+ try {
229
+ const controller = new AbortController()
230
+ const timeoutId = setTimeout(() => controller.abort(), this.config.ipApiTimeout)
231
+
232
+ const url = this.config.ipApiToken
233
+ ? `${this.config.ipApiUrl}?token=${this.config.ipApiToken}`
234
+ : this.config.ipApiUrl
235
+
236
+ const response = await fetch(url, {
237
+ signal: controller.signal
238
+ })
239
+
240
+ clearTimeout(timeoutId)
241
+
242
+ if (!response.ok) {
243
+ throw new Error(`IP API returned ${response.status}`)
244
+ }
245
+
246
+ const data = await response.json()
247
+
248
+ this._debug('IP API response:', data)
249
+
250
+ return {
251
+ country: data.country_code || data.country,
252
+ state: data.region_code || this._mapUSState(data.region) // Map full state name to state code
253
+ }
254
+ } catch (error) {
255
+ this._debug('IP API error:', error.message)
256
+ return null
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Map US state names to codes
262
+ * @private
263
+ */
264
+ _mapUSState(stateName) {
265
+ if (!stateName) return null
266
+
267
+ const stateMap = {
268
+ 'Alabama': 'AL', 'Alaska': 'AK', 'Arizona': 'AZ', 'Arkansas': 'AR',
269
+ 'California': 'CA', 'Colorado': 'CO', 'Connecticut': 'CT', 'Delaware': 'DE',
270
+ 'Florida': 'FL', 'Georgia': 'GA', 'Hawaii': 'HI', 'Idaho': 'ID',
271
+ 'Illinois': 'IL', 'Indiana': 'IN', 'Iowa': 'IA', 'Kansas': 'KS',
272
+ 'Kentucky': 'KY', 'Louisiana': 'LA', 'Maine': 'ME', 'Maryland': 'MD',
273
+ 'Massachusetts': 'MA', 'Michigan': 'MI', 'Minnesota': 'MN', 'Mississippi': 'MS',
274
+ 'Missouri': 'MO', 'Montana': 'MT', 'Nebraska': 'NE', 'Nevada': 'NV',
275
+ 'New Hampshire': 'NH', 'New Jersey': 'NJ', 'New Mexico': 'NM', 'New York': 'NY',
276
+ 'North Carolina': 'NC', 'North Dakota': 'ND', 'Ohio': 'OH', 'Oklahoma': 'OK',
277
+ 'Oregon': 'OR', 'Pennsylvania': 'PA', 'Rhode Island': 'RI', 'South Carolina': 'SC',
278
+ 'South Dakota': 'SD', 'Tennessee': 'TN', 'Texas': 'TX', 'Utah': 'UT',
279
+ 'Vermont': 'VT', 'Virginia': 'VA', 'Washington': 'WA', 'West Virginia': 'WV',
280
+ 'Wisconsin': 'WI', 'Wyoming': 'WY'
281
+ }
282
+
283
+ return stateMap[stateName] || null
284
+ }
285
+
286
+ /**
287
+ * Detect from freegeoip.app
288
+ * @private
289
+ * @returns {Promise<Object|null>} { country, state }
290
+ */
291
+ async _detectFromFreeGeoIp() {
292
+ if (typeof fetch === 'undefined') {
293
+ return null
294
+ }
295
+
296
+ try {
297
+ const controller = new AbortController()
298
+ const timeoutId = setTimeout(() => controller.abort(), this.config.ipApiTimeout)
299
+
300
+ const response = await fetch(this.config.freeGeoIpUrl, {
301
+ signal: controller.signal
302
+ })
303
+
304
+ clearTimeout(timeoutId)
305
+
306
+ if (!response.ok) {
307
+ throw new Error(`freegeoip.app returned ${response.status}`)
308
+ }
309
+
310
+ const data = await response.json()
311
+
312
+ this._debug('freegeoip.app response:', data)
313
+
314
+ // freegeoip.app format: {country_code: "US", region_name: "Florida"}
315
+ return {
316
+ country: data.country_code,
317
+ state: this._mapUSState(data.region_name)
318
+ }
319
+ } catch (error) {
320
+ this._debug('freegeoip.app error:', error.message)
321
+ return null
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Detect from ip-api.com
327
+ * @private
328
+ * @returns {Promise<Object|null>} { country, state }
329
+ */
330
+ async _detectFromIpApiCom() {
331
+ if (typeof fetch === 'undefined') {
332
+ return null
333
+ }
334
+
335
+ try {
336
+ const controller = new AbortController()
337
+ const timeoutId = setTimeout(() => controller.abort(), this.config.ipApiTimeout)
338
+
339
+ const response = await fetch(this.config.ipApiComUrl, {
340
+ signal: controller.signal
341
+ })
342
+
343
+ clearTimeout(timeoutId)
344
+
345
+ if (!response.ok) {
346
+ throw new Error(`ip-api.com returned ${response.status}`)
347
+ }
348
+
349
+ const data = await response.json()
350
+
351
+ this._debug('ip-api.com response:', data)
352
+
353
+ // ip-api.com format: {countryCode: "US", regionName: "Florida"}
354
+ return {
355
+ country: data.countryCode,
356
+ state: this._mapUSState(data.regionName)
357
+ }
358
+ } catch (error) {
359
+ this._debug('ip-api.com error:', error.message)
360
+ return null
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Detect from backup IP API (ipinfo.io)
366
+ * @private
367
+ * @returns {Promise<Object|null>} { country, state }
368
+ */
369
+ async _detectFromBackupIPAPI() {
370
+ if (typeof fetch === 'undefined') {
371
+ return null
372
+ }
373
+
374
+ try {
375
+ const controller = new AbortController()
376
+ const timeoutId = setTimeout(() => controller.abort(), this.config.ipApiTimeout)
377
+
378
+ const response = await fetch(this.config.backupIpApiUrl, {
379
+ signal: controller.signal
380
+ })
381
+
382
+ clearTimeout(timeoutId)
383
+
384
+ if (!response.ok) {
385
+ throw new Error(`Backup IP API returned ${response.status}`)
386
+ }
387
+
388
+ const data = await response.json()
389
+
390
+ this._debug('Backup IP API response:', data)
391
+
392
+ // ipinfo.io format: { country: "US", region: "Florida" }
393
+ return {
394
+ country: data.country,
395
+ state: this._mapUSState(data.region) // Map full state name to state code
396
+ }
397
+ } catch (error) {
398
+ this._debug('Backup IP API error:', error.message)
399
+ return null
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Detect from browser timezone (approximate)
405
+ * @private
406
+ * @returns {string|null} Country code
407
+ */
408
+ _detectFromTimezone() {
409
+ if (typeof Intl === 'undefined') {
410
+ return null
411
+ }
412
+
413
+ try {
414
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
415
+
416
+ // Approximate mapping: timezone → country
417
+ const timezoneMap = {
418
+ // Europe
419
+ 'Europe/London': 'GB',
420
+ 'Europe/Dublin': 'IE',
421
+ 'Europe/Paris': 'FR',
422
+ 'Europe/Berlin': 'DE',
423
+ 'Europe/Rome': 'IT',
424
+ 'Europe/Madrid': 'ES',
425
+ 'Europe/Amsterdam': 'NL',
426
+ 'Europe/Brussels': 'BE',
427
+ 'Europe/Vienna': 'AT',
428
+ 'Europe/Stockholm': 'SE',
429
+ 'Europe/Copenhagen': 'DK',
430
+ 'Europe/Helsinki': 'FI',
431
+ 'Europe/Oslo': 'NO',
432
+ 'Europe/Warsaw': 'PL',
433
+ 'Europe/Prague': 'CZ',
434
+ 'Europe/Budapest': 'HU',
435
+ 'Europe/Bucharest': 'RO',
436
+ 'Europe/Athens': 'GR',
437
+ 'Europe/Lisbon': 'PT',
438
+ 'Europe/Zurich': 'CH',
439
+
440
+ // Americas
441
+ 'America/New_York': 'US',
442
+ 'America/Chicago': 'US',
443
+ 'America/Denver': 'US',
444
+ 'America/Los_Angeles': 'US',
445
+ 'America/Sao_Paulo': 'BR',
446
+ 'America/Mexico_City': 'MX',
447
+ 'America/Toronto': 'CA',
448
+
449
+ // Asia
450
+ 'Asia/Tokyo': 'JP',
451
+ 'Asia/Shanghai': 'CN',
452
+ 'Asia/Hong_Kong': 'HK',
453
+ 'Asia/Singapore': 'SG',
454
+ 'Asia/Dubai': 'AE',
455
+ 'Asia/Kolkata': 'IN',
456
+
457
+ // Australia
458
+ 'Australia/Sydney': 'AU',
459
+ 'Australia/Melbourne': 'AU'
460
+ }
461
+
462
+ const countryCode = timezoneMap[timezone]
463
+
464
+ if (countryCode) {
465
+ return countryCode
466
+ }
467
+
468
+ // Fallback: check timezone prefix
469
+ if (timezone.startsWith('Europe/')) {
470
+ // Return a representative EU country instead of 'EU'
471
+ // This ensures getRegion() works correctly
472
+ return 'DE' // Germany as default EU country
473
+ }
474
+
475
+ return null
476
+ } catch (error) {
477
+ this._debug('Timezone detection error:', error)
478
+ return null
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Get cached region
484
+ * @private
485
+ */
486
+ _getCachedRegion() {
487
+ if (typeof localStorage === 'undefined') {
488
+ return null
489
+ }
490
+
491
+ try {
492
+ const cached = localStorage.getItem(this.config.cacheKey)
493
+ if (!cached) return null
494
+
495
+ const data = JSON.parse(cached)
496
+ const age = Date.now() - data.timestamp
497
+
498
+ if (age > this.config.cacheDuration) {
499
+ // Cache expired
500
+ localStorage.removeItem(this.config.cacheKey)
501
+ return null
502
+ }
503
+
504
+ return data
505
+ } catch (error) {
506
+ return null
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Cache region
512
+ * @private
513
+ */
514
+ _cacheRegion(region) {
515
+ if (typeof localStorage === 'undefined') {
516
+ return
517
+ }
518
+
519
+ try {
520
+ const data = {
521
+ region,
522
+ timestamp: Date.now(),
523
+ method: this.detectionMethod
524
+ }
525
+ localStorage.setItem(this.config.cacheKey, JSON.stringify(data))
526
+ } catch (error) {
527
+ this._debug('Failed to cache region:', error)
528
+ }
529
+ }
530
+
531
+ _debug(...args) {
532
+ if (this.config.debug) {
533
+ console.log('[GeoDetector]', ...args)
534
+ }
535
+ }
536
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Region Rules — GDPR/CCPA region definitions
3
+ * @module RegionRules
4
+ */
5
+
6
+ export const REGION_RULES = {
7
+ // European Union (GDPR)
8
+ EU: {
9
+ name: 'European Union',
10
+ countries: [
11
+ 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',
12
+ 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
13
+ 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'MD'
14
+ ],
15
+ regulation: 'GDPR',
16
+ requiresConsent: true,
17
+ defaultMode: 'opt-in'
18
+ },
19
+
20
+ // European Economic Area (GDPR)
21
+ EEA: {
22
+ name: 'European Economic Area',
23
+ countries: ['IS', 'LI', 'NO'],
24
+ regulation: 'GDPR',
25
+ requiresConsent: true,
26
+ defaultMode: 'opt-in'
27
+ },
28
+
29
+ // United Kingdom (UK GDPR)
30
+ UK: {
31
+ name: 'United Kingdom',
32
+ countries: ['GB'],
33
+ regulation: 'UK GDPR',
34
+ requiresConsent: true,
35
+ defaultMode: 'opt-in'
36
+ },
37
+
38
+ // Switzerland (FADP)
39
+ CH: {
40
+ name: 'Switzerland',
41
+ countries: ['CH'],
42
+ regulation: 'FADP',
43
+ requiresConsent: true,
44
+ defaultMode: 'opt-in'
45
+ },
46
+
47
+ // United States (Federal)
48
+ US: {
49
+ name: 'United States',
50
+ countries: ['US'],
51
+ regulation: 'US Privacy Laws',
52
+ requiresConsent: true,
53
+ defaultMode: 'opt-out'
54
+ },
55
+
56
+ // Brazil (LGPD)
57
+ BR: {
58
+ name: 'Brazil',
59
+ countries: ['BR'],
60
+ regulation: 'LGPD',
61
+ requiresConsent: true,
62
+ defaultMode: 'opt-in'
63
+ },
64
+
65
+ // Canada (PIPEDA)
66
+ CA: {
67
+ name: 'Canada',
68
+ countries: ['CA'],
69
+ regulation: 'PIPEDA',
70
+ requiresConsent: true,
71
+ defaultMode: 'opt-in'
72
+ },
73
+
74
+ // Rest of World
75
+ ROW: {
76
+ name: 'Rest of World',
77
+ countries: [],
78
+ regulation: null,
79
+ requiresConsent: true, // Show essential modal
80
+ defaultMode: 'opt-out',
81
+ essentialOnly: true // Only necessary cookies
82
+ }
83
+ }
84
+
85
+ export function isEU(countryCode) {
86
+ return REGION_RULES.EU.countries.includes(countryCode)
87
+ }
88
+
89
+ export function isEEA(countryCode) {
90
+ return REGION_RULES.EEA.countries.includes(countryCode)
91
+ }
92
+
93
+ export function isUK(countryCode) {
94
+ return countryCode === 'GB'
95
+ }
96
+
97
+ export function requiresGDPR(countryCode) {
98
+ return isEU(countryCode) || isEEA(countryCode) || isUK(countryCode) || countryCode === 'CH'
99
+ }
100
+
101
+ export function getRegion(countryCode, stateCode = null) {
102
+ if (isEU(countryCode)) return 'EU'
103
+ if (isEEA(countryCode)) return 'EEA'
104
+ if (isUK(countryCode)) return 'UK'
105
+ if (countryCode === 'CH') return 'CH'
106
+ if (countryCode === 'BR') return 'BR'
107
+ if (countryCode === 'CA') return 'CA'
108
+ if (countryCode === 'US') return 'US' // All US states now use US region
109
+
110
+ return 'ROW'
111
+ }
112
+
113
+ export function requiresConsentBanner(region) {
114
+ const rule = REGION_RULES[region]
115
+ return rule ? rule.requiresConsent : false
116
+ }
117
+
118
+ export function getRegulation(region) {
119
+ const rule = REGION_RULES[region]
120
+ return rule ? rule.regulation : null
121
+ }
122
+
123
+ export function isEssentialOnly(region) {
124
+ const rule = REGION_RULES[region]
125
+ return rule ? rule.essentialOnly || false : false
126
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Geo Detection System — detect user's geographic region for GDPR/CCPA compliance
3
+ * @module geo
4
+ */
5
+
6
+ export { GeoDetector } from './GeoDetector.js'
7
+ export {
8
+ REGION_RULES,
9
+ isEU,
10
+ isEEA,
11
+ isUK,
12
+ requiresGDPR,
13
+ getRegion,
14
+ requiresConsentBanner,
15
+ getRegulation
16
+ } from './RegionRules.js'