@osano/osano-cmp-react-native 0.18.1 → 0.18.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osano/osano-cmp-react-native",
3
- "version": "0.18.1",
3
+ "version": "0.18.3",
4
4
  "description": "Package and example for the react-native Osano CMP SDK",
5
5
  "main": "lib/commonjs/index",
6
6
  "module": "lib/module/index",
@@ -31,9 +31,9 @@
31
31
  ],
32
32
  "scripts": {
33
33
  "example": "yarn workspace osano-cmp-sdk-example",
34
- "test": "jest src/services/__tests__/consent.test.ts src/__tests__/index.test.tsx src/services/__tests__/google-consent-mode.test.ts src/__tests__/OsanoLogger.test.ts src/__tests__/constants.test.ts src/__tests__/reducer.test.ts src/__tests__/selectors.test.ts src/__tests__/ConsentManager.test.ts src/__tests__/ConsentUiBuilder.test.tsx src/utils/__tests__/WebStorage.test.ts src/native/__tests__/OsanoCmp.test.ts",
35
- "test:legacy": "REACT_NATIVE_NEW_ARCH_ENABLED=false jest src/services/__tests__/consent.test.ts src/__tests__/index.test.tsx src/services/__tests__/google-consent-mode.test.ts src/__tests__/OsanoLogger.test.ts src/__tests__/constants.test.ts src/__tests__/reducer.test.ts src/__tests__/selectors.test.ts src/__tests__/ConsentManager.test.ts src/__tests__/ConsentUiBuilder.test.tsx src/utils/__tests__/WebStorage.test.ts src/native/__tests__/OsanoCmp.test.ts",
36
- "test:newarch": "REACT_NATIVE_NEW_ARCH_ENABLED=true jest src/services/__tests__/consent.test.ts src/__tests__/index.test.tsx src/services/__tests__/google-consent-mode.test.ts src/__tests__/OsanoLogger.test.ts src/__tests__/constants.test.ts src/__tests__/reducer.test.ts src/__tests__/selectors.test.ts src/__tests__/ConsentManager.test.ts src/__tests__/ConsentUiBuilder.test.tsx src/utils/__tests__/WebStorage.test.ts src/native/__tests__/OsanoCmp.test.ts",
34
+ "test": "jest src/services/__tests__/consent.test.ts src/services/__tests__/fetchConfig.test.ts src/__tests__/index.test.tsx src/services/__tests__/google-consent-mode.test.ts src/__tests__/OsanoLogger.test.ts src/__tests__/constants.test.ts src/__tests__/reducer.test.ts src/__tests__/selectors.test.ts src/__tests__/ConsentManager.test.ts src/__tests__/ConsentUiBuilder.test.tsx src/utils/__tests__/WebStorage.test.ts src/native/__tests__/OsanoCmp.test.ts",
35
+ "test:legacy": "REACT_NATIVE_NEW_ARCH_ENABLED=false jest src/services/__tests__/consent.test.ts src/services/__tests__/fetchConfig.test.ts src/__tests__/index.test.tsx src/services/__tests__/google-consent-mode.test.ts src/__tests__/OsanoLogger.test.ts src/__tests__/constants.test.ts src/__tests__/reducer.test.ts src/__tests__/selectors.test.ts src/__tests__/ConsentManager.test.ts src/__tests__/ConsentUiBuilder.test.tsx src/utils/__tests__/WebStorage.test.ts src/native/__tests__/OsanoCmp.test.ts",
36
+ "test:newarch": "REACT_NATIVE_NEW_ARCH_ENABLED=true jest src/services/__tests__/consent.test.ts src/services/__tests__/fetchConfig.test.ts src/__tests__/index.test.tsx src/services/__tests__/google-consent-mode.test.ts src/__tests__/OsanoLogger.test.ts src/__tests__/constants.test.ts src/__tests__/reducer.test.ts src/__tests__/selectors.test.ts src/__tests__/ConsentManager.test.ts src/__tests__/ConsentUiBuilder.test.tsx src/utils/__tests__/WebStorage.test.ts src/native/__tests__/OsanoCmp.test.ts",
37
37
  "test:google-consent-mode": "jest src/services/__tests__/google-consent-mode.test.ts",
38
38
  "typecheck": "tsc --noEmit",
39
39
  "lint": "eslint",
@@ -28,9 +28,6 @@ import {
28
28
  clearConsent as clearStoredConsent,
29
29
  fetchConfig,
30
30
  fetchConsent,
31
- getStoredConfig,
32
- hasStaleConfig,
33
- storeConfig,
34
31
  } from './services/consent';
35
32
  import { GoogleConsentModeMapper } from './services/google-consent-mode-mapper';
36
33
  import type { OsanoOverrides } from './types';
@@ -153,17 +150,6 @@ async function fetchConfigWithRetries(
153
150
  retries: number = 3,
154
151
  delay: number = 500,
155
152
  ): Promise<Config | null> {
156
- const isStale = await hasStaleConfig(configId);
157
-
158
- if (!isStale) {
159
- // If the config is not stale, we can return the cached config
160
- const cachedConfig = await getStoredConfig(configId);
161
-
162
- if (cachedConfig) {
163
- return cachedConfig;
164
- }
165
- }
166
-
167
153
  for (let attempt = 1; attempt <= retries; attempt++) {
168
154
  try {
169
155
  const config = await fetchConfig(customerId, configId);
@@ -221,7 +207,6 @@ export class ConsentConfig extends EventEmitter {
221
207
  config.palette,
222
208
  ) as Palette;
223
209
  this._config = merge(this._config, config) as Config;
224
- storeConfig(this._configId, this._config);
225
210
  this.emit('loadSuccess', this._config);
226
211
  })
227
212
  .catch((err) => {
@@ -256,10 +241,11 @@ export class ConsentConfig extends EventEmitter {
256
241
  return [...new Set(categories)];
257
242
  }
258
243
  get jurisdiction(): string {
244
+ // Lowercased to match the web SDK (Osano.cm.jurisdiction), e.g. "us" / "us-ca".
245
+ const composite = `${this.country}${this.region ? `-${this.region}` : ''}`;
259
246
  return (
260
- mappedJurisdictions[`${this.country}`.toLowerCase()] ||
261
- `${this.country}${this.region ? `-${this.region}` : ''}`
262
- );
247
+ mappedJurisdictions[`${this.country}`.toLowerCase()] || composite
248
+ ).toLowerCase();
263
249
  }
264
250
  get ccpaRelaxed(): boolean {
265
251
  return this._config.ccpaRelaxed;
@@ -663,6 +649,15 @@ export class ConsentManager {
663
649
  return this._config;
664
650
  }
665
651
 
652
+ /**
653
+ * The visitor's jurisdiction as a lowercase composite of country and region,
654
+ * e.g. "us" or "us-ca". Mirrors the web SDK's `Osano.cm.jurisdiction` and the
655
+ * Android SDK's `getJurisdiction()`.
656
+ */
657
+ public get jurisdiction(): string {
658
+ return this._config.jurisdiction;
659
+ }
660
+
666
661
  constructor(options?: OsanoOverrides, extUsrData?: string) {
667
662
  const { locale, flavor, iabEnabled, iabFrameworks, iabVersions, palette } =
668
663
  options || {};
package/src/context.ts CHANGED
@@ -24,6 +24,7 @@ type OsanoPublicContextType = {
24
24
  category?: ConsentCategoryType,
25
25
  ) => boolean | ConsentCategoryType[];
26
26
  getDeviceId: () => string;
27
+ getJurisdiction: () => string;
27
28
  clearConsent: () => void;
28
29
  consent: (
29
30
  consent?: ConsentCategoryType | ConsentCategoryType[],
@@ -63,6 +64,7 @@ export const OsanoPublicContext = React.createContext<OsanoPublicContextType>({
63
64
  return false;
64
65
  },
65
66
  getDeviceId: () => '',
67
+ getJurisdiction: () => '',
66
68
  consent: () => Promise.resolve([ConsentCategory.ESSENTIAL]),
67
69
  clearConsent: noop,
68
70
  setLocale: noop,
package/src/index.tsx CHANGED
@@ -157,11 +157,16 @@ export const Osano: React.FunctionComponent<
157
157
  return consentManager.current.deviceId;
158
158
  }, []);
159
159
 
160
+ const handleGetJurisdiction = useCallback(() => {
161
+ return consentManager.current.jurisdiction;
162
+ }, []);
163
+
160
164
  const context = useMemo(() => {
161
165
  return {
162
166
  locale: consentManager.current.translator.locale,
163
167
  getConsent: handleGetConsent,
164
168
  getDeviceId: handleGetDeviceId,
169
+ getJurisdiction: handleGetJurisdiction,
165
170
  consent: handleRecordConsent,
166
171
  clearConsent: handleClearConsent,
167
172
  setLocale: handleSetLocale,
@@ -422,35 +422,17 @@ export const fetchConsent = async (cmp: ConsentManager) => {
422
422
  }
423
423
  };
424
424
 
425
- export const fetchConfig = (customerId: string, configId: string) => {
426
- return API.get(`${OSANO_ORIGIN}/${customerId}/${configId}/config.json`, true)
427
- .then((response) => {
428
- const { headers, json } = response as {
429
- json: Config;
430
- headers: Headers;
431
- };
432
- return merge(
433
- {
434
- ...json,
435
- country: headers.get('x-osano-country'),
436
- region: headers.get('x-osano-region'),
437
- variant: headers.get('x-osano-variant'),
438
- gdprApplies: headers.get('x-osano-gdpr-applies') === 'true',
439
- },
440
- overrideConfig,
441
- ) as Config;
442
- })
443
- .catch(() => {
444
- // Consent not found or network/server error
445
- throw new Error('Error fetching config');
446
- });
447
- };
425
+ const configCacheKey = (customerId: string, configId: string, suffix: string) =>
426
+ `${STORAGE_KEY}_${customerId}_${configId}_${suffix}`;
448
427
 
449
428
  export const getStoredConfig = async (
429
+ customerId: string,
450
430
  configId: string,
451
431
  ): Promise<Config | null> => {
452
432
  try {
453
- const config = await getStorage(`${STORAGE_KEY}_${configId}_config`);
433
+ const config = await getStorage(
434
+ configCacheKey(customerId, configId, 'config'),
435
+ );
454
436
  if (config) {
455
437
  return JSON.parse(config) as Config;
456
438
  }
@@ -461,14 +443,18 @@ export const getStoredConfig = async (
461
443
  };
462
444
 
463
445
  export const storeConfig = async (
446
+ customerId: string,
464
447
  configId: string,
465
448
  config: Config,
466
449
  ): Promise<void> => {
467
450
  try {
468
451
  await Promise.all([
469
- setStorage(`${STORAGE_KEY}_${configId}_config`, JSON.stringify(config)),
470
452
  setStorage(
471
- `${STORAGE_KEY}_${configId}_config_last_stored_at`,
453
+ configCacheKey(customerId, configId, 'config'),
454
+ JSON.stringify(config),
455
+ ),
456
+ setStorage(
457
+ configCacheKey(customerId, configId, 'config_last_stored_at'),
472
458
  JSON.stringify(new Date()),
473
459
  ),
474
460
  ]);
@@ -478,11 +464,12 @@ export const storeConfig = async (
478
464
  };
479
465
 
480
466
  export const getConfigLastStoredAt = async (
467
+ customerId: string,
481
468
  configId: string,
482
469
  ): Promise<Date | null> => {
483
470
  try {
484
471
  const lastStoredAt = await getStorage(
485
- `${STORAGE_KEY}_${configId}_config_last_stored_at`,
472
+ configCacheKey(customerId, configId, 'config_last_stored_at'),
486
473
  );
487
474
  if (lastStoredAt) {
488
475
  return new Date(JSON.parse(lastStoredAt));
@@ -493,20 +480,170 @@ export const getConfigLastStoredAt = async (
493
480
  return null;
494
481
  };
495
482
 
496
- /**
497
- * Determine if a cached config is stale
498
- *
499
- * @async
500
- * @param {string} configId
501
- * @returns {Promise<boolean>}
502
- */
503
- export const hasStaleConfig = async (configId: string): Promise<boolean> => {
504
- const lastStoredAt = await getConfigLastStoredAt(configId);
483
+ export const hasStaleConfig = async (
484
+ customerId: string,
485
+ configId: string,
486
+ ): Promise<boolean> => {
487
+ const lastStoredAt = await getConfigLastStoredAt(customerId, configId);
505
488
  if (!lastStoredAt) {
506
489
  return true;
507
490
  }
508
491
  const now = new Date();
509
- const diff = now.getTime() - lastStoredAt.getTime();
510
- const diffInMinutes = Math.floor(diff / 60000);
511
- return diffInMinutes > 1440; // 24 hours = 1440 minutes
492
+ // Treat clock skew (future timestamp) as expired so we force a full refetch
493
+ if (lastStoredAt.getTime() > now.getTime()) {
494
+ return true;
495
+ }
496
+ return (
497
+ lastStoredAt.getFullYear() !== now.getFullYear() ||
498
+ lastStoredAt.getMonth() !== now.getMonth() ||
499
+ lastStoredAt.getDate() !== now.getDate()
500
+ );
501
+ };
502
+
503
+ export const getStoredEtag = async (
504
+ customerId: string,
505
+ configId: string,
506
+ ): Promise<string | null> => {
507
+ try {
508
+ return (
509
+ (await getStorage(configCacheKey(customerId, configId, 'config_etag'))) ??
510
+ null
511
+ );
512
+ } catch {
513
+ return null;
514
+ }
515
+ };
516
+
517
+ export const getStoredLastModified = async (
518
+ customerId: string,
519
+ configId: string,
520
+ ): Promise<string | null> => {
521
+ try {
522
+ return (
523
+ (await getStorage(
524
+ configCacheKey(customerId, configId, 'config_last_modified'),
525
+ )) ?? null
526
+ );
527
+ } catch {
528
+ return null;
529
+ }
530
+ };
531
+
532
+ export const fetchConfig = async (
533
+ customerId: string,
534
+ configId: string,
535
+ ): Promise<Config> => {
536
+ const [cachedConfig, stale, storedEtag, storedLastModified] =
537
+ await Promise.all([
538
+ getStoredConfig(customerId, configId),
539
+ hasStaleConfig(customerId, configId),
540
+ getStoredEtag(customerId, configId),
541
+ getStoredLastModified(customerId, configId),
542
+ ]);
543
+
544
+ const conditionalHeaders: Record<string, string> = {};
545
+ if (cachedConfig && !stale) {
546
+ if (storedEtag) conditionalHeaders['If-None-Match'] = storedEtag;
547
+ if (storedLastModified)
548
+ conditionalHeaders['If-Modified-Since'] = storedLastModified;
549
+ }
550
+
551
+ try {
552
+ const response = await fetch(
553
+ `${OSANO_ORIGIN}/${customerId}/${configId}/config.json`,
554
+ {
555
+ method: 'GET',
556
+ headers: {
557
+ Accept: 'application/json',
558
+ 'Content-Type': 'application/json',
559
+ 'Cache-Control': 'no-cache',
560
+ ...conditionalHeaders,
561
+ },
562
+ mode: 'cors',
563
+ referrer: 'no-referrer',
564
+ },
565
+ );
566
+
567
+ if (response.status === 304) {
568
+ if (!cachedConfig) {
569
+ throw new Error('304 with no cached config');
570
+ }
571
+
572
+ // 304 may still carry updated geo/jurisdiction headers (e.g. user
573
+ // moved between regions). Apply them on top of the cached body, but
574
+ // fall back to the previously-cached values when a header is absent
575
+ // rather than the first-fetch defaults.
576
+ const variant =
577
+ response.headers.get('x-osano-variant') ?? cachedConfig.variant;
578
+ const country =
579
+ response.headers.get('x-osano-country') ?? cachedConfig.country;
580
+ const region =
581
+ response.headers.get('x-osano-region') ?? cachedConfig.region;
582
+ const gdprAppliesHeader = response.headers.get('x-osano-gdpr-applies');
583
+ const gdprApplies =
584
+ gdprAppliesHeader != null
585
+ ? gdprAppliesHeader === 'true'
586
+ : cachedConfig.gdprApplies;
587
+
588
+ const updated = merge(
589
+ { ...cachedConfig, country, region, variant, gdprApplies },
590
+ overrideConfig,
591
+ ) as Config;
592
+
593
+ // Persist the body update without bumping lastUsage — TTL stays
594
+ // anchored to the last 200 response.
595
+ await setStorage(
596
+ configCacheKey(customerId, configId, 'config'),
597
+ JSON.stringify(updated),
598
+ );
599
+
600
+ return updated;
601
+ }
602
+
603
+ if (!response.ok) {
604
+ throw response;
605
+ }
606
+
607
+ const json = (await response.json()) as Config;
608
+
609
+ const variant = response.headers.get('x-osano-variant') ?? 'two';
610
+ const country = response.headers.get('x-osano-country') ?? 'us';
611
+ const region = response.headers.get('x-osano-region') ?? '';
612
+ const gdprApplies = response.headers.get('x-osano-gdpr-applies') === 'true';
613
+ const newEtag = response.headers.get('etag');
614
+ const newLastModified = response.headers.get('last-modified');
615
+
616
+ const config = merge(
617
+ { ...json, country, region, variant, gdprApplies },
618
+ overrideConfig,
619
+ ) as Config;
620
+
621
+ const writes: Promise<unknown>[] = [
622
+ storeConfig(customerId, configId, config),
623
+ ];
624
+ if (newEtag) {
625
+ writes.push(
626
+ setStorage(
627
+ configCacheKey(customerId, configId, 'config_etag'),
628
+ newEtag,
629
+ ),
630
+ );
631
+ }
632
+ if (newLastModified) {
633
+ writes.push(
634
+ setStorage(
635
+ configCacheKey(customerId, configId, 'config_last_modified'),
636
+ newLastModified,
637
+ ),
638
+ );
639
+ }
640
+ await Promise.all(writes);
641
+
642
+ return config;
643
+ } catch {
644
+ if (cachedConfig) {
645
+ return cachedConfig;
646
+ }
647
+ throw new Error('Error fetching config');
648
+ }
512
649
  };