@chipmobilesdk/rn-ads 0.5.1 → 0.7.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.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The ads instance's public contract: what `createAds` returns and what each
3
+ * operation reports. Implementation lives beside this file, one module per
4
+ * responsibility (ADS-10).
5
+ */
6
+ import type { AdsError } from '../../diagnostics/errors';
7
+ import type {
8
+ ConsentSnapshot,
9
+ Enablement,
10
+ InitStatus,
11
+ PlacementState,
12
+ PrivacyBlockReason,
13
+ PrivacyOptions,
14
+ UnavailableReason,
15
+ Unsubscribe,
16
+ } from '../../config/types';
17
+ import type { AdEventListener } from '../../diagnostics/events';
18
+ import type { AdsEngine } from '../../engine/port';
19
+ import type { EnablementResult } from '../enablement';
20
+ import type { Scheduler } from '../timers';
21
+
22
+ export type InitResult =
23
+ | { readonly status: 'ready' }
24
+ | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
25
+ | { readonly status: 'disabled' }
26
+ /** The app has not decided whether ads are on. Nothing was requested (FR-115). */
27
+ | { readonly status: 'undecided' }
28
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
29
+ | { readonly status: 'failed'; readonly error: AdsError };
30
+
31
+ export type PrivacyUpdateResult =
32
+ | { readonly status: 'applied' }
33
+ | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
34
+ | { readonly status: 'requires_reinitialization' }
35
+ | { readonly status: 'disabled' }
36
+ | { readonly status: 'undecided' }
37
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
38
+ /**
39
+ * The initialization this update triggered failed (added in 0.5.0). Before,
40
+ * it was reported as `applied`, so the app never retried and no ad ever
41
+ * showed, with nothing to say why.
42
+ */
43
+ | { readonly status: 'failed'; readonly error: AdsError };
44
+
45
+ export type LoadResult =
46
+ | { readonly status: 'loaded'; readonly correlationId: string }
47
+ | { readonly status: 'disabled' }
48
+ | { readonly status: 'undecided' }
49
+ | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
50
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
51
+ | { readonly status: 'no_fill' }
52
+ /**
53
+ * Refused by the per-placement request throttle (FR-140), added in 0.3.0.
54
+ *
55
+ * Nothing was requested and nothing is broken — this placement asked too
56
+ * recently. Distinct from `no_fill` on purpose: no-fill means the network had
57
+ * nothing to give, this means we did not ask. An app that treats them the same
58
+ * will misdiagnose the first time a banner goes quiet.
59
+ */
60
+ | ThrottledResult
61
+ | { readonly status: 'timeout' }
62
+ | { readonly status: 'failed'; readonly error: AdsError };
63
+
64
+ export interface ThrottledResult {
65
+ readonly status: 'throttled';
66
+ readonly retryAfterMs: number;
67
+ }
68
+
69
+ /** A gate refusal: what a load gets when it may not ask at all. */
70
+ export type LoadRefusal = Extract<
71
+ LoadResult,
72
+ { status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }
73
+ >;
74
+
75
+ export type ShowResult =
76
+ | { readonly status: 'shown'; readonly correlationId: string }
77
+ | { readonly status: 'not_ready' }
78
+ | { readonly status: 'busy' }
79
+ | { readonly status: 'consumed' }
80
+ | { readonly status: 'app_inactive' }
81
+ /**
82
+ * The user came back to the app from an ad, or from a browser an ad opened,
83
+ * so an app-open ad would be an ad immediately after an ad. Distinct from
84
+ * `busy` and `not_ready` because the app cannot detect this itself and
85
+ * therefore cannot infer it from either of those (FR-128).
86
+ */
87
+ | { readonly status: 'ad_originated_return' }
88
+ | { readonly status: 'disabled' }
89
+ | { readonly status: 'undecided' }
90
+ | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
91
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
92
+ | { readonly status: 'timeout' }
93
+ | { readonly status: 'failed'; readonly error: AdsError };
94
+
95
+ /**
96
+ * What `consent.showPrivacyOptionsForm()` reports (FR-151), added in 0.4.0.
97
+ *
98
+ * Every refusal is its own member, so an app can tell "there is nothing to
99
+ * offer here" from "you asked too early" from "it broke" without parsing an
100
+ * error. None of them throws.
101
+ */
102
+ export type PrivacyOptionsFormResult =
103
+ /**
104
+ * The provider's form was presented and has closed. `consent` is the state it
105
+ * left behind, already applied to the gate. A user who closed the form without
106
+ * changing anything lands here too, with the state they already had.
107
+ */
108
+ | { readonly status: 'shown'; readonly consent: ConsentSnapshot }
109
+ /** The provider does not require privacy options here. Nothing was presented. */
110
+ | { readonly status: 'not_required' }
111
+ /**
112
+ * The requirement is not known yet: no successful `requestInfoUpdate()` since
113
+ * the instance was created or reset, or the last one failed. Nothing was
114
+ * presented.
115
+ */
116
+ | { readonly status: 'info_update_required' }
117
+ /** A consent form is already on screen. Nothing was presented. */
118
+ | { readonly status: 'busy' }
119
+ /**
120
+ * The form could not be presented or failed while open. The consent state is
121
+ * whatever the provider reports it still holds, and never a new grant.
122
+ */
123
+ | { readonly status: 'failed'; readonly error: AdsError }
124
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason };
125
+
126
+ /**
127
+ * The optional consent capability (FR-101).
128
+ *
129
+ * Grouped under its own namespace rather than flattened onto the instance so
130
+ * that FR-102's optionality stays legible: an app that never touches
131
+ * `ads.consent` never starts a flow. Five loose methods on the instance would
132
+ * invite the assumption that they are part of the normal path.
133
+ */
134
+ export interface ConsentApi {
135
+ /** Refreshes consent information from the provider. */
136
+ requestInfoUpdate(): Promise<ConsentSnapshot>;
137
+ /** Presents the provider's form if one is required. Renders nothing itself. */
138
+ showFormIfRequired(): Promise<ConsentSnapshot>;
139
+ /**
140
+ * Presents the provider's privacy options form, so a user can change an
141
+ * answer already given (FR-151). Added in 0.4.0.
142
+ *
143
+ * Offer it when `getState().privacyOptionsRequirement` is `required`. Where
144
+ * the entry point lives, and what it says, stay the app's. The outcome feeds
145
+ * the privacy gate by itself, exactly as `showFormIfRequired` does.
146
+ */
147
+ showPrivacyOptionsForm(): Promise<PrivacyOptionsFormResult>;
148
+ getState(): ConsentSnapshot;
149
+ subscribe(listener: (snapshot: ConsentSnapshot) => void): Unsubscribe;
150
+ /** Clears the stored decision. Inert in production (FR-113). */
151
+ reset(): Promise<void>;
152
+ }
153
+
154
+ export interface AdsInstance {
155
+ initialize(privacy: PrivacyOptions): Promise<InitResult>;
156
+ updatePrivacy(privacy: PrivacyOptions): Promise<PrivacyUpdateResult>;
157
+ /**
158
+ * Turns ads on or off. Returns a result rather than `void` (FR-122).
159
+ *
160
+ * 0.1.0 returned nothing, which meant an app re-enabling ads after a lapsed
161
+ * subscription had no way to learn that it also had to re-initialize — and
162
+ * every load and show after that was refused in silence.
163
+ */
164
+ setEnabled(enabled: boolean): EnablementResult;
165
+ /** The same, including the third state the app has not resolved yet. */
166
+ setEnablement(next: Enablement): EnablementResult;
167
+ setAppActive(active: boolean): void;
168
+ readonly consent: ConsentApi;
169
+ getStatus(): InitStatus;
170
+ subscribeStatus(listener: (status: InitStatus) => void): Unsubscribe;
171
+
172
+ load(placementKey: string): Promise<LoadResult>;
173
+ show(placementKey: string): Promise<ShowResult>;
174
+ getPlacementState(placementKey: string): PlacementState;
175
+ subscribePlacement(key: string, listener: (state: PlacementState) => void): Unsubscribe;
176
+
177
+ subscribeEvents(listener: AdEventListener): Unsubscribe;
178
+
179
+ /** FR-011a — substitution is observable state, not only a log line. */
180
+ getSubstitutedPlacements(): readonly string[];
181
+ /** FR-113 — the same treatment for consent tools dropped in production. */
182
+ getIgnoredConsentTools(): readonly string[];
183
+ dispose(): void;
184
+ }
185
+
186
+ export interface CreateAdsOptions {
187
+ /** Injected in tests; the AdMob adapter is the default in production. */
188
+ readonly engine?: AdsEngine;
189
+ /** Injected so the FR-012 rule is testable without touching a global. */
190
+ readonly isDevBuild?: boolean;
191
+ readonly now?: () => number;
192
+ /**
193
+ * Injected so retry backoff, app-open expiry, and the undecided warning can
194
+ * be driven by a test clock instead of by waiting. Production uses real
195
+ * timers.
196
+ */
197
+ readonly scheduler?: Scheduler;
198
+ }
@@ -83,24 +83,21 @@ export interface RequestThrottle {
83
83
  * banner path depends on it. React may run a `useMemo` or an effect more than
84
84
  * once for a single committed render — StrictMode does it deliberately — and
85
85
  * without a token the second run would consume a second slot and be refused,
86
- * so a banner would never appear in development. Passing the cycle's
87
- * correlation id means "this is the same request I already asked about", and
88
- * the previous decision is returned unchanged.
86
+ * so a banner would never appear in development. Passing the banner mount's
87
+ * token means "this is the same request I already asked about", and the
88
+ * previous decision is returned unchanged.
89
89
  *
90
90
  * Omit `token` for a genuinely new imperative request, where every call is a
91
91
  * distinct intent to ask the network.
92
92
  */
93
93
  tryAcquire(key: string, minIntervalMs: number, token?: string): ThrottleDecision;
94
94
  /**
95
- * Forgets a placement's last request.
96
- *
97
- * Used when a placement's resources are released, so a genuinely new cycle is
98
- * not charged for the previous one.
95
+ * How long until this placement may ask again, without taking a slot: 0 when
96
+ * it may ask now. A load retry waits at least this long, so a retry policy
97
+ * cannot request faster than the floor allows (ADS-07).
99
98
  */
100
- reset(key: string): void;
99
+ waitFor(key: string, minIntervalMs: number): number;
101
100
  clear(): void;
102
- /** Diagnostics only: when the placement last requested, or undefined. */
103
- lastRequestAt(key: string): number | undefined;
104
101
  }
105
102
 
106
103
  export function createRequestThrottle(now: () => number): RequestThrottle {
@@ -134,6 +131,10 @@ export function createRequestThrottle(now: () => number): RequestThrottle {
134
131
  };
135
132
 
136
133
  return {
134
+ waitFor(key, minIntervalMs) {
135
+ const decision = decide(key, minIntervalMs);
136
+ return decision.allowed ? 0 : decision.retryAfterMs;
137
+ },
137
138
  tryAcquire(key, minIntervalMs, token) {
138
139
  if (token !== undefined) {
139
140
  const previous = lastToken.get(key);
@@ -161,16 +162,9 @@ export function createRequestThrottle(now: () => number): RequestThrottle {
161
162
  }
162
163
  return decision;
163
164
  },
164
- reset(key) {
165
- lastAt.delete(key);
166
- lastToken.delete(key);
167
- },
168
165
  clear() {
169
166
  lastAt.clear();
170
167
  lastToken.clear();
171
168
  },
172
- lastRequestAt(key) {
173
- return lastAt.get(key);
174
- },
175
169
  };
176
170
  }
@@ -21,6 +21,8 @@
21
21
  * TimerSet so `dispose()` can prove it released everything.
22
22
  */
23
23
 
24
+ import { computeBackoff } from '@chipmobilesdk/rn-core';
25
+
24
26
  import { AdsConfigError } from '../diagnostics/errors';
25
27
  import { checkBand, DEFAULT_RETRY_POLICY, RETRY_BOUNDS } from './timers';
26
28
  import type { RetryPolicy } from '../config/types';
@@ -47,16 +49,20 @@ export function resolveRetryPolicy(policy: RetryPolicy, label: string): Resolved
47
49
  `${label}.maxRetries`,
48
50
  policy.maxRetries ?? DEFAULT_RETRY_POLICY.maxRetries,
49
51
  RETRY_BOUNDS.maxRetries,
52
+ 'retry_out_of_bounds',
53
+ '',
50
54
  );
51
55
  const initialDelayMs = checkBand(
52
56
  `${label}.initialDelayMs`,
53
57
  policy.initialDelayMs ?? DEFAULT_RETRY_POLICY.initialDelayMs,
54
58
  RETRY_BOUNDS.initialDelayMs,
59
+ 'retry_out_of_bounds',
55
60
  );
56
61
  const maxDelayMs = checkBand(
57
62
  `${label}.maxDelayMs`,
58
63
  policy.maxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs,
59
64
  RETRY_BOUNDS.maxDelayMs,
65
+ 'retry_out_of_bounds',
60
66
  );
61
67
 
62
68
  if (!Number.isInteger(maxRetries)) {
@@ -117,8 +123,12 @@ export function resolveEffectiveRetry(
117
123
  * than a property that does not apply.
118
124
  */
119
125
  export function delayForAttempt(policy: ResolvedRetryPolicy, attempt: number): number {
120
- const raw = policy.initialDelayMs * Math.pow(2, Math.max(0, attempt - 1));
121
- return Math.min(raw, policy.maxDelayMs);
126
+ return computeBackoff(attempt, {
127
+ initialDelayMs: policy.initialDelayMs,
128
+ maxDelayMs: policy.maxDelayMs,
129
+ factor: 2,
130
+ jitter: 'none',
131
+ });
122
132
  }
123
133
 
124
134
  /** Whether another attempt is permitted. `attemptsMade` counts retries only. */
@@ -8,8 +8,10 @@
8
8
  * something it did not (FR-008).
9
9
  */
10
10
 
11
- import { AdsConfigError } from '../diagnostics/errors';
12
- import type { TimeoutConfig, Unsubscribe } from '../config/types';
11
+ import { checkRange, type Range } from '@chipmobilesdk/rn-core';
12
+
13
+ import { AdsConfigError, type AdsErrorCode } from '../diagnostics/errors';
14
+ import type { TimeoutConfig } from '../config/types';
13
15
 
14
16
  /** Comfortably longer than a normal fill on a slow mobile network, still bounded. */
15
17
  export const DEFAULT_LOAD_TIMEOUT_MS = 30_000;
@@ -96,17 +98,19 @@ export interface ResolvedTimeouts {
96
98
  readonly undecidedWarning: number;
97
99
  }
98
100
 
101
+ /**
102
+ * `value` when it is inside `bounds`, else an `AdsConfigError` with `code`: each
103
+ * field reports the code its documentation names, so a retry policy out of bounds
104
+ * is `retry_out_of_bounds`, not a timeout's code (CORE-04, ADS-16).
105
+ */
99
106
  export function checkBand(
100
107
  label: string,
101
108
  value: number,
102
- bounds: { readonly min: number; readonly max: number },
109
+ bounds: Range,
110
+ code: AdsErrorCode = 'timeout_out_of_bounds',
111
+ unit = 'ms',
103
112
  ): number {
104
- if (!Number.isFinite(value) || value < bounds.min || value > bounds.max) {
105
- throw new AdsConfigError('timeout_out_of_bounds', {
106
- message: `${label} must be between ${bounds.min}ms and ${bounds.max}ms; received ${value}`,
107
- });
108
- }
109
- return value;
113
+ return checkRange(label, value, bounds, message => new AdsConfigError(code, { message }), unit);
110
114
  }
111
115
 
112
116
  export interface TimeoutInput {
@@ -148,67 +152,11 @@ export function resolveTimeouts(input: TimeoutInput): ResolvedTimeouts {
148
152
  }
149
153
 
150
154
  /**
151
- * How timers are actually scheduled.
152
- *
153
- * Injectable so a test can drive retry backoff, app-open expiry, and the
154
- * undecided warning by moving a clock rather than by waiting. The production
155
- * default is `setTimeout`; the fake engine supplies a controllable one. Without
156
- * this, every timing assertion in the suite would either sleep for real or
157
- * depend on the test runner's timer mocking, and NFR-103's "no device, no
158
- * network" would quietly become "no device, no network, but several seconds".
155
+ * How timers are scheduled, and the set that tracks them so `dispose()` can prove
156
+ * it released every one (SC-008): rn-core's, injectable so a test drives retry
157
+ * backoff, app-open expiry and the undecided warning by moving a clock.
159
158
  */
160
- export interface Scheduler {
161
- setTimeout(callback: () => void, ms: number): unknown;
162
- clearTimeout(handle: unknown): void;
163
- }
164
-
165
- const realScheduler: Scheduler = {
166
- setTimeout: (callback, ms) => setTimeout(callback, ms),
167
- clearTimeout: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
168
- };
169
-
170
- /**
171
- * Tracks every timer the instance owns so `dispose()` can prove it released
172
- * them. Counting is not incidental — SC-008 asserts the count reaches zero, and
173
- * a leaked timer is otherwise invisible until it fires into a disposed consumer.
174
- */
175
- export interface TimerSet {
176
- after(ms: number, callback: () => void): Unsubscribe;
177
- clearAll(): void;
178
- size(): number;
179
- }
180
-
181
- export function createTimerSet(scheduler: Scheduler = realScheduler): TimerSet {
182
- const handles = new Set<unknown>();
183
-
184
- return {
185
- after(ms, callback) {
186
- const handle = scheduler.setTimeout(() => {
187
- handles.delete(handle);
188
- callback();
189
- }, ms);
190
- handles.add(handle);
191
- let cleared = false;
192
- return () => {
193
- if (cleared) {
194
- return;
195
- }
196
- cleared = true;
197
- scheduler.clearTimeout(handle);
198
- handles.delete(handle);
199
- };
200
- },
201
- clearAll() {
202
- for (const handle of handles) {
203
- scheduler.clearTimeout(handle);
204
- }
205
- handles.clear();
206
- },
207
- size() {
208
- return handles.size;
209
- },
210
- };
211
- }
159
+ export { createTimerSet, type Scheduler, type TimerSet } from '@chipmobilesdk/rn-core';
212
160
 
213
161
  /** A loaded ad past its validity window must never be reported ready (FR-034). */
214
162
  export function isStale(loadedAtMs: number, nowMs: number, adValidityMs: number): boolean {
@@ -10,6 +10,8 @@
10
10
  * carrier for identifiers the package promises never to emit (FR-021).
11
11
  */
12
12
 
13
+ import { SdkError, createRedactor } from '@chipmobilesdk/rn-core';
14
+
13
15
  import type { AdFormat } from '../config/types';
14
16
 
15
17
  export type AdsOperation = 'initialize' | 'load' | 'show' | 'configure';
@@ -58,6 +60,10 @@ export type AdsErrorCode =
58
60
  // is a show refusal that resolves with time rather than with another attempt.
59
61
  | 'ad_originated_return'
60
62
  | 'provider_error'
63
+ // The provider refused the request itself: an invalid ad unit or request, a
64
+ // missing app id. Not retryable — the same request fails the same way, and
65
+ // retrying would spend billable requests on a certainty (FR-135, ADS-08).
66
+ | 'provider_rejected'
61
67
  | 'internal_error'
62
68
  // Configuration — a retry policy outside its bounds. Rejected rather than
63
69
  // clamped, matching `timeout_out_of_bounds`: silently clamping tells the app
@@ -94,9 +100,8 @@ export interface AdsErrorOptions {
94
100
  readonly cause?: unknown;
95
101
  }
96
102
 
97
- export class AdsError extends Error {
98
- readonly code: AdsErrorCode;
99
- readonly retryable: boolean;
103
+ /** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-ads'`. */
104
+ export class AdsError extends SdkError<AdsErrorCode> {
100
105
  readonly operation?: AdsOperation;
101
106
  readonly placementKey?: string;
102
107
  readonly format?: AdFormat;
@@ -104,15 +109,17 @@ export class AdsError extends Error {
104
109
  readonly cause?: string;
105
110
 
106
111
  constructor(code: AdsErrorCode, options: AdsErrorOptions = {}) {
107
- super(options.message ?? code);
112
+ const cause = options.cause === undefined ? undefined : redact(options.cause);
113
+ super('rn-ads', code, {
114
+ message: options.message,
115
+ retryable: RETRYABLE.has(code),
116
+ redactedCause: cause,
117
+ });
108
118
  this.name = 'AdsError';
109
- this.code = code;
110
- this.retryable = RETRYABLE.has(code);
111
119
  this.operation = options.operation;
112
120
  this.placementKey = options.placementKey;
113
121
  this.format = options.format;
114
- this.cause = options.cause === undefined ? undefined : redact(options.cause);
115
- Object.setPrototypeOf(this, AdsError.prototype);
122
+ this.cause = cause;
116
123
  }
117
124
  }
118
125
 
@@ -125,7 +132,6 @@ export class AdsConfigError extends AdsError {
125
132
  constructor(code: AdsErrorCode, options: AdsErrorOptions = {}) {
126
133
  super(code, { ...options, operation: options.operation ?? 'configure' });
127
134
  this.name = 'AdsConfigError';
128
- Object.setPrototypeOf(this, AdsConfigError.prototype);
129
135
  }
130
136
  }
131
137
 
@@ -133,23 +139,15 @@ export function isAdsError(value: unknown): value is AdsError {
133
139
  return value instanceof AdsError;
134
140
  }
135
141
 
136
- const REDACTED = '[redacted]';
137
-
138
142
  /** An AdMob unit or app id. Redacted so a log never carries a live identifier. */
139
143
  const AD_UNIT_PATTERN = /ca-app-pub-\d+[~/]\d+/g;
140
- /** GAID and any other dashed UUID an SDK might put in a message. */
141
- const UUID_PATTERN = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g;
142
- /** Base64-ish runs long enough to be an encoded payload such as a consent string. */
143
- const PAYLOAD_PATTERN = /[A-Za-z0-9+/=_-]{40,}/g;
144
- /** Anything path- or URL-like, which can carry query parameters with identifiers. */
145
- const URL_PATTERN = /[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"']+/g;
146
144
 
147
145
  /**
148
146
  * Keys dropped wholesale rather than pattern-matched, because their values are
149
147
  * sensitive regardless of shape — a bare advertising id has no separator for a
150
148
  * pattern to catch.
151
149
  */
152
- const SENSITIVE_KEYS = new Set([
150
+ const SENSITIVE_KEYS = [
153
151
  'adid',
154
152
  'adUnitId',
155
153
  'advertisingId',
@@ -160,63 +158,54 @@ const SENSITIVE_KEYS = new Set([
160
158
  'tcString',
161
159
  'token',
162
160
  'userId',
163
- ]);
161
+ ];
164
162
 
165
- function redactString(value: string): string {
166
- return value
167
- .replace(URL_PATTERN, REDACTED)
168
- .replace(AD_UNIT_PATTERN, REDACTED)
169
- .replace(UUID_PATTERN, REDACTED)
170
- .replace(PAYLOAD_PATTERN, REDACTED);
171
- }
163
+ /**
164
+ * rn-core's pattern redaction (URLs, JWTs, e-mail addresses, UUIDs, payload-sized
165
+ * runs) plus ad unit ids, with the keys above dropped. Provider messages are kept
166
+ * readable: they describe the failure, and every identifier in them is shaped.
167
+ */
168
+ const redactor = createRedactor({
169
+ text: 'patterns',
170
+ patterns: [AD_UNIT_PATTERN],
171
+ dropKeys: SENSITIVE_KEYS,
172
+ maxDepth: 2,
173
+ });
172
174
 
173
175
  /**
174
176
  * Reduces an arbitrary provider value to a safe string (FR-053, NFR-006).
175
177
  *
176
178
  * Returns a string rather than a structure on purpose: a redacted object still
177
179
  * hands the consumer a reference into provider-owned memory, and the contract
178
- * promises no provider object crosses the boundary.
180
+ * promises no provider object crosses the boundary. An `AdsError` reduces to its
181
+ * code.
179
182
  */
180
- export function redact(value: unknown, depth = 0): string {
181
- if (value === null || value === undefined) {
182
- return '';
183
- }
184
- if (typeof value === 'string') {
185
- return redactString(value);
186
- }
187
- if (typeof value === 'number' || typeof value === 'boolean') {
188
- return String(value);
189
- }
190
- if (value instanceof Error) {
191
- return redactString(value.message);
192
- }
193
- if (depth >= 2) {
194
- return REDACTED;
195
- }
196
- if (Array.isArray(value)) {
197
- return value.map(item => redact(item, depth + 1)).join(', ');
198
- }
199
- if (typeof value === 'object') {
200
- const parts: string[] = [];
201
- for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
202
- if (SENSITIVE_KEYS.has(key)) {
203
- parts.push(`${key}=${REDACTED}`);
204
- continue;
205
- }
206
- parts.push(`${key}=${redact(entry, depth + 1)}`);
207
- }
208
- return parts.join(' ');
209
- }
210
- return REDACTED;
183
+ export function redact(value: unknown): string {
184
+ return redactor.text(value);
211
185
  }
212
186
 
213
187
  /**
214
- * Maps a provider failure onto the normalized taxonomy. Provider codes vary by
215
- * SDK version, so the match is on substrings that have been stable across
216
- * releases, and anything unrecognized becomes a retryable `provider_error`
217
- * rather than being silently reshaped into a code that means something specific.
188
+ * Maps a provider failure onto the normalized taxonomy.
189
+ *
190
+ * The adapter's classification wins when it gave one (ADS-08). Otherwise the
191
+ * match is on substrings that have been stable across provider releases, and
192
+ * anything unrecognized becomes a retryable `provider_error` rather than being
193
+ * reshaped into a code that means something specific.
218
194
  */
219
- export function normalizeProviderError(value: unknown, options: AdsErrorOptions = {}): AdsError {
195
+ export function normalizeProviderError(
196
+ value: unknown,
197
+ options: AdsErrorOptions = {},
198
+ kind?: 'no_fill' | 'network' | 'rejected',
199
+ ): AdsError {
200
+ switch (kind) {
201
+ case 'no_fill':
202
+ return new AdsError('no_fill', { ...options, cause: value });
203
+ case 'network':
204
+ return new AdsError('network_error', { ...options, cause: value });
205
+ case 'rejected':
206
+ return new AdsError('provider_rejected', { ...options, cause: value });
207
+ default:
208
+ }
220
209
  const text = value instanceof Error ? value.message : String(value ?? '');
221
210
  const lowered = text.toLowerCase();
222
211