react-native-revnix 1.4.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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +766 -0
  3. package/dist/blocks/background.d.ts +101 -0
  4. package/dist/blocks/background.js +165 -0
  5. package/dist/blocks/color.d.ts +88 -0
  6. package/dist/blocks/color.js +329 -0
  7. package/dist/blocks/locale.d.ts +42 -0
  8. package/dist/blocks/locale.js +263 -0
  9. package/dist/blocks/selection.d.ts +49 -0
  10. package/dist/blocks/selection.js +109 -0
  11. package/dist/blocks/style.d.ts +93 -0
  12. package/dist/blocks/style.js +365 -0
  13. package/dist/blocks/tags.d.ts +12 -0
  14. package/dist/blocks/tags.js +121 -0
  15. package/dist/blocks/types.d.ts +301 -0
  16. package/dist/blocks/types.js +44 -0
  17. package/dist/client.d.ts +402 -0
  18. package/dist/client.js +1653 -0
  19. package/dist/device.d.ts +29 -0
  20. package/dist/device.js +137 -0
  21. package/dist/errors.d.ts +39 -0
  22. package/dist/errors.js +68 -0
  23. package/dist/esm/blocks/background.js +160 -0
  24. package/dist/esm/blocks/color.js +318 -0
  25. package/dist/esm/blocks/locale.js +255 -0
  26. package/dist/esm/blocks/selection.js +100 -0
  27. package/dist/esm/blocks/style.js +356 -0
  28. package/dist/esm/blocks/tags.js +117 -0
  29. package/dist/esm/blocks/types.js +40 -0
  30. package/dist/esm/client.js +1649 -0
  31. package/dist/esm/device.js +130 -0
  32. package/dist/esm/errors.js +59 -0
  33. package/dist/esm/iap.js +101 -0
  34. package/dist/esm/implicit.js +63 -0
  35. package/dist/esm/index.js +29 -0
  36. package/dist/esm/package.json +1 -0
  37. package/dist/esm/storage.js +78 -0
  38. package/dist/esm/types.js +4 -0
  39. package/dist/esm/ui/RevnixBlockPaywall.js +706 -0
  40. package/dist/esm/ui/RevnixPaywall.js +890 -0
  41. package/dist/esm/ui/index.js +6 -0
  42. package/dist/esm/version.js +4 -0
  43. package/dist/iap.d.ts +20 -0
  44. package/dist/iap.js +104 -0
  45. package/dist/implicit.d.ts +69 -0
  46. package/dist/implicit.js +67 -0
  47. package/dist/index.d.ts +18 -0
  48. package/dist/index.js +50 -0
  49. package/dist/storage.d.ts +14 -0
  50. package/dist/storage.js +85 -0
  51. package/dist/types.d.ts +463 -0
  52. package/dist/types.js +5 -0
  53. package/dist/ui/RevnixBlockPaywall.d.ts +96 -0
  54. package/dist/ui/RevnixBlockPaywall.js +742 -0
  55. package/dist/ui/RevnixPaywall.d.ts +148 -0
  56. package/dist/ui/RevnixPaywall.js +927 -0
  57. package/dist/ui/index.d.ts +7 -0
  58. package/dist/ui/index.js +25 -0
  59. package/dist/version.d.ts +1 -0
  60. package/dist/version.js +7 -0
  61. package/package.json +79 -0
  62. package/src/blocks/background.ts +232 -0
  63. package/src/blocks/color.ts +352 -0
  64. package/src/blocks/locale.ts +271 -0
  65. package/src/blocks/paywall-background-wire.json +12 -0
  66. package/src/blocks/paywall-selection-wire.json +149 -0
  67. package/src/blocks/selection.ts +161 -0
  68. package/src/blocks/style.ts +411 -0
  69. package/src/blocks/tags.ts +125 -0
  70. package/src/blocks/types.ts +352 -0
  71. package/src/client.ts +1993 -0
  72. package/src/device.ts +146 -0
  73. package/src/errors.ts +69 -0
  74. package/src/iap.ts +137 -0
  75. package/src/implicit.ts +129 -0
  76. package/src/index.ts +82 -0
  77. package/src/storage.ts +92 -0
  78. package/src/types.ts +528 -0
  79. package/src/ui/RevnixBlockPaywall.tsx +1423 -0
  80. package/src/ui/RevnixPaywall.tsx +1542 -0
  81. package/src/ui/index.ts +43 -0
  82. package/src/version.ts +4 -0
  83. package/ui.d.ts +1 -0
  84. package/ui.js +5 -0
package/src/client.ts ADDED
@@ -0,0 +1,1993 @@
1
+ import {
2
+ PurchaseBlockedError,
3
+ RevnixAuthError,
4
+ RevnixError,
5
+ RevnixNetworkError,
6
+ RevnixRateLimitError,
7
+ RevnixTimeoutError,
8
+ } from "./errors.js";
9
+ import {
10
+ generateAnonymousId,
11
+ generateEventId,
12
+ generateViewId,
13
+ resolveStorage,
14
+ } from "./storage.js";
15
+ import {
16
+ DEVICE_HEADER,
17
+ INSTALLED_AT_KEY,
18
+ compactPayload,
19
+ detectDeviceFacts,
20
+ encodeDeviceHeader,
21
+ } from "./device.js";
22
+ import { SDK_VERSION } from "./version.js";
23
+ import {
24
+ browserLifecycle,
25
+ DEFAULT_SESSION_TIMEOUT_MS,
26
+ IMPLICIT_CONFIG_RETRY_MS,
27
+ IMPLICIT_PLACEMENTS,
28
+ PREVIEW_PLACEMENT_KEY,
29
+ type ImplicitPlacement,
30
+ type RevnixAppState,
31
+ type RevnixLifecycle,
32
+ } from "./implicit.js";
33
+ import type {
34
+ AdRevenue,
35
+ AliasResult,
36
+ ImplicitResolution,
37
+ DeviceFacts,
38
+ RevnixDiagnostic,
39
+ CustomerEntitlements,
40
+ IdentifyResult,
41
+ IdentifyStrategy,
42
+ IdentityInfo,
43
+ LastDeepLink,
44
+ MmpAttribution,
45
+ PaywallInteraction,
46
+ PlacementResolution,
47
+ RegisterPurchaseInput,
48
+ RegisterPurchaseResult,
49
+ RevnixAttribution,
50
+ RevnixConfig,
51
+ RevnixStorage,
52
+ } from "./types.js";
53
+
54
+ const CUSTOMER_ID_KEY = "revnix.customerId";
55
+ const SESSION_STARTED_AT_KEY = "revnix.sessionStartedAt";
56
+ const LAST_BACKGROUND_AT_KEY = "revnix.lastBackgroundAt";
57
+ const DEFERRED_DEEP_LINK_DELIVERED_KEY = "revnix.deferredDeepLinkDelivered";
58
+ const LAST_DEEP_LINK_KEY = "revnix.lastDeepLink";
59
+ const ATTRIBUTION_KEY = "revnix.attribution";
60
+ const ENTITLEMENTS_CACHE_KEY = "revnix.entitlements";
61
+ const PENDING_PURCHASES_KEY = "revnix.pendingPurchases";
62
+ const PLACEMENTS_CACHE_KEY = "revnix.placements";
63
+ const LAST_ATTRIBUTION_KEY = "revnix.lastAttribution";
64
+ const LAST_PUSH_TOKEN_KEY = "revnix.lastPushToken";
65
+ const DEFAULT_TIMEOUT_MS = 10_000;
66
+
67
+ // REV-204 (L10): how many customers keep a cached entitlements snapshot.
68
+ const ENTITLEMENTS_CACHE_MAX = 4;
69
+
70
+ // REV-204 (L10): http:// is tolerated only for development hosts.
71
+ const PRIVATE_HTTP_HOST =
72
+ /^http:\/\/(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|\[::1\])/i;
73
+
74
+ // URL/URLSearchParams are avoided: React Native's URL polyfill lacks parts of both.
75
+ const PREVIEW_TOKEN_RE = /[?&]revnix_preview=([0-9a-f]{64})(?:[&#]|$)/;
76
+
77
+ // REV-157: how long past a cached entitlement's expiresAt it is still served
78
+ // as active. An offline device cannot see the renewal that most likely
79
+ // happened at expiry — cutting access at the cached timestamp would lock a
80
+ // paying subscriber out mid-flight. Three days absorbs the overwhelmingly
81
+ // common auto-renew; a genuinely lapsed subscription still shuts off. Only
82
+ // cache-served reads are re-evaluated; a fresh server read is authoritative.
83
+ const LOCAL_EXPIRY_GRACE_MS = 3 * 24 * 60 * 60 * 1000;
84
+
85
+ // REV-198 (CTO-R2 M23): how old a cached snapshot may be and still grant
86
+ // access. The expiry rule above bounds entitlements that CARRY an expiresAt —
87
+ // but a lifetime grant has none, and rolling the clock back moves every
88
+ // expiresAt into "the future", so blocking the domain plus a clock rollback
89
+ // was indefinite premium. Bounding on the cache's own age (fetchedAt) closes
90
+ // the unbounded variant: client checks are always defeatable, but now it
91
+ // takes re-editing the JSON every two weeks, not one Settings change.
92
+ const DEFAULT_OFFLINE_MAX_CACHE_AGE_MS = 14 * 24 * 60 * 60 * 1000;
93
+ // now < fetchedAt means the clock is BEHIND the moment the cache was written
94
+ // — a rolled-back clock. Small negative skew is real (NTP corrections), a
95
+ // large one is not.
96
+ const CLOCK_ROLLBACK_TOLERANCE_MS = 5 * 60 * 1000;
97
+
98
+ // C7: a purchase that failed to register, persisted so it survives an app
99
+ // restart and retries later (idempotent server-side via purchaseKey).
100
+ interface PendingPurchase {
101
+ customerId: string;
102
+ input: RegisterPurchaseInput;
103
+ }
104
+
105
+ interface InstallResponse {
106
+ deferredDeepLink?: unknown;
107
+ appleAttribution?: string;
108
+ }
109
+
110
+ interface AttributionResponse extends Omit<RevnixAttribution, "installMatch" | "attributedAt"> {
111
+ installMatch: RevnixAttribution["installMatch"] | "unknown";
112
+ attributedAt?: number;
113
+ }
114
+
115
+ // One queue entry per store charge — repeated failures collapse onto it, and
116
+ // the retry pass removes by this key rather than by array position.
117
+ const pendingKey = (p: RegisterPurchaseInput) =>
118
+ `${p.source}:${p.token}:${p.transactionId}`;
119
+
120
+ // Queue only failures that can succeed later. Permanent 4xx (bad input,
121
+ // revoked key, blocked transfer) would be retried every launch forever;
122
+ // unknown non-Revnix errors count as transient to err on the keeping side.
123
+ const worthQueueing = (err: unknown): boolean =>
124
+ !(err instanceof RevnixError) || err.isRetryable;
125
+
126
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
127
+
128
+ // REV-199 (CTO-R2 M24): ±20% on every scheduled delay.
129
+ const jittered = (ms: number) => ms * (0.8 + Math.random() * 0.4);
130
+
131
+ // REV-199 (M21): how long a fresh entitlements read answers repeat calls
132
+ // from memory. Short enough that a webhook-driven change is picked up within
133
+ // half a minute; long enough that gating five screens costs one request.
134
+ const DEFAULT_ENTITLEMENTS_TTL_MS = 30_000;
135
+
136
+ export class RevnixClient {
137
+ private readonly apiKey: string;
138
+ private readonly baseUrl: string;
139
+ private readonly storage: RevnixStorage;
140
+ private readonly fetchImpl: typeof fetch;
141
+ private readonly reportInstalls: boolean;
142
+ private readonly persistentStorage: boolean;
143
+ /** True while the current id is a generated anon id living on
144
+ * NON-persistent storage — it changes every launch, so sending it to
145
+ * the experiments path would mint a phantom enrollment per launch
146
+ * (same hazard class the M5 install guard covers). */
147
+ private ephemeralAnonId = false;
148
+ private readonly installMetadata?: {
149
+ platform?: string;
150
+ appVersion?: string;
151
+ };
152
+ private readonly platform: string | undefined;
153
+ // REV-268: the encoded X-Revnix-Device value, built once per client. Null
154
+ // when the app opted out (`device: false`). Resolved asynchronously because
155
+ // installedAt/firstOpen come from storage, which may be async.
156
+ private readonly deviceHeaderPromise: Promise<string | null>;
157
+ private readonly requestTimeoutMs: number;
158
+ private readonly readYourWritesDelaysMs: number[];
159
+ private readonly offlineMaxCacheAgeMs: number;
160
+ private readonly entitlementsTtlMs: number;
161
+ // REV-200 (CTO-R2 M26): background failures used to vanish into
162
+ // catch(() => {}) — a fleet failing before the server produced silence.
163
+ // Every such site now reports here: the app's onDiagnostic callback gets
164
+ // the event, and a counter rides the next successful request as an
165
+ // X-Revnix-Bg-Failures header so the backend can see field pain it would
166
+ // otherwise never receive.
167
+ private readonly onDiagnostic?: (event: RevnixDiagnostic) => void;
168
+ private bgFailures = 0;
169
+ // REV-204 (L10): set by dispose() — stops background loops so a replaced
170
+ // client (dev fast-refresh re-running configure()) does not keep retrying
171
+ // and prefetching alongside its successor.
172
+ private disposed = false;
173
+ private customerIdPromise: Promise<string>;
174
+ // REV-272: implicit placements. Off unless the host said what to do with a
175
+ // paywall — without a handler there is nothing to do with the answer, and
176
+ // firing anyway would spend requests and ledger rows on nobody's behalf.
177
+ private readonly onImplicitPaywall?: RevnixConfig["onImplicitPaywall"];
178
+ private readonly onDeferredDeepLink?: RevnixConfig["onDeferredDeepLink"];
179
+ private readonly onAttribution?: RevnixConfig["onAttribution"];
180
+ private deferredDeepLinkClaimed = false;
181
+ private landingUrl: string | null = null;
182
+ private lastDeepLinkWrite: Promise<void> = Promise.resolve();
183
+ private readonly implicitEnabled: boolean;
184
+ private readonly sessionTimeoutMs: number;
185
+ private implicitConfigPromise: Promise<ReadonlySet<string>> | null = null;
186
+ private implicitConfigRetryAt = 0;
187
+ private lifecycleUnsubscribe: (() => void) | null = null;
188
+ /** The cold-start batch while it runs, resolving to whether it presented a
189
+ * paywall. A deep link delivered on the first frame — SwiftUI's onOpenURL,
190
+ * RN's getInitialURL — must wait for it, or the customer gets the launch
191
+ * paywall AND the link paywall stacked. Null once the batch is over. */
192
+ private launchBatch: Promise<boolean> | null = null;
193
+ /** When the app last went to the BACKGROUND, or null while it has not.
194
+ * A return after `sessionTimeoutMs` away is a new session; anything sooner
195
+ * is an app switch. Measured from the background transition, not from the
196
+ * last return — the latter would mint a session after 35 minutes of
197
+ * continuous use plus a three-second switch. */
198
+ private lastBackgroundAt: number | null = null;
199
+ /** LOOP GUARD: view ids of displays that came FROM an implicit trigger. A
200
+ * paywall shown because a paywall was dismissed must not itself fire
201
+ * `paywall_decline`, or the customer is handed the same screen forever.
202
+ * Recognised from the placementKey the host reports on logPaywallShown —
203
+ * a host that reports no placementKey cannot be protected here, which is
204
+ * why the server keeps its own same-paywall backstop. */
205
+ private readonly implicitViewIds = new Set<string>();
206
+ /** True when this launch generated a fresh anonymous id — the same signal
207
+ * /v1/installs uses, and independent of the device header (which an app
208
+ * can turn off entirely). */
209
+ private freshInstall = false;
210
+
211
+ constructor(config: RevnixConfig) {
212
+ if (!config.apiKey) throw new Error("Revnix: apiKey is required");
213
+ if (!config.baseUrl) throw new Error("Revnix: baseUrl is required");
214
+ this.apiKey = config.apiKey;
215
+ this.baseUrl = config.baseUrl.replace(/\/+$/, "");
216
+ // REV-204 (L10): a plaintext public base URL ships API keys and
217
+ // entitlements over HTTP. Loopback/private hosts stay allowed — local
218
+ // convex dev and device-to-LAN development legitimately use them.
219
+ if (
220
+ this.baseUrl.startsWith("http://") &&
221
+ !PRIVATE_HTTP_HOST.test(this.baseUrl)
222
+ ) {
223
+ throw new Error(
224
+ "Revnix: baseUrl must be https:// (http:// is only allowed for localhost/private-network development hosts)"
225
+ );
226
+ }
227
+ // M5: web falls back to localStorage automatically; truly memory-only
228
+ // (RN without an adapter) warns and disables install reporting below.
229
+ const resolved = resolveStorage(config.storage);
230
+ this.storage = resolved.storage;
231
+ this.fetchImpl = config.fetch ?? fetch;
232
+ this.reportInstalls = (config.reportInstalls ?? true) && resolved.persistent;
233
+ this.persistentStorage = resolved.persistent;
234
+ this.installMetadata = config.installMetadata;
235
+ this.platform =
236
+ (config.device || undefined)?.platform ??
237
+ config.installMetadata?.platform ??
238
+ detectDeviceFacts().platform;
239
+ this.deviceHeaderPromise = this.buildDeviceHeader(config.device);
240
+ this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS;
241
+ this.offlineMaxCacheAgeMs =
242
+ config.offlineMaxCacheAgeMs ?? DEFAULT_OFFLINE_MAX_CACHE_AGE_MS;
243
+ this.entitlementsTtlMs =
244
+ config.entitlementsTtlMs ?? DEFAULT_ENTITLEMENTS_TTL_MS;
245
+ this.onDiagnostic = config.onDiagnostic;
246
+ this.onDeferredDeepLink = config.onDeferredDeepLink;
247
+ this.onAttribution = config.onAttribution;
248
+ this.readYourWritesDelaysMs =
249
+ config.readYourWritesDelaysMs ?? [250, 500, 1000, 2000];
250
+ this.customerIdPromise = this.loadCustomerId(config.customerId);
251
+ // C7: retry any purchases that failed to register on a previous launch, so
252
+ // a paying user never stays locked out because a POST didn't land.
253
+ void this.retryPendingPurchases().catch((err) =>
254
+ this.diag("retryPendingPurchases", err)
255
+ );
256
+ // REV-157: warm the placement cache at launch, so the first paywall of the
257
+ // session can render offline. Fire-and-forget; a failed key just stays on
258
+ // whatever an earlier launch cached.
259
+ for (const key of config.prefetchPlacements ?? []) {
260
+ void this.resolvePlacement(key).catch((err) =>
261
+ this.diag("prefetchPlacement", err)
262
+ );
263
+ }
264
+ // REV-272: the six moments. Started last so a slow /v1/config can never
265
+ // delay the calls above, and fire-and-forget throughout — an implicit
266
+ // paywall that fails to resolve costs the paywall, never the launch.
267
+ this.onImplicitPaywall = config.onImplicitPaywall;
268
+ this.implicitEnabled =
269
+ config.implicitPlacements ?? config.onImplicitPaywall !== undefined;
270
+ this.sessionTimeoutMs =
271
+ config.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
272
+ if (this.implicitEnabled) this.startImplicit(config.lifecycle);
273
+ const { document, location } = globalThis as {
274
+ document?: unknown;
275
+ location?: { href: string; search: string };
276
+ };
277
+ if (
278
+ document &&
279
+ location &&
280
+ /^\?(?:[^&]*&)*(utm_\w+|revnix_link)=/i.test(location.search)
281
+ ) {
282
+ this.handleDeepLink(location.href);
283
+ this.landingUrl = location.href;
284
+ }
285
+ }
286
+
287
+ /** The id every call is made as: the stored identified/canonical id, or a
288
+ * generated-and-persisted anonymous id (rvx_anon_…) on first launch. */
289
+ getCustomerId(): Promise<string> {
290
+ return this.customerIdPromise;
291
+ }
292
+
293
+ // C3: identity mutations are server-only — from a device, ANY caller could
294
+ // merge itself into any customer. The server 403s a publishable key; refuse
295
+ // before the network so the failure is deterministic in development (not
296
+ // first discovered on a real login) and the message says what to do.
297
+ //
298
+ // The identity READ is gated the same way (server-side REV-184): the
299
+ // response is an identity graph, and tenants routinely use emails and user
300
+ // ids as customer ids, so a publishable key could walk candidate ids and
301
+ // read back who is a customer. Until REV-259 getIdentity() skipped this
302
+ // check and every device call ended in a 403 the docs never mentioned.
303
+ private requireSecretKey(method: string): void {
304
+ if (this.apiKey.startsWith("rvx_pk_")) {
305
+ throw new RevnixAuthError(
306
+ 403,
307
+ `Revnix: ${method}() requires a secret key (rvx_sk_…) and must be ` +
308
+ `called from your backend. A publishable key ships inside the app ` +
309
+ `binary, so it may not read or rewire the identity graph. Send ` +
310
+ `the ids to your server and call ${method}() there. Never embed a ` +
311
+ `secret key in the app.`
312
+ );
313
+ }
314
+ }
315
+
316
+ /** Merges the current (usually anonymous) customer into `userId`. The
317
+ * canonical id from the response becomes the id all subsequent calls use.
318
+ * Safe to call repeatedly — a no-op when already identified as `userId`.
319
+ *
320
+ * Secret-key only — call this from YOUR BACKEND on login, not from the
321
+ * device. On a publishable key it throws RevnixAuthError locally, before
322
+ * any network call: identity merges from a device would let any caller
323
+ * claim any customer. Device flow: send `getCustomerId()` and your user id
324
+ * to your server, identify there with an rvx_sk_ key, and the device picks
325
+ * up the canonical id on its next registerPurchase() (its response always
326
+ * carries the caller's canonical id). */
327
+ async identify(
328
+ userId: string,
329
+ options?: { strategy?: IdentifyStrategy }
330
+ ): Promise<IdentifyResult> {
331
+ this.requireSecretKey("identify");
332
+ const previousId = await this.customerIdPromise;
333
+ if (previousId === userId) {
334
+ return { canonicalId: userId, merged: false };
335
+ }
336
+ const result = await this.request<IdentifyResult>(
337
+ "POST",
338
+ "/v1/identify",
339
+ {
340
+ previousId,
341
+ newId: userId,
342
+ strategy: options?.strategy,
343
+ }
344
+ );
345
+ await this.setCustomerId(result.canonicalId);
346
+ return result;
347
+ }
348
+
349
+ /** Attach an additional external id (legacy id, email hash, …) to the
350
+ * current customer without switching to it.
351
+ *
352
+ * Secret-key only, like identify() — throws RevnixAuthError locally on a
353
+ * publishable key; call it from your backend instead. */
354
+ async alias(aliasId: string): Promise<AliasResult> {
355
+ this.requireSecretKey("alias");
356
+ const customerId = await this.customerIdPromise;
357
+ return await this.request<AliasResult>(
358
+ "POST",
359
+ `/v1/customers/${encodeURIComponent(customerId)}/alias`,
360
+ { aliasId }
361
+ );
362
+ }
363
+
364
+ /** Call on logout: forget the identified customer and start a fresh
365
+ * anonymous one. Never merges — purchases made while logged out belong
366
+ * to the new anonymous customer until the next identify(). */
367
+ async logout(): Promise<string> {
368
+ const anonymousId = generateAnonymousId();
369
+ await this.setCustomerId(anonymousId);
370
+ this.ephemeralAnonId = !this.persistentStorage;
371
+ return anonymousId;
372
+ }
373
+
374
+ /** Call right after a successful store purchase: binds the store token to
375
+ * the current customer and appends the purchase to the ledger, so
376
+ * entitlement never waits on the store webhook. Throws
377
+ * PurchaseBlockedError when the token belongs to another customer and the
378
+ * app's transfer behavior forbids reassignment. */
379
+ async registerPurchase(
380
+ input: RegisterPurchaseInput
381
+ ): Promise<RegisterPurchaseResult> {
382
+ const customerId = await this.customerIdPromise;
383
+ try {
384
+ const result = await this.postPurchase(customerId, input);
385
+ // Identity resolution may have merged this device's id away (restore
386
+ // after login), so the server's id is the one to keep using.
387
+ //
388
+ // REV-162: this used to adopt whatever came back, and what came back on
389
+ // a REFUSED claim was the rightful owner's id — so a denied purchase
390
+ // quietly moved this device onto that customer, and the next
391
+ // entitlements read unlocked their subscription. The server now answers
392
+ // the two questions separately: `customerId` is always this caller's own
393
+ // canonical id, and `tokenOwner` (secret-key callers only) says the
394
+ // token belongs to someone else. Following `customerId` is therefore
395
+ // safe in every outcome.
396
+ if (result.customerId !== customerId) {
397
+ await this.setCustomerId(result.customerId);
398
+ }
399
+ // M9: read-your-writes — re-fetch entitlements until the read reflects
400
+ // this purchase (cursor >= seq), warming the C7 cache at exactly the
401
+ // moment it matters. Best-effort: a failure here never fails the
402
+ // purchase (it already registered). Disabled entirely by
403
+ // readYourWritesDelaysMs: [].
404
+ // REV-162: skip the poll when the purchase was recorded against someone
405
+ // else — `seq` is then a position in THEIR stream, which this customer's
406
+ // cursor can never reach, so every delay would be waited out in full
407
+ // before giving up.
408
+ if (this.readYourWritesDelaysMs.length > 0 && !result.ownedByOtherCustomer) {
409
+ await this.waitForEntitlements(result.seq).catch((err) =>
410
+ this.diag("waitForEntitlements", err)
411
+ );
412
+ }
413
+ return result;
414
+ } catch (err) {
415
+ // C7: network/timeout/429/5xx — persist the receipt so the purchase
416
+ // isn't lost, then retry on the next launch / connectivity regain.
417
+ // Idempotent server-side (store.ts purchaseKey), so a later retry or the
418
+ // store webhook never double-counts. Permanent rejections (blocked
419
+ // transfer, bad input, revoked key) are NOT queued — they won't fix
420
+ // themselves. Rethrow either way so the caller can still react now.
421
+ if (worthQueueing(err)) {
422
+ await this.enqueuePurchase(customerId, input);
423
+ }
424
+ throw err;
425
+ }
426
+ }
427
+
428
+ private postPurchase(
429
+ customerId: string,
430
+ input: RegisterPurchaseInput
431
+ ): Promise<RegisterPurchaseResult> {
432
+ return this.request<RegisterPurchaseResult>("POST", "/v1/purchases", {
433
+ customerId,
434
+ ...input,
435
+ });
436
+ }
437
+
438
+ /** C7: retry purchases that failed to register and were persisted locally.
439
+ * Runs automatically at construction; call again when connectivity returns.
440
+ * Idempotent and safe to call repeatedly. */
441
+ async retryPendingPurchases(): Promise<void> {
442
+ const queue = await this.readPurchaseQueue();
443
+ for (const item of queue) {
444
+ if (this.disposed) return;
445
+ try {
446
+ await this.postPurchase(item.customerId, item.input);
447
+ // Delivered — remove it.
448
+ } catch (err) {
449
+ if (worthQueueing(err)) {
450
+ // REV-200 (M26): a queued purchase that STILL cannot land is the
451
+ // most important background failure there is — report it.
452
+ this.diag("retryPendingPurchases", err);
453
+ continue; // transient — keep for next time
454
+ }
455
+ // Permanent rejection — remove it.
456
+ this.diag("retryPendingPurchases", err);
457
+ }
458
+ // Remove THIS item from the queue as it stands now, not from the
459
+ // snapshot iterated above: each postPurchase can spend seconds in a
460
+ // timeout, and a purchase failing during that window (likely — both
461
+ // happen exactly when the network is bad) enqueues concurrently. A
462
+ // snapshot write-back at the end would silently drop it.
463
+ await this.mutateQueue((current) =>
464
+ current.filter((q) => pendingKey(q.input) !== pendingKey(item.input))
465
+ );
466
+ }
467
+ }
468
+
469
+ // REV-199 (CTO-R2 M21): the last fresh network read, held in memory so
470
+ // reads within the soft TTL answer without a round trip — apps gate many
471
+ // screens on isEntitled(), and every one of those was a full request for an
472
+ // answer that had not changed. Stale cache-fallback results are never held
473
+ // here: only a real server read satisfies the TTL.
474
+ private freshEntitlements: {
475
+ customerId: string;
476
+ data: CustomerEntitlements;
477
+ at: number;
478
+ } | null = null;
479
+ // REV-199 (M21): concurrent getEntitlements() calls share one request.
480
+ private inflightEntitlements: {
481
+ customerId: string;
482
+ promise: Promise<CustomerEntitlements>;
483
+ } | null = null;
484
+
485
+ /** Current entitlements for this customer, merged across every id linked to
486
+ * them. Served from memory when a fresh network read is younger than
487
+ * `entitlementsTtlMs` (REV-199 — pass 0 to disable); otherwise reads the
488
+ * network (concurrent callers share one request) and caches the result; if
489
+ * that read FAILS transiently (offline / incident) it falls back to the
490
+ * last cached entitlements with `stale: true`, so a paying customer is
491
+ * never shown as unsubscribed just because the network is down. Throws on
492
+ * a deliberate server rejection (revoked key, …) or when there is nothing
493
+ * cached. */
494
+ async getEntitlements(): Promise<CustomerEntitlements> {
495
+ const customerId = await this.customerIdPromise;
496
+ const held = this.freshEntitlements;
497
+ if (held && held.customerId === customerId) {
498
+ const age = Date.now() - held.at;
499
+ if (age >= 0 && age < this.entitlementsTtlMs) return held.data;
500
+ }
501
+ if (this.inflightEntitlements?.customerId === customerId) {
502
+ return this.inflightEntitlements.promise;
503
+ }
504
+ const promise = this.fetchEntitlementsWithFallback(customerId);
505
+ this.inflightEntitlements = { customerId, promise };
506
+ const clear = () => {
507
+ if (this.inflightEntitlements?.promise === promise) {
508
+ this.inflightEntitlements = null;
509
+ }
510
+ };
511
+ promise.then(clear, clear);
512
+ return promise;
513
+ }
514
+
515
+ /** The authoritative network read: no TTL, no cache fallback — throws so
516
+ * the caller sees the real failure (waitForEntitlements needs 429s). */
517
+ private async fetchFreshEntitlements(
518
+ customerId: string
519
+ ): Promise<CustomerEntitlements> {
520
+ const fresh = await this.request<CustomerEntitlements>(
521
+ "GET",
522
+ `/v1/customers/${encodeURIComponent(customerId)}/entitlements`
523
+ );
524
+ await this.writeEntitlementsCache(customerId, fresh);
525
+ const result = { ...fresh, stale: false, fetchedAt: Date.now() };
526
+ this.freshEntitlements = { customerId, data: result, at: Date.now() };
527
+ return result;
528
+ }
529
+
530
+ private async fetchEntitlementsWithFallback(
531
+ customerId: string
532
+ ): Promise<CustomerEntitlements> {
533
+ try {
534
+ return await this.fetchFreshEntitlements(customerId);
535
+ } catch (err) {
536
+ // REV-198 (CTO-R2 M22): the cache exists to ride out failures that
537
+ // might succeed next time — network, timeout, 429, 5xx. A deliberate
538
+ // server answer (401/403 revoked key, 404, 409) is not an outage, and
539
+ // serving last-known-good over it deleted every kill switch: a revoked
540
+ // key kept unlocking cached entitlements fleet-wide. Non-retryable →
541
+ // the caller hears exactly what the server said.
542
+ if (err instanceof RevnixError && !err.isRetryable) throw err;
543
+ const cached = await this.readEntitlementsCache(customerId);
544
+ if (cached) {
545
+ return this.withLocalExpiry({
546
+ ...cached.data,
547
+ stale: true,
548
+ fetchedAt: cached.fetchedAt,
549
+ });
550
+ }
551
+ throw err;
552
+ }
553
+ }
554
+
555
+ /** M9: read-your-writes — poll getEntitlements until the response's ledger
556
+ * cursor is at least `seq` (i.e. the read provably includes that write),
557
+ * then resolve with it. Resolves with the LAST read if the delays run out
558
+ * or reads only come from the stale cache — never spins forever. Called
559
+ * automatically by registerPurchase; public for manual flows (e.g. after
560
+ * a webhook-driven grant the app learns about out-of-band). */
561
+ async waitForEntitlements(seq: number): Promise<CustomerEntitlements> {
562
+ const customerId = await this.customerIdPromise;
563
+ let last = await this.getEntitlements();
564
+ for (const delayMs of this.readYourWritesDelaysMs) {
565
+ if (this.disposed || (!last.stale && last.cursor >= seq)) return last;
566
+ // REV-199 (M24): ±20% jitter — promo pushes synchronize a fleet's
567
+ // purchases, and identical delay schedules kept every device's poll in
568
+ // lockstep against our own rate limiter.
569
+ await sleep(jittered(delayMs));
570
+ try {
571
+ // TTL-bypassing read: the whole point of this poll is a FRESH cursor,
572
+ // so the M21 soft-TTL must not answer from memory here.
573
+ last = await this.fetchFreshEntitlements(customerId);
574
+ } catch (err) {
575
+ // REV-199 (M24): the server said exactly how long to back off —
576
+ // honor it instead of fighting our own rate limiter.
577
+ if (err instanceof RevnixRateLimitError && err.retryAfterMs) {
578
+ await sleep(err.retryAfterMs);
579
+ }
580
+ // Transient failure: keep the last read and let the schedule run.
581
+ }
582
+ }
583
+ return last;
584
+ }
585
+
586
+ /** C7: the last entitlements cached for the current customer, with NO network
587
+ * call — for an instant, offline-safe gate at launch. Always `stale: true`;
588
+ * follow with getEntitlements() to revalidate. Null if nothing is cached. */
589
+ async getCachedEntitlements(): Promise<CustomerEntitlements | null> {
590
+ const customerId = await this.customerIdPromise;
591
+ const cached = await this.readEntitlementsCache(customerId);
592
+ return cached
593
+ ? this.withLocalExpiry({
594
+ ...cached.data,
595
+ stale: true,
596
+ fetchedAt: cached.fetchedAt,
597
+ })
598
+ : null;
599
+ }
600
+
601
+ /** REV-157: cached entitlements are a snapshot — re-evaluate expiry against
602
+ * the local clock when serving them, so a subscription that ended long ago
603
+ * never stays "active" offline forever. Active entitlements keep access
604
+ * for LOCAL_EXPIRY_GRACE_MS past expiresAt (the renewal an offline device
605
+ * cannot see yet). Inactive stays inactive; no expiresAt (lifetime/grant)
606
+ * is untouched. */
607
+ private withLocalExpiry(data: CustomerEntitlements): CustomerEntitlements {
608
+ const now = Date.now();
609
+ // REV-198 (M23): fetchedAt was stored but never consulted. A snapshot
610
+ // older than the offline window — or "younger than when it was written"
611
+ // (rolled-back clock) — no longer grants access at all.
612
+ const age = now - (data.fetchedAt ?? now);
613
+ if (
614
+ age > this.offlineMaxCacheAgeMs ||
615
+ age < -CLOCK_ROLLBACK_TOLERANCE_MS
616
+ ) {
617
+ return {
618
+ ...data,
619
+ entitlements: data.entitlements.map((e) =>
620
+ e.isActive ? { ...e, isActive: false } : e
621
+ ),
622
+ };
623
+ }
624
+ const cutoff = now - LOCAL_EXPIRY_GRACE_MS;
625
+ return {
626
+ ...data,
627
+ entitlements: data.entitlements.map((e) =>
628
+ e.isActive && e.expiresAt !== undefined && e.expiresAt < cutoff
629
+ ? { ...e, isActive: false }
630
+ : e
631
+ ),
632
+ };
633
+ }
634
+
635
+ /** Convenience: is one entitlement active right now? */
636
+ async isEntitled(entitlementId: string): Promise<boolean> {
637
+ const { entitlements } = await this.getEntitlements();
638
+ return entitlements.some(
639
+ (e) => e.entitlementId === entitlementId && e.isActive
640
+ );
641
+ }
642
+
643
+ /** How the current id resolves: canonical id, alias status, linked ids.
644
+ *
645
+ * Secret-key only — the response is an identity graph, so the server 403s
646
+ * a publishable key (REV-184). On an rvx_pk_ key this throws
647
+ * RevnixAuthError locally, before any network call, like identify(). */
648
+ async getIdentity(): Promise<IdentityInfo> {
649
+ this.requireSecretKey("getIdentity");
650
+ const customerId = await this.customerIdPromise;
651
+ return await this.request<IdentityInfo>(
652
+ "GET",
653
+ `/v1/customers/${encodeURIComponent(customerId)}/identity`
654
+ );
655
+ }
656
+
657
+ /** Resolve a named in-app location to the currently published offering —
658
+ * the one-line paywall call. REV-157: reads the network first and caches
659
+ * the result; if that read FAILS (offline / incident) it falls back to the
660
+ * last cached resolution with `stale: true`, so the paywall renders the
661
+ * last published version rather than not at all. Throws only when there
662
+ * is nothing cached. */
663
+ async resolvePlacement(placementKey: string): Promise<PlacementResolution> {
664
+ const customerId = await this.customerIdPromise;
665
+ try {
666
+ // REV-219: identify the caller so a running experiment on this
667
+ // placement can assign a (sticky, deterministic) variant. Servers
668
+ // without experiments simply ignore the parameter. An EPHEMERAL anon
669
+ // id (memory-only storage — new id every launch) is deliberately NOT
670
+ // sent: each launch would enroll a phantom customer and flip the same
671
+ // user between variants; the server serves the placement default
672
+ // when no customer is identified (same guard class as M5 installs).
673
+ const path = `/v1/placements/${encodeURIComponent(placementKey)}/offering`;
674
+ // REV-268: the device facts ride along so the server can evaluate
675
+ // targeting rules against THIS device on THIS request, and store them
676
+ // as device.* attributes. Not sent for an ephemeral anon id: the server
677
+ // would have no customer to keep them on, and it serves such callers
678
+ // the untargeted default anyway.
679
+ const deviceHeader = this.ephemeralAnonId
680
+ ? null
681
+ : await this.deviceHeaderPromise;
682
+ const fresh = await this.request<PlacementResolution>(
683
+ "GET",
684
+ this.ephemeralAnonId
685
+ ? path
686
+ : `${path}?customer=${encodeURIComponent(customerId)}`,
687
+ undefined,
688
+ deviceHeader ? { [DEVICE_HEADER]: deviceHeader } : undefined
689
+ );
690
+ await this.writePlacementCache(placementKey, fresh, customerId);
691
+ return { ...fresh, stale: false, fetchedAt: Date.now() };
692
+ } catch (err) {
693
+ // REV-198 (M22): same rule as getEntitlements — an unpublished catalog
694
+ // (409) or deleted placement (404) is the server's deliberate answer,
695
+ // and caching over it meant an unpublished paywall kept rendering
696
+ // fleet-wide with no way to pull it.
697
+ if (err instanceof RevnixError && !err.isRetryable) throw err;
698
+ const cached = await this.readPlacementCache(placementKey, customerId);
699
+ if (cached) {
700
+ return { ...cached.data, stale: true, fetchedAt: cached.fetchedAt };
701
+ }
702
+ throw err;
703
+ }
704
+ }
705
+
706
+ /** REV-157: the last resolution cached for `placementKey`, with NO network
707
+ * call — for an instant, offline-safe paywall at launch. Always
708
+ * `stale: true`; follow with resolvePlacement() to revalidate. Null if
709
+ * nothing is cached yet. */
710
+ async getCachedPlacement(
711
+ placementKey: string
712
+ ): Promise<PlacementResolution | null> {
713
+ const customerId = await this.customerIdPromise;
714
+ const cached = await this.readPlacementCache(placementKey, customerId);
715
+ return cached
716
+ ? { ...cached.data, stale: true, fetchedAt: cached.fetchedAt }
717
+ : null;
718
+ }
719
+
720
+ /** Report that a paywall was displayed (REV-094). Fire-and-forget like
721
+ * install reporting — never blocks rendering and never throws; each call
722
+ * is one view (retries of the same display are deduped by viewId).
723
+ * RevnixPaywall calls this automatically when given a `client`; headless
724
+ * paywall UIs call it on their own show event. Powers the analytics
725
+ * funnel's "Paywall displayed" stage and View → Trial/Paid conversions.
726
+ *
727
+ * Returns the view id it generated (REV-252). Hold onto it and hand it to
728
+ * logPaywallClosed when the customer dismisses this display: the pair
729
+ * sharing one id is what lets the ledger tie a close to the display it
730
+ * ended, rather than guessing from timestamps. Ignoring the return value
731
+ * is still valid — it stays a fire-and-forget beacon. */
732
+ logPaywallShown(options?: {
733
+ placementKey?: string;
734
+ paywallId?: string;
735
+ }): string {
736
+ const viewId = generateViewId();
737
+ // REV-272 LOOP GUARD: a display whose placement is one of the six came
738
+ // FROM an implicit trigger, so its dismissal must not fire another one —
739
+ // otherwise "show a win-back when a paywall is declined" hands the
740
+ // customer the same screen until they force-quit. Remembered here rather
741
+ // than asked of the host, because the host already tells us the placement.
742
+ if (
743
+ this.implicitEnabled &&
744
+ options?.placementKey !== undefined &&
745
+ (IMPLICIT_PLACEMENTS as readonly string[]).includes(options.placementKey)
746
+ ) {
747
+ this.implicitViewIds.add(viewId);
748
+ }
749
+ if (options?.placementKey === PREVIEW_PLACEMENT_KEY) return viewId;
750
+ void this.customerIdPromise
751
+ .then((customerId) =>
752
+ this.request("POST", "/v1/paywalls/viewed", {
753
+ customerId,
754
+ viewId,
755
+ occurredAt: Date.now(),
756
+ sdkVersion: SDK_VERSION,
757
+ ...(options?.placementKey ? { placementKey: options.placementKey } : {}),
758
+ ...(options?.paywallId ? { paywallId: options.paywallId } : {}),
759
+ })
760
+ )
761
+ .catch((err) => this.diag("logPaywallShown", err));
762
+ return viewId;
763
+ }
764
+
765
+ /** Report that the customer dismissed a paywall (REV-252) — the other half
766
+ * of a display's life. Fire-and-forget and idempotent per view id, exactly
767
+ * like logPaywallShown.
768
+ *
769
+ * Pass the id that logPaywallShown returned for THIS display. The two
770
+ * events then share a view id, so a reader can pair them, and the gap
771
+ * between their timestamps is the customer's dwell on the screen. Calling
772
+ * it with a fresh id still records a dismissal; it just cannot be tied
773
+ * back to the display it ended. RevnixPaywall does all of this for you.
774
+ *
775
+ * A close is a DECLINE. Do not report one for a display that ended in a
776
+ * purchase — with implicit placements on, a close is also the
777
+ * `paywall_decline` moment, and a win-back offer seconds after a successful
778
+ * purchase is the one thing an operator never means. RevnixPaywall only
779
+ * reports the close affordances, never a purchase-driven dismissal. */
780
+ logPaywallClosed(
781
+ viewId: string,
782
+ options?: { placementKey?: string; paywallId?: string }
783
+ ): void {
784
+ if (options?.placementKey === PREVIEW_PLACEMENT_KEY) return;
785
+ void this.customerIdPromise
786
+ .then((customerId) =>
787
+ this.request("POST", "/v1/paywalls/closed", {
788
+ customerId,
789
+ viewId,
790
+ occurredAt: Date.now(),
791
+ sdkVersion: SDK_VERSION,
792
+ ...(options?.placementKey ? { placementKey: options.placementKey } : {}),
793
+ ...(options?.paywallId ? { paywallId: options.paywallId } : {}),
794
+ })
795
+ )
796
+ .catch((err) => this.diag("logPaywallClosed", err));
797
+ // REV-272: the dismissal IS the `paywall_decline` moment. No second ledger
798
+ // event — the server reuses the paywall.closed just reported — so this is
799
+ // only the resolve that decides whether anything is attached to it.
800
+ this.fireImplicitFromPaywall("paywall_decline", viewId, options?.paywallId);
801
+ }
802
+
803
+ /** Report one of the six paywall interactions (REV-263) — what the customer
804
+ * DID on a display, between the `logPaywallShown` that opened it and the
805
+ * `logPaywallClosed` (or purchase) that ended it:
806
+ *
807
+ * - `selected` — a package was picked
808
+ * - `purchase_started` — checkout was started
809
+ * - `purchase_abandoned` — the customer backed out at the store sheet
810
+ * - `purchase_failed` — the store refused the payment
811
+ * - `restore` — restore purchases was tapped
812
+ * - `error` — the paywall itself failed (config, products, render)
813
+ *
814
+ * Fire-and-forget like the other beacons: never blocks, never throws.
815
+ *
816
+ * `viewId` is the id `logPaywallShown` returned for THIS display. Passing
817
+ * it is what threads the whole life of one impression together and puts
818
+ * the event on the paywall's own analytics row — a fresh id still records
819
+ * the event, it just cannot be tied back to the display.
820
+ *
821
+ * RevnixPaywall reports `selected`, `purchase_started`, `restore` and a
822
+ * no-products `error` for you. The purchase OUTCOME is yours: only the
823
+ * host performs the store call, so report `purchase_abandoned` /
824
+ * `purchase_failed` from your own StoreKit / Play error handling — or let
825
+ * RevnixPaywall do it by returning the outcome from `onPurchase`.
826
+ *
827
+ * `eventId` is the idempotency key and defaults to the display's `viewId`,
828
+ * which caps the report at one per display per event type. Pass one per
829
+ * occurrence (and reuse it across your own retries) to record every
830
+ * occurrence — e.g. a customer who fails, retries and fails again. */
831
+ logPaywallEvent(
832
+ event: PaywallInteraction,
833
+ viewId: string,
834
+ options?: {
835
+ placementKey?: string;
836
+ paywallId?: string;
837
+ productId?: string;
838
+ /** Store error code — `purchase_failed` / `error`. */
839
+ code?: string;
840
+ message?: string;
841
+ /** Idempotency key for this occurrence; defaults to `viewId`. */
842
+ eventId?: string;
843
+ }
844
+ ): void {
845
+ if (options?.placementKey === PREVIEW_PLACEMENT_KEY) return;
846
+ void this.customerIdPromise
847
+ .then((customerId) =>
848
+ this.request("POST", "/v1/paywalls/events", {
849
+ customerId,
850
+ viewId,
851
+ event,
852
+ occurredAt: Date.now(),
853
+ sdkVersion: SDK_VERSION,
854
+ ...(options?.eventId ? { eventId: options.eventId } : {}),
855
+ ...(options?.placementKey ? { placementKey: options.placementKey } : {}),
856
+ ...(options?.paywallId ? { paywallId: options.paywallId } : {}),
857
+ ...(options?.productId ? { productId: options.productId } : {}),
858
+ ...(options?.code ? { code: options.code } : {}),
859
+ ...(options?.message
860
+ ? { message: options.message.slice(0, 1024) }
861
+ : {}),
862
+ })
863
+ )
864
+ .catch((err) => this.diag("logPaywallEvent", err));
865
+ // REV-272: backing out of the store sheet is the `transaction_abandon`
866
+ // moment. Reuses the paywall.purchase_abandoned event just reported.
867
+ if (event === "purchase_abandoned") {
868
+ this.fireImplicitFromPaywall(
869
+ "transaction_abandon",
870
+ viewId,
871
+ options?.paywallId
872
+ );
873
+ }
874
+ }
875
+
876
+ /** Reports one ad impression's payout (POST /v1/ad-revenue).
877
+ * Fire-and-forget like the other beacons: never blocks, never throws. */
878
+ logAdRevenue(input: AdRevenue): void {
879
+ if (!Number.isFinite(input.revenue) || input.revenue <= 0) {
880
+ this.diag("logAdRevenue", new Error("revenue must be a finite number > 0"));
881
+ return;
882
+ }
883
+ void this.customerIdPromise
884
+ .then((customerId) =>
885
+ this.request("POST", "/v1/ad-revenue", {
886
+ customerId,
887
+ occurredAt: Date.now(),
888
+ revenue: input.revenue,
889
+ currency: input.currency,
890
+ ...(input.network ? { network: input.network } : {}),
891
+ ...(input.mediation ? { mediation: input.mediation } : {}),
892
+ ...(input.adUnit ? { adUnit: input.adUnit } : {}),
893
+ ...(input.placement ? { placement: input.placement } : {}),
894
+ ...(input.format ? { format: input.format } : {}),
895
+ ...(input.eventId ? { eventId: input.eventId } : {}),
896
+ })
897
+ )
898
+ .catch((err) => this.diag("logAdRevenue", err));
899
+ }
900
+
901
+ /** Reports one custom in-app event (POST /v1/events), not for purchases.
902
+ * Fire-and-forget like the other beacons: never blocks, never throws. */
903
+ track(
904
+ event: string,
905
+ properties?: Record<string, string | number | boolean>,
906
+ options?: { eventId?: string }
907
+ ): void {
908
+ if (!/^[a-z0-9_]{1,64}$/.test(event)) {
909
+ this.diag("track", new Error(`invalid event name: ${event}`));
910
+ return;
911
+ }
912
+ const eventId = options?.eventId ?? generateEventId();
913
+ void this.customerIdPromise
914
+ .then((customerId) =>
915
+ this.request("POST", "/v1/events", {
916
+ customerId,
917
+ event,
918
+ eventId,
919
+ occurredAt: Date.now(),
920
+ ...(properties ? { properties } : {}),
921
+ })
922
+ )
923
+ .catch((err) => this.diag("track", err));
924
+ }
925
+
926
+ /** PT11: forward an MMP's attribution callback (Adjust, AppsFlyer, …) so
927
+ * Revnix credits revenue to the right network/campaign. */
928
+ setAttribution(attribution: MmpAttribution): void {
929
+ const payload = JSON.stringify({
930
+ provider: attribution.provider,
931
+ network: attribution.network,
932
+ campaign: attribution.campaign ?? null,
933
+ adGroup: attribution.adGroup ?? null,
934
+ creative: attribution.creative ?? null,
935
+ });
936
+ void this.customerIdPromise
937
+ .then(async (customerId) => {
938
+ const last = await this.storage.getItem(LAST_ATTRIBUTION_KEY);
939
+ if (last === payload) return;
940
+ await this.request("POST", "/v1/attribution", {
941
+ customerId,
942
+ provider: attribution.provider,
943
+ network: attribution.network,
944
+ ...(attribution.campaign ? { campaign: attribution.campaign } : {}),
945
+ ...(attribution.adGroup ? { adGroup: attribution.adGroup } : {}),
946
+ ...(attribution.creative ? { creative: attribution.creative } : {}),
947
+ });
948
+ await this.storage.setItem(LAST_ATTRIBUTION_KEY, payload);
949
+ })
950
+ .catch((err) => this.diag("setAttribution", err));
951
+ }
952
+
953
+ /** Register this device's push token for uninstall measurement: Revnix
954
+ * sends a daily silent probe and records `app.uninstalled` when APNs/FCM
955
+ * report the token dead. iOS/Android only (no-op on web),
956
+ * fire-and-forget. */
957
+ setPushToken(token: string): void {
958
+ const trimmed = token.trim();
959
+ if (!trimmed) return;
960
+ const platform = this.platform;
961
+ if (platform !== "ios" && platform !== "android") {
962
+ this.diag(
963
+ "setPushToken",
964
+ new Error("push tokens are only collected on iOS and Android")
965
+ );
966
+ return;
967
+ }
968
+ void this.customerIdPromise
969
+ .then(async (customerId) => {
970
+ const payload = JSON.stringify({ customerId, platform, token: trimmed });
971
+ const last = await this.storage.getItem(LAST_PUSH_TOKEN_KEY);
972
+ if (last === payload) return;
973
+ await this.request("POST", "/v1/push-token", {
974
+ customerId,
975
+ platform,
976
+ token: trimmed,
977
+ });
978
+ await this.storage.setItem(LAST_PUSH_TOKEN_KEY, payload);
979
+ })
980
+ .catch((err) => this.diag("setPushToken", err));
981
+ }
982
+
983
+ /** REV-272: the two moments that happen ON a paywall, with the loop guard
984
+ * applied. `fromPaywallId` travels so the server can refuse to hand back
985
+ * the very paywall being dismissed — the backstop for a host that reports
986
+ * no placementKey, and for an operator who pointed the rule at the source
987
+ * paywall by hand. */
988
+ private fireImplicitFromPaywall(
989
+ placement: ImplicitPlacement,
990
+ viewId: string,
991
+ paywallId?: string
992
+ ): void {
993
+ if (this.implicitViewIds.has(viewId)) {
994
+ // One hop, never a chain: this display was itself implicit. The guard
995
+ // is never released: a double-tapped close reports two closes on one
996
+ // id (the server dedupes the event), and releasing on the first would
997
+ // let the second re-enter the loop. The set is bounded by implicit
998
+ // displays per process — a handful of ids, not a leak.
999
+ return;
1000
+ }
1001
+ this.fireImplicit(placement, {
1002
+ fromViewId: viewId,
1003
+ ...(paywallId ? { fromPaywallId: paywallId } : {}),
1004
+ });
1005
+ }
1006
+
1007
+ /** Set attributes on the current customer (REV-033 v2). Attributes are what
1008
+ * A/B-test audiences target, so this is how a mobile-only app becomes able
1009
+ * to run "US + UK users" tests: set `country`, `app_version`, `locale`, or
1010
+ * any custom key you want to segment on.
1011
+ *
1012
+ * Pass `null` as a value to delete a key. Awaits the write — unlike the
1013
+ * fire-and-forget telemetry calls — because the very next placement
1014
+ * resolve may depend on it, and silently racing the audience check would
1015
+ * make targeting look broken.
1016
+ *
1017
+ * Two server rules to know: `email` and `username` are reserved and only
1018
+ * settable with a secret key from your own backend, and any attribute your
1019
+ * backend already set is authoritative and cannot be changed from a device.
1020
+ * Both throw rather than partially applying. */
1021
+ async setAttributes(
1022
+ attributes: Record<string, string | number | null>
1023
+ ): Promise<void> {
1024
+ const customerId = await this.customerIdPromise;
1025
+ await this.request(
1026
+ "POST",
1027
+ `/v1/customers/${encodeURIComponent(customerId)}/attributes`,
1028
+ { attributes }
1029
+ );
1030
+ }
1031
+
1032
+ /** REV-272: hand the SDK the URL that opened your app, from wherever you
1033
+ * already receive it (Linking.getInitialURL / onNewIntent / your router).
1034
+ * This is the one implicit moment the SDK cannot see for itself — the URL
1035
+ * goes to your entry point, and an SDK intercepting it would be fighting
1036
+ * your router.
1037
+ *
1038
+ * Fire-and-forget: never blocks, never throws. An ordinary link is
1039
+ * always reported so its `link.*` facts land on the customer; it only
1040
+ * presents a paywall when implicit placements are on AND `deeplink_open`
1041
+ * is configured in the dashboard. A dashboard preview link is always
1042
+ * handed to `onImplicitPaywall`. */
1043
+ handleDeepLink(url: string): void {
1044
+ if (url === this.landingUrl) {
1045
+ this.landingUrl = null;
1046
+ return;
1047
+ }
1048
+ const previewToken = PREVIEW_TOKEN_RE.exec(url)?.[1];
1049
+ if (previewToken !== undefined) {
1050
+ void this.presentPreview(previewToken).catch((err) =>
1051
+ this.diag("preview", err)
1052
+ );
1053
+ return;
1054
+ }
1055
+ void this.rememberDeepLink(url);
1056
+ const extra = { url: url.slice(0, 1024) };
1057
+ const batch = this.launchBatch;
1058
+ if (batch === null) {
1059
+ this.fireImplicit("deeplink_open", extra);
1060
+ return;
1061
+ }
1062
+ // Delivered on the first frame, while the cold-start batch is still
1063
+ // deciding what to show: wait for it, and present only if it showed
1064
+ // nothing. The moment is still reported either way.
1065
+ void batch
1066
+ .then((launchPresented) =>
1067
+ this.triggerImplicit("deeplink_open", extra, !launchPresented)
1068
+ )
1069
+ .catch((err) => this.diag("implicit:deeplink_open", err));
1070
+ }
1071
+
1072
+ /** Most recent deep link seen via `handleDeepLink` or a delivered
1073
+ * deferred deep link. Null when none recorded. Never throws. */
1074
+ async getLastDeepLink(): Promise<LastDeepLink | null> {
1075
+ await this.lastDeepLinkWrite;
1076
+ try {
1077
+ const raw = await this.storage.getItem(LAST_DEEP_LINK_KEY);
1078
+ if (!raw) return null;
1079
+ const parsed = JSON.parse(raw);
1080
+ if (
1081
+ !parsed ||
1082
+ typeof parsed !== "object" ||
1083
+ typeof parsed.url !== "string" ||
1084
+ typeof parsed.receivedAt !== "number"
1085
+ ) {
1086
+ return null;
1087
+ }
1088
+ return { url: parsed.url, receivedAt: parsed.receivedAt };
1089
+ } catch (err) {
1090
+ this.diag("getLastDeepLink", err);
1091
+ return null;
1092
+ }
1093
+ }
1094
+
1095
+ /** AT11: the install-attribution verdict for this customer — `null` when
1096
+ * none has been recorded yet (a normal cold-start race, matching the
1097
+ * server's `installMatch: "unknown"`) or when the read fails. Fetches
1098
+ * fresh on every call rather than caching in memory, since the point is
1099
+ * to answer with whatever the server currently believes; a changed
1100
+ * answer also reaches `onAttribution`, so a host that only wants updates
1101
+ * need not call this at all. Never throws. */
1102
+ async getAttribution(): Promise<RevnixAttribution | null> {
1103
+ if (this.disposed) return null;
1104
+ try {
1105
+ const customerId = await this.customerIdPromise;
1106
+ const verdict = await this.request<AttributionResponse>(
1107
+ "GET",
1108
+ `/v1/customers/${encodeURIComponent(customerId)}/attribution`
1109
+ );
1110
+ if (verdict.installMatch === "unknown") return null;
1111
+ const attribution = verdict as RevnixAttribution;
1112
+ await this.deliverAttribution(attribution);
1113
+ return attribution;
1114
+ } catch (err) {
1115
+ this.diag("getAttribution", err);
1116
+ return null;
1117
+ }
1118
+ }
1119
+
1120
+ private refreshAttribution(): void {
1121
+ if (this.onAttribution) void this.getAttribution();
1122
+ }
1123
+
1124
+ private async deliverAttribution(attribution: RevnixAttribution): Promise<void> {
1125
+ const serialized = JSON.stringify(attribution);
1126
+ let cached: string | null = null;
1127
+ try {
1128
+ cached = await this.storage.getItem(ATTRIBUTION_KEY);
1129
+ } catch (err) {
1130
+ this.diag("onAttribution", err);
1131
+ }
1132
+ if (cached === serialized) return;
1133
+ try {
1134
+ await this.storage.setItem(ATTRIBUTION_KEY, serialized);
1135
+ } catch (err) {
1136
+ this.diag("onAttribution", err);
1137
+ }
1138
+ if (this.disposed || !this.onAttribution) return;
1139
+ try {
1140
+ this.onAttribution(attribution);
1141
+ } catch (err) {
1142
+ this.diag("onAttribution", err);
1143
+ }
1144
+ }
1145
+
1146
+ private rememberDeepLink(url: string): Promise<void> {
1147
+ const entry: LastDeepLink = { url, receivedAt: Date.now() };
1148
+ this.lastDeepLinkWrite = this.lastDeepLinkWrite.then(async () => {
1149
+ try {
1150
+ await this.storage.setItem(LAST_DEEP_LINK_KEY, JSON.stringify(entry));
1151
+ } catch (err) {
1152
+ this.diag("rememberDeepLink", err);
1153
+ }
1154
+ });
1155
+ return this.lastDeepLinkWrite;
1156
+ }
1157
+
1158
+ private async presentPreview(token: string): Promise<void> {
1159
+ await this.launchBatch;
1160
+ if (this.disposed) return;
1161
+ const resolution = await this.request<PlacementResolution>(
1162
+ "GET",
1163
+ `/v1/paywalls/preview/${token}`
1164
+ );
1165
+ if (this.disposed) return;
1166
+ if (!this.onImplicitPaywall) {
1167
+ this.diag("preview", new Error("no onImplicitPaywall handler configured"));
1168
+ return;
1169
+ }
1170
+ try {
1171
+ this.onImplicitPaywall({
1172
+ placement: PREVIEW_PLACEMENT_KEY,
1173
+ resolution: {
1174
+ ...resolution,
1175
+ status: "ok",
1176
+ revision: resolution.revision ?? 0,
1177
+ stale: false,
1178
+ fetchedAt: Date.now(),
1179
+ skipReason: null,
1180
+ recorded: false,
1181
+ },
1182
+ });
1183
+ } catch (err) {
1184
+ this.diag("onImplicitPaywall", err);
1185
+ }
1186
+ }
1187
+
1188
+ /**
1189
+ * REV-283: hand over the Android Play Store install referrer.
1190
+ *
1191
+ * The deferred half of link attribution. A deep link only ever reaches an
1192
+ * app that already exists, which leaves out the customers an ad actually
1193
+ * won — they saw it, had no app, and installed one. Google Play carries a
1194
+ * referrer string through that install, so on Android the answer is exact
1195
+ * rather than guessed. (Apple ships no equivalent; that half stays an
1196
+ * attribution provider's job.)
1197
+ *
1198
+ * Read it yourself with the Play Install Referrer library and pass the raw
1199
+ * string — `utm_source=instagram&utm_medium=cpc` — exactly as you received
1200
+ * it. The SDK cannot read it for you: it is an Android API behind a service
1201
+ * binding, and a cross-platform SDK has no business linking it.
1202
+ *
1203
+ * Safe to call late, and safe to call twice. The Play API answers
1204
+ * asynchronously, so the install has usually been reported already; the
1205
+ * server absorbs the duplicate and seeds the campaign facts from it anyway.
1206
+ * They are fill-only, so a deep link the customer has opened since is never
1207
+ * overwritten.
1208
+ *
1209
+ * Fire-and-forget: never blocks, never throws.
1210
+ */
1211
+ handleInstallReferrer(referrer: string): void {
1212
+ if (this.disposed || referrer.trim().length === 0) return;
1213
+ // An id that changes every launch would file the campaign against a
1214
+ // customer nobody will see again, and fill the Channel axis with
1215
+ // one-launch phantoms. Same guard, same reason, as triggerImplicit.
1216
+ if (this.ephemeralAnonId) return;
1217
+ void this.customerIdPromise
1218
+ .then((customerId) =>
1219
+ this.request<InstallResponse>("POST", "/v1/installs", {
1220
+ customerId,
1221
+ occurredAt: Date.now(),
1222
+ sdkVersion: SDK_VERSION,
1223
+ installReferrer: referrer.slice(0, 1024),
1224
+ ...this.installMetadata,
1225
+ })
1226
+ )
1227
+ .then((body) => {
1228
+ this.refreshAttribution();
1229
+ return this.deliverDeferredDeepLink(body.deferredDeepLink);
1230
+ })
1231
+ .catch((err) => this.diag("handleInstallReferrer", err));
1232
+ }
1233
+
1234
+ /**
1235
+ * AT9: the Apple Search Ads half of install attribution. `AAAttribution
1236
+ * .attributionToken()` is an iOS framework call with no JavaScript
1237
+ * binding, so a React Native host mints the token in a native module and
1238
+ * hands it here — this package cannot mint it itself.
1239
+ *
1240
+ * Unlike `handleInstallReferrer`, this resolves to the server's verdict
1241
+ * (`InstallResponse.appleAttribution`) rather than returning void, because
1242
+ * a freshly minted token is unregistered on Apple's side for a few
1243
+ * seconds and the server reports that as `"pending"`. The native iOS SDK
1244
+ * retries on its own in that case; a host driving this manually needs the
1245
+ * verdict to know whether a fresh token on the next cold start is worth
1246
+ * trying. `"resolved"` (Apple named a campaign) and `"organic"` (Apple
1247
+ * answered, not an ASA install) are final. Resolves to `undefined` when a
1248
+ * guard held or the request failed — safe to leave unawaited.
1249
+ *
1250
+ * Same guards as `handleInstallReferrer`, same reasons: bails when
1251
+ * disposed, when the trimmed token is empty, and when `ephemeralAnonId` is
1252
+ * set, since an id that changes every launch would file the campaign
1253
+ * against a customer nobody will see again.
1254
+ */
1255
+ handleAttributionToken(token: string): Promise<string | undefined> {
1256
+ if (this.disposed || token.trim().length === 0) return Promise.resolve(undefined);
1257
+ if (this.ephemeralAnonId) return Promise.resolve(undefined);
1258
+ return this.customerIdPromise
1259
+ .then((customerId) =>
1260
+ this.request<InstallResponse>("POST", "/v1/installs", {
1261
+ customerId,
1262
+ occurredAt: Date.now(),
1263
+ sdkVersion: SDK_VERSION,
1264
+ attributionToken: token.slice(0, 2048),
1265
+ ...this.installMetadata,
1266
+ })
1267
+ )
1268
+ .then((body) => {
1269
+ this.refreshAttribution();
1270
+ this.deliverDeferredDeepLink(body.deferredDeepLink);
1271
+ return body.appleAttribution;
1272
+ })
1273
+ .catch((err) => {
1274
+ this.diag("handleAttributionToken", err);
1275
+ return undefined;
1276
+ });
1277
+ }
1278
+
1279
+ /** Unwraps a link an email provider rewrote through its click-tracking
1280
+ * domain into the deep link it points at. Never throws; returns `url`
1281
+ * unchanged on any failure. The result can still be an http(s) URL if the
1282
+ * chain could not be unwrapped, so check the scheme before routing on it. */
1283
+ async resolveDeepLink(url: string): Promise<string> {
1284
+ const trimmed = url.trim();
1285
+ if (
1286
+ trimmed.length === 0 ||
1287
+ trimmed.length > 1024 ||
1288
+ !/^https?:\/\//i.test(trimmed) ||
1289
+ this.disposed
1290
+ ) {
1291
+ return url;
1292
+ }
1293
+ try {
1294
+ const body = await this.request<{ url: string }>(
1295
+ "GET",
1296
+ `/v1/links/resolve?url=${encodeURIComponent(trimmed)}`
1297
+ );
1298
+ return typeof body.url === "string" && body.url.length > 0 ? body.url : url;
1299
+ } catch (err) {
1300
+ this.diag("resolveDeepLink", err);
1301
+ return url;
1302
+ }
1303
+ }
1304
+
1305
+ private implicitConfig(): Promise<ReadonlySet<string>> {
1306
+ if (this.implicitConfigPromise === null && Date.now() < this.implicitConfigRetryAt) {
1307
+ return Promise.resolve(new Set<string>());
1308
+ }
1309
+ return (this.implicitConfigPromise ??= this.customerIdPromise
1310
+ .then((customerId) =>
1311
+ this.request<{ implicitPlacements?: string[] }>(
1312
+ "GET",
1313
+ // The id does not change the answer — this route is
1314
+ // customer-independent — it only picks the server's rate-limit
1315
+ // bucket, so one busy app cannot 429 its own fleet off the feature.
1316
+ `/v1/config?customer=${encodeURIComponent(customerId)}`
1317
+ )
1318
+ )
1319
+ .then((body) => new Set(body.implicitPlacements ?? []))
1320
+ .catch((err) => {
1321
+ this.diag("implicitConfig", err);
1322
+ // Not memoised: an offline cold start must not disable every implicit
1323
+ // moment for the rest of the session. Retried after a short hold
1324
+ // rather than on the very next moment, so an offline burst of paywall
1325
+ // interactions costs one probe, not one each.
1326
+ this.implicitConfigPromise = null;
1327
+ this.implicitConfigRetryAt = Date.now() + IMPLICIT_CONFIG_RETRY_MS;
1328
+ return new Set<string>();
1329
+ }));
1330
+ }
1331
+
1332
+ /** REV-272: report one implicit moment and present whatever it resolves to.
1333
+ * Fire-and-forget in every direction — this runs on an app launch and on
1334
+ * every return to the foreground, so it must never be able to throw into
1335
+ * the host or delay a frame. */
1336
+ private fireImplicit(
1337
+ placement: ImplicitPlacement,
1338
+ extra?: Record<string, string | number>
1339
+ ): void {
1340
+ void this.triggerImplicit(placement, extra, true).catch((err) =>
1341
+ this.diag(`implicit:${placement}`, err)
1342
+ );
1343
+ }
1344
+
1345
+ /** The trigger itself. Resolves to true when a paywall was handed to the
1346
+ * host. `present` false still reports the moment (its ledger event is a
1347
+ * fact either way) but hands nothing over — how the launch batch keeps a
1348
+ * cold start to ONE paywall. Never throws past its caller's catch. */
1349
+ private async triggerImplicit(
1350
+ placement: ImplicitPlacement,
1351
+ extra: Record<string, string | number> | undefined,
1352
+ present: boolean
1353
+ ): Promise<boolean> {
1354
+ if (this.disposed) return false;
1355
+ const configured = this.implicitEnabled
1356
+ ? await this.implicitConfig()
1357
+ : new Set<string>();
1358
+ const resolve = configured.has(placement);
1359
+ if (!resolve && placement !== "deeplink_open") return false;
1360
+ const customerId = await this.customerIdPromise;
1361
+ if (this.disposed || this.ephemeralAnonId) return false;
1362
+ const deviceHeader = await this.deviceHeaderPromise;
1363
+ const result = await this.request<ImplicitResolution>(
1364
+ "POST",
1365
+ "/v1/placements/triggered",
1366
+ {
1367
+ customerId,
1368
+ placement,
1369
+ // One id per occurrence: retries of the same launch are absorbed,
1370
+ // a genuine second launch counts separately. Required server-side
1371
+ // for the three moments that append an event.
1372
+ occurrenceId: generateViewId(),
1373
+ occurredAt: Date.now(),
1374
+ sdkVersion: SDK_VERSION,
1375
+ ...(resolve ? null : { resolve: false }),
1376
+ ...extra,
1377
+ },
1378
+ deviceHeader ? { [DEVICE_HEADER]: deviceHeader } : undefined
1379
+ );
1380
+ if (this.disposed || !resolve || !result.paywall || !present)
1381
+ return false;
1382
+ try {
1383
+ this.onImplicitPaywall?.({ placement, resolution: result });
1384
+ } catch (err) {
1385
+ // The host's presentation code must never take the SDK down.
1386
+ this.diag("onImplicitPaywall", err);
1387
+ }
1388
+ return true;
1389
+ }
1390
+
1391
+ private async consumePreviousSessionMs(): Promise<
1392
+ Record<string, string | number> | undefined
1393
+ > {
1394
+ const [startedRaw, lastBgRaw] = await Promise.all([
1395
+ this.storage.getItem(SESSION_STARTED_AT_KEY),
1396
+ this.storage.getItem(LAST_BACKGROUND_AT_KEY),
1397
+ ]);
1398
+ const started = Number.parseInt(startedRaw ?? "", 10);
1399
+ const lastBg = Number.parseInt(lastBgRaw ?? "", 10);
1400
+ const extra =
1401
+ Number.isFinite(started) &&
1402
+ Number.isFinite(lastBg) &&
1403
+ lastBg >= started
1404
+ ? { previousSessionMs: lastBg - started }
1405
+ : undefined;
1406
+ await this.storage.setItem(SESSION_STARTED_AT_KEY, String(Date.now()));
1407
+ return extra;
1408
+ }
1409
+
1410
+ /** REV-272: the launch-time moments, plus the foreground listener that
1411
+ * `session_start` is built on.
1412
+ *
1413
+ * A cold start is always both a launch AND a session — an operator who
1414
+ * configured only `session_start` still wants the first one — and it is an
1415
+ * install too when this launch generated the anonymous id. All three are
1416
+ * independent placements, so an app may have any subset configured and the
1417
+ * others cost nothing. */
1418
+ private startImplicit(configured: RevnixConfig["lifecycle"]): void {
1419
+ // freshInstall is set inside loadCustomerId, which the constructor kicked
1420
+ // off but did not await — so read it after that promise settles rather
1421
+ // than now, when it is still false on a genuine first launch.
1422
+ //
1423
+ // The three launch moments run SEQUENTIALLY, most specific first, and only
1424
+ // the first that resolves to a paywall is handed to the host. All three
1425
+ // are still reported — a launch is a launch whether or not a paywall
1426
+ // showed — but an app that configured all three must not have three
1427
+ // paywalls pushed onto its first frame.
1428
+ // Subscribed BEFORE the batch, which can take a full network timeout per
1429
+ // moment when offline: a customer who backgrounds the app during that
1430
+ // window and comes back an hour later is a session, and missing the
1431
+ // background transition would lose it.
1432
+ if (configured !== false) {
1433
+ const lifecycle: RevnixLifecycle | null =
1434
+ configured ?? browserLifecycle();
1435
+ if (lifecycle) {
1436
+ try {
1437
+ this.lifecycleUnsubscribe = lifecycle.onStateChange((state) =>
1438
+ this.onAppState(state)
1439
+ );
1440
+ } catch (err) {
1441
+ // A broken adapter costs session detection, not the client.
1442
+ this.diag("lifecycleSubscribe", err);
1443
+ }
1444
+ }
1445
+ }
1446
+
1447
+ const sessionExtra = this.consumePreviousSessionMs().catch(
1448
+ (err: unknown) => {
1449
+ this.diag("session", err);
1450
+ return undefined;
1451
+ }
1452
+ );
1453
+ this.launchBatch = this.customerIdPromise
1454
+ .then(async () => {
1455
+ if (this.disposed) return false;
1456
+ let presented = false;
1457
+ const moments: ImplicitPlacement[] = [
1458
+ ...(this.freshInstall ? (["app_install"] as const) : []),
1459
+ "app_launch",
1460
+ "session_start",
1461
+ ];
1462
+ for (const placement of moments) {
1463
+ const shown: boolean = await this.triggerImplicit(
1464
+ placement,
1465
+ placement === "session_start" ? await sessionExtra : undefined,
1466
+ !presented
1467
+ ).catch((err: unknown) => {
1468
+ this.diag(`implicit:${placement}`, err);
1469
+ return false;
1470
+ });
1471
+ presented ||= shown;
1472
+ }
1473
+ return presented;
1474
+ })
1475
+ .catch((err) => {
1476
+ this.diag("implicitLaunch", err);
1477
+ return false;
1478
+ })
1479
+ .finally(() => {
1480
+ this.launchBatch = null;
1481
+ });
1482
+ }
1483
+
1484
+ private onAppState(state: RevnixAppState): void {
1485
+ if (this.disposed) return;
1486
+ if (state === "background") {
1487
+ // First report wins: a platform that repeats "background" (a paused
1488
+ // Activity re-reporting) must not keep resetting the clock forward.
1489
+ if (this.lastBackgroundAt === null) {
1490
+ const now = Date.now();
1491
+ this.lastBackgroundAt = now;
1492
+ try {
1493
+ void Promise.resolve(
1494
+ this.storage.setItem(LAST_BACKGROUND_AT_KEY, String(now))
1495
+ ).catch((err) => this.diag("session", err));
1496
+ } catch (err) {
1497
+ this.diag("session", err);
1498
+ }
1499
+ }
1500
+ return;
1501
+ }
1502
+ const since = this.lastBackgroundAt;
1503
+ this.lastBackgroundAt = null;
1504
+ // A foreground with no background before it is the launch itself, which
1505
+ // the batch above already counted — or a duplicate report.
1506
+ if (since === null) return;
1507
+ // An app switch is not a session.
1508
+ if (Date.now() - since >= this.sessionTimeoutMs) {
1509
+ // A new session is also when the memoised config is re-asked: the
1510
+ // server promises an operator's change shows up within ~30s, and a
1511
+ // process backgrounded for days would otherwise keep firing a moment
1512
+ // the operator turned off — or never fire one they turned on — until
1513
+ // the next cold start.
1514
+ this.implicitConfigPromise = null;
1515
+ void this.consumePreviousSessionMs()
1516
+ .catch((err) => {
1517
+ this.diag("session", err);
1518
+ return undefined;
1519
+ })
1520
+ .then((extra) => this.fireImplicit("session_start", extra));
1521
+ }
1522
+ }
1523
+
1524
+ /** REV-204 (L10): stop this client's background work (purchase retries,
1525
+ * read-your-writes polls). Called automatically when Revnix.configure()
1526
+ * replaces the singleton; call it yourself when discarding a manually
1527
+ * constructed client. */
1528
+ dispose(): void {
1529
+ this.disposed = true;
1530
+ // REV-272: a replaced client (dev fast-refresh re-running configure())
1531
+ // would otherwise keep a foreground listener alive and mint a session on
1532
+ // every return alongside its successor.
1533
+ this.lifecycleUnsubscribe?.();
1534
+ this.lifecycleUnsubscribe = null;
1535
+ }
1536
+
1537
+ /** A block paywall reporting a paint string it could not read. Routed to the
1538
+ * same sink as every other swallowed failure, so a host that already wired
1539
+ * `onDiagnostic` needs no new wiring to see render fallbacks. */
1540
+ reportRenderDiagnostic(message: string): void {
1541
+ this.diag("paywall.render", new Error(message));
1542
+ }
1543
+
1544
+ // REV-200 (M26): record one background failure. Never throws.
1545
+ private diag(op: string, error: unknown): void {
1546
+ this.bgFailures += 1;
1547
+ try {
1548
+ this.onDiagnostic?.({ op, error, at: Date.now() });
1549
+ } catch {
1550
+ // The reporter itself must never take the SDK down.
1551
+ }
1552
+ }
1553
+
1554
+ private async deliverDeferredDeepLink(link: unknown): Promise<void> {
1555
+ if (this.disposed || !this.onDeferredDeepLink || this.deferredDeepLinkClaimed) return;
1556
+ if (typeof link !== "object" || link === null) return;
1557
+ const { url, match } = link as { url?: unknown; match?: unknown };
1558
+ if (typeof url !== "string" || (match !== "exact" && match !== "probabilistic")) return;
1559
+ this.deferredDeepLinkClaimed = true;
1560
+ let delivered: string | null = null;
1561
+ try {
1562
+ delivered = await this.storage.getItem(DEFERRED_DEEP_LINK_DELIVERED_KEY);
1563
+ } catch (err) {
1564
+ this.diag("onDeferredDeepLink", err);
1565
+ }
1566
+ if (delivered !== null) return;
1567
+ try {
1568
+ await this.storage.setItem(DEFERRED_DEEP_LINK_DELIVERED_KEY, "1");
1569
+ } catch (err) {
1570
+ this.diag("onDeferredDeepLink", err);
1571
+ }
1572
+ if (this.disposed) return;
1573
+ void this.rememberDeepLink(url);
1574
+ try {
1575
+ this.onDeferredDeepLink(url, match);
1576
+ } catch (err) {
1577
+ this.diag("onDeferredDeepLink", err);
1578
+ }
1579
+ }
1580
+
1581
+ private async loadCustomerId(initial?: string): Promise<string> {
1582
+ // REV-204 (L10): a throwing storage adapter (quota, corruption) used to
1583
+ // reject customerIdPromise FOREVER — every subsequent API call then
1584
+ // failed on the await of an id that exists in memory. Storage problems
1585
+ // are reported (M26) and survived; the id lives on for the session.
1586
+ if (initial) {
1587
+ try {
1588
+ await this.storage.setItem(CUSTOMER_ID_KEY, initial);
1589
+ } catch (err) {
1590
+ this.diag("storageWrite", err);
1591
+ }
1592
+ return initial;
1593
+ }
1594
+ let stored: string | null = null;
1595
+ try {
1596
+ stored = await this.storage.getItem(CUSTOMER_ID_KEY);
1597
+ } catch (err) {
1598
+ this.diag("storageRead", err);
1599
+ }
1600
+ if (stored) return stored;
1601
+ const anonymousId = generateAnonymousId();
1602
+ this.ephemeralAnonId = !this.persistentStorage;
1603
+ // REV-272: what makes `app_install` an install rather than a launch.
1604
+ this.freshInstall = true;
1605
+ try {
1606
+ await this.storage.setItem(CUSTOMER_ID_KEY, anonymousId);
1607
+ } catch (err) {
1608
+ this.diag("storageWrite", err);
1609
+ }
1610
+ // A freshly generated id IS the install signal: first launch on this
1611
+ // device (or a reinstall that wiped storage). logout() deliberately does
1612
+ // not report — a new anonymous session is not a new install.
1613
+ if (this.reportInstalls) this.reportInstall(anonymousId);
1614
+ return anonymousId;
1615
+ }
1616
+
1617
+ /** Fire-and-forget install report (REV-093). Never blocks startup and
1618
+ * never throws — the backend absorbs retries idempotently per id. */
1619
+ private reportInstall(customerId: string): void {
1620
+ void this.request<InstallResponse>("POST", "/v1/installs", {
1621
+ customerId,
1622
+ occurredAt: Date.now(),
1623
+ sdkVersion: SDK_VERSION,
1624
+ ...this.installMetadata,
1625
+ })
1626
+ .then((body) => {
1627
+ if (!this.ephemeralAnonId) this.refreshAttribution();
1628
+ return this.deliverDeferredDeepLink(body.deferredDeepLink);
1629
+ })
1630
+ .catch((err) => this.diag("reportInstall", err));
1631
+ }
1632
+
1633
+ /** REV-268: assemble and encode the device facts once. Runtime-detected
1634
+ * facts first, the app's `device` over them, then the three the SDK owns.
1635
+ * `installedAt` is the first launch this storage ever saw — written on
1636
+ * that launch and read back on every later one — and `firstOpen` is true
1637
+ * for the whole of that first session. Both are skipped on non-persistent
1638
+ * storage: memory-only storage would make every launch a first open,
1639
+ * which is the same phantom-new-customer hazard the M5 install guard
1640
+ * exists for. */
1641
+ private async buildDeviceHeader(
1642
+ configured: DeviceFacts | false | undefined
1643
+ ): Promise<string | null> {
1644
+ if (configured === false) return null;
1645
+ const facts: DeviceFacts = {
1646
+ ...detectDeviceFacts(),
1647
+ // Pre-REV-268 apps described the device for the install report only;
1648
+ // those two facts are still true facts, so they count.
1649
+ ...(this.installMetadata?.platform !== undefined
1650
+ ? { platform: this.installMetadata.platform }
1651
+ : {}),
1652
+ ...(this.installMetadata?.appVersion !== undefined
1653
+ ? { appVersion: this.installMetadata.appVersion }
1654
+ : {}),
1655
+ ...(configured ?? {}),
1656
+ };
1657
+ let installedAt: number | undefined;
1658
+ let firstOpen: boolean | undefined;
1659
+ if (this.persistentStorage) {
1660
+ try {
1661
+ const stored = await this.storage.getItem(INSTALLED_AT_KEY);
1662
+ const parsed = stored === null ? NaN : Number(stored);
1663
+ if (Number.isFinite(parsed) && parsed > 0) {
1664
+ installedAt = parsed;
1665
+ firstOpen = false;
1666
+ } else {
1667
+ installedAt = Date.now();
1668
+ firstOpen = true;
1669
+ await this.storage.setItem(INSTALLED_AT_KEY, String(installedAt));
1670
+ }
1671
+ } catch (err) {
1672
+ // Storage trouble costs the two SDK-owned facts, nothing else.
1673
+ this.diag("storageWrite", err);
1674
+ installedAt = undefined;
1675
+ firstOpen = undefined;
1676
+ }
1677
+ }
1678
+ return encodeDeviceHeader(
1679
+ compactPayload({
1680
+ ...facts,
1681
+ sdkVersion: SDK_VERSION,
1682
+ installedAt,
1683
+ firstOpen,
1684
+ })
1685
+ );
1686
+ }
1687
+
1688
+ /** REV-268: the decoded facts this client sends — for tests and for apps
1689
+ * that want to show what Revnix knows. `null` when disabled. */
1690
+ async getDeviceHeader(): Promise<string | null> {
1691
+ return this.deviceHeaderPromise;
1692
+ }
1693
+
1694
+ private async setCustomerId(customerId: string): Promise<void> {
1695
+ // Callers pass stable ids (identify/canonical adoption) — logout()
1696
+ // re-flags its generated anon id itself.
1697
+ this.ephemeralAnonId = false;
1698
+ this.customerIdPromise = Promise.resolve(customerId);
1699
+ await this.storage.setItem(CUSTOMER_ID_KEY, customerId);
1700
+ }
1701
+
1702
+ // ——— C7: entitlement cache (best-effort; storage failures never throw) ———
1703
+
1704
+ // REV-204 (L10): the cache used to be single-slot — writing customer B
1705
+ // dropped customer A, so the first offline launch after identify()/logout()
1706
+ // had no gate at all. It is a small per-customer map now (legacy single-slot
1707
+ // payloads migrate on read); ENTITLEMENTS_CACHE_MAX bounds it because
1708
+ // logout() mints a fresh anonymous id every time.
1709
+ private async readEntitlementsMap(): Promise<
1710
+ Record<string, { data: CustomerEntitlements; fetchedAt: number }>
1711
+ > {
1712
+ try {
1713
+ const raw = await this.storage.getItem(ENTITLEMENTS_CACHE_KEY);
1714
+ if (!raw) return {};
1715
+ const parsed = JSON.parse(raw);
1716
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1717
+ return {};
1718
+ }
1719
+ // Pre-REV-204 single-slot shape: { customerId, data, fetchedAt }.
1720
+ if ("customerId" in parsed && "data" in parsed) {
1721
+ const legacy = parsed as {
1722
+ customerId: string;
1723
+ data: CustomerEntitlements;
1724
+ fetchedAt: number;
1725
+ };
1726
+ return {
1727
+ [legacy.customerId]: {
1728
+ data: legacy.data,
1729
+ fetchedAt: legacy.fetchedAt,
1730
+ },
1731
+ };
1732
+ }
1733
+ return parsed as Record<
1734
+ string,
1735
+ { data: CustomerEntitlements; fetchedAt: number }
1736
+ >;
1737
+ } catch (err) {
1738
+ this.diag("storageRead", err);
1739
+ return {};
1740
+ }
1741
+ }
1742
+
1743
+ private async writeEntitlementsCache(
1744
+ customerId: string,
1745
+ data: CustomerEntitlements
1746
+ ): Promise<void> {
1747
+ try {
1748
+ const map = await this.readEntitlementsMap();
1749
+ map[customerId] = { data, fetchedAt: Date.now() };
1750
+ const ids = Object.keys(map);
1751
+ if (ids.length > ENTITLEMENTS_CACHE_MAX) {
1752
+ ids
1753
+ .sort((a, b) => (map[a]?.fetchedAt ?? 0) - (map[b]?.fetchedAt ?? 0))
1754
+ .slice(0, ids.length - ENTITLEMENTS_CACHE_MAX)
1755
+ .forEach((id) => delete map[id]);
1756
+ }
1757
+ await this.storage.setItem(ENTITLEMENTS_CACHE_KEY, JSON.stringify(map));
1758
+ } catch (err) {
1759
+ // Caching is best-effort — a failure to persist must never break a
1760
+ // read. REV-200 (M26): but a broken storage adapter is exactly the
1761
+ // fleet-wide failure that used to produce pure silence — report it.
1762
+ this.diag("storageWrite", err);
1763
+ }
1764
+ }
1765
+
1766
+ private async readEntitlementsCache(
1767
+ customerId: string
1768
+ ): Promise<{ data: CustomerEntitlements; fetchedAt: number } | null> {
1769
+ const map = await this.readEntitlementsMap();
1770
+ return map[customerId] ?? null;
1771
+ }
1772
+
1773
+ // ——— REV-157: placement cache (best-effort; storage failures never throw) ———
1774
+ // One JSON map keyed by placementKey. A resolution WITHOUT an experiment is
1775
+ // app-scoped and servable to any customer; one WITH an experiment embeds
1776
+ // the assigned variant (offering/paywall/experiment) and belongs to the
1777
+ // customer it was fetched for — reads treat it as a miss for anyone else
1778
+ // (REV-219; the "stamp + check ownership on read" idiom of
1779
+ // freshEntitlements). Entries are not cleared on logout/identify so the
1780
+ // common no-experiment offline paywall keeps working across identity
1781
+ // changes.
1782
+
1783
+ private async readPlacementMap(): Promise<
1784
+ Record<
1785
+ string,
1786
+ { data: PlacementResolution; fetchedAt: number; customerId?: string }
1787
+ >
1788
+ > {
1789
+ try {
1790
+ const raw = await this.storage.getItem(PLACEMENTS_CACHE_KEY);
1791
+ if (!raw) return {};
1792
+ const parsed = JSON.parse(raw);
1793
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
1794
+ ? (parsed as Record<
1795
+ string,
1796
+ {
1797
+ data: PlacementResolution;
1798
+ fetchedAt: number;
1799
+ customerId?: string;
1800
+ }
1801
+ >)
1802
+ : {};
1803
+ } catch {
1804
+ return {};
1805
+ }
1806
+ }
1807
+
1808
+ private async readPlacementCache(
1809
+ placementKey: string,
1810
+ customerId: string
1811
+ ): Promise<{ data: PlacementResolution; fetchedAt: number } | null> {
1812
+ const map = await this.readPlacementMap();
1813
+ const entry = map[placementKey];
1814
+ if (!entry) return null;
1815
+ // An experiment-assigned resolution must never cross customers — the
1816
+ // wrong variant (and its pricing) would be shown and the purchase
1817
+ // attributed to an assignment the server never made for this caller.
1818
+ // Pre-REV-219 entries carry no customerId and no experiment; they pass.
1819
+ if (
1820
+ entry.data.experiment != null &&
1821
+ entry.customerId !== undefined &&
1822
+ entry.customerId !== customerId
1823
+ ) {
1824
+ return null;
1825
+ }
1826
+ return entry;
1827
+ }
1828
+
1829
+ private async writePlacementCache(
1830
+ placementKey: string,
1831
+ data: PlacementResolution,
1832
+ customerId: string
1833
+ ): Promise<void> {
1834
+ try {
1835
+ const map = await this.readPlacementMap();
1836
+ map[placementKey] = { data, fetchedAt: Date.now(), customerId };
1837
+ await this.storage.setItem(PLACEMENTS_CACHE_KEY, JSON.stringify(map));
1838
+ } catch (err) {
1839
+ // Best-effort, but reported (REV-200 M26).
1840
+ this.diag("storageWrite", err);
1841
+ }
1842
+ }
1843
+
1844
+ // ——— C7: persisted purchase retry queue ———
1845
+
1846
+ // Serializes every queue read-modify-write. Storage adapters may be async,
1847
+ // so two interleaved mutations (an enqueue during the retry pass, retry
1848
+ // passes from constructor + a manual call) would otherwise clobber each
1849
+ // other's writes and lose purchases.
1850
+ private queueLock: Promise<void> = Promise.resolve();
1851
+
1852
+ private mutateQueue(
1853
+ mutate: (queue: PendingPurchase[]) => PendingPurchase[]
1854
+ ): Promise<void> {
1855
+ const run = this.queueLock.then(async () => {
1856
+ const queue = await this.readPurchaseQueue();
1857
+ const next = mutate(queue);
1858
+ if (next !== queue) await this.writePurchaseQueue(next);
1859
+ });
1860
+ // The chain must survive a failed mutation or every later one deadlocks.
1861
+ this.queueLock = run.catch(() => {});
1862
+ return run;
1863
+ }
1864
+
1865
+ private async readPurchaseQueue(): Promise<PendingPurchase[]> {
1866
+ try {
1867
+ const raw = await this.storage.getItem(PENDING_PURCHASES_KEY);
1868
+ if (!raw) return [];
1869
+ const parsed = JSON.parse(raw);
1870
+ return Array.isArray(parsed) ? (parsed as PendingPurchase[]) : [];
1871
+ } catch {
1872
+ return [];
1873
+ }
1874
+ }
1875
+
1876
+ private async writePurchaseQueue(queue: PendingPurchase[]): Promise<void> {
1877
+ try {
1878
+ await this.storage.setItem(PENDING_PURCHASES_KEY, JSON.stringify(queue));
1879
+ } catch (err) {
1880
+ // Best-effort, but reported (REV-200 M26) — a queue that cannot
1881
+ // persist is a purchase that will not survive a restart.
1882
+ this.diag("storageWrite", err);
1883
+ }
1884
+ }
1885
+
1886
+ private enqueuePurchase(
1887
+ customerId: string,
1888
+ input: RegisterPurchaseInput
1889
+ ): Promise<void> {
1890
+ // Repeated failures of the same purchase collapse to one queued entry.
1891
+ return this.mutateQueue((queue) =>
1892
+ queue.some((q) => pendingKey(q.input) === pendingKey(input))
1893
+ ? queue
1894
+ : [...queue, { customerId, input }]
1895
+ );
1896
+ }
1897
+
1898
+ private async request<T>(
1899
+ method: "GET" | "POST",
1900
+ path: string,
1901
+ body?: unknown,
1902
+ extraHeaders?: Record<string, string>
1903
+ ): Promise<T> {
1904
+ // C7: abort the request after requestTimeoutMs so a slow or dead backend
1905
+ // can't hang the app. AbortController is guarded for very old runtimes.
1906
+ const controller =
1907
+ typeof AbortController !== "undefined" ? new AbortController() : undefined;
1908
+ const timer = controller
1909
+ ? setTimeout(() => controller.abort(), this.requestTimeoutMs)
1910
+ : undefined;
1911
+
1912
+ // REV-200 (M26): piggyback the background-failure counter on this
1913
+ // request so field pain reaches the backend's request logs. Cleared only
1914
+ // for the count actually sent, and only on a delivered response —
1915
+ // failures that pile up mid-flight ride the next one.
1916
+ const reportedBgFailures = this.bgFailures;
1917
+ let res: Response;
1918
+ try {
1919
+ res = await this.fetchImpl(`${this.baseUrl}${path}`, {
1920
+ method,
1921
+ headers: {
1922
+ Authorization: `Bearer ${this.apiKey}`,
1923
+ // L3: identify the SDK on EVERY request (reads included) so the
1924
+ // backend can triage by version without depending on body fields.
1925
+ "X-Revnix-SDK": `revnix-react/${SDK_VERSION}`,
1926
+ ...(reportedBgFailures > 0
1927
+ ? { "X-Revnix-Bg-Failures": String(reportedBgFailures) }
1928
+ : {}),
1929
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
1930
+ ...(extraHeaders ?? {}),
1931
+ },
1932
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
1933
+ ...(controller ? { signal: controller.signal } : {}),
1934
+ });
1935
+ } catch (err) {
1936
+ if (
1937
+ controller?.signal.aborted ||
1938
+ (err instanceof Error && err.name === "AbortError")
1939
+ ) {
1940
+ throw new RevnixTimeoutError(
1941
+ `Revnix request to ${path} timed out after ${this.requestTimeoutMs}ms`
1942
+ );
1943
+ }
1944
+ throw new RevnixNetworkError(
1945
+ err instanceof Error ? err.message : "network request failed"
1946
+ );
1947
+ } finally {
1948
+ if (timer !== undefined) clearTimeout(timer);
1949
+ }
1950
+
1951
+ // The counter reached the server (any HTTP status is a delivery).
1952
+ this.bgFailures = Math.max(0, this.bgFailures - reportedBgFailures);
1953
+
1954
+ let parsed: unknown;
1955
+ try {
1956
+ parsed = await res.json();
1957
+ } catch {
1958
+ parsed = undefined;
1959
+ }
1960
+ // REV-204 (L10): a 200 with a non-JSON body used to come back as
1961
+ // `undefined as T` — registerPurchase then threw a TypeError AFTER the
1962
+ // server had already recorded the purchase. Fail as a typed, retryable
1963
+ // error instead (a proxy/captive portal mangling the body is transient).
1964
+ if (res.ok && parsed === undefined) {
1965
+ throw new RevnixError(
1966
+ 0,
1967
+ `Revnix: server returned a non-JSON response for ${path}`
1968
+ );
1969
+ }
1970
+ if (!res.ok) {
1971
+ const message =
1972
+ (parsed as { error?: string } | undefined)?.error ??
1973
+ `Revnix request failed (${res.status})`;
1974
+ if (res.status === 409 && path === "/v1/purchases") {
1975
+ throw new PurchaseBlockedError(message);
1976
+ }
1977
+ if (res.status === 401 || res.status === 403) {
1978
+ throw new RevnixAuthError(res.status, message);
1979
+ }
1980
+ if (res.status === 429) {
1981
+ const retryAfter = Number(res.headers.get("Retry-After"));
1982
+ throw new RevnixRateLimitError(
1983
+ message,
1984
+ Number.isFinite(retryAfter) && retryAfter > 0
1985
+ ? retryAfter * 1000
1986
+ : undefined
1987
+ );
1988
+ }
1989
+ throw new RevnixError(res.status, message);
1990
+ }
1991
+ return parsed as T;
1992
+ }
1993
+ }