@chipmobilesdk/rn-ads 0.2.1 → 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.
package/package.json CHANGED
@@ -1,49 +1,49 @@
1
- {
2
- "name": "@chipmobilesdk/rn-ads",
3
- "version": "0.2.1",
4
- "private": false,
5
- "license": "UNLICENSED",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./src/index.ts",
11
- "react-native": "./src/index.ts",
12
- "default": "./src/index.ts"
13
- },
14
- "./testing": {
15
- "types": "./src/testing/index.ts",
16
- "react-native": "./src/testing/index.ts",
17
- "default": "./src/testing/index.ts"
18
- }
19
- },
20
- "files": [
21
- "src/index.ts",
22
- "src/config",
23
- "src/core",
24
- "src/diagnostics",
25
- "src/engine",
26
- "src/react",
27
- "src/testing",
28
- "skills",
29
- "README.md",
30
- "CHANGELOG.md"
31
- ],
32
- "sideEffects": false,
33
- "scripts": {
34
- "typecheck": "tsc -p tsconfig.json --noEmit",
35
- "pack:dry-run": "npm pack --dry-run"
36
- },
37
- "peerDependencies": {
38
- "react": ">=19",
39
- "react-native": ">=0.85",
40
- "react-native-google-mobile-ads": ">=16.0.3 <16.1.0"
41
- },
42
- "engines": {
43
- "node": ">=22.11.0"
44
- },
45
- "publishConfig": {
46
- "access": "public",
47
- "registry": "https://registry.npmjs.org/"
48
- }
49
- }
1
+ {
2
+ "name": "@chipmobilesdk/rn-ads",
3
+ "version": "0.3.0",
4
+ "private": false,
5
+ "license": "UNLICENSED",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "react-native": "./src/index.ts",
12
+ "default": "./src/index.ts"
13
+ },
14
+ "./testing": {
15
+ "types": "./src/testing/index.ts",
16
+ "react-native": "./src/testing/index.ts",
17
+ "default": "./src/testing/index.ts"
18
+ }
19
+ },
20
+ "files": [
21
+ "src/index.ts",
22
+ "src/config",
23
+ "src/core",
24
+ "src/diagnostics",
25
+ "src/engine",
26
+ "src/react",
27
+ "src/testing",
28
+ "skills",
29
+ "README.md",
30
+ "CHANGELOG.md"
31
+ ],
32
+ "sideEffects": false,
33
+ "scripts": {
34
+ "typecheck": "tsc -p tsconfig.json --noEmit",
35
+ "pack:dry-run": "npm pack --dry-run"
36
+ },
37
+ "peerDependencies": {
38
+ "react": ">=19",
39
+ "react-native": ">=0.85",
40
+ "react-native-google-mobile-ads": ">=16.0.3 <16.1.0"
41
+ },
42
+ "engines": {
43
+ "node": ">=22.11.0"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public",
47
+ "registry": "https://registry.npmjs.org/"
48
+ }
49
+ }
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: sdk-rn-ads
3
- description: "Advertising via @chipmobilesdk/rn-ads: AdMob banner/interstitial/rewarded/app-open behind a provider-neutral contract, placement keys, optional consent orchestration, three-state enablement, bounded retry, per-environment test-ad substitution. Use when adding ads to a screen, running a consent flow without importing AdMob, deferring ads until a purchase check resolves, debugging an ad that never loads, mapping ad units per environment, or answering a Data safety question about advertising."
3
+ description: "Advertising via @chipmobilesdk/rn-ads: AdMob banner/interstitial/rewarded/app-open behind a provider-neutral contract, placement keys, optional consent orchestration, three-state enablement, bounded retry, per-environment test-ad substitution, per-placement request throttling. Use when adding ads to a screen, running a consent flow without importing AdMob, deferring ads until a purchase check resolves, debugging an ad that never loads or reports throttled, placing a banner inside a list or a view the user swipes between, mapping ad units per environment, or answering a Data safety question about advertising."
4
4
  metadata:
5
5
  package: "@chipmobilesdk/rn-ads"
6
- packageVersion: "0.2.0"
6
+ packageVersion: "0.3.0"
7
7
  user-invocable: true
8
8
  disable-model-invocation: false
9
9
  ---
@@ -85,7 +85,32 @@ defect and it is fixed. The result now says which happened:
85
85
 
86
86
  Existing `ads.setEnabled(false);` call sites still compile untouched.
87
87
 
88
- ## Four things not to "fix"
88
+ ## A banner in a list or a swipeable view gets throttled — by design
89
+
90
+ New in 0.3.0. A banner mounted inside a view the user navigates between — a day in a
91
+ calendar, a tab, a row that scrolls in and out — is unmounted and remounted by React on
92
+ every navigation, and **each remount is a billable request**. Ten quick swipes used to be
93
+ ten requests in a few seconds, which ad networks read as invalid traffic. The penalty is
94
+ account-level and no shipped fix recovers it.
95
+
96
+ So each placement now has a minimum interval between *requests*: **60s for a banner**
97
+ (AdMob's own refresh floor), **off for full-screen formats** — those are loaded by an
98
+ explicit call, not mounted by a renderer, so they never had the problem. A refusal reports
99
+ `throttled` on the placement and emits `request_throttled` with `retryAfterMs`.
100
+
101
+ Three things to know before you reach for `minRequestIntervalMs: 0`:
102
+
103
+ - **`throttled` is not `failed` and not a no-fill.** Nothing is wrong and nothing was
104
+ requested. Collapse the space if you like, but do not log it as an error.
105
+ - **Rotation is exempt.** A width re-adaptation inside one mount is not charged, so a
106
+ rotated screen keeps its banner.
107
+ - **The better fix is architectural.** Keep the banner outside the subtree that remounts
108
+ and the throttle never fires. It is a safety net, not a licence to ignore the problem.
109
+
110
+ This is **not** a frequency cap. It limits how often a placement may *ask*; how often you
111
+ *show* is yours, and the package deliberately ships no pacing (see `## Do not use this for`).
112
+
113
+ ## Five things not to "fix"
89
114
 
90
115
  - **The bridge pin.** `react-native-google-mobile-ads` is peered at `>=16.0.3 <16.1.0`;
91
116
  16.1.0+ carries Kotlin 2.3 metadata that React Native 0.85's pinned compiler cannot
@@ -101,6 +126,9 @@ Existing `ads.setEnabled(false);` call sites still compile untouched.
101
126
  - **A declined consent still reporting `obtained` / `canRequestAds: true`.** That is UMP's
102
127
  meaning, not a bug: `OBTAINED` means *an answer was recorded*, and non-personalized ads
103
128
  stay servable. The package forwards the provider's verdict rather than inventing one.
129
+ - **A banner reporting `throttled` right after a navigation.** That is 0.3.0's request
130
+ floor working — see the section above. Lower it knowingly or move the banner out of the
131
+ remounting subtree; do not set `0` to make the symptom go away.
104
132
 
105
133
  ## Retry is off unless you ask
106
134
 
@@ -120,7 +148,7 @@ on a return **from an ad** — or from a browser an ad opened — is refused wit
120
148
  return apart from any other; the package can. It cannot survive process death, and a
121
149
  browser opened without a preceding click is undetectable. See `## App open`.
122
150
 
123
- ## Public API (v0.2.0)
151
+ ## Public API (v0.3.0)
124
152
 
125
153
  Runtime: `createAds` (throws `AdsConfigError` on a bad catalog or an out-of-range retry
126
154
  policy; everything else returns a typed result rather than throwing) · `AdsProvider`,
@@ -129,7 +157,9 @@ policy; everything else returns a typed result rather than throwing) · `AdsProv
129
157
  `DEFAULT_SHOW_TIMEOUT_MS`, `DEFAULT_AD_VALIDITY_MS`, `DEFAULT_APP_OPEN_VALIDITY_MS`,
130
158
  `DEFAULT_RETRY_POLICY`, `DEFAULT_UNDECIDED_WARNING_MS`, `LOAD_TIMEOUT_BOUNDS`,
131
159
  `SHOW_TIMEOUT_BOUNDS`, `AD_VALIDITY_BOUNDS`, `APP_OPEN_VALIDITY_BOUNDS`, `RETRY_BOUNDS`,
132
- `UNDECIDED_WARNING_BOUNDS` · `createFakeAdsEngine`, `createTestClock` from
160
+ `UNDECIDED_WARNING_BOUNDS`, `DEFAULT_BANNER_MIN_REQUEST_INTERVAL_MS`,
161
+ `DEFAULT_FULL_SCREEN_MIN_REQUEST_INTERVAL_MS`, `MIN_REQUEST_INTERVAL_BOUNDS`,
162
+ `defaultMinRequestIntervalMs` · `createFakeAdsEngine`, `createTestClock` from
133
163
  `@chipmobilesdk/rn-ads/testing` (test doubles only).
134
164
 
135
165
  Instance: `initialize`, `updatePrivacy`, `setEnabled`, `setEnablement`, `setAppActive`,
@@ -151,6 +181,24 @@ Types: `AdsInstance`, `AdsConfig`, `PlacementDefinition`, `PrivacyOptions`, `AdF
151
181
  Call `dispose()` when the owning screen unmounts; it is idempotent, and so is every
152
182
  `Unsubscribe`.
153
183
 
184
+ ## Upgrading 0.2.x → 0.3.0
185
+
186
+ One compile-time change and one behaviour change.
187
+
188
+ | Union | New member |
189
+ |---|---|
190
+ | `PlacementState` | `'throttled'` |
191
+ | `LoadResult` | `{ status: 'throttled', retryAfterMs }` |
192
+ | `AdEventType` | `'request_throttled'` |
193
+
194
+ **The behaviour change:** banner placements now have a 60s floor between requests. If your
195
+ banner lives inside a list row, a tab, or a view the user swipes between, it will report
196
+ `throttled` after a quick navigation where 0.2.x silently issued another request. That was
197
+ the bug; this is the fix. Read the throttle section above before setting
198
+ `minRequestIntervalMs: 0`.
199
+
200
+ Full-screen formats are unchanged — their default floor is `0`.
201
+
154
202
  ## Upgrading 0.1.0 → 0.2.0
155
203
 
156
204
  One required change, compile-time only: five unions gained members, so an exhaustive
@@ -182,6 +230,7 @@ Read one section of `node_modules/@chipmobilesdk/rn-ads/README.md`:
182
230
  | App-open and ad-originated return | `## App open` |
183
231
  | Turning ads off, undecided, re-enabling | `## Turning ads off — three states` |
184
232
  | Retry and reconciling it against a bill | `## Retry` |
233
+ | Request throttling, banners in lists | `## Request throttling` |
185
234
  | Why a request was refused | `## Why an ad did not show` |
186
235
  | Results, events, errors, timeouts, cleanup | `## State, events, and errors` |
187
236
  | Tests without a device | `## Testing` |
@@ -65,6 +65,18 @@ export interface PlacementDefinition {
65
65
  * override can narrow behavior but never exceed a ceiling (FR-136a).
66
66
  */
67
67
  readonly retry?: RetryPolicy | false;
68
+ /**
69
+ * Minimum milliseconds between two provider requests for this placement
70
+ * (FR-140), added in 0.3.0.
71
+ *
72
+ * Absent takes the format-aware default: 60s for a banner, off for full-screen
73
+ * formats. `0` turns it off here while leaving it on elsewhere.
74
+ *
75
+ * This is an **account-safety** floor, not a frequency cap: it limits how often
76
+ * a placement may *ask* for an ad, never how often the app may *show* one. If
77
+ * you are reaching for this to pace interstitials, that belongs in the app.
78
+ */
79
+ readonly minRequestIntervalMs?: number;
68
80
  }
69
81
 
70
82
  /**
@@ -269,6 +281,16 @@ export type PlacementState =
269
281
  | 'failed'
270
282
  /** Mirrors `InitStatus.undecided` so the banner host can report it directly. */
271
283
  | 'undecided'
284
+ /**
285
+ * Refused by the per-placement request throttle (FR-140), added in 0.3.0.
286
+ *
287
+ * Deliberately distinct from `failed` and from a no-fill: nothing is wrong,
288
+ * nothing was requested, and the same placement will work again shortly. An
289
+ * app that collapses its ad slot on `failed` will usually want to collapse on
290
+ * this too, but it must be able to tell the two apart when diagnosing why a
291
+ * banner is absent.
292
+ */
293
+ | 'throttled'
272
294
  | 'unavailable';
273
295
 
274
296
  /** FR-047. The provider's reward descriptor, normalized. */
@@ -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
+ }