react-native-revnix 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +766 -0
  3. package/dist/blocks/background.d.ts +101 -0
  4. package/dist/blocks/background.js +165 -0
  5. package/dist/blocks/color.d.ts +88 -0
  6. package/dist/blocks/color.js +329 -0
  7. package/dist/blocks/locale.d.ts +42 -0
  8. package/dist/blocks/locale.js +263 -0
  9. package/dist/blocks/selection.d.ts +49 -0
  10. package/dist/blocks/selection.js +109 -0
  11. package/dist/blocks/style.d.ts +93 -0
  12. package/dist/blocks/style.js +365 -0
  13. package/dist/blocks/tags.d.ts +12 -0
  14. package/dist/blocks/tags.js +121 -0
  15. package/dist/blocks/types.d.ts +301 -0
  16. package/dist/blocks/types.js +44 -0
  17. package/dist/client.d.ts +402 -0
  18. package/dist/client.js +1653 -0
  19. package/dist/device.d.ts +29 -0
  20. package/dist/device.js +137 -0
  21. package/dist/errors.d.ts +39 -0
  22. package/dist/errors.js +68 -0
  23. package/dist/esm/blocks/background.js +160 -0
  24. package/dist/esm/blocks/color.js +318 -0
  25. package/dist/esm/blocks/locale.js +255 -0
  26. package/dist/esm/blocks/selection.js +100 -0
  27. package/dist/esm/blocks/style.js +356 -0
  28. package/dist/esm/blocks/tags.js +117 -0
  29. package/dist/esm/blocks/types.js +40 -0
  30. package/dist/esm/client.js +1649 -0
  31. package/dist/esm/device.js +130 -0
  32. package/dist/esm/errors.js +59 -0
  33. package/dist/esm/iap.js +101 -0
  34. package/dist/esm/implicit.js +63 -0
  35. package/dist/esm/index.js +29 -0
  36. package/dist/esm/package.json +1 -0
  37. package/dist/esm/storage.js +78 -0
  38. package/dist/esm/types.js +4 -0
  39. package/dist/esm/ui/RevnixBlockPaywall.js +706 -0
  40. package/dist/esm/ui/RevnixPaywall.js +890 -0
  41. package/dist/esm/ui/index.js +6 -0
  42. package/dist/esm/version.js +4 -0
  43. package/dist/iap.d.ts +20 -0
  44. package/dist/iap.js +104 -0
  45. package/dist/implicit.d.ts +69 -0
  46. package/dist/implicit.js +67 -0
  47. package/dist/index.d.ts +18 -0
  48. package/dist/index.js +50 -0
  49. package/dist/storage.d.ts +14 -0
  50. package/dist/storage.js +85 -0
  51. package/dist/types.d.ts +463 -0
  52. package/dist/types.js +5 -0
  53. package/dist/ui/RevnixBlockPaywall.d.ts +96 -0
  54. package/dist/ui/RevnixBlockPaywall.js +742 -0
  55. package/dist/ui/RevnixPaywall.d.ts +148 -0
  56. package/dist/ui/RevnixPaywall.js +927 -0
  57. package/dist/ui/index.d.ts +7 -0
  58. package/dist/ui/index.js +25 -0
  59. package/dist/version.d.ts +1 -0
  60. package/dist/version.js +7 -0
  61. package/package.json +79 -0
  62. package/src/blocks/background.ts +232 -0
  63. package/src/blocks/color.ts +352 -0
  64. package/src/blocks/locale.ts +271 -0
  65. package/src/blocks/paywall-background-wire.json +12 -0
  66. package/src/blocks/paywall-selection-wire.json +149 -0
  67. package/src/blocks/selection.ts +161 -0
  68. package/src/blocks/style.ts +411 -0
  69. package/src/blocks/tags.ts +125 -0
  70. package/src/blocks/types.ts +352 -0
  71. package/src/client.ts +1993 -0
  72. package/src/device.ts +146 -0
  73. package/src/errors.ts +69 -0
  74. package/src/iap.ts +137 -0
  75. package/src/implicit.ts +129 -0
  76. package/src/index.ts +82 -0
  77. package/src/storage.ts +92 -0
  78. package/src/types.ts +528 -0
  79. package/src/ui/RevnixBlockPaywall.tsx +1423 -0
  80. package/src/ui/RevnixPaywall.tsx +1542 -0
  81. package/src/ui/index.ts +43 -0
  82. package/src/version.ts +4 -0
  83. package/ui.d.ts +1 -0
  84. package/ui.js +5 -0
@@ -0,0 +1,130 @@
1
+ // REV-268: the device attribute contract, SDK side. Every SDK sends the same
2
+ // twelve facts about the device on each placement resolve, in the
3
+ // X-Revnix-Device header, and the server stores them as reserved `device.*`
4
+ // customer attributes — the facts a targeting rule ("US only", "app version
5
+ // at least 3", "first open") evaluates against on the very request that
6
+ // serves the paywall.
7
+ //
8
+ // This SDK runs in a browser, in React Native, and under Node, so it cannot
9
+ // read most of the facts itself the way the native SDKs can: there is no
10
+ // StoreKit storefront in a browser and no `navigator` in Node. The split is
11
+ // - what the runtime can tell us safely (detectDeviceFacts): platform,
12
+ // locale, and — in a browser — the OS version from the user agent;
13
+ // - what the app must tell us (RevnixConfig.device): app version, model,
14
+ // storefront, currency, sandbox — a React Native app has
15
+ // react-native-device-info in hand and this SDK should not depend on it;
16
+ // - what the SDK owns outright: sdkVersion, installedAt, firstOpen.
17
+ // Anything unknown is simply omitted; the server treats every field as
18
+ // optional and normalizes what it gets (lib/deviceAttributes.ts in
19
+ // revnix-app), so nothing here needs to be exact — only honest.
20
+ /** The header the server reads. Keep in sync with
21
+ * convex/lib/deviceAttributes.ts DEVICE_HEADER. */
22
+ export const DEVICE_HEADER = "X-Revnix-Device";
23
+ /** Storage key for the first-launch timestamp. */
24
+ export const INSTALLED_AT_KEY = "revnix.installedAt";
25
+ /** The facts this runtime can report about itself without help. Browser
26
+ * only in practice — under Node and React Native `navigator` is absent or
27
+ * says nothing useful, and those callers pass `device` in configure(). */
28
+ export function detectDeviceFacts() {
29
+ const nav = globalThis.navigator;
30
+ const facts = {};
31
+ if (!nav)
32
+ return facts;
33
+ // A React Native runtime also exposes `navigator` (product: "ReactNative")
34
+ // but its language/userAgent are not the device's — leave those to the app.
35
+ if (nav.product === "ReactNative")
36
+ return facts;
37
+ if (typeof nav.userAgent === "string" && nav.userAgent.length > 0) {
38
+ facts.platform = "web";
39
+ const os = osVersionFromUserAgent(nav.userAgent);
40
+ if (os !== undefined)
41
+ facts.osVersion = os;
42
+ }
43
+ if (typeof nav.language === "string" && nav.language.length > 0) {
44
+ facts.locale = nav.language;
45
+ }
46
+ return facts;
47
+ }
48
+ /** Best-effort OS version from a browser UA string; undefined when the UA
49
+ * gives nothing recognizable. Only the major desktop/mobile families —
50
+ * this is a targeting hint, not a fingerprinting library. */
51
+ export function osVersionFromUserAgent(ua) {
52
+ const ios = /(?:iPhone|iPad|CPU) OS (\d+(?:_\d+)*)/.exec(ua)?.[1];
53
+ if (ios !== undefined)
54
+ return ios.replace(/_/g, ".");
55
+ const android = /Android (\d+(?:\.\d+)*)/.exec(ua)?.[1];
56
+ if (android !== undefined)
57
+ return android;
58
+ const mac = /Mac OS X (\d+(?:[._]\d+)*)/.exec(ua)?.[1];
59
+ if (mac !== undefined)
60
+ return mac.replace(/_/g, ".");
61
+ const windows = /Windows NT (\d+(?:\.\d+)*)/.exec(ua)?.[1];
62
+ if (windows !== undefined)
63
+ return windows;
64
+ return undefined;
65
+ }
66
+ /** Drop undefined/empty fields so the encoded header carries only what is
67
+ * known — the server would drop them anyway, but bytes on every resolve
68
+ * are bytes on every resolve. */
69
+ export function compactPayload(payload) {
70
+ const out = {};
71
+ for (const [key, value] of Object.entries(payload)) {
72
+ if (value === undefined || value === null || value === "")
73
+ continue;
74
+ out[key] = value;
75
+ }
76
+ return out;
77
+ }
78
+ /** base64url of the UTF-8 JSON payload. Hand-rolled rather than
79
+ * btoa/TextEncoder: neither is guaranteed on every React Native runtime
80
+ * this SDK supports, and a missing global here would fail every paywall
81
+ * resolve on that platform for the sake of one header. */
82
+ export function encodeDeviceHeader(payload) {
83
+ return base64url(utf8Bytes(JSON.stringify(payload)));
84
+ }
85
+ function utf8Bytes(text) {
86
+ const bytes = [];
87
+ for (let i = 0; i < text.length; i++) {
88
+ let code = text.charCodeAt(i);
89
+ // Surrogate pair → one code point.
90
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
91
+ const low = text.charCodeAt(i + 1);
92
+ if (low >= 0xdc00 && low <= 0xdfff) {
93
+ code = 0x10000 + ((code - 0xd800) << 10) + (low - 0xdc00);
94
+ i++;
95
+ }
96
+ }
97
+ if (code < 0x80) {
98
+ bytes.push(code);
99
+ }
100
+ else if (code < 0x800) {
101
+ bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
102
+ }
103
+ else if (code < 0x10000) {
104
+ bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
105
+ }
106
+ else {
107
+ bytes.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
108
+ }
109
+ }
110
+ return bytes;
111
+ }
112
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
113
+ function base64url(bytes) {
114
+ let out = "";
115
+ for (let i = 0; i < bytes.length; i += 3) {
116
+ const a = bytes[i] ?? 0;
117
+ const b = i + 1 < bytes.length ? bytes[i + 1] : undefined;
118
+ const c = i + 2 < bytes.length ? bytes[i + 2] : undefined;
119
+ out += B64[a >> 2];
120
+ out += B64[((a & 0x03) << 4) | ((b ?? 0) >> 4)];
121
+ if (b === undefined)
122
+ break;
123
+ out += B64[((b & 0x0f) << 2) | ((c ?? 0) >> 6)];
124
+ if (c === undefined)
125
+ break;
126
+ out += B64[c & 0x3f];
127
+ }
128
+ // No padding: base64url as the server decodes it (it re-pads itself).
129
+ return out;
130
+ }
@@ -0,0 +1,59 @@
1
+ export class RevnixError extends Error {
2
+ constructor(status, message) {
3
+ super(message);
4
+ this.name = "RevnixError";
5
+ this.status = status;
6
+ }
7
+ /** Transient failures worth retrying (network, timeout, rate limit, 5xx).
8
+ * 4xx other than 429 are the caller's fault and won't fix themselves. */
9
+ get isRetryable() {
10
+ return this.status === 0 || this.status === 429 || this.status >= 500;
11
+ }
12
+ }
13
+ /** POST /v1/purchases returned 409: the store token belongs to another
14
+ * customer and the app's transfer behavior is "block". Surface this to the
15
+ * user as "this subscription is linked to a different account". */
16
+ export class PurchaseBlockedError extends RevnixError {
17
+ constructor(message) {
18
+ super(409, message);
19
+ this.name = "PurchaseBlockedError";
20
+ }
21
+ }
22
+ /** C7: the request didn't complete within the configured timeout (default
23
+ * 10s). `fetch` has no default timeout on React Native / browsers, so without
24
+ * this a slow or dead backend would hang the app until the OS TCP timeout. */
25
+ export class RevnixTimeoutError extends RevnixError {
26
+ constructor(message = "Revnix request timed out") {
27
+ super(0, message);
28
+ this.name = "RevnixTimeoutError";
29
+ }
30
+ }
31
+ /** C7: the request never reached the server (offline, DNS/TLS failure, captive
32
+ * portal). Distinct from an HTTP error so callers can show an offline state
33
+ * instead of treating it as a bug. */
34
+ export class RevnixNetworkError extends RevnixError {
35
+ constructor(message = "Revnix request failed to reach the server") {
36
+ super(0, message);
37
+ this.name = "RevnixNetworkError";
38
+ }
39
+ }
40
+ /** L4: the API returned 401/403 — the key is missing, revoked, or the wrong
41
+ * kind (e.g. a secret key where a public key is required) — or the SDK
42
+ * refused locally before the network for the same reason (identify()/alias()
43
+ * on a publishable key). Retrying is pointless; the fix is reconfiguration,
44
+ * so surface this to the developer. */
45
+ export class RevnixAuthError extends RevnixError {
46
+ constructor(status, message) {
47
+ super(status, message);
48
+ this.name = "RevnixAuthError";
49
+ }
50
+ }
51
+ /** C7: the API returned 429 (rate limited). `retryAfterMs` mirrors the
52
+ * server's Retry-After header when present — back off at least that long. */
53
+ export class RevnixRateLimitError extends RevnixError {
54
+ constructor(message, retryAfterMs) {
55
+ super(429, message);
56
+ this.name = "RevnixRateLimitError";
57
+ this.retryAfterMs = retryAfterMs;
58
+ }
59
+ }
@@ -0,0 +1,101 @@
1
+ // A JWS is three dot-separated base64url segments. Older react-native-iap
2
+ // versions put other receipt shapes in purchaseToken on iOS — shape-check so
3
+ // only a real JWS is sent as proof (the server rejects a malformed one).
4
+ const JWS_SHAPE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
5
+ function jwsFromIap(purchase) {
6
+ for (const candidate of [purchase.jwsRepresentation, purchase.purchaseToken]) {
7
+ if (candidate && JWS_SHAPE.test(candidate))
8
+ return candidate;
9
+ }
10
+ return undefined;
11
+ }
12
+ // REV-200 (CTO-R2 M25): base64url → string without atob (not available on
13
+ // every React Native runtime). The ids read from the payload are ASCII;
14
+ // multi-byte content is never consulted.
15
+ const B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
16
+ function base64UrlDecode(segment) {
17
+ const b64 = segment.replace(/-/g, "+").replace(/_/g, "/");
18
+ let out = "";
19
+ let buffer = 0;
20
+ let bits = 0;
21
+ for (const ch of b64) {
22
+ const idx = B64_ALPHABET.indexOf(ch);
23
+ if (idx === -1)
24
+ continue; // padding / stray characters
25
+ buffer = (buffer << 6) | idx;
26
+ bits += 6;
27
+ if (bits >= 8) {
28
+ bits -= 8;
29
+ out += String.fromCharCode((buffer >> bits) & 0xff);
30
+ }
31
+ }
32
+ return out;
33
+ }
34
+ // The JWS payload's own ids — read WITHOUT verifying the signature, which is
35
+ // fine here: the server verifies the chain and only trusts the proof when the
36
+ // verified payload's ids match what we send. Reading them locally is exactly
37
+ // how we make them match.
38
+ function jwsPayload(jws) {
39
+ try {
40
+ const segment = jws.split(".")[1];
41
+ if (!segment)
42
+ return undefined;
43
+ const parsed = JSON.parse(base64UrlDecode(segment));
44
+ return typeof parsed === "object" && parsed !== null
45
+ ? parsed
46
+ : undefined;
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ export function purchaseInputFromIap(purchase, source, extras) {
53
+ if (source === "apple") {
54
+ const transactionId = purchase.transactionId;
55
+ if (!transactionId) {
56
+ throw new Error("Revnix: Apple purchase is missing transactionId");
57
+ }
58
+ const jws = jwsFromIap(purchase);
59
+ const proof = jws ? jwsPayload(jws) : undefined;
60
+ return {
61
+ source,
62
+ // REV-200 (M25): token precedence. The old fallback (original id
63
+ // missing → the renewal's own transactionId) minted a purchaseKey the
64
+ // store webhook would never produce — a second purchase.completed and
65
+ // double-counted revenue — AND failed the server's proof check, which
66
+ // requires the verified JWS's originalTransactionId to EQUAL this
67
+ // token, silently downgrading a proof-carrying purchase to
68
+ // provisional. The JWS payload names the true original id, so when a
69
+ // JWS is present it wins; the IAP library's field is next; the bare
70
+ // transactionId remains only for the proof-less first purchase, where
71
+ // original === transaction anyway.
72
+ token: proof?.originalTransactionId ??
73
+ purchase.originalTransactionIdentifierIOS ??
74
+ transactionId,
75
+ productId: purchase.productId,
76
+ transactionId,
77
+ occurredAt: purchase.transactionDate ?? undefined,
78
+ expiresAt: extras?.expiresAt,
79
+ rawPayload: extras?.rawPayload,
80
+ // Without this the purchase lands PROVISIONAL (see
81
+ // RegisterPurchaseInput.signedTransactionInfo).
82
+ signedTransactionInfo: jws,
83
+ };
84
+ }
85
+ const token = purchase.purchaseToken;
86
+ if (!token) {
87
+ throw new Error("Revnix: Google purchase is missing purchaseToken");
88
+ }
89
+ return {
90
+ source,
91
+ token,
92
+ productId: purchase.productId,
93
+ // The Google webhook adapter (convex/lib/google.ts) reports the
94
+ // purchaseToken as the transactionId — send the same, never the Play
95
+ // orderId, or client-register and webhook won't share a purchaseKey.
96
+ transactionId: token,
97
+ occurredAt: purchase.transactionDate ?? undefined,
98
+ expiresAt: extras?.expiresAt,
99
+ rawPayload: extras?.rawPayload,
100
+ };
101
+ }
@@ -0,0 +1,63 @@
1
+ // REV-272: implicit placements — the six moments the SDK reports on its own.
2
+ //
3
+ // A placement is normally a location the HOST resolves by name, so every new
4
+ // place a paywall could appear costs a code change and a release. Six moments
5
+ // are the same in every app and visible to the SDK without the host saying
6
+ // anything, so an operator can attach a paywall to them from the dashboard
7
+ // alone. This module owns the vocabulary and the platform-lifecycle seam;
8
+ // client.ts owns the firing.
9
+ //
10
+ // Mirrors convex/lib/implicitPlacements.ts in revnix-app — the backend is the
11
+ // source of truth for the key spellings, and a mismatch here is silent (the
12
+ // server 400s a key it does not know, and the moment is simply never
13
+ // configured), so the two lists are checked against each other in the tests.
14
+ /** The six reserved keys. Lowercase throughout: a placement key must match
15
+ * `^[a-z0-9][a-z0-9._-]{0,63}$` server-side, which is why this is
16
+ * `deeplink_open` and not Superwall's `deepLink_open`. */
17
+ export const IMPLICIT_PLACEMENTS = [
18
+ "app_install",
19
+ "app_launch",
20
+ "session_start",
21
+ "deeplink_open",
22
+ "paywall_decline",
23
+ "transaction_abandon",
24
+ ];
25
+ /** The one moment the SDK cannot see unaided: the URL that opened the app is
26
+ * delivered to the host's own entry point, and an SDK trying to intercept it
27
+ * would be fighting the host's router. Call `handleDeepLink(url)`. */
28
+ export const HOST_CALL_PLACEMENT = "deeplink_open";
29
+ /** The placementKey `handleDeepLink` reports for a dashboard QR/link preview
30
+ * (`<scheme>://revnix-preview?revnix_preview=<token>`) — not one of the six
31
+ * above, and never sent to /v1/placements/triggered. */
32
+ export const PREVIEW_PLACEMENT_KEY = "revnix_preview";
33
+ /** How long the app must have been backgrounded for the return to count as a
34
+ * new session rather than an app switch. Matches Superwall's own definition
35
+ * so a team moving over gets the same numbers. */
36
+ export const DEFAULT_SESSION_TIMEOUT_MS = 30 * 60000;
37
+ /** How long after a FAILED /v1/config read the SDK waits before asking again.
38
+ * See RevnixClient.implicitConfigRetryAt. */
39
+ export const IMPLICIT_CONFIG_RETRY_MS = 60000;
40
+ /** Foreground detection in a browser tab. `visibilitychange` rather than
41
+ * `focus`: switching windows fires focus without the page ever having been
42
+ * hidden, which would mint sessions for someone alt-tabbing. Returns null
43
+ * where there is no document (React Native, Node, SSR) — the host passes its
44
+ * own adapter there. */
45
+ export function browserLifecycle() {
46
+ const doc = globalThis.document;
47
+ if (!doc ||
48
+ typeof doc.addEventListener !== "function" ||
49
+ typeof doc.removeEventListener !== "function") {
50
+ return null;
51
+ }
52
+ const add = doc.addEventListener.bind(doc);
53
+ const remove = doc.removeEventListener.bind(doc);
54
+ return {
55
+ onStateChange(handler) {
56
+ const listener = () => {
57
+ handler(doc.visibilityState === "visible" ? "foreground" : "background");
58
+ };
59
+ add("visibilitychange", listener);
60
+ return () => remove("visibilitychange", listener);
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,29 @@
1
+ import { RevnixClient } from "./client.js";
2
+ import { setLocale } from "./blocks/locale.js";
3
+ export { RevnixClient } from "./client.js";
4
+ export { PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError, RevnixRateLimitError, RevnixTimeoutError, } from "./errors.js";
5
+ export { purchaseInputFromIap } from "./iap.js";
6
+ export { memoryStorage, generateAnonymousId } from "./storage.js";
7
+ export { SDK_VERSION } from "./version.js";
8
+ export { DEVICE_HEADER, encodeDeviceHeader } from "./device.js";
9
+ export { browserLifecycle, DEFAULT_SESSION_TIMEOUT_MS, HOST_CALL_PLACEMENT, IMPLICIT_PLACEMENTS, PREVIEW_PLACEMENT_KEY, } from "./implicit.js";
10
+ let instance = null;
11
+ /** Singleton facade — configure once at app start, then import `Revnix`
12
+ * anywhere. Prefer constructing RevnixClient directly in tests. */
13
+ export const Revnix = {
14
+ configure(config) {
15
+ // REV-204 (L10): dev fast-refresh re-runs configure(); without teardown
16
+ // the old client kept its retry/prefetch background work running
17
+ // alongside the new one.
18
+ instance?.dispose();
19
+ instance = new RevnixClient(config);
20
+ return instance;
21
+ },
22
+ setLocale,
23
+ get client() {
24
+ if (!instance) {
25
+ throw new Error("Revnix: call Revnix.configure(...) before use");
26
+ }
27
+ return instance;
28
+ },
29
+ };
@@ -0,0 +1 @@
1
+ {"type":"module"}
@@ -0,0 +1,78 @@
1
+ /** Default storage: survives only the current JS runtime. Real apps must
2
+ * pass a persistent adapter (MMKV, AsyncStorage, localStorage) or every
3
+ * launch becomes a new anonymous customer. */
4
+ export function memoryStorage() {
5
+ const map = new Map();
6
+ return {
7
+ getItem: (key) => map.get(key) ?? null,
8
+ setItem: (key, value) => {
9
+ map.set(key, value);
10
+ },
11
+ };
12
+ }
13
+ // CTO-SCALE-REVIEW M5: what actually backs the client when `storage` is
14
+ // omitted. Memory-only used to be the silent default — every launch became a
15
+ // new anonymous customer + a new install report (analytics inflated by the
16
+ // DAU multiplier, purchases orphaned on the previous launch's id). Now:
17
+ // 1. explicit adapter → use it (unchanged);
18
+ // 2. web (localStorage usable) → use localStorage automatically;
19
+ // 3. truly nothing (React Native without an adapter) → memory, but WARN
20
+ // loudly, and the client skips install reporting (see client.ts) so a
21
+ // forgotten config line can't fabricate fleet-wide installs.
22
+ export function resolveStorage(configured) {
23
+ if (configured)
24
+ return { storage: configured, persistent: true };
25
+ const ls = globalThis.localStorage;
26
+ if (ls) {
27
+ try {
28
+ const probe = "revnix.__probe";
29
+ ls.setItem(probe, "1");
30
+ ls.removeItem(probe);
31
+ return {
32
+ storage: {
33
+ getItem: (key) => ls.getItem(key),
34
+ setItem: (key, value) => {
35
+ ls.setItem(key, value);
36
+ },
37
+ },
38
+ persistent: true,
39
+ };
40
+ }
41
+ catch {
42
+ // Private mode / quota quirks — fall through to memory.
43
+ }
44
+ }
45
+ console.warn("[revnix] No `storage` adapter configured and no localStorage available; " +
46
+ "the customer id lives in memory only. Every launch will look like a NEW " +
47
+ "anonymous customer: prior purchases won't be attached and analytics " +
48
+ "would be inflated, so install reporting and experiment enrollment are " +
49
+ "disabled. Pass a persistent adapter (AsyncStorage/MMKV on React " +
50
+ "Native) to fix this.");
51
+ return { storage: memoryStorage(), persistent: false };
52
+ }
53
+ function randomHex(byteLength) {
54
+ const bytes = new Uint8Array(byteLength);
55
+ const cryptoObj = globalThis.crypto;
56
+ // L2: no Math.random fallback — a guessable customer id is readable by
57
+ // anyone holding the public key, so failing loudly beats failing silently.
58
+ // Every supported runtime (browsers, RN ≥0.72 via Hermes, Node ≥19) exposes
59
+ // getRandomValues; older Node needs `globalThis.crypto = require("crypto").webcrypto`.
60
+ if (!cryptoObj?.getRandomValues) {
61
+ throw new Error("[revnix] crypto.getRandomValues is unavailable in this runtime; " +
62
+ "cannot generate a secure customer id. Polyfill globalThis.crypto " +
63
+ "or pass an explicit `customerId` in configure().");
64
+ }
65
+ cryptoObj.getRandomValues(bytes);
66
+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
67
+ }
68
+ export function generateAnonymousId() {
69
+ return `rvx_anon_${randomHex(16)}`;
70
+ }
71
+ /** One id per paywall display (REV-094) — the backend dedupes retries on it
72
+ * while genuine repeat views (fresh ids) count separately. */
73
+ export function generateViewId() {
74
+ return `rvx_view_${randomHex(16)}`;
75
+ }
76
+ export function generateEventId() {
77
+ return `rvx_evt_${randomHex(16)}`;
78
+ }
@@ -0,0 +1,4 @@
1
+ // Response and input types for the Revnix /v1 API. These mirror the shapes
2
+ // returned by convex/http.ts in revnix-app — the backend is the source of
3
+ // truth; update here when the API surface changes.
4
+ export {};