@chipmobilesdk/rn-ads 0.1.0 → 0.2.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,98 +1,98 @@
1
- ---
2
- name: sdk-rn-ads
3
- description: "Advertising via @chipmobilesdk/rn-ads: AdMob banner/interstitial/rewarded behind a provider-neutral contract, placement keys, consent gating, per-environment test-ad substitution. Use when adding ads to a screen, debugging an ad that never loads, mapping ad units per environment, or answering a Data safety question about advertising."
4
- metadata:
5
- package: "@chipmobilesdk/rn-ads"
6
- packageVersion: "0.1.0"
7
- user-invocable: true
8
- disable-model-invocation: false
9
- ---
10
-
11
- # @chipmobilesdk/rn-ads
12
-
13
- Adaptive banner, interstitial and rewarded ads on Android via Google AdMob, behind a
14
- contract that names no provider. The app names a placement key; the package owns the
15
- request. Consuming code never imports the Google Mobile Ads SDK.
16
-
17
- ## Use this when
18
-
19
- - Adding a banner, interstitial or rewarded ad to a screen
20
- - Mapping AdMob ad units to placement keys per environment
21
- - Debugging an ad that never loads, or a `blocked` / `unavailable` / `provider_error`
22
- - Wiring consent so requests cannot precede it
23
- - Answering a Data safety / App Privacy question about advertising
24
-
25
- ## Do not use this for
26
-
27
- - **Consent UI or copy** — the package consumes a gate outcome and enforces ordering; it
28
- renders no dialog and ships no legal text.
29
- - **Where or how often ads appear** — no frequency cap, no placement rule, no auto-insert.
30
- - **Granting the reward** — it reports `reward_earned`; entitlement is the app's.
31
- - iOS — not implemented; every call returns `unavailable` / `unsupported_platform`.
32
-
33
- ## Nothing loads until consent and initialization complete
34
-
35
- This is enforced, and it is the answer to almost every "the ad never appears". With the
36
- gate withheld, **zero** provider calls occur — not even configuration. `childDirected`,
37
- `underAgeOfConsent` and `maxAdContentRating` take effect only if they reach
38
- `initialize()` *before* the provider starts; changing one later returns
39
- `requires_reinitialization` rather than silently not applying.
40
- See `## Initialize — consent comes first`.
41
-
42
- ## Never gate `<AdsBanner>` on its own placement state
43
-
44
- `AdsBanner` is what measures the container and starts the load, so it must be mounted
45
- unconditionally. Rendering it only when the state is `loading`/`ready` deadlocks at
46
- `idle` and no ad ever appears. Use the state for the *surrounding* placeholder and to
47
- decide whether to hold or reclaim space — never to decide whether to mount the host. It
48
- renders `null` on its own when there is nothing to show.
49
-
50
- ## Two things not to "fix"
51
-
52
- - **The bridge pin.** `react-native-google-mobile-ads` is peered at `>=16.0.3 <16.1.0`;
53
- 16.1.0+ carries Kotlin 2.3 metadata that React Native 0.85's pinned compiler cannot
54
- read, and the Android build fails outright. Read `## Compatibility` before widening it.
55
- - **Test ads in dev.** Outside `production`, non-official ad units are replaced with
56
- Google's test units and reported via `config_warning` / `getSubstitutedPlacements()`.
57
- That is the safety guarantee working, not a misconfiguration. Never click a production
58
- ad to verify anything.
59
-
60
- ## Public API (v0.1.0)
61
-
62
- Runtime: `createAds` (throws `AdsConfigError` on a bad catalog; everything else returns a
63
- typed result rather than throwing) · `AdsProvider`, `useAds` · `AdsBanner` ·
64
- `usePlacementState`, `useAdsStatus`, `useAdsEvents` · `AdsError`, `AdsConfigError`,
65
- `isAdsError`, `isFullScreenFormat` · `DEFAULT_LOAD_TIMEOUT_MS`, `DEFAULT_SHOW_TIMEOUT_MS`,
66
- `DEFAULT_AD_VALIDITY_MS`, `LOAD_TIMEOUT_BOUNDS`, `SHOW_TIMEOUT_BOUNDS`,
67
- `AD_VALIDITY_BOUNDS` · `createFakeAdsEngine` from `@chipmobilesdk/rn-ads/testing`
68
- (test doubles only).
69
-
70
- Instance: `initialize`, `updatePrivacy`, `setEnabled`, `setAppActive`, `getStatus`,
71
- `subscribeStatus`, `load`, `show`, `getPlacementState`, `subscribePlacement`,
72
- `subscribeEvents`, `getSubstitutedPlacements`, `dispose`.
73
-
74
- Types: `AdsInstance`, `AdsConfig`, `PlacementDefinition`, `PrivacyOptions`, `AdFormat`,
75
- `FullScreenFormat`, `Environment`, `InitStatus`, `PlacementState`, `InitResult`,
76
- `LoadResult`, `ShowResult`, `PrivacyUpdateResult`, `Reward`, `AdEvent`, `AdEventType`,
77
- `AdEventListener`, `ImpressionSource`, `AdsErrorCode`, `AdsOperation`, `TimeoutConfig`,
78
- `AdContentRating`, `PrivacyBlockReason`, `UnavailableReason`, `Unsubscribe`,
79
- `AdsBannerProps`, `AdsProviderProps`.
80
-
81
- Call `dispose()` when the owning screen unmounts; it is idempotent, and so is every
82
- `Unsubscribe`.
83
-
84
- ## Where the detail lives
85
-
86
- Read one section of `node_modules/@chipmobilesdk/rn-ads/README.md`:
87
-
88
- | Need | Section |
89
- |---|---|
90
- | First integration | `## Install`, `## Configure` |
91
- | Consent ordering | `## Initialize — consent comes first` |
92
- | Rendering a banner | `## Banner` |
93
- | Full-screen formats | `## Interstitial and rewarded` |
94
- | Results, events, errors, timeouts, cleanup | `## State, events, and errors` |
95
- | Tests without a device | `## Testing` |
96
- | Permissions, Data safety, families | `## Install`, `## Release checklists` |
97
- | Build failure after a bridge upgrade | `## Compatibility` |
98
- | What is deferred | `## Known limitations`, `## Extending to iOS` |
1
+ ---
2
+ name: sdk-rn-ads
3
+ description: "Advertising via @chipmobilesdk/rn-ads: AdMob banner/interstitial/rewarded behind a provider-neutral contract, placement keys, consent gating, per-environment test-ad substitution. Use when adding ads to a screen, debugging an ad that never loads, mapping ad units per environment, or answering a Data safety question about advertising."
4
+ metadata:
5
+ package: "@chipmobilesdk/rn-ads"
6
+ packageVersion: "0.1.0"
7
+ user-invocable: true
8
+ disable-model-invocation: false
9
+ ---
10
+
11
+ # @chipmobilesdk/rn-ads
12
+
13
+ Adaptive banner, interstitial and rewarded ads on Android via Google AdMob, behind a
14
+ contract that names no provider. The app names a placement key; the package owns the
15
+ request. Consuming code never imports the Google Mobile Ads SDK.
16
+
17
+ ## Use this when
18
+
19
+ - Adding a banner, interstitial or rewarded ad to a screen
20
+ - Mapping AdMob ad units to placement keys per environment
21
+ - Debugging an ad that never loads, or a `blocked` / `unavailable` / `provider_error`
22
+ - Wiring consent so requests cannot precede it
23
+ - Answering a Data safety / App Privacy question about advertising
24
+
25
+ ## Do not use this for
26
+
27
+ - **Consent UI or copy** — the package consumes a gate outcome and enforces ordering; it
28
+ renders no dialog and ships no legal text.
29
+ - **Where or how often ads appear** — no frequency cap, no placement rule, no auto-insert.
30
+ - **Granting the reward** — it reports `reward_earned`; entitlement is the app's.
31
+ - iOS — not implemented; every call returns `unavailable` / `unsupported_platform`.
32
+
33
+ ## Nothing loads until consent and initialization complete
34
+
35
+ This is enforced, and it is the answer to almost every "the ad never appears". With the
36
+ gate withheld, **zero** provider calls occur — not even configuration. `childDirected`,
37
+ `underAgeOfConsent` and `maxAdContentRating` take effect only if they reach
38
+ `initialize()` *before* the provider starts; changing one later returns
39
+ `requires_reinitialization` rather than silently not applying.
40
+ See `## Initialize — consent comes first`.
41
+
42
+ ## Never gate `<AdsBanner>` on its own placement state
43
+
44
+ `AdsBanner` is what measures the container and starts the load, so it must be mounted
45
+ unconditionally. Rendering it only when the state is `loading`/`ready` deadlocks at
46
+ `idle` and no ad ever appears. Use the state for the *surrounding* placeholder and to
47
+ decide whether to hold or reclaim space — never to decide whether to mount the host. It
48
+ renders `null` on its own when there is nothing to show.
49
+
50
+ ## Two things not to "fix"
51
+
52
+ - **The bridge pin.** `react-native-google-mobile-ads` is peered at `>=16.0.3 <16.1.0`;
53
+ 16.1.0+ carries Kotlin 2.3 metadata that React Native 0.85's pinned compiler cannot
54
+ read, and the Android build fails outright. Read `## Compatibility` before widening it.
55
+ - **Test ads in dev.** Outside `production`, non-official ad units are replaced with
56
+ Google's test units and reported via `config_warning` / `getSubstitutedPlacements()`.
57
+ That is the safety guarantee working, not a misconfiguration. Never click a production
58
+ ad to verify anything.
59
+
60
+ ## Public API (v0.1.0)
61
+
62
+ Runtime: `createAds` (throws `AdsConfigError` on a bad catalog; everything else returns a
63
+ typed result rather than throwing) · `AdsProvider`, `useAds` · `AdsBanner` ·
64
+ `usePlacementState`, `useAdsStatus`, `useAdsEvents` · `AdsError`, `AdsConfigError`,
65
+ `isAdsError`, `isFullScreenFormat` · `DEFAULT_LOAD_TIMEOUT_MS`, `DEFAULT_SHOW_TIMEOUT_MS`,
66
+ `DEFAULT_AD_VALIDITY_MS`, `LOAD_TIMEOUT_BOUNDS`, `SHOW_TIMEOUT_BOUNDS`,
67
+ `AD_VALIDITY_BOUNDS` · `createFakeAdsEngine` from `@chipmobilesdk/rn-ads/testing`
68
+ (test doubles only).
69
+
70
+ Instance: `initialize`, `updatePrivacy`, `setEnabled`, `setAppActive`, `getStatus`,
71
+ `subscribeStatus`, `load`, `show`, `getPlacementState`, `subscribePlacement`,
72
+ `subscribeEvents`, `getSubstitutedPlacements`, `dispose`.
73
+
74
+ Types: `AdsInstance`, `AdsConfig`, `PlacementDefinition`, `PrivacyOptions`, `AdFormat`,
75
+ `FullScreenFormat`, `Environment`, `InitStatus`, `PlacementState`, `InitResult`,
76
+ `LoadResult`, `ShowResult`, `PrivacyUpdateResult`, `Reward`, `AdEvent`, `AdEventType`,
77
+ `AdEventListener`, `ImpressionSource`, `AdsErrorCode`, `AdsOperation`, `TimeoutConfig`,
78
+ `AdContentRating`, `PrivacyBlockReason`, `UnavailableReason`, `Unsubscribe`,
79
+ `AdsBannerProps`, `AdsProviderProps`.
80
+
81
+ Call `dispose()` when the owning screen unmounts; it is idempotent, and so is every
82
+ `Unsubscribe`.
83
+
84
+ ## Where the detail lives
85
+
86
+ Read one section of `node_modules/@chipmobilesdk/rn-ads/README.md`:
87
+
88
+ | Need | Section |
89
+ |---|---|
90
+ | First integration | `## Install`, `## Configure` |
91
+ | Consent ordering | `## Initialize — consent comes first` |
92
+ | Rendering a banner | `## Banner` |
93
+ | Full-screen formats | `## Interstitial and rewarded` |
94
+ | Results, events, errors, timeouts, cleanup | `## State, events, and errors` |
95
+ | Tests without a device | `## Testing` |
96
+ | Permissions, Data safety, families | `## Install`, `## Release checklists` |
97
+ | Build failure after a bridge upgrade | `## Compatibility` |
98
+ | What is deferred | `## Known limitations`, `## Extending to iOS` |
@@ -1,3 +1,5 @@
1
+ import type { AdsError } from '../diagnostics/errors';
2
+
1
3
  /**
2
4
  * Public configuration and state types (FR-001, FR-003, FR-004).
3
5
  *
@@ -9,14 +11,26 @@
9
11
  * one instance and is released by `dispose()`.
10
12
  */
