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