@chipmobilesdk/rn-ads 0.2.0 → 0.3.0

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.
@@ -1,188 +1,211 @@
1
- /**
2
- * Catalog and configuration validation (FR-008), plus the fail-closed check
3
- * that substitution cannot cover (FR-012).
4
- *
5
- * Configuration faults throw rather than returning a result. They mean the app
6
- * is wired wrong, and no runtime status could describe a usable outcome — every
7
- * other failure in this package is a typed result instead.
8
- *
9
- * Every message names the offending placement key. An error that says only
10
- * "invalid configuration" makes the integrator hunt through a catalog by hand.
11
- */
12
-
13
- import { AdsConfigError } from '../diagnostics/errors';
14
- import { resolvePlacementAdUnits, type ResolvedCatalog } from './resolveAdUnit';
15
- import { resolveTimeouts, type ResolvedTimeouts } from '../core/timers';
16
- import { toEnablement } from '../core/enablement';
17
- import { resolveEffectiveRetry, type ResolvedRetryPolicy } from '../core/retryPolicy';
18
- import type {
19
- AdFormat,
20
- AdsConfig,
21
- ConsentTestingOptions,
22
- Enablement,
23
- Environment,
24
- PlacementDefinition,
25
- } from './types';
26
-
27
- const FORMATS: ReadonlySet<string> = new Set<AdFormat>([
28
- 'banner',
29
- 'interstitial',
30
- 'rewarded',
31
- 'app_open',
32
- ]);
33
-
34
- export interface ValidateOptions {
35
- /**
36
- * Whether this is a debug build. Injected rather than read from `__DEV__`
37
- * directly so the fail-closed rule is testable without manipulating a global
38
- * the test runner also depends on.
39
- */
40
- readonly isDevBuild: boolean;
41
- }
42
-
43
- export interface ResolvedConfig {
44
- readonly enablement: Enablement;
45
- readonly environment: Environment;
46
- readonly placements: ReadonlyMap<string, PlacementDefinition>;
47
- readonly catalog: ResolvedCatalog;
48
- readonly timeouts: ResolvedTimeouts;
49
- /** Empty in production regardless of what was configured (FR-014). */
50
- readonly testDeviceIds: readonly string[];
51
- /** Effective retry per placement. `null` means retry is off there (FR-134). */
52
- readonly retryByKey: ReadonlyMap<string, ResolvedRetryPolicy | null>;
53
- /** Stripped entirely in production (FR-113). */
54
- readonly consentTesting: ConsentTestingOptions | undefined;
55
- /**
56
- * Which consent tools were dropped because the environment is production.
57
- * Surfaced so the inertness is observable rather than merely documented —
58
- * the same treatment FR-011a gives substituted ad units.
59
- */
60
- readonly ignoredConsentTools: readonly string[];
61
- }
62
-
63
- export function validateConfig(config: AdsConfig, options: ValidateOptions): ResolvedConfig {
64
- // FR-012. Substitution deliberately does not apply in production, so a debug
65
- // build declaring `production` is the one case it cannot make safe: it would
66
- // send real requests from a build that should never generate traffic. Fail
67
- // closed rather than continue.
68
- if (config.environment === 'production' && options.isDevBuild) {
69
- throw new AdsConfigError('production_in_dev_build', {
70
- message:
71
- 'environment is "production" but this is a development build. ' +
72
- 'Non-production builds must declare "development" or "test" so the package can ' +
73
- 'substitute official Google test ad units and keep production traffic impossible.',
74
- });
75
- }
76
-
77
- if (!Array.isArray(config.placements) || config.placements.length === 0) {
78
- throw new AdsConfigError('invalid_config', {
79
- message: 'placements must be a non-empty array; the package can show nothing without one',
80
- });
81
- }
82
-
83
- const byKey = new Map<string, PlacementDefinition>();
84
-
85
- for (const placement of config.placements) {
86
- const key = typeof placement?.key === 'string' ? placement.key.trim() : '';
87
-
88
- if (!key) {
89
- throw new AdsConfigError('invalid_config', {
90
- message: 'every placement needs a non-empty key',
91
- });
92
- }
93
-
94
- if (byKey.has(key)) {
95
- // Rejecting the whole catalog rather than picking a winner: a repeated
96
- // key means the app's own naming produced two placements for one slot,
97
- // and the package has no basis to guess which was meant.
98
- throw new AdsConfigError('duplicate_placement_key', {
99
- message: `duplicate placement key "${key}"`,
100
- placementKey: key,
101
- });
102
- }
103
-
104
- if (!FORMATS.has(placement.format)) {
105
- throw new AdsConfigError('invalid_config', {
106
- message: `placement "${key}" has unsupported format "${String(placement.format)}"; supported formats are banner, interstitial, rewarded, and app_open`,
107
- placementKey: key,
108
- });
109
- }
110
-
111
- if (!placement.androidAdUnitId || !placement.androidAdUnitId.trim()) {
112
- throw new AdsConfigError('missing_ad_unit_id', {
113
- message: `placement "${key}" is missing androidAdUnitId`,
114
- placementKey: key,
115
- format: placement.format,
116
- });
117
- }
118
-
119
- byKey.set(key, { ...placement, key });
120
- }
121
-
122
- const timeouts = resolveTimeouts({
123
- timeouts: config.timeouts,
124
- adValidityMs: config.adValidityMs,
125
- appOpenValidityMs: config.appOpenValidityMs,
126
- undecidedWarningMs: config.undecidedWarningMs,
127
- });
128
- const catalog = resolvePlacementAdUnits(Array.from(byKey.values()), config.environment);
129
-
130
- // Resolved eagerly, for every placement, so an out-of-bounds override fails
131
- // at construction rather than on the first failed load in production — which
132
- // is the one moment nobody is watching for a configuration error.
133
- const retryByKey = new Map<string, ResolvedRetryPolicy | null>();
134
- for (const [key, placement] of byKey) {
135
- retryByKey.set(key, resolveEffectiveRetry(config.retry, placement.retry, key));
136
- }
137
-
138
- const isProduction = config.environment === 'production';
139
- const { consentTesting, ignoredConsentTools } = resolveConsentTesting(
140
- config.consentTesting,
141
- isProduction,
142
- );
143
-
144
- return {
145
- enablement: toEnablement(config.enabled),
146
- environment: config.environment,
147
- placements: byKey,
148
- catalog,
149
- timeouts,
150
- // FR-014: debug and test-device settings must not be able to alter
151
- // production behavior, so they are dropped rather than carried and
152
- // conditionally ignored somewhere further down.
153
- testDeviceIds: isProduction ? [] : [...(config.testDeviceIds ?? [])],
154
- retryByKey,
155
- consentTesting,
156
- ignoredConsentTools,
157
- };
158
- }
159
-
160
- /**
161
- * Neutralizes the consent testing tools in production (FR-113).
162
- *
163
- * Dropped here, at the single validation point, rather than checked wherever
164
- * they are used. That is what makes the guarantee provable: a rule enforced in
165
- * one place can be asserted by a test, while one spread across the consent
166
- * module's call sites is a hope. It is the same reasoning that makes the ad-unit
167
- * substitution guarantee hold, and it is why both are enforced identically.
168
- *
169
- * The names of what was dropped are returned rather than only logged, so an
170
- * automated test — and the demo app's diagnostics screen — can see it.
171
- */
172
- function resolveConsentTesting(
173
- testing: ConsentTestingOptions | undefined,
174
- isProduction: boolean,
175
- ): { consentTesting: ConsentTestingOptions | undefined; ignoredConsentTools: readonly string[] } {
176
- if (!testing || !isProduction) {
177
- return { consentTesting: testing, ignoredConsentTools: [] };
178
- }
179
-
180
- const ignored: string[] = [];
181
- if (testing.debugGeography !== undefined && testing.debugGeography !== 'disabled') {
182
- ignored.push('debugGeography');
183
- }
184
- if (testing.resetOnStart === true) {
185
- ignored.push('resetOnStart');
186
- }
187
- return { consentTesting: undefined, ignoredConsentTools: ignored };
188
- }
1
+ /**
2
+ * Catalog and configuration validation (FR-008), plus the fail-closed check
3
+ * that substitution cannot cover (FR-012).
4
+ *
5
+ * Configuration faults throw rather than returning a result. They mean the app
6
+ * is wired wrong, and no runtime status could describe a usable outcome — every
7
+ * other failure in this package is a typed result instead.
8
+ *
9
+ * Every message names the offending placement key. An error that says only
10
+ * "invalid configuration" makes the integrator hunt through a catalog by hand.
11
+ */
12
+
13
+ import { AdsConfigError } from '../diagnostics/errors';
14
+ import { resolvePlacementAdUnits, type ResolvedCatalog } from './resolveAdUnit';
15
+ import { checkBand, resolveTimeouts, type ResolvedTimeouts } from '../core/timers';
16
+ import {
17
+ defaultMinRequestIntervalMs,
18
+ MIN_REQUEST_INTERVAL_BOUNDS,
19
+ } from '../core/requestThrottle';
20
+ import { toEnablement } from '../core/enablement';
21
+ import { resolveEffectiveRetry, type ResolvedRetryPolicy } from '../core/retryPolicy';
22
+ import type {
23
+ AdFormat,
24
+ AdsConfig,
25
+ ConsentTestingOptions,
26
+ Enablement,
27
+ Environment,
28
+ PlacementDefinition,
29
+ } from './types';
30
+
31
+ const FORMATS: ReadonlySet<string> = new Set<AdFormat>([
32
+ 'banner',
33
+ 'interstitial',
34
+ 'rewarded',
35
+ 'app_open',
36
+ ]);
37
+
38
+ export interface ValidateOptions {
39
+ /**
40
+ * Whether this is a debug build. Injected rather than read from `__DEV__`
41
+ * directly so the fail-closed rule is testable without manipulating a global
42
+ * the test runner also depends on.
43
+ */
44
+ readonly isDevBuild: boolean;
45
+ }
46
+
47
+ export interface ResolvedConfig {
48
+ readonly enablement: Enablement;
49
+ readonly environment: Environment;
50
+ readonly placements: ReadonlyMap<string, PlacementDefinition>;
51
+ readonly catalog: ResolvedCatalog;
52
+ readonly timeouts: ResolvedTimeouts;
53
+ /** Empty in production regardless of what was configured (FR-014). */
54
+ readonly testDeviceIds: readonly string[];
55
+ /** Effective retry per placement. `null` means retry is off there (FR-134). */
56
+ readonly retryByKey: ReadonlyMap<string, ResolvedRetryPolicy | null>;
57
+ /**
58
+ * Effective minimum interval between provider requests, per placement
59
+ * (FR-140). Zero means the throttle is off for that placement.
60
+ */
61
+ readonly minRequestIntervalByKey: ReadonlyMap<string, number>;
62
+ /** Stripped entirely in production (FR-113). */
63
+ readonly consentTesting: ConsentTestingOptions | undefined;
64
+ /**
65
+ * Which consent tools were dropped because the environment is production.
66
+ * Surfaced so the inertness is observable rather than merely documented —
67
+ * the same treatment FR-011a gives substituted ad units.
68
+ */
69
+ readonly ignoredConsentTools: readonly string[];
70
+ }
71
+
72
+ export function validateConfig(config: AdsConfig, options: ValidateOptions): ResolvedConfig {
73
+ // FR-012. Substitution deliberately does not apply in production, so a debug
74
+ // build declaring `production` is the one case it cannot make safe: it would
75
+ // send real requests from a build that should never generate traffic. Fail
76
+ // closed rather than continue.
77
+ if (config.environment === 'production' && options.isDevBuild) {
78
+ throw new AdsConfigError('production_in_dev_build', {
79
+ message:
80
+ 'environment is "production" but this is a development build. ' +
81
+ 'Non-production builds must declare "development" or "test" so the package can ' +
82
+ 'substitute official Google test ad units and keep production traffic impossible.',
83
+ });
84
+ }
85
+
86
+ if (!Array.isArray(config.placements) || config.placements.length === 0) {
87
+ throw new AdsConfigError('invalid_config', {
88
+ message: 'placements must be a non-empty array; the package can show nothing without one',
89
+ });
90
+ }
91
+
92
+ const byKey = new Map<string, PlacementDefinition>();
93
+
94
+ for (const placement of config.placements) {
95
+ const key = typeof placement?.key === 'string' ? placement.key.trim() : '';
96
+
97
+ if (!key) {
98
+ throw new AdsConfigError('invalid_config', {
99
+ message: 'every placement needs a non-empty key',
100
+ });
101
+ }
102
+
103
+ if (byKey.has(key)) {
104
+ // Rejecting the whole catalog rather than picking a winner: a repeated
105
+ // key means the app's own naming produced two placements for one slot,
106
+ // and the package has no basis to guess which was meant.
107
+ throw new AdsConfigError('duplicate_placement_key', {
108
+ message: `duplicate placement key "${key}"`,
109
+ placementKey: key,
110
+ });
111
+ }
112
+
113
+ if (!FORMATS.has(placement.format)) {
114
+ throw new AdsConfigError('invalid_config', {
115
+ message: `placement "${key}" has unsupported format "${String(placement.format)}"; supported formats are banner, interstitial, rewarded, and app_open`,
116
+ placementKey: key,
117
+ });
118
+ }
119
+
120
+ if (!placement.androidAdUnitId || !placement.androidAdUnitId.trim()) {
121
+ throw new AdsConfigError('missing_ad_unit_id', {
122
+ message: `placement "${key}" is missing androidAdUnitId`,
123
+ placementKey: key,
124
+ format: placement.format,
125
+ });
126
+ }
127
+
128
+ byKey.set(key, { ...placement, key });
129
+ }
130
+
131
+ const timeouts = resolveTimeouts({
132
+ timeouts: config.timeouts,
133
+ adValidityMs: config.adValidityMs,
134
+ appOpenValidityMs: config.appOpenValidityMs,
135
+ undecidedWarningMs: config.undecidedWarningMs,
136
+ });
137
+ const catalog = resolvePlacementAdUnits(Array.from(byKey.values()), config.environment);
138
+
139
+ // Resolved eagerly, for every placement, so an out-of-bounds override fails
140
+ // at construction rather than on the first failed load in production — which
141
+ // is the one moment nobody is watching for a configuration error.
142
+ const retryByKey = new Map<string, ResolvedRetryPolicy | null>();
143
+ // Resolved in the same loop and for the same reason: an out-of-bounds floor
144
+ // must fail at construction, not silently refuse every request in production.
145
+ const minRequestIntervalByKey = new Map<string, number>();
146
+ for (const [key, placement] of byKey) {
147
+ retryByKey.set(key, resolveEffectiveRetry(config.retry, placement.retry, key));
148
+ minRequestIntervalByKey.set(
149
+ key,
150
+ placement.minRequestIntervalMs === undefined
151
+ ? defaultMinRequestIntervalMs(placement.format)
152
+ : checkBand(
153
+ `placements["${key}"].minRequestIntervalMs`,
154
+ placement.minRequestIntervalMs,
155
+ MIN_REQUEST_INTERVAL_BOUNDS,
156
+ ),
157
+ );
158
+ }
159
+
160
+ const isProduction = config.environment === 'production';
161
+ const { consentTesting, ignoredConsentTools } = resolveConsentTesting(
162
+ config.consentTesting,
163
+ isProduction,
164
+ );
165
+
166
+ return {
167
+ enablement: toEnablement(config.enabled),
168
+ environment: config.environment,
169
+ placements: byKey,
170
+ catalog,
171
+ timeouts,
172
+ // FR-014: debug and test-device settings must not be able to alter
173
+ // production behavior, so they are dropped rather than carried and
174
+ // conditionally ignored somewhere further down.
175
+ testDeviceIds: isProduction ? [] : [...(config.testDeviceIds ?? [])],
176
+ retryByKey,
177
+ minRequestIntervalByKey,
178
+ consentTesting,
179
+ ignoredConsentTools,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Neutralizes the consent testing tools in production (FR-113).
185
+ *
186
+ * Dropped here, at the single validation point, rather than checked wherever
187
+ * they are used. That is what makes the guarantee provable: a rule enforced in
188
+ * one place can be asserted by a test, while one spread across the consent
189
+ * module's call sites is a hope. It is the same reasoning that makes the ad-unit
190
+ * substitution guarantee hold, and it is why both are enforced identically.
191
+ *
192
+ * The names of what was dropped are returned rather than only logged, so an
193
+ * automated test — and the demo app's diagnostics screen — can see it.
194
+ */
195
+ function resolveConsentTesting(
196
+ testing: ConsentTestingOptions | undefined,
197
+ isProduction: boolean,
198
+ ): { consentTesting: ConsentTestingOptions | undefined; ignoredConsentTools: readonly string[] } {
199
+ if (!testing || !isProduction) {
200
+ return { consentTesting: testing, ignoredConsentTools: [] };
201
+ }
202
+
203
+ const ignored: string[] = [];
204
+ if (testing.debugGeography !== undefined && testing.debugGeography !== 'disabled') {
205
+ ignored.push('debugGeography');
206
+ }
207
+ if (testing.resetOnStart === true) {
208
+ ignored.push('resetOnStart');
209
+ }
210
+ return { consentTesting: undefined, ignoredConsentTools: ignored };
211
+ }