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