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

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 (69) hide show
  1. package/lib/commonjs/ConsentManager.js +70 -3
  2. package/lib/commonjs/ConsentManager.js.map +1 -1
  3. package/lib/commonjs/constants.js +2 -3
  4. package/lib/commonjs/constants.js.map +1 -1
  5. package/lib/commonjs/context.js +1 -0
  6. package/lib/commonjs/context.js.map +1 -1
  7. package/lib/commonjs/doNotSell.js +95 -0
  8. package/lib/commonjs/doNotSell.js.map +1 -0
  9. package/lib/commonjs/index.js +13 -1
  10. package/lib/commonjs/index.js.map +1 -1
  11. package/lib/commonjs/reducer.js +41 -41
  12. package/lib/commonjs/reducer.js.map +1 -1
  13. package/lib/commonjs/selectors.js +20 -2
  14. package/lib/commonjs/selectors.js.map +1 -1
  15. package/lib/commonjs/services/consent.js +163 -34
  16. package/lib/commonjs/services/consent.js.map +1 -1
  17. package/lib/commonjs/services/gdprMappings.js +115 -0
  18. package/lib/commonjs/services/gdprMappings.js.map +1 -0
  19. package/lib/commonjs/views/Drawer/Drawer.ConsentCategories.js +1 -1
  20. package/lib/commonjs/views/Drawer/Drawer.ConsentCategories.js.map +1 -1
  21. package/lib/module/ConsentManager.js +70 -3
  22. package/lib/module/ConsentManager.js.map +1 -1
  23. package/lib/module/constants.js +2 -3
  24. package/lib/module/constants.js.map +1 -1
  25. package/lib/module/context.js +1 -0
  26. package/lib/module/context.js.map +1 -1
  27. package/lib/module/doNotSell.js +87 -0
  28. package/lib/module/doNotSell.js.map +1 -0
  29. package/lib/module/index.js +13 -1
  30. package/lib/module/index.js.map +1 -1
  31. package/lib/module/reducer.js +41 -41
  32. package/lib/module/reducer.js.map +1 -1
  33. package/lib/module/selectors.js +16 -1
  34. package/lib/module/selectors.js.map +1 -1
  35. package/lib/module/services/consent.js +161 -33
  36. package/lib/module/services/consent.js.map +1 -1
  37. package/lib/module/services/gdprMappings.js +109 -0
  38. package/lib/module/services/gdprMappings.js.map +1 -0
  39. package/lib/module/views/Drawer/Drawer.ConsentCategories.js +2 -2
  40. package/lib/module/views/Drawer/Drawer.ConsentCategories.js.map +1 -1
  41. package/lib/typescript/src/ConsentManager.d.ts +29 -1
  42. package/lib/typescript/src/ConsentManager.d.ts.map +1 -1
  43. package/lib/typescript/src/constants.d.ts.map +1 -1
  44. package/lib/typescript/src/context.d.ts +1 -0
  45. package/lib/typescript/src/context.d.ts.map +1 -1
  46. package/lib/typescript/src/doNotSell.d.ts +48 -0
  47. package/lib/typescript/src/doNotSell.d.ts.map +1 -0
  48. package/lib/typescript/src/index.d.ts.map +1 -1
  49. package/lib/typescript/src/reducer.d.ts.map +1 -1
  50. package/lib/typescript/src/selectors.d.ts +3 -0
  51. package/lib/typescript/src/selectors.d.ts.map +1 -1
  52. package/lib/typescript/src/services/consent.d.ts +8 -3
  53. package/lib/typescript/src/services/consent.d.ts.map +1 -1
  54. package/lib/typescript/src/services/gdprMappings.d.ts +32 -0
  55. package/lib/typescript/src/services/gdprMappings.d.ts.map +1 -0
  56. package/lib/typescript/src/views/Drawer/Drawer.ConsentCategories.d.ts.map +1 -1
  57. package/package.json +4 -4
  58. package/src/ConsentManager.ts +88 -7
  59. package/src/constants.ts +2 -3
  60. package/src/context.ts +2 -0
  61. package/src/doNotSell.ts +122 -0
  62. package/src/index.tsx +20 -1
  63. package/src/reducer.ts +53 -56
  64. package/src/selectors.ts +25 -2
  65. package/src/services/consent.ts +217 -44
  66. package/src/services/gdprMappings.ts +126 -0
  67. package/src/types/consent-config.d.ts +6 -0
  68. package/src/types/index.d.ts +9 -0
  69. package/src/views/Drawer/Drawer.ConsentCategories.tsx +2 -1
@@ -9,7 +9,9 @@ import {
9
9
  formatConsentArray,
10
10
  makeConsentFromCategories,
11
11
  } from '../constants';
12
+ import { applyDoNotSellDefault, normalizeDoNotSellConsent } from '../doNotSell';
12
13
  import { clearStorage, getStorage, setStorage } from '../native/OsanoCmp';
14
+ import { getConsentExpirationSeconds } from './gdprMappings';
13
15
  import type { Config } from '../types/consent-config';
14
16
  import { merge } from '../utils';
15
17
  import API from '../utils/API';
@@ -83,6 +85,15 @@ function createConsentRecordPayload(
83
85
 
84
86
  const STORAGE_KEY = 'osano_consentmanager';
85
87
 
88
+ // The consent timestamp is scoped by configId so it tracks the consent record
89
+ // for that config (both also carry the userSuffix). Older builds wrote a single
90
+ // un-scoped key shared across configs; retrieveLastConsentTimestamp falls back
91
+ // to it so existing installs keep their recorded date until the next write.
92
+ const lastConsentDateKey = (configId: string, userSuffix: string) =>
93
+ `${STORAGE_KEY}_last_recorded_consent_date_${configId}${userSuffix}`;
94
+ const legacyLastConsentDateKey = (userSuffix: string) =>
95
+ `${STORAGE_KEY}_last_recorded_consent_date${userSuffix}`;
96
+
86
97
  function consentRecordedRemotely(
87
98
  configId: string,
88
99
  success: boolean,
@@ -105,6 +116,10 @@ export const storeConsent = async (
105
116
  cmp: ConsentManager,
106
117
  consent?: ConsentCategoryType[],
107
118
  recordedRemotely?: boolean,
119
+ // When restoring a remote record, the server's consent timestamp. Preserving
120
+ // it (instead of defaulting to "now") stops an old remote record from being
121
+ // re-dated as fresh. Defaults to now for locally-made choices.
122
+ consentDate?: string | Date,
108
123
  ): Promise<ConsentCategoryType[]> => {
109
124
  cmp.consent = consent ?? cmp.consent;
110
125
  return new Promise<ConsentCategoryType[]>((resolve, reject) => {
@@ -118,13 +133,17 @@ export const storeConsent = async (
118
133
  cmp.extUsrData,
119
134
  );
120
135
  const userSuffix = hashedExtUsrData ? `_${hashedExtUsrData}` : '';
136
+ const parsedDate = consentDate ? new Date(consentDate) : new Date();
137
+ const recordedDate = Number.isNaN(parsedDate.getTime())
138
+ ? new Date()
139
+ : parsedDate;
121
140
  switch (Platform.OS) {
122
141
  case 'ios':
123
142
  case 'android':
124
143
  default: {
125
144
  setStorage(
126
- `${STORAGE_KEY}_last_recorded_consent_date${userSuffix}`,
127
- JSON.stringify(new Date()),
145
+ lastConsentDateKey(cmp.configId, userSuffix),
146
+ JSON.stringify(recordedDate),
128
147
  )
129
148
  .then(() => {
130
149
  setStorage(
@@ -202,7 +221,13 @@ export const migrateConsentStorage = async (
202
221
  let consent = JSON.parse(oldConsent) as ConsentCategoryType[];
203
222
  if (consent.length > 0) {
204
223
  consent = formatConsentArray(
205
- makeConsentFromCategories(consent),
224
+ normalizeDoNotSellConsent(
225
+ makeConsentFromCategories(consent),
226
+ {
227
+ enableDoNotSell: cmp.config.enableDoNotSell,
228
+ doNotSellCategories: cmp.config.doNotSellCategories,
229
+ },
230
+ ),
206
231
  );
207
232
  // Store with new user-specific key
208
233
  storeConsent(cmp, consent).then(() => {
@@ -229,10 +254,23 @@ export const migrateConsentStorage = async (
229
254
 
230
255
  // The consent to fall back to when a visitor has no stored consent yet. Flavor 7
231
256
  // shows analytics enabled on first load; every other flavor stays essential-only.
232
- const firstLoadConsent = (cmp: ConsentManager): ConsentCategoryType[] =>
233
- cmp.config?.analyticsDefaultsOnAtFirstLoad
234
- ? [ConsentCategory.ESSENTIAL, ConsentCategory.ANALYTICS]
235
- : [ConsentCategory.ESSENTIAL];
257
+ // The Do-Not-Sell default is then applied, but only when the opt-out would
258
+ // actually be visible (tenant flag + CCPA/US), so we never seed an OPT_OUT the
259
+ // user cannot see or control.
260
+ const firstLoadConsent = (cmp: ConsentManager): ConsentCategoryType[] => {
261
+ let categories: ConsentCategoryType[] = [ConsentCategory.ESSENTIAL];
262
+ if (cmp.config?.analyticsDefaultsOnAtFirstLoad) {
263
+ categories.push(ConsentCategory.ANALYTICS);
264
+ }
265
+ if (cmp.config?.isDoNotSellVisible) {
266
+ categories = applyDoNotSellDefault(categories, {
267
+ enableDoNotSell: cmp.config.enableDoNotSell,
268
+ enableDoNotSellDefault: cmp.config.enableDoNotSellDefault,
269
+ doNotSellCategories: cmp.config.doNotSellCategories,
270
+ });
271
+ }
272
+ return categories;
273
+ };
236
274
 
237
275
  // Clear any stale storage, then seed the first-load default. Because this only
238
276
  // runs when there is no stored consent, a returning visitor's saved opt-out is
@@ -246,7 +284,27 @@ const clearToFirstLoadDefault = async (
246
284
  return seed;
247
285
  };
248
286
 
287
+ // After an expired/invalid record, settle on a persisted essential-only state.
288
+ // Persisting (rather than only clearing the payload with clearConsent) keeps a
289
+ // subsequent load in this safe state instead of falling back to the first-load
290
+ // default — which, for Flavor 7, would re-enable analytics. This matters
291
+ // because a later load may resolve no expiration window (testing off, offline
292
+ // with no mappings cache, or a country with no interval), in which case empty
293
+ // storage would otherwise re-seed the first-load default.
294
+ const settleEssentialOnly = (
295
+ cmp: ConsentManager,
296
+ ): Promise<ConsentCategoryType[]> =>
297
+ storeConsent(cmp, [ConsentCategory.ESSENTIAL]);
298
+
249
299
  const retrieveConsent = async (cmp: ConsentManager) => {
300
+ // If stored consent has aged past its expiration window (production
301
+ // per-country interval, or the debug testing override), settle on a persisted
302
+ // essential-only state so callers see essential-only and a later load stays
303
+ // here (mirrors Android's clearExpiredConsent() + essential-only
304
+ // getConsentedCategories()).
305
+ if (await isConsentExpired(cmp)) {
306
+ return settleEssentialOnly(cmp);
307
+ }
250
308
  return new Promise<ConsentCategoryType[]>((resolve) => {
251
309
  const resConsent = (consentJson: string | null | undefined) => {
252
310
  if (consentJson) {
@@ -298,34 +356,99 @@ const retrieveConsent = async (cmp: ConsentManager) => {
298
356
  });
299
357
  };
300
358
 
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,
359
+ const retrieveLastConsentTimestamp = async (
360
+ cmp: ConsentManager,
361
+ ): Promise<string> => {
362
+ const hashedExtUsrData = hashExtUsrData(
363
+ cmp.customerId,
364
+ cmp.configId,
365
+ cmp.extUsrData,
366
+ );
367
+ const userSuffix = hashedExtUsrData ? `_${hashedExtUsrData}` : '';
368
+
369
+ const parse = (json: string | null | undefined): string => {
370
+ if (!json) {
371
+ return '';
372
+ }
373
+ try {
374
+ return JSON.parse(json) as string;
375
+ } catch {
376
+ return '';
377
+ }
378
+ };
379
+
380
+ try {
381
+ const scoped = parse(
382
+ await getStorage(lastConsentDateKey(cmp.configId, userSuffix)),
317
383
  );
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;
384
+ if (scoped) {
385
+ return scoped;
327
386
  }
328
- });
387
+ // Fall back to the legacy un-scoped key for installs written before the
388
+ // timestamp was scoped by configId.
389
+ return parse(await getStorage(legacyLastConsentDateKey(userSuffix)));
390
+ } catch {
391
+ // Fail closed: a storage rejection resolves to no timestamp instead of
392
+ // hanging the consent load.
393
+ return '';
394
+ }
395
+ };
396
+
397
+ /**
398
+ * Resolves the effective expiration window in seconds, mirroring the Android
399
+ * SDK's single path:
400
+ *
401
+ * debug testing override ?? production interval (per-country, from the GDPR
402
+ * mappings served at /mappings.json)
403
+ *
404
+ * Testing mode does not create a separate expiration path — it overrides the
405
+ * production interval. Returns null when neither is configured, in which case
406
+ * consent has no expiration.
407
+ */
408
+ const effectiveExpirationSeconds = async (
409
+ cmp: ConsentManager,
410
+ ): Promise<number | null> => {
411
+ const testingSeconds = cmp.consentExpirationTestingSeconds;
412
+ // Testing override short-circuits the production lookup entirely, so debug
413
+ // runs never depend on the mappings fetch.
414
+ const seconds =
415
+ testingSeconds ?? (await getConsentExpirationSeconds(cmp.config?.country));
416
+ return seconds && seconds > 0 ? seconds : null;
417
+ };
418
+
419
+ /**
420
+ * True when `timestamp` is older than the `seconds` window. Fails closed
421
+ * (returns false) for a missing or unparseable timestamp, or when no window is
422
+ * configured, so a storage hiccup or a null window never wrongly expires
423
+ * consent.
424
+ */
425
+ const isTimestampExpired = (
426
+ timestamp: string | number | Date | null | undefined,
427
+ seconds: number | null,
428
+ ): boolean => {
429
+ if (!seconds || !timestamp) {
430
+ return false;
431
+ }
432
+ const consentedAtMs = new Date(timestamp).getTime();
433
+ if (Number.isNaN(consentedAtMs)) {
434
+ return false;
435
+ }
436
+ return Date.now() - consentedAtMs > seconds * 1000;
437
+ };
438
+
439
+ /**
440
+ * Returns true when the locally stored consent has aged past its expiration
441
+ * window (see {@link effectiveExpirationSeconds}).
442
+ */
443
+ export const isConsentExpired = async (
444
+ cmp: ConsentManager,
445
+ ): Promise<boolean> => {
446
+ const seconds = await effectiveExpirationSeconds(cmp);
447
+ if (!seconds) {
448
+ return false;
449
+ }
450
+ const timestamp = await retrieveLastConsentTimestamp(cmp);
451
+ return isTimestampExpired(timestamp, seconds);
329
452
  };
330
453
 
331
454
  export const recordConsent = (
@@ -339,7 +462,13 @@ export const recordConsent = (
339
462
  .then(() => consent ?? cmp.consent ?? [ConsentCategory.ESSENTIAL])
340
463
  .then((consentCategories) => {
341
464
  consentCategories = formatConsentArray(
342
- makeConsentFromCategories(consentCategories),
465
+ normalizeDoNotSellConsent(
466
+ makeConsentFromCategories(consentCategories),
467
+ {
468
+ enableDoNotSell: cmp.config.enableDoNotSell,
469
+ doNotSellCategories: cmp.config.doNotSellCategories,
470
+ },
471
+ ),
343
472
  );
344
473
  storeConsent(cmp, consentCategories, true);
345
474
  return consentCategories;
@@ -399,17 +528,51 @@ export const fetchConsent = async (cmp: ConsentManager) => {
399
528
  response as ConsentLookupResponseType;
400
529
 
401
530
  if (!consented || !lastConsentTimestamp) {
402
- // No remote consent found, use local consent
403
- await storeConsent(cmp, consent);
531
+ // No remote record — keep the local consent as-is. Re-storing it here
532
+ // would reset the recorded-consent timestamp to "now" on every load, so
533
+ // expiration would never fire for remote + extUsrData users, and it
534
+ // could re-persist an essential-only clear. The local consent is already
535
+ // stored with its real timestamp (or is a first-load default that stays
536
+ // re-seeded).
404
537
  return consent;
405
538
  }
406
539
 
540
+ // Evaluate the remote record against the server's own timestamp (when the
541
+ // visitor actually consented), not "now", so an already-expired remote
542
+ // record is never restored and re-dated as fresh.
543
+ const seconds = await effectiveExpirationSeconds(cmp);
544
+ const remoteExpired = isTimestampExpired(lastConsentTimestamp, seconds);
545
+
407
546
  // If extUsrData is not null and not empty, always use remote consent (cross-device consent enabled)
408
547
  if (extUsrData && extUsrData.trim() !== '') {
409
- const remoteConsentCategories = consented.map(
410
- (category) => ConsentCategory[category as keyof typeof ConsentCategory],
548
+ if (remoteExpired) {
549
+ // The authoritative remote record has expired; settle on a persisted
550
+ // essential-only state rather than restoring stale consent, so a
551
+ // subsequent load stays here instead of falling back to the
552
+ // first-load default.
553
+ return settleEssentialOnly(cmp);
554
+ }
555
+ const remoteConsentCategories = formatConsentArray(
556
+ normalizeDoNotSellConsent(
557
+ makeConsentFromCategories(
558
+ consented.map(
559
+ (category) =>
560
+ ConsentCategory[category as keyof typeof ConsentCategory],
561
+ ),
562
+ ),
563
+ {
564
+ enableDoNotSell: cmp.config.enableDoNotSell,
565
+ doNotSellCategories: cmp.config.doNotSellCategories,
566
+ },
567
+ ),
568
+ );
569
+ // Preserve the server timestamp so restoring can't re-date the record.
570
+ await storeConsent(
571
+ cmp,
572
+ remoteConsentCategories,
573
+ true,
574
+ lastConsentTimestamp,
411
575
  );
412
- await storeConsent(cmp, remoteConsentCategories, true);
413
576
  return remoteConsentCategories;
414
577
  }
415
578
 
@@ -417,14 +580,24 @@ export const fetchConsent = async (cmp: ConsentManager) => {
417
580
  const internalTimestamp = await retrieveLastConsentTimestamp(cmp);
418
581
  try {
419
582
  if (
420
- new Date(lastConsentTimestamp) > new Date(internalTimestamp) ||
421
- internalTimestamp === ''
583
+ !remoteExpired &&
584
+ (new Date(lastConsentTimestamp) > new Date(internalTimestamp) ||
585
+ internalTimestamp === '')
422
586
  ) {
423
- // Remote consent is newer or we haven't recorded any consent yet. Store remote consent.
587
+ // Remote consent is newer, unexpired, or we haven't recorded any
588
+ // consent yet. Store it, preserving the server timestamp.
424
589
  const remoteConsentCategories = formatConsentArray(
425
- makeConsentFromCategories(consented),
590
+ normalizeDoNotSellConsent(makeConsentFromCategories(consented), {
591
+ enableDoNotSell: cmp.config.enableDoNotSell,
592
+ doNotSellCategories: cmp.config.doNotSellCategories,
593
+ }),
594
+ );
595
+ await storeConsent(
596
+ cmp,
597
+ remoteConsentCategories,
598
+ true,
599
+ lastConsentTimestamp,
426
600
  );
427
- await storeConsent(cmp, remoteConsentCategories, true);
428
601
 
429
602
  return remoteConsentCategories;
430
603
  }
@@ -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
+ };
@@ -44,6 +44,12 @@ export type Config = {
44
44
  categories: ConsentCategoryType[];
45
45
  ccpaApplies: boolean;
46
46
  ccpaRelaxed: boolean;
47
+ // "Do Not Sell / Share" opt-out controls. `enableDoNotSell` gates whether the
48
+ // opt-out toggle renders; `enableDoNotSellDefault` seeds it on at first load;
49
+ // `doNotSellCategories` lists the categories the opt-out applies to.
50
+ enableDoNotSell: boolean;
51
+ enableDoNotSellDefault: boolean;
52
+ doNotSellCategories: string[];
47
53
  configId: string;
48
54
  country: string;
49
55
  crossDomain: boolean;
@@ -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 {
@@ -50,6 +50,7 @@ import {
50
50
  getStack,
51
51
  isCategoryAccepted,
52
52
  isCategoryDisabled,
53
+ shouldRenderOptOut,
53
54
  } from '../../selectors';
54
55
  import type { Disclosure } from '../../types/store';
55
56
  import DrawerHeader from './Drawer.Header';
@@ -598,7 +599,7 @@ export const ConsentCategoriesDrawer: React.FC = () => {
598
599
  return 'none';
599
600
  }, [previousStack, stack]);
600
601
  const useTcf = config?.tcfIsApplicable;
601
- const needsOptOut = config?.shouldRenderOptOut;
602
+ const needsOptOut = shouldRenderOptOut(state);
602
603
  const categories: ConsentCategoryItem[] = useMemo(() => {
603
604
  const newCategories: ConsentCategoryItem[] = (
604
605
  useTcf ? config.iabCategories : config?.categories || []