@osano/osano-cmp-react-native 0.18.4 → 0.18.5

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 (36) hide show
  1. package/lib/commonjs/ConsentManager.js +50 -1
  2. package/lib/commonjs/ConsentManager.js.map +1 -1
  3. package/lib/commonjs/context.js +1 -0
  4. package/lib/commonjs/context.js.map +1 -1
  5. package/lib/commonjs/index.js +4 -0
  6. package/lib/commonjs/index.js.map +1 -1
  7. package/lib/commonjs/services/consent.js +129 -29
  8. package/lib/commonjs/services/consent.js.map +1 -1
  9. package/lib/commonjs/services/gdprMappings.js +115 -0
  10. package/lib/commonjs/services/gdprMappings.js.map +1 -0
  11. package/lib/module/ConsentManager.js +50 -1
  12. package/lib/module/ConsentManager.js.map +1 -1
  13. package/lib/module/context.js +1 -0
  14. package/lib/module/context.js.map +1 -1
  15. package/lib/module/index.js +4 -0
  16. package/lib/module/index.js.map +1 -1
  17. package/lib/module/services/consent.js +127 -28
  18. package/lib/module/services/consent.js.map +1 -1
  19. package/lib/module/services/gdprMappings.js +109 -0
  20. package/lib/module/services/gdprMappings.js.map +1 -0
  21. package/lib/typescript/src/ConsentManager.d.ts +24 -0
  22. package/lib/typescript/src/ConsentManager.d.ts.map +1 -1
  23. package/lib/typescript/src/context.d.ts +1 -0
  24. package/lib/typescript/src/context.d.ts.map +1 -1
  25. package/lib/typescript/src/index.d.ts.map +1 -1
  26. package/lib/typescript/src/services/consent.d.ts +6 -1
  27. package/lib/typescript/src/services/consent.d.ts.map +1 -1
  28. package/lib/typescript/src/services/gdprMappings.d.ts +32 -0
  29. package/lib/typescript/src/services/gdprMappings.d.ts.map +1 -0
  30. package/package.json +4 -4
  31. package/src/ConsentManager.ts +68 -2
  32. package/src/context.ts +2 -0
  33. package/src/index.tsx +10 -0
  34. package/src/services/consent.ts +168 -35
  35. package/src/services/gdprMappings.ts +126 -0
  36. package/src/types/index.d.ts +9 -0
@@ -683,12 +683,22 @@ export class ConsentManager {
683
683
  private _translator: Translator = new Translator(this);
684
684
  private _overridePalette?: Palette;
685
685
  private _googleConsentModeCallback?: GoogleConsentModeCallback;
686
+ private _consentExpirationTestingSeconds?: number;
686
687
 
687
688
  private _deviceId = '';
688
689
  public get deviceId(): string {
689
690
  return this._deviceId;
690
691
  }
691
692
 
693
+ /**
694
+ * The debug-only consent expiration testing window, in seconds, or
695
+ * `undefined` when testing mode is disabled. Read by the consent service to
696
+ * decide whether stored consent should be treated as expired.
697
+ */
698
+ public get consentExpirationTestingSeconds(): number | undefined {
699
+ return this._consentExpirationTestingSeconds;
700
+ }
701
+
692
702
  public get customerId(): string {
693
703
  return this._config.customerId;
694
704
  }
@@ -727,8 +737,28 @@ export class ConsentManager {
727
737
  }
728
738
 
729
739
  constructor(options?: OsanoOverrides, extUsrData?: string) {
730
- const { locale, flavor, iabEnabled, iabFrameworks, iabVersions, palette } =
731
- options || {};
740
+ const {
741
+ locale,
742
+ flavor,
743
+ iabEnabled,
744
+ iabFrameworks,
745
+ iabVersions,
746
+ palette,
747
+ consentExpirationTestingSeconds,
748
+ } = options || {};
749
+
750
+ // Debug-only: allow a short consent expiration window for testing. Held
751
+ // in memory only (never persisted), and ignored outside __DEV__.
752
+ if (
753
+ __DEV__ &&
754
+ consentExpirationTestingSeconds != null &&
755
+ consentExpirationTestingSeconds > 0
756
+ ) {
757
+ this._consentExpirationTestingSeconds = consentExpirationTestingSeconds;
758
+ OsanoLogger.warn(
759
+ `⚠️ TESTING MODE - Consent will expire in ${consentExpirationTestingSeconds} seconds`,
760
+ );
761
+ }
732
762
 
733
763
  this._config.iabEnabled = iabEnabled ?? undefined;
734
764
  this._config.iabFrameworks = iabFrameworks || this._config.iabFrameworks;
@@ -994,6 +1024,42 @@ export class ConsentManager {
994
1024
  return this;
995
1025
  }
996
1026
 
1027
+ /**
1028
+ * Debug-only. Force stored consent to expire after `expirationSeconds` so the
1029
+ * consent banner can be re-triggered quickly during testing. Pass `null` (or a
1030
+ * non-positive value) to disable. The value is held in memory only and is
1031
+ * never persisted. Outside `__DEV__` this is a no-op (mirrors the Android
1032
+ * SDK's `setConsentExpirationTestingMode`, which is only available in debug
1033
+ * builds).
1034
+ *
1035
+ * Note: the expiration check runs while consent is loaded, so setting this at
1036
+ * init time (via `overrides.consentExpirationTestingSeconds`) is the reliable
1037
+ * path for a reload-and-reprompt test. A runtime call takes effect on the
1038
+ * next consent load.
1039
+ *
1040
+ * @param expirationSeconds - Seconds until consent expires, or `null` to disable.
1041
+ * @returns this ConsentManager instance for method chaining
1042
+ */
1043
+ public setConsentExpirationTestingMode(
1044
+ expirationSeconds: number | null,
1045
+ ): this {
1046
+ if (!__DEV__) {
1047
+ // Documented as a no-op outside debug builds — stay completely silent
1048
+ // so production callers see no logging or side effects.
1049
+ return this;
1050
+ }
1051
+ if (expirationSeconds == null || expirationSeconds <= 0) {
1052
+ this._consentExpirationTestingSeconds = undefined;
1053
+ OsanoLogger.info('Consent expiration testing mode disabled');
1054
+ } else {
1055
+ this._consentExpirationTestingSeconds = expirationSeconds;
1056
+ OsanoLogger.warn(
1057
+ `⚠️ TESTING MODE - Consent will expire in ${expirationSeconds} seconds`,
1058
+ );
1059
+ }
1060
+ return this;
1061
+ }
1062
+
997
1063
  /**
998
1064
  * Trigger Google Consent Mode update with current consent categories
999
1065
  *
package/src/context.ts CHANGED
@@ -32,6 +32,7 @@ type OsanoPublicContextType = {
32
32
  setLocale: (locale: string) => void;
33
33
  showBanner: () => void;
34
34
  showDrawer: () => void;
35
+ setConsentExpirationTestingMode: (expirationSeconds: number | null) => void;
35
36
  isReady: boolean;
36
37
  };
37
38
 
@@ -70,6 +71,7 @@ export const OsanoPublicContext = React.createContext<OsanoPublicContextType>({
70
71
  setLocale: noop,
71
72
  showBanner: noop,
72
73
  showDrawer: noop,
74
+ setConsentExpirationTestingMode: noop,
73
75
  isReady: false,
74
76
  });
75
77
 
package/src/index.tsx CHANGED
@@ -161,6 +161,15 @@ export const Osano: React.FunctionComponent<
161
161
  return consentManager.current.jurisdiction;
162
162
  }, []);
163
163
 
164
+ const handleSetConsentExpirationTestingMode = useCallback(
165
+ (expirationSeconds: number | null) => {
166
+ consentManager.current.setConsentExpirationTestingMode(
167
+ expirationSeconds,
168
+ );
169
+ },
170
+ [],
171
+ );
172
+
164
173
  const context = useMemo(() => {
165
174
  return {
166
175
  locale: consentManager.current.translator.locale,
@@ -172,6 +181,7 @@ export const Osano: React.FunctionComponent<
172
181
  setLocale: handleSetLocale,
173
182
  showBanner: handleShowDialog,
174
183
  showDrawer: handleShowDrawer,
184
+ setConsentExpirationTestingMode: handleSetConsentExpirationTestingMode,
175
185
  isReady: consentManager.current.loaded,
176
186
  };
177
187
  }, [
@@ -10,6 +10,7 @@ import {
10
10
  makeConsentFromCategories,
11
11
  } from '../constants';
12
12
  import { clearStorage, getStorage, setStorage } from '../native/OsanoCmp';
13
+ import { getConsentExpirationSeconds } from './gdprMappings';
13
14
  import type { Config } from '../types/consent-config';
14
15
  import { merge } from '../utils';
15
16
  import API from '../utils/API';
@@ -83,6 +84,15 @@ function createConsentRecordPayload(
83
84
 
84
85
  const STORAGE_KEY = 'osano_consentmanager';
85
86
 
87
+ // The consent timestamp is scoped by configId so it tracks the consent record
88
+ // for that config (both also carry the userSuffix). Older builds wrote a single
89
+ // un-scoped key shared across configs; retrieveLastConsentTimestamp falls back
90
+ // to it so existing installs keep their recorded date until the next write.
91
+ const lastConsentDateKey = (configId: string, userSuffix: string) =>
92
+ `${STORAGE_KEY}_last_recorded_consent_date_${configId}${userSuffix}`;
93
+ const legacyLastConsentDateKey = (userSuffix: string) =>
94
+ `${STORAGE_KEY}_last_recorded_consent_date${userSuffix}`;
95
+
86
96
  function consentRecordedRemotely(
87
97
  configId: string,
88
98
  success: boolean,
@@ -105,6 +115,10 @@ export const storeConsent = async (
105
115
  cmp: ConsentManager,
106
116
  consent?: ConsentCategoryType[],
107
117
  recordedRemotely?: boolean,
118
+ // When restoring a remote record, the server's consent timestamp. Preserving
119
+ // it (instead of defaulting to "now") stops an old remote record from being
120
+ // re-dated as fresh. Defaults to now for locally-made choices.
121
+ consentDate?: string | Date,
108
122
  ): Promise<ConsentCategoryType[]> => {
109
123
  cmp.consent = consent ?? cmp.consent;
110
124
  return new Promise<ConsentCategoryType[]>((resolve, reject) => {
@@ -118,13 +132,17 @@ export const storeConsent = async (
118
132
  cmp.extUsrData,
119
133
  );
120
134
  const userSuffix = hashedExtUsrData ? `_${hashedExtUsrData}` : '';
135
+ const parsedDate = consentDate ? new Date(consentDate) : new Date();
136
+ const recordedDate = Number.isNaN(parsedDate.getTime())
137
+ ? new Date()
138
+ : parsedDate;
121
139
  switch (Platform.OS) {
122
140
  case 'ios':
123
141
  case 'android':
124
142
  default: {
125
143
  setStorage(
126
- `${STORAGE_KEY}_last_recorded_consent_date${userSuffix}`,
127
- JSON.stringify(new Date()),
144
+ lastConsentDateKey(cmp.configId, userSuffix),
145
+ JSON.stringify(recordedDate),
128
146
  )
129
147
  .then(() => {
130
148
  setStorage(
@@ -246,7 +264,27 @@ const clearToFirstLoadDefault = async (
246
264
  return seed;
247
265
  };
248
266
 
267
+ // After an expired/invalid record, settle on a persisted essential-only state.
268
+ // Persisting (rather than only clearing the payload with clearConsent) keeps a
269
+ // subsequent load in this safe state instead of falling back to the first-load
270
+ // default — which, for Flavor 7, would re-enable analytics. This matters
271
+ // because a later load may resolve no expiration window (testing off, offline
272
+ // with no mappings cache, or a country with no interval), in which case empty
273
+ // storage would otherwise re-seed the first-load default.
274
+ const settleEssentialOnly = (
275
+ cmp: ConsentManager,
276
+ ): Promise<ConsentCategoryType[]> =>
277
+ storeConsent(cmp, [ConsentCategory.ESSENTIAL]);
278
+
249
279
  const retrieveConsent = async (cmp: ConsentManager) => {
280
+ // If stored consent has aged past its expiration window (production
281
+ // per-country interval, or the debug testing override), settle on a persisted
282
+ // essential-only state so callers see essential-only and a later load stays
283
+ // here (mirrors Android's clearExpiredConsent() + essential-only
284
+ // getConsentedCategories()).
285
+ if (await isConsentExpired(cmp)) {
286
+ return settleEssentialOnly(cmp);
287
+ }
250
288
  return new Promise<ConsentCategoryType[]>((resolve) => {
251
289
  const resConsent = (consentJson: string | null | undefined) => {
252
290
  if (consentJson) {
@@ -298,34 +336,99 @@ const retrieveConsent = async (cmp: ConsentManager) => {
298
336
  });
299
337
  };
300
338
 
301
- const retrieveLastConsentTimestamp = async (cmp: ConsentManager) => {
302
- return new Promise<string>((resolve) => {
303
- const resValue = (consentJson: string | null | undefined) => {
304
- if (consentJson) {
305
- const timestamp = JSON.parse(consentJson);
306
- resolve(timestamp);
307
- return timestamp;
308
- } else {
309
- resolve('');
310
- return '';
311
- }
312
- };
313
- const hashedExtUsrData = hashExtUsrData(
314
- cmp.customerId,
315
- cmp.configId,
316
- cmp.extUsrData,
339
+ const retrieveLastConsentTimestamp = async (
340
+ cmp: ConsentManager,
341
+ ): Promise<string> => {
342
+ const hashedExtUsrData = hashExtUsrData(
343
+ cmp.customerId,
344
+ cmp.configId,
345
+ cmp.extUsrData,
346
+ );
347
+ const userSuffix = hashedExtUsrData ? `_${hashedExtUsrData}` : '';
348
+
349
+ const parse = (json: string | null | undefined): string => {
350
+ if (!json) {
351
+ return '';
352
+ }
353
+ try {
354
+ return JSON.parse(json) as string;
355
+ } catch {
356
+ return '';
357
+ }
358
+ };
359
+
360
+ try {
361
+ const scoped = parse(
362
+ await getStorage(lastConsentDateKey(cmp.configId, userSuffix)),
317
363
  );
318
- const userSuffix = hashedExtUsrData ? `_${hashedExtUsrData}` : '';
319
- switch (Platform.OS) {
320
- case 'ios':
321
- case 'android':
322
- default:
323
- getStorage(
324
- `${STORAGE_KEY}_last_recorded_consent_date${userSuffix}`,
325
- ).then(resValue);
326
- break;
364
+ if (scoped) {
365
+ return scoped;
327
366
  }
328
- });
367
+ // Fall back to the legacy un-scoped key for installs written before the
368
+ // timestamp was scoped by configId.
369
+ return parse(await getStorage(legacyLastConsentDateKey(userSuffix)));
370
+ } catch {
371
+ // Fail closed: a storage rejection resolves to no timestamp instead of
372
+ // hanging the consent load.
373
+ return '';
374
+ }
375
+ };
376
+
377
+ /**
378
+ * Resolves the effective expiration window in seconds, mirroring the Android
379
+ * SDK's single path:
380
+ *
381
+ * debug testing override ?? production interval (per-country, from the GDPR
382
+ * mappings served at /mappings.json)
383
+ *
384
+ * Testing mode does not create a separate expiration path — it overrides the
385
+ * production interval. Returns null when neither is configured, in which case
386
+ * consent has no expiration.
387
+ */
388
+ const effectiveExpirationSeconds = async (
389
+ cmp: ConsentManager,
390
+ ): Promise<number | null> => {
391
+ const testingSeconds = cmp.consentExpirationTestingSeconds;
392
+ // Testing override short-circuits the production lookup entirely, so debug
393
+ // runs never depend on the mappings fetch.
394
+ const seconds =
395
+ testingSeconds ?? (await getConsentExpirationSeconds(cmp.config?.country));
396
+ return seconds && seconds > 0 ? seconds : null;
397
+ };
398
+
399
+ /**
400
+ * True when `timestamp` is older than the `seconds` window. Fails closed
401
+ * (returns false) for a missing or unparseable timestamp, or when no window is
402
+ * configured, so a storage hiccup or a null window never wrongly expires
403
+ * consent.
404
+ */
405
+ const isTimestampExpired = (
406
+ timestamp: string | number | Date | null | undefined,
407
+ seconds: number | null,
408
+ ): boolean => {
409
+ if (!seconds || !timestamp) {
410
+ return false;
411
+ }
412
+ const consentedAtMs = new Date(timestamp).getTime();
413
+ if (Number.isNaN(consentedAtMs)) {
414
+ return false;
415
+ }
416
+ return Date.now() - consentedAtMs > seconds * 1000;
417
+ };
418
+
419
+ /**
420
+ * Returns true when the locally stored consent has aged past its expiration
421
+ * window (see {@link effectiveExpirationSeconds}).
422
+ */
423
+ export const isConsentExpired = async (
424
+ cmp: ConsentManager,
425
+ ): Promise<boolean> => {
426
+ const seconds = await effectiveExpirationSeconds(cmp);
427
+ if (!seconds) {
428
+ return false;
429
+ }
430
+ const timestamp = await retrieveLastConsentTimestamp(cmp);
431
+ return isTimestampExpired(timestamp, seconds);
329
432
  };
330
433
 
331
434
  export const recordConsent = (
@@ -399,17 +502,40 @@ export const fetchConsent = async (cmp: ConsentManager) => {
399
502
  response as ConsentLookupResponseType;
400
503
 
401
504
  if (!consented || !lastConsentTimestamp) {
402
- // No remote consent found, use local consent
403
- await storeConsent(cmp, consent);
505
+ // No remote record — keep the local consent as-is. Re-storing it here
506
+ // would reset the recorded-consent timestamp to "now" on every load, so
507
+ // expiration would never fire for remote + extUsrData users, and it
508
+ // could re-persist an essential-only clear. The local consent is already
509
+ // stored with its real timestamp (or is a first-load default that stays
510
+ // re-seeded).
404
511
  return consent;
405
512
  }
406
513
 
514
+ // Evaluate the remote record against the server's own timestamp (when the
515
+ // visitor actually consented), not "now", so an already-expired remote
516
+ // record is never restored and re-dated as fresh.
517
+ const seconds = await effectiveExpirationSeconds(cmp);
518
+ const remoteExpired = isTimestampExpired(lastConsentTimestamp, seconds);
519
+
407
520
  // If extUsrData is not null and not empty, always use remote consent (cross-device consent enabled)
408
521
  if (extUsrData && extUsrData.trim() !== '') {
522
+ if (remoteExpired) {
523
+ // The authoritative remote record has expired; settle on a persisted
524
+ // essential-only state rather than restoring stale consent, so a
525
+ // subsequent load stays here instead of falling back to the
526
+ // first-load default.
527
+ return settleEssentialOnly(cmp);
528
+ }
409
529
  const remoteConsentCategories = consented.map(
410
530
  (category) => ConsentCategory[category as keyof typeof ConsentCategory],
411
531
  );
412
- await storeConsent(cmp, remoteConsentCategories, true);
532
+ // Preserve the server timestamp so restoring can't re-date the record.
533
+ await storeConsent(
534
+ cmp,
535
+ remoteConsentCategories,
536
+ true,
537
+ lastConsentTimestamp,
538
+ );
413
539
  return remoteConsentCategories;
414
540
  }
415
541
 
@@ -417,14 +543,21 @@ export const fetchConsent = async (cmp: ConsentManager) => {
417
543
  const internalTimestamp = await retrieveLastConsentTimestamp(cmp);
418
544
  try {
419
545
  if (
420
- new Date(lastConsentTimestamp) > new Date(internalTimestamp) ||
421
- internalTimestamp === ''
546
+ !remoteExpired &&
547
+ (new Date(lastConsentTimestamp) > new Date(internalTimestamp) ||
548
+ internalTimestamp === '')
422
549
  ) {
423
- // Remote consent is newer or we haven't recorded any consent yet. Store remote consent.
550
+ // Remote consent is newer, unexpired, or we haven't recorded any
551
+ // consent yet. Store it, preserving the server timestamp.
424
552
  const remoteConsentCategories = formatConsentArray(
425
553
  makeConsentFromCategories(consented),
426
554
  );
427
- await storeConsent(cmp, remoteConsentCategories, true);
555
+ await storeConsent(
556
+ cmp,
557
+ remoteConsentCategories,
558
+ true,
559
+ lastConsentTimestamp,
560
+ );
428
561
 
429
562
  return remoteConsentCategories;
430
563
  }
@@ -0,0 +1,126 @@
1
+ import { env } from '../constants';
2
+ import { getStorage, setStorage } from '../native/OsanoCmp';
3
+ import { osanoLogger } from '../utils/OsanoLogger';
4
+
5
+ const { OSANO_ORIGIN } = env;
6
+
7
+ const STORAGE_KEY = 'osano_consentmanager';
8
+ const MAPPINGS_KEY = `${STORAGE_KEY}_gdpr_mappings`;
9
+ const MAPPINGS_LAST_FETCHED_KEY = `${STORAGE_KEY}_gdpr_mappings_last_fetched`;
10
+
11
+ // Refresh the mappings at most once a day, matching the Android SDK's
12
+ // GdprCountryCache cache window.
13
+ const CACHE_DURATION_MS = 24 * 60 * 60 * 1000;
14
+
15
+ // Fallback bucket in the mappings document, used when the visitor's country has
16
+ // no entry of its own. Mirrors the Android SDK's DEFAULT_GDPR_COUNTRY_KEY.
17
+ const DEFAULT_COUNTRY_KEY = 'defaults';
18
+
19
+ /**
20
+ * Per-country GDPR metadata served at `/mappings.json`. Only the fields the SDK
21
+ * consumes are typed here; the document may carry more (e.g. forced
22
+ * classifications) that we intentionally ignore.
23
+ */
24
+ export type GdprCountryInfo = {
25
+ gdprApplies?: boolean;
26
+ flavor?: string;
27
+ // Production consent lifetime, in seconds, measured from when the visitor
28
+ // consented. Consumed as seconds to match the Android SDK.
29
+ consentExpiration?: number;
30
+ };
31
+
32
+ export type GdprMappings = Record<string, GdprCountryInfo>;
33
+
34
+ const readCachedMappings = async (): Promise<GdprMappings | null> => {
35
+ try {
36
+ const raw = await getStorage(MAPPINGS_KEY);
37
+ return raw ? (JSON.parse(raw) as GdprMappings) : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ };
42
+
43
+ const isCacheFresh = async (): Promise<boolean> => {
44
+ try {
45
+ const raw = await getStorage(MAPPINGS_LAST_FETCHED_KEY);
46
+ if (!raw) {
47
+ return false;
48
+ }
49
+ const lastFetched = Number(JSON.parse(raw));
50
+ if (Number.isNaN(lastFetched)) {
51
+ return false;
52
+ }
53
+ return Date.now() - lastFetched < CACHE_DURATION_MS;
54
+ } catch {
55
+ return false;
56
+ }
57
+ };
58
+
59
+ /**
60
+ * Returns the GDPR country mappings, preferring a fresh (<24h) cache and
61
+ * otherwise fetching `${OSANO_ORIGIN}/mappings.json`. On a network/parse error
62
+ * it falls back to any stale cache rather than losing production expiration.
63
+ * Returns null only when there is neither a usable network response nor a
64
+ * cached copy.
65
+ */
66
+ export const fetchGdprMappings = async (): Promise<GdprMappings | null> => {
67
+ if (await isCacheFresh()) {
68
+ const cached = await readCachedMappings();
69
+ if (cached) {
70
+ return cached;
71
+ }
72
+ }
73
+
74
+ try {
75
+ const response = await fetch(`${OSANO_ORIGIN}/mappings.json`, {
76
+ method: 'GET',
77
+ headers: {
78
+ Accept: 'application/json',
79
+ 'Content-Type': 'application/json',
80
+ },
81
+ mode: 'cors',
82
+ referrer: 'no-referrer',
83
+ });
84
+
85
+ if (!response.ok) {
86
+ throw response;
87
+ }
88
+
89
+ const mappings = (await response.json()) as GdprMappings;
90
+ await Promise.all([
91
+ setStorage(MAPPINGS_KEY, JSON.stringify(mappings)),
92
+ setStorage(MAPPINGS_LAST_FETCHED_KEY, JSON.stringify(Date.now())),
93
+ ]);
94
+ return mappings;
95
+ } catch (error) {
96
+ osanoLogger.error('Error fetching GDPR mappings:', error as Error);
97
+ // Serve stale data over nothing so production expiration keeps working
98
+ // while offline.
99
+ return readCachedMappings();
100
+ }
101
+ };
102
+
103
+ /**
104
+ * Resolves the production consent-expiration interval (in seconds) for a
105
+ * country from the GDPR mappings: the country's own value, falling back to the
106
+ * `defaults` bucket. Returns null when no positive interval is configured — in
107
+ * that case consent has no production expiration.
108
+ *
109
+ * Note: unlike the Android SDK, there is no hardcoded numeric fallback here.
110
+ * Android returns `365` when nothing is configured, but consumes it as seconds
111
+ * (~6 minutes), which would silently expire production consent; failing open is
112
+ * the safer behavior.
113
+ */
114
+ export const getConsentExpirationSeconds = async (
115
+ country: string | undefined,
116
+ ): Promise<number | null> => {
117
+ const mappings = await fetchGdprMappings();
118
+ if (!mappings) {
119
+ return null;
120
+ }
121
+ const key = (country ?? '').toLowerCase();
122
+ const value =
123
+ mappings[key]?.consentExpiration ??
124
+ mappings[DEFAULT_COUNTRY_KEY]?.consentExpiration;
125
+ return typeof value === 'number' && value > 0 ? value : null;
126
+ };
@@ -19,6 +19,15 @@ export type OsanoOverrides = {
19
19
  usp?: UspVersionType;
20
20
  };
21
21
  palette?: Palette;
22
+ /**
23
+ * Debug-only. Overrides the production consent-expiration interval with this
24
+ * many seconds so the consent banner can be re-triggered quickly during
25
+ * testing. This does not create a separate expiration path — production
26
+ * consent expires on the per-country interval from the GDPR mappings, and
27
+ * this simply substitutes a shorter window. Ignored outside `__DEV__`.
28
+ * Mirrors the Android SDK's builder `setConsentExpirationTestingMode`.
29
+ */
30
+ consentExpirationTestingSeconds?: number;
22
31
  };
23
32
 
24
33
  export interface OsanoProps {