@osano/osano-cmp-react-native 0.19.0 → 0.19.1
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/lib/commonjs/ConsentManager.js +23 -0
- package/lib/commonjs/ConsentManager.js.map +1 -1
- package/lib/commonjs/iab/tcf/iab-storage-utils.js +53 -49
- package/lib/commonjs/iab/tcf/iab-storage-utils.js.map +1 -1
- package/lib/commonjs/iab/tcf/middleware.js +20 -23
- package/lib/commonjs/iab/tcf/middleware.js.map +1 -1
- package/lib/commonjs/iab/tcf/reducer.js +0 -22
- package/lib/commonjs/iab/tcf/reducer.js.map +1 -1
- package/lib/commonjs/index.js +16 -2
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/reducer.js +5 -1
- package/lib/commonjs/reducer.js.map +1 -1
- package/lib/commonjs/services/consent.js +178 -77
- package/lib/commonjs/services/consent.js.map +1 -1
- package/lib/module/ConsentManager.js +23 -0
- package/lib/module/ConsentManager.js.map +1 -1
- package/lib/module/iab/tcf/iab-storage-utils.js +53 -49
- package/lib/module/iab/tcf/iab-storage-utils.js.map +1 -1
- package/lib/module/iab/tcf/middleware.js +22 -25
- package/lib/module/iab/tcf/middleware.js.map +1 -1
- package/lib/module/iab/tcf/reducer.js +1 -23
- package/lib/module/iab/tcf/reducer.js.map +1 -1
- package/lib/module/index.js +17 -3
- package/lib/module/index.js.map +1 -1
- package/lib/module/reducer.js +5 -1
- package/lib/module/reducer.js.map +1 -1
- package/lib/module/services/consent.js +174 -75
- package/lib/module/services/consent.js.map +1 -1
- package/lib/typescript/src/ConsentManager.d.ts +11 -1
- package/lib/typescript/src/ConsentManager.d.ts.map +1 -1
- package/lib/typescript/src/iab/tcf/iab-storage-utils.d.ts +7 -7
- package/lib/typescript/src/iab/tcf/iab-storage-utils.d.ts.map +1 -1
- package/lib/typescript/src/iab/tcf/middleware.d.ts.map +1 -1
- package/lib/typescript/src/iab/tcf/reducer.d.ts +1 -0
- package/lib/typescript/src/iab/tcf/reducer.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/reducer.d.ts.map +1 -1
- package/lib/typescript/src/services/consent.d.ts +28 -0
- package/lib/typescript/src/services/consent.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/ConsentManager.ts +24 -1
- package/src/iab/tcf/iab-storage-utils.ts +101 -56
- package/src/iab/tcf/middleware.ts +36 -32
- package/src/iab/tcf/reducer.ts +2 -29
- package/src/index.tsx +15 -1
- package/src/reducer.ts +4 -0
- package/src/services/consent.ts +220 -86
- package/src/types/consent-config.d.ts +5 -1
- package/src/types/store.d.ts +5 -0
package/src/index.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
|
2
2
|
import { Platform, UIManager } from 'react-native';
|
|
3
3
|
import {
|
|
4
4
|
CLEAR_CONSENT,
|
|
5
|
+
SAVE_BEGIN,
|
|
5
6
|
reload,
|
|
6
7
|
setConfig,
|
|
7
8
|
setLocale,
|
|
@@ -105,7 +106,13 @@ export const Osano: React.FunctionComponent<
|
|
|
105
106
|
}),
|
|
106
107
|
);
|
|
107
108
|
}
|
|
108
|
-
|
|
109
|
+
dispatch({ type: SAVE_BEGIN, payload: consentedCategories });
|
|
110
|
+
return recordConsent(consentManager.current, consentedCategories).then(
|
|
111
|
+
(recordedConsent) => {
|
|
112
|
+
consentManager.current.triggerGoogleConsentUpdate(recordedConsent);
|
|
113
|
+
return recordedConsent;
|
|
114
|
+
},
|
|
115
|
+
);
|
|
109
116
|
},
|
|
110
117
|
[],
|
|
111
118
|
);
|
|
@@ -208,6 +215,7 @@ export const Osano: React.FunctionComponent<
|
|
|
208
215
|
})
|
|
209
216
|
.then((reloadProps) => {
|
|
210
217
|
// We need to reset the state when the configId or customerId changes
|
|
218
|
+
const consentInvalidated = consentManager.current.consentInvalidated;
|
|
211
219
|
dispatch(
|
|
212
220
|
reload({
|
|
213
221
|
// Pass config so init-time opt-out normalization runs on the
|
|
@@ -217,8 +225,14 @@ export const Osano: React.FunctionComponent<
|
|
|
217
225
|
configId: reloadProps.configId,
|
|
218
226
|
customerId: reloadProps.customerId,
|
|
219
227
|
consent: reloadProps.consent,
|
|
228
|
+
// Signals the IAB TCF middleware to wipe (not rehydrate) the
|
|
229
|
+
// stored TC string on this RELOAD; see its RELOAD handler.
|
|
230
|
+
consentInvalidated,
|
|
220
231
|
}),
|
|
221
232
|
);
|
|
233
|
+
if (consentManager.current.consentPromptRequired) {
|
|
234
|
+
dispatch(showDialog());
|
|
235
|
+
}
|
|
222
236
|
return reloadProps;
|
|
223
237
|
})
|
|
224
238
|
.catch(() => {
|
package/src/reducer.ts
CHANGED
|
@@ -127,6 +127,10 @@ export function reducer(state: OsanoState, action: OsanoAction): OsanoState {
|
|
|
127
127
|
...state,
|
|
128
128
|
consent,
|
|
129
129
|
savedConsent: { ...consent } as Record<ConsentCategoryType, boolean>,
|
|
130
|
+
// The user just re-consented, so the invalidation is resolved. Clear
|
|
131
|
+
// the marker so a later SET_CONFIG rehydrates this freshly-saved TCF
|
|
132
|
+
// payload instead of wiping it (the IAB middleware wipes while set).
|
|
133
|
+
consentInvalidated: false,
|
|
130
134
|
};
|
|
131
135
|
}
|
|
132
136
|
case CLEAR_CONSENT: {
|
package/src/services/consent.ts
CHANGED
|
@@ -94,6 +94,36 @@ const lastConsentDateKey = (configId: string, userSuffix: string) =>
|
|
|
94
94
|
const legacyLastConsentDateKey = (userSuffix: string) =>
|
|
95
95
|
`${STORAGE_KEY}_last_recorded_consent_date${userSuffix}`;
|
|
96
96
|
|
|
97
|
+
const parseStoredTimestamp = (json: string | null | undefined): string => {
|
|
98
|
+
if (!json) {
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(json) as string;
|
|
103
|
+
} catch {
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const retrieveTimestampForSuffix = async (
|
|
109
|
+
configId: string,
|
|
110
|
+
userSuffix: string,
|
|
111
|
+
): Promise<string> => {
|
|
112
|
+
try {
|
|
113
|
+
const scoped = parseStoredTimestamp(
|
|
114
|
+
await getStorage(lastConsentDateKey(configId, userSuffix)),
|
|
115
|
+
);
|
|
116
|
+
if (scoped) {
|
|
117
|
+
return scoped;
|
|
118
|
+
}
|
|
119
|
+
return parseStoredTimestamp(
|
|
120
|
+
await getStorage(legacyLastConsentDateKey(userSuffix)),
|
|
121
|
+
);
|
|
122
|
+
} catch {
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
97
127
|
function consentRecordedRemotely(
|
|
98
128
|
configId: string,
|
|
99
129
|
success: boolean,
|
|
@@ -208,48 +238,47 @@ export const migrateConsentStorage = async (
|
|
|
208
238
|
return null;
|
|
209
239
|
}
|
|
210
240
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
241
|
+
if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
214
244
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (oldConsent) {
|
|
221
|
-
let consent = JSON.parse(oldConsent) as ConsentCategoryType[];
|
|
222
|
-
if (consent.length > 0) {
|
|
223
|
-
consent = formatConsentArray(
|
|
224
|
-
normalizeDoNotSellConsent(
|
|
225
|
-
makeConsentFromCategories(consent),
|
|
226
|
-
{
|
|
227
|
-
enableDoNotSell: cmp.config.enableDoNotSell,
|
|
228
|
-
doNotSellCategories: cmp.config.doNotSellCategories,
|
|
229
|
-
},
|
|
230
|
-
),
|
|
231
|
-
);
|
|
232
|
-
// Store with new user-specific key
|
|
233
|
-
storeConsent(cmp, consent).then(() => {
|
|
234
|
-
// Clean up old storage
|
|
235
|
-
clearStorage(oldKey);
|
|
236
|
-
clearStorage(oldTimestampKey);
|
|
237
|
-
resolve(consent);
|
|
238
|
-
});
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
resolve(null); // No old consent found
|
|
243
|
-
})
|
|
244
|
-
.catch(() => {
|
|
245
|
-
resolve(null); // Old key not found
|
|
246
|
-
});
|
|
247
|
-
break;
|
|
248
|
-
default:
|
|
249
|
-
resolve(null);
|
|
250
|
-
break;
|
|
245
|
+
const oldKey = `${STORAGE_KEY}_${cmp.configId}`;
|
|
246
|
+
try {
|
|
247
|
+
const oldConsent = await getStorage(oldKey);
|
|
248
|
+
if (!oldConsent) {
|
|
249
|
+
return null;
|
|
251
250
|
}
|
|
252
|
-
|
|
251
|
+
let consent = JSON.parse(oldConsent) as ConsentCategoryType[];
|
|
252
|
+
if (consent.length === 0) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
consent = formatConsentArray(
|
|
256
|
+
normalizeDoNotSellConsent(makeConsentFromCategories(consent), {
|
|
257
|
+
enableDoNotSell: cmp.config.enableDoNotSell,
|
|
258
|
+
doNotSellCategories: cmp.config.doNotSellCategories,
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
const sourceTimestamp = await retrieveTimestampForSuffix(cmp.configId, '');
|
|
262
|
+
const parsedSourceTimestamp = new Date(sourceTimestamp);
|
|
263
|
+
const migrationTimestamp = Number.isNaN(parsedSourceTimestamp.getTime())
|
|
264
|
+
? new Date(0)
|
|
265
|
+
: parsedSourceTimestamp;
|
|
266
|
+
await storeConsent(cmp, consent, undefined, migrationTimestamp);
|
|
267
|
+
await Promise.all(
|
|
268
|
+
[
|
|
269
|
+
oldKey,
|
|
270
|
+
lastConsentDateKey(cmp.configId, ''),
|
|
271
|
+
legacyLastConsentDateKey(''),
|
|
272
|
+
].map(async (key) => {
|
|
273
|
+
try {
|
|
274
|
+
await clearStorage(key);
|
|
275
|
+
} catch {}
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
278
|
+
return consent;
|
|
279
|
+
} catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
253
282
|
};
|
|
254
283
|
|
|
255
284
|
// The consent to fall back to when a visitor has no stored consent yet. Flavor 7
|
|
@@ -284,6 +313,25 @@ const clearToFirstLoadDefault = async (
|
|
|
284
313
|
return seed;
|
|
285
314
|
};
|
|
286
315
|
|
|
316
|
+
const retrieveStoredConsent = async (
|
|
317
|
+
cmp: ConsentManager,
|
|
318
|
+
): Promise<ConsentCategoryType[] | null> => {
|
|
319
|
+
const hashedExtUsrData = hashExtUsrData(
|
|
320
|
+
cmp.customerId,
|
|
321
|
+
cmp.configId,
|
|
322
|
+
cmp.extUsrData,
|
|
323
|
+
);
|
|
324
|
+
const userSuffix = hashedExtUsrData ? `_${hashedExtUsrData}` : '';
|
|
325
|
+
try {
|
|
326
|
+
const stored = await getStorage(
|
|
327
|
+
`${STORAGE_KEY}_${cmp.configId}${userSuffix}`,
|
|
328
|
+
);
|
|
329
|
+
return stored ? (JSON.parse(stored) as ConsentCategoryType[]) : null;
|
|
330
|
+
} catch {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
287
335
|
// After an expired/invalid record, settle on a persisted essential-only state.
|
|
288
336
|
// Persisting (rather than only clearing the payload with clearConsent) keeps a
|
|
289
337
|
// subsequent load in this safe state instead of falling back to the first-load
|
|
@@ -293,17 +341,47 @@ const clearToFirstLoadDefault = async (
|
|
|
293
341
|
// storage would otherwise re-seed the first-load default.
|
|
294
342
|
const settleEssentialOnly = (
|
|
295
343
|
cmp: ConsentManager,
|
|
296
|
-
|
|
297
|
-
|
|
344
|
+
// Keep invalidation sticky until the visitor records a new choice.
|
|
345
|
+
consentDate?: string | Date,
|
|
346
|
+
): Promise<ConsentCategoryType[]> => {
|
|
347
|
+
const categories: ConsentCategoryType[] = [ConsentCategory.ESSENTIAL];
|
|
348
|
+
if (
|
|
349
|
+
cmp.config?.isDoNotSellVisible &&
|
|
350
|
+
(cmp.config.enableDoNotSellDefault ||
|
|
351
|
+
cmp.consent?.includes(ConsentCategory.OPT_OUT))
|
|
352
|
+
) {
|
|
353
|
+
categories.push(ConsentCategory.OPT_OUT);
|
|
354
|
+
}
|
|
355
|
+
return storeConsent(cmp, categories, undefined, consentDate);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const markConsentInvalidated = (cmp: ConsentManager) => {
|
|
359
|
+
cmp.consentInvalidated = true;
|
|
360
|
+
cmp.consentPromptRequired = true;
|
|
361
|
+
};
|
|
298
362
|
|
|
299
363
|
const retrieveConsent = async (cmp: ConsentManager) => {
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
// essential-only
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
364
|
+
// Invalidate stored consent for either independent reason — the record
|
|
365
|
+
// predates the forceReconsent stamp, or it aged past its expiration window —
|
|
366
|
+
// and settle on essential-only. Only read the timestamp when there is a stamp
|
|
367
|
+
// or window to evaluate, so a no-signal load skips the read as the previous
|
|
368
|
+
// expiry-only path did. `consentInvalidated` (read in index.tsx) drives the
|
|
369
|
+
// auto re-prompt; it is set only for a previously recorded consent, never on
|
|
370
|
+
// the first-run path.
|
|
371
|
+
const forceSeconds = effectiveForceReconsentSeconds(cmp);
|
|
372
|
+
const expirationSeconds = await effectiveExpirationSeconds(cmp);
|
|
373
|
+
if (forceSeconds !== null || expirationSeconds !== null) {
|
|
374
|
+
const timestamp = await retrieveLastConsentTimestamp(cmp);
|
|
375
|
+
const expired = isTimestampExpired(timestamp, expirationSeconds);
|
|
376
|
+
const forced = predatesForceReconsent(cmp, timestamp);
|
|
377
|
+
if (expired || forced) {
|
|
378
|
+
const storedConsent = await retrieveStoredConsent(cmp);
|
|
379
|
+
if (storedConsent?.length) {
|
|
380
|
+
cmp.consent = storedConsent;
|
|
381
|
+
markConsentInvalidated(cmp);
|
|
382
|
+
return settleEssentialOnly(cmp, timestamp);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
307
385
|
}
|
|
308
386
|
return new Promise<ConsentCategoryType[]>((resolve) => {
|
|
309
387
|
const resConsent = (consentJson: string | null | undefined) => {
|
|
@@ -337,7 +415,15 @@ const retrieveConsent = async (cmp: ConsentManager) => {
|
|
|
337
415
|
// Try migration if new key not found
|
|
338
416
|
const migratedConsent = await migrateConsentStorage(cmp);
|
|
339
417
|
if (migratedConsent) {
|
|
340
|
-
|
|
418
|
+
const timestamp = await retrieveLastConsentTimestamp(cmp);
|
|
419
|
+
const expired = isTimestampExpired(timestamp, expirationSeconds);
|
|
420
|
+
const forced = predatesForceReconsent(cmp, timestamp);
|
|
421
|
+
if (expired || forced) {
|
|
422
|
+
markConsentInvalidated(cmp);
|
|
423
|
+
resolve(await settleEssentialOnly(cmp, timestamp));
|
|
424
|
+
} else {
|
|
425
|
+
resolve(migratedConsent);
|
|
426
|
+
}
|
|
341
427
|
} else {
|
|
342
428
|
// No consent found, seed the first-load default
|
|
343
429
|
clearToFirstLoadDefault(cmp).then(resolve);
|
|
@@ -365,33 +451,7 @@ const retrieveLastConsentTimestamp = async (
|
|
|
365
451
|
cmp.extUsrData,
|
|
366
452
|
);
|
|
367
453
|
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)),
|
|
383
|
-
);
|
|
384
|
-
if (scoped) {
|
|
385
|
-
return scoped;
|
|
386
|
-
}
|
|
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
|
-
}
|
|
454
|
+
return retrieveTimestampForSuffix(cmp.configId, userSuffix);
|
|
395
455
|
};
|
|
396
456
|
|
|
397
457
|
/**
|
|
@@ -436,6 +496,72 @@ const isTimestampExpired = (
|
|
|
436
496
|
return Date.now() - consentedAtMs > seconds * 1000;
|
|
437
497
|
};
|
|
438
498
|
|
|
499
|
+
/**
|
|
500
|
+
* Resolves the effective `forceReconsent` signal in whole seconds, validating
|
|
501
|
+
* before trusting it. The raw field ships over the network and is human-edited,
|
|
502
|
+
* so treat any malformed value as "no signal" (null) rather than invalidating
|
|
503
|
+
* every consent:
|
|
504
|
+
*
|
|
505
|
+
* - absent / null → null (no signal)
|
|
506
|
+
* - <= 0 → null (guards zero and negatives)
|
|
507
|
+
* - in the future → null (clock skew, or a value mistakenly stamped in
|
|
508
|
+
* milliseconds, which reads as ~1000× "now" ⇒ future)
|
|
509
|
+
*
|
|
510
|
+
* Failing toward *not* invalidating means the worst a bad value causes is a
|
|
511
|
+
* missed prompt, never a permanent lockout.
|
|
512
|
+
*/
|
|
513
|
+
export const effectiveForceReconsentSeconds = (
|
|
514
|
+
cmp: ConsentManager,
|
|
515
|
+
): number | null => {
|
|
516
|
+
const raw: unknown = cmp.config?.forceReconsent;
|
|
517
|
+
if (raw == null) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
const seconds =
|
|
521
|
+
typeof raw === 'number'
|
|
522
|
+
? raw
|
|
523
|
+
: typeof raw === 'string' && raw.trim() !== ''
|
|
524
|
+
? Number(raw)
|
|
525
|
+
: Number.NaN;
|
|
526
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
if (seconds > Math.floor(Date.now() / 1000)) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
return seconds;
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* True when a consent record predates the server's `forceReconsent` stamp.
|
|
537
|
+
* `timestamp` is the recorded-consent instant (stored as an ISO/ms string).
|
|
538
|
+
*
|
|
539
|
+
* Unit alignment is the subtle part: the signal is in SECONDS and the timestamp
|
|
540
|
+
* is in MILLISECONDS, so the signal is scaled up (`* 1000`). The boundary is
|
|
541
|
+
* strict `<` — consent recorded exactly at the stamp instant stays valid.
|
|
542
|
+
*
|
|
543
|
+
* A missing/unparseable timestamp fails closed (returns false), so first-run
|
|
544
|
+
* (no timestamp) is not wrongly invalidated. `storeConsent` always co-stamps,
|
|
545
|
+
* so a consented-but-timestampless record is unreachable here anyway.
|
|
546
|
+
*/
|
|
547
|
+
export const predatesForceReconsent = (
|
|
548
|
+
cmp: ConsentManager,
|
|
549
|
+
timestamp: string | number | Date | null | undefined,
|
|
550
|
+
): boolean => {
|
|
551
|
+
const forceSeconds = effectiveForceReconsentSeconds(cmp);
|
|
552
|
+
if (forceSeconds === null) {
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
if (!timestamp) {
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
const consentedAtMs = new Date(timestamp).getTime();
|
|
559
|
+
if (Number.isNaN(consentedAtMs)) {
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
return consentedAtMs < forceSeconds * 1000;
|
|
563
|
+
};
|
|
564
|
+
|
|
439
565
|
/**
|
|
440
566
|
* Returns true when the locally stored consent has aged past its expiration
|
|
441
567
|
* window (see {@link effectiveExpirationSeconds}).
|
|
@@ -455,6 +581,8 @@ export const recordConsent = (
|
|
|
455
581
|
cmp: ConsentManager,
|
|
456
582
|
consent?: ConsentCategoryType[],
|
|
457
583
|
) => {
|
|
584
|
+
cmp.consentInvalidated = false;
|
|
585
|
+
cmp.consentPromptRequired = false;
|
|
458
586
|
const payload = createConsentRecordPayload(cmp, consent);
|
|
459
587
|
return API.post(`${CONSENT_URI}/record`, payload, {
|
|
460
588
|
referrer: cmp.consentingDomain,
|
|
@@ -539,18 +667,22 @@ export const fetchConsent = async (cmp: ConsentManager) => {
|
|
|
539
667
|
|
|
540
668
|
// Evaluate the remote record against the server's own timestamp (when the
|
|
541
669
|
// visitor actually consented), not "now", so an already-expired remote
|
|
542
|
-
// record is never restored and re-dated as fresh.
|
|
670
|
+
// record is never restored and re-dated as fresh. A record that predates
|
|
671
|
+
// the server's forceReconsent stamp is stale for the same reason — applying
|
|
672
|
+
// it would silently re-grant the very consent the signal invalidated.
|
|
543
673
|
const seconds = await effectiveExpirationSeconds(cmp);
|
|
544
|
-
const
|
|
674
|
+
const remoteForced = predatesForceReconsent(cmp, lastConsentTimestamp);
|
|
675
|
+
const remoteStale =
|
|
676
|
+
isTimestampExpired(lastConsentTimestamp, seconds) || remoteForced;
|
|
545
677
|
|
|
546
678
|
// If extUsrData is not null and not empty, always use remote consent (cross-device consent enabled)
|
|
547
679
|
if (extUsrData && extUsrData.trim() !== '') {
|
|
548
|
-
if (
|
|
549
|
-
// The authoritative remote record
|
|
550
|
-
// essential-only
|
|
551
|
-
//
|
|
552
|
-
|
|
553
|
-
return settleEssentialOnly(cmp);
|
|
680
|
+
if (remoteStale) {
|
|
681
|
+
// The authoritative remote record is stale (expired or predates the
|
|
682
|
+
// stamp); settle essential-only and flag the invalidation rather than
|
|
683
|
+
// restoring it.
|
|
684
|
+
markConsentInvalidated(cmp);
|
|
685
|
+
return settleEssentialOnly(cmp, lastConsentTimestamp);
|
|
554
686
|
}
|
|
555
687
|
const remoteConsentCategories = formatConsentArray(
|
|
556
688
|
normalizeDoNotSellConsent(
|
|
@@ -573,6 +705,7 @@ export const fetchConsent = async (cmp: ConsentManager) => {
|
|
|
573
705
|
true,
|
|
574
706
|
lastConsentTimestamp,
|
|
575
707
|
);
|
|
708
|
+
cmp.consentPromptRequired = false;
|
|
576
709
|
return remoteConsentCategories;
|
|
577
710
|
}
|
|
578
711
|
|
|
@@ -580,7 +713,7 @@ export const fetchConsent = async (cmp: ConsentManager) => {
|
|
|
580
713
|
const internalTimestamp = await retrieveLastConsentTimestamp(cmp);
|
|
581
714
|
try {
|
|
582
715
|
if (
|
|
583
|
-
!
|
|
716
|
+
!remoteStale &&
|
|
584
717
|
(new Date(lastConsentTimestamp) > new Date(internalTimestamp) ||
|
|
585
718
|
internalTimestamp === '')
|
|
586
719
|
) {
|
|
@@ -598,6 +731,7 @@ export const fetchConsent = async (cmp: ConsentManager) => {
|
|
|
598
731
|
true,
|
|
599
732
|
lastConsentTimestamp,
|
|
600
733
|
);
|
|
734
|
+
cmp.consentPromptRequired = false;
|
|
601
735
|
|
|
602
736
|
return remoteConsentCategories;
|
|
603
737
|
}
|
|
@@ -56,7 +56,11 @@ export type Config = {
|
|
|
56
56
|
customerId: string;
|
|
57
57
|
domains: Array<string>;
|
|
58
58
|
forceManagePreferences: boolean;
|
|
59
|
-
|
|
59
|
+
// Server-stamped Unix timestamp in whole SECONDS. When present, consent
|
|
60
|
+
// recorded before this instant is treated as no longer valid, re-prompting the
|
|
61
|
+
// visitor. Read-only on the client (the server authors it); absent/undefined
|
|
62
|
+
// means "no signal". See src/services/consent.ts effectiveForceReconsentSeconds.
|
|
63
|
+
forceReconsent?: number;
|
|
60
64
|
gdprApplies?: boolean;
|
|
61
65
|
googleConsent: boolean;
|
|
62
66
|
gpcSupport: boolean;
|
package/src/types/store.d.ts
CHANGED
|
@@ -30,6 +30,11 @@ export type OsanoState = {
|
|
|
30
30
|
scrollToType: string;
|
|
31
31
|
scrollToId: number;
|
|
32
32
|
stack: string[];
|
|
33
|
+
// Transient marker set by a RELOAD whose consent was invalidated (expired or
|
|
34
|
+
// predating the forceReconsent stamp). The IAB TCF middleware reads it on
|
|
35
|
+
// RELOAD/SET_CONFIG to wipe stale TC strings instead of rehydrating them.
|
|
36
|
+
// Reset on SAVE_BEGIN (the user re-consented) and on the next clean load.
|
|
37
|
+
consentInvalidated?: boolean;
|
|
33
38
|
};
|
|
34
39
|
|
|
35
40
|
export type ActionCreator<S, T> = (payload?: T) => OsanoAction<S, T>;
|