11
13
 
12
- /** Formats supported in v1. App-open, native, and rewarded-interstitial are deferred. */
13
- export type AdFormat = 'banner' | 'interstitial' | 'rewarded';
14
+ /**
15
+ * Supported formats. Native and rewarded-interstitial remain deferred.
16
+ *
17
+ * `app_open` joined in 0.2.0. It is a member of this union rather than a
18
+ * separate concept because FR-132 forbids a parallel vocabulary: app-open uses
19
+ * the same placement states, events, and error codes as everything else. The
20
+ * cost is that a consumer's exhaustive `switch` over this union stops compiling
21
+ * — the one required migration step, documented in the 0.2.0 release notes.
22
+ */
23
+ export type AdFormat = 'banner' | 'interstitial' | 'rewarded' | 'app_open';
14
24
 
15
- /** Formats that take over the screen, and so contend for the single-show lock. */
16
- export type FullScreenFormat = Extract<AdFormat, 'interstitial' | 'rewarded'>;
25
+ /**
26
+ * Formats that take over the screen, and so contend for the single-show lock.
27
+ * App-open is included deliberately: two full-screen ads at once is a broken
28
+ * app regardless of which formats produced them (FR-129).
29
+ */
30
+ export type FullScreenFormat = Extract<AdFormat, 'interstitial' | 'rewarded' | 'app_open'>;
17
31
 
