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