18
32
  export function isFullScreenFormat(format: AdFormat): format is FullScreenFormat {
19
- return format === 'interstitial' || format === 'rewarded';
33
+ return format === 'interstitial' || format === 'rewarded' || format === 'app_open';
20
34
  }
21
35
 
22
36
  /**
@@ -41,6 +55,96 @@ export interface PlacementDefinition {
41
55
  * type change for every existing consumer.
42
56
  */
43
57
  readonly iosAdUnitId?: string;
58
+ /**
59
+ * Per-placement retry, overriding `AdsConfig.retry` for this placement only.
60
+ * Absent inherits; `false` turns retry off here while leaving it on elsewhere.
61
+ *
62
+ * An override *replaces* the instance-wide policy rather than merging into
63
+ * it, so the effective policy for a placement is readable from configuration
64
+ * without simulating a merge. Both are held to the same bounds, so an
65
+ * override can narrow behavior but never exceed a ceiling (FR-136a).
66
+ */
67
+ readonly retry?: RetryPolicy | false;
68
+ }
69
+
70
+ /**
71
+ * Bounded retry for failed loads. Off unless `AdsConfig.retry` is supplied —
72
+ * upgrading from 0.1.0 without configuring this issues not one extra request
73
+ * (FR-134).
74
+ *
75
+ * Only errors already flagged `retryable` are retried. Configuration errors,
76
+ * consent errors, an unsupported platform, and a disabled or undecided instance
77
+ * never are (FR-135).
78
+ *
79
+ * Backoff is exponential with a fixed x2 multiplier and no jitter. Jitter exists
80
+ * to desynchronize a fleet hitting one server; a single device retrying its own
81
+ * load has no herd to avoid, and omitting it keeps the attempt count an exact
82
+ * assertion rather than a statistical one.
83
+ */
84
+ export interface RetryPolicy {
85
+ /** Attempts *after* the first. Default 2, bounds 1-5. */
86
+ readonly maxRetries?: number;
87
+ /** Delay before the first retry. Default 2000ms, bounds 500-30000. */
88
+ readonly initialDelayMs?: number;
89
+ /** Ceiling the doubling cannot pass. Default 30000ms, bounds 1000-60000. */
90
+ readonly maxDelayMs?: number;
91
+ }
92
+
93
+ /**
94
+ * Whether the app wants ads, including the case where it does not know yet.
95
+ *
96
+ * The package never resolves `undecided` on the app's behalf and never learns
97
+ * why the app chose what it chose — no entitlement, no receipt, no reason
98
+ * (FR-119, FR-120).
99
+ */
100
+ export type Enablement = 'enabled' | 'disabled' | 'undecided';
101
+
102
+ /**
103
+ * Tools for exercising the consent flow from a device outside a
104
+ * consent-required region.
105
+ *
106
+ * **Stripped entirely when `environment` is `production`.** That is enforced at
107
+ * configuration validation rather than at each call site, so no path through the
108
+ * consent module can forget it, and the fact that they were ignored is
109
+ * observable through a `config_warning` event and `getIgnoredConsentTools()` —
110
+ * not merely documented (FR-113).
111
+ */
112
+ export interface ConsentTestingOptions {
113
+ /** Makes the flow behave as though the device were in this region. */
114
+ readonly debugGeography?: 'disabled' | 'eea' | 'not_eea' | 'regulated_us_state' | 'other';
115
+ /** Clears any stored decision so the flow runs from the beginning. */
116
+ readonly resetOnStart?: boolean;
117
+ }
118
+
119
+ /**
120
+ * Consent status, normalized. Carries no provider type and no payload (FR-104).
121
+ *
122
+ * Shaped so an iOS adapter can later express both a consent flow and the
123
+ * platform's tracking-permission mechanism, plus the ordering between them,
124
+ * without changing shape.
125
+ */
126
+ export type ConsentState =
127
+ /** Not yet asked. The initial value, and the value after a reset. */
128
+ | 'undetermined'
129
+ /** The provider reports this region does not require consent. */
130
+ | 'not_required'
131
+ /** Required and not yet obtained. */
132
+ | 'required'
133
+ /** Obtained. */
134
+ | 'obtained'
135
+ /**
136
+ * The flow failed — a provider error, a network failure, a cancellation, or
137
+ * a form dismissed without an answer. Never, under any circumstance, read as
138
+ * consent granted (FR-106).
139
+ */
140
+ | 'error';
141
+
142
+ export interface ConsentSnapshot {
143
+ readonly state: ConsentState;
144
+ /** Always false when `state` is `error`. */
145
+ readonly canRequestAds: boolean;
146
+ readonly isFormAvailable: boolean;
147
+ readonly error: AdsError | null;
44
148
  }
45
149
 
46
150
  /** Bounds exist because an unbounded timeout is the same defect as no timeout. */
@@ -50,8 +154,16 @@ export interface TimeoutConfig {
50
154
  }
51
155
 
52
156
  export interface AdsConfig {
53
- /** FR-009. When false, every operation is a safe no-op reporting `disabled`. */
54
- readonly enabled: boolean;
157
+ /**
158
+ * FR-009. When `false`, every operation is a safe no-op reporting `disabled`.
159
+ *
160
+ * `'undecided'` was added in 0.2.0 for an app that cannot answer this at
161
+ * construction time — one selling an ad-removal tier has to ask a store
162
+ * first. Both boolean spellings keep their exact 0.1.0 meaning; widening an
163
+ * *input* union cannot break a consumer, because consumers write this value
164
+ * rather than switching on it (FR-114, BC-003).
165
+ */
166
+ readonly enabled: boolean | 'undecided';
55
167
  readonly environment: Environment;
56
168
  readonly placements: readonly PlacementDefinition[];
57
169
  readonly timeouts?: TimeoutConfig;
@@ -59,6 +171,30 @@ export interface AdsConfig {
59
171
  readonly adValidityMs?: number;
60
172
  /** Ignored entirely in `production` (FR-014). */
61
173
  readonly testDeviceIds?: readonly string[];
174
+ /**
175
+ * How long a loaded app-open ad stays presentable. Separate from
176
+ * `adValidityMs`, which continues to govern interstitial and rewarded.
177
+ *
178
+ * Default 30 minutes, half the other formats' window. Note this is *our*
179
+ * constraint, not the provider's — Google expires app-open ads after four
180
+ * hours, which is longer than this package's general full-screen window. The
181
+ * shorter window trades some fill for a fresher creative at the most
182
+ * prominent moment in the app (FR-130).
183
+ */
184
+ readonly appOpenValidityMs?: number;
185
+ /**
186
+ * How long `enabled: 'undecided'` may go unresolved before the package emits
187
+ * one diagnostic warning. Default 10 seconds.
188
+ *
189
+ * It warns and does nothing else. Resolving on the app's behalf would mean
190
+ * guessing at exactly the business decision FR-120 forbids the package from
191
+ * knowing.
192
+ */
193
+ readonly undecidedWarningMs?: number;
194
+ /** Instance-wide retry. Absent means retry is entirely off (FR-134). */
195
+ readonly retry?: RetryPolicy;
196
+ /** Consent testing tools. Stripped in `production` (FR-113). */
197
+ readonly consentTesting?: ConsentTestingOptions;
62
198
  }
63
199
 
64
200
  /** Content ratings, normalized. Maps onto the provider's own rating scale. */
@@ -82,7 +218,20 @@ export interface PrivacyOptions {
82
218
  readonly personalizedAds?: boolean;
83
219
  }
84
220
 
85
- export type PrivacyBlockReason = 'consent_required' | 'consent_undetermined';
221
+ /**
222
+ * Why the consent gate is closed.
223
+ *
224
+ * The two original members keep their exact v1 meaning — the app's own gate —
225
+ * so no existing consumer's interpretation changes. The two added in 0.2.0 name
226
+ * the package's consent flow instead. The split exists because the gate has two
227
+ * independent inputs that combine as most-restrictive-wins, and a refusal has to
228
+ * say *which* of them is blocking or the app cannot act on it (FR-105a).
229
+ */
230
+ export type PrivacyBlockReason =
231
+ | 'consent_required'
232
+ | 'consent_undetermined'
233
+ | 'consent_flow_required'
234
+ | 'consent_flow_error';
86
235
 
87
236
  export type UnavailableReason = 'unsupported_platform' | 'provider_missing';
88
237
 
@@ -93,6 +242,17 @@ export type InitStatus =
93
242
  | 'initializing'
94
243
  | 'ready'
95
244
  | 'disabled'
245
+ /**
246
+ * The app has not decided whether ads are on yet — it is still waiting on
247
+ * whatever answers that question, typically a store entitlement sync.
248
+ *
249
+ * Behaves exactly like `disabled` for requests (zero reach the provider) but
250
+ * is deliberately a separate value. That distinction is the entire point:
251
+ * `disabled` means a settled no, and a banner host can reclaim its space;
252
+ * `undecided` means not yet, and the app may want to hold the space so a
253
+ * paying user never sees an ad flash in and out (FR-116).
254
+ */
255
+ | 'undecided'
96
256
  | 'unavailable'
97
257
  | 'failed';
98
258
 
@@ -107,6 +267,8 @@ export type PlacementState =
107
267
  | 'showing'
108
268
  | 'consumed'
109
269
  | 'failed'
270
+ /** Mirrors `InitStatus.undecided` so the banner host can report it directly. */
271
+ | 'undecided'
110
272
  | 'unavailable';
111
273
 
112
274
  /** FR-047. The provider's reward descriptor, normalized. */
@@ -13,9 +13,23 @@
13
13
  import { AdsConfigError } from '../diagnostics/errors';
14
14
  import { resolvePlacementAdUnits, type ResolvedCatalog } from './resolveAdUnit';
15
15
  import { resolveTimeouts, type ResolvedTimeouts } from '../core/timers';
16
- import type { AdFormat, AdsConfig, Environment, PlacementDefinition } from './types';
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';
17
26
 
18
- const FORMATS: ReadonlySet<string> = new Set<AdFormat>(['banner', 'interstitial', 'rewarded']);
27
+ const FORMATS: ReadonlySet<string> = new Set<AdFormat>([
28
+ 'banner',
29
+ 'interstitial',
30
+ 'rewarded',
31
+ 'app_open',
32
+ ]);
19
33
 
20
34
  export interface ValidateOptions {
21
35
  /**
@@ -27,13 +41,23 @@ export interface ValidateOptions {
27
41
  }
28
42
 
29
43
  export interface ResolvedConfig {
30
- readonly enabled: boolean;
44
+ readonly enablement: Enablement;
31
45
  readonly environment: Environment;
32
46
  readonly placements: ReadonlyMap<string, PlacementDefinition>;
33
47
  readonly catalog: ResolvedCatalog;
34
48
  readonly timeouts: ResolvedTimeouts;
35
49
  /** Empty in production regardless of what was configured (FR-014). */
36
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[];
37
61
  }
38
62
 
39
63
  export function validateConfig(config: AdsConfig, options: ValidateOptions): ResolvedConfig {
@@ -79,7 +103,7 @@ export function validateConfig(config: AdsConfig, options: ValidateOptions): Res
79
103
 
80
104
  if (!FORMATS.has(placement.format)) {
81
105
  throw new AdsConfigError('invalid_config', {
82
- message: `placement "${key}" has unsupported format "${String(placement.format)}"; v1 supports banner, interstitial, and rewarded`,
106
+ message: `placement "${key}" has unsupported format "${String(placement.format)}"; supported formats are banner, interstitial, rewarded, and app_open`,
83
107
  placementKey: key,
84
108
  });
85
109
  }
@@ -95,11 +119,30 @@ export function validateConfig(config: AdsConfig, options: ValidateOptions): Res
95
119
  byKey.set(key, { ...placement, key });
96
120
  }
97
121
 
98
- const timeouts = resolveTimeouts(config.timeouts, config.adValidityMs);
122
+ const timeouts = resolveTimeouts({
123
+ timeouts: config.timeouts,
124
+ adValidityMs: config.adValidityMs,
125
+ appOpenValidityMs: config.appOpenValidityMs,
126
+ undecidedWarningMs: config.undecidedWarningMs,
127
+ });
99
128
  const catalog = resolvePlacementAdUnits(Array.from(byKey.values()), config.environment);
100
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
+
101
144
  return {
102
- enabled: config.enabled !== false,
145
+ enablement: toEnablement(config.enabled),
103
146
  environment: config.environment,
104
147
  placements: byKey,
105
148
  catalog,
@@ -107,6 +150,39 @@ export function validateConfig(config: AdsConfig, options: ValidateOptions): Res
107
150
  // FR-014: debug and test-device settings must not be able to alter
108
151
  // production behavior, so they are dropped rather than carried and
109
152
  // conditionally ignored somewhere further down.
110
- testDeviceIds: config.environment === 'production' ? [] : [...(config.testDeviceIds ?? [])],
153
+ testDeviceIds: isProduction ? [] : [...(config.testDeviceIds ?? [])],
154
+ retryByKey,
155
+ consentTesting,
156
+ ignoredConsentTools,
111
157
  };
112
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
+ }