react-native-revnix 1.6.0 → 1.7.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 (41) hide show
  1. package/README.md +16 -0
  2. package/android/build.gradle +3 -0
  3. package/android/consumer-rules.pro +1 -0
  4. package/android/src/main/kotlin/com/revnix/reactnative/RevnixStoreModule.kt +43 -0
  5. package/dist/blocks/locale.js +7 -0
  6. package/dist/blocks/selection.d.ts +7 -1
  7. package/dist/blocks/selection.js +9 -0
  8. package/dist/blocks/tags.d.ts +3 -0
  9. package/dist/blocks/tags.js +33 -0
  10. package/dist/blocks/types.d.ts +45 -3
  11. package/dist/blocks/types.js +1 -0
  12. package/dist/client.d.ts +4 -0
  13. package/dist/client.js +73 -5
  14. package/dist/esm/blocks/locale.js +7 -0
  15. package/dist/esm/blocks/selection.js +8 -0
  16. package/dist/esm/blocks/tags.js +31 -0
  17. package/dist/esm/blocks/types.js +1 -0
  18. package/dist/esm/client.js +73 -5
  19. package/dist/esm/store/index.js +7 -0
  20. package/dist/esm/ui/RevnixBlockPaywall.js +103 -22
  21. package/dist/esm/version.js +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/store/index.d.ts +8 -0
  24. package/dist/store/index.js +10 -1
  25. package/dist/types.d.ts +32 -0
  26. package/dist/ui/RevnixBlockPaywall.js +100 -19
  27. package/dist/version.d.ts +1 -1
  28. package/dist/version.js +1 -1
  29. package/ios/RevnixStore.m +4 -0
  30. package/ios/RevnixStore.swift +29 -0
  31. package/package.json +1 -1
  32. package/src/blocks/locale.ts +7 -0
  33. package/src/blocks/selection.ts +13 -0
  34. package/src/blocks/tags.ts +38 -0
  35. package/src/blocks/types.ts +51 -2
  36. package/src/client.ts +90 -8
  37. package/src/index.ts +1 -0
  38. package/src/store/index.ts +15 -0
  39. package/src/types.ts +30 -0
  40. package/src/ui/RevnixBlockPaywall.tsx +186 -20
  41. package/src/version.ts +1 -1
@@ -15,9 +15,11 @@ const PENDING_PURCHASES_KEY = "revnix.pendingPurchases";
15
15
  const PLACEMENTS_CACHE_KEY = "revnix.placements";
16
16
  const LAST_ATTRIBUTION_KEY = "revnix.lastAttribution";
17
17
  const LAST_PUSH_TOKEN_KEY = "revnix.lastPushToken";
18
+ const LAST_AD_ID_KEY = "revnix.lastAdId";
18
19
  const EVENT_QUEUE_KEY = "revnix.eventQueue";
19
20
  const PLACEMENTS_INVALIDATED_AT_KEY = "revnix.placementsInvalidatedAt";
20
21
  const DEFAULT_TIMEOUT_MS = 10000;
22
+ const INTEGRITY_TIMEOUT_MS = 10000;
21
23
  const EVENT_QUEUE_CAP = 1000;
22
24
  const EVENT_BATCH_SIZE = 100;
23
25
  const EVENT_FLUSH_THRESHOLD = 20;
@@ -98,6 +100,19 @@ const jittered = (ms) => ms * (0.8 + Math.random() * 0.4);
98
100
  // from memory. Short enough that a webhook-driven change is picked up within
99
101
  // half a minute; long enough that gating five screens costs one request.
100
102
  const DEFAULT_ENTITLEMENTS_TTL_MS = 30000;
103
+ const fitsWithin = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max;
104
+ function wellFormedIntegrity(evidence) {
105
+ if (typeof evidence !== "object" || evidence === null)
106
+ return undefined;
107
+ const { platform, keyId, attestation, token } = evidence;
108
+ if (platform === "ios" && fitsWithin(keyId, 128) && fitsWithin(attestation, 16384)) {
109
+ return { platform, keyId, attestation };
110
+ }
111
+ if (platform === "android" && fitsWithin(token, 16384)) {
112
+ return { platform, token };
113
+ }
114
+ return undefined;
115
+ }
101
116
  export class RevnixClient {
102
117
  constructor(config) {
103
118
  /** True while the current id is a generated anon id living on
@@ -110,6 +125,7 @@ export class RevnixClient {
110
125
  // client (dev fast-refresh re-running configure()) does not keep retrying
111
126
  // and prefetching alongside its successor.
112
127
  this.disposed = false;
128
+ this.integrityRequest = null;
113
129
  this.deferredDeepLinkClaimed = false;
114
130
  this.landingUrl = null;
115
131
  this.lastDeepLinkWrite = Promise.resolve();
@@ -186,12 +202,15 @@ export class RevnixClient {
186
202
  this.onDiagnostic = config.onDiagnostic;
187
203
  this.onDeferredDeepLink = config.onDeferredDeepLink;
188
204
  this.onAttribution = config.onAttribution;
205
+ this.deviceIntegrity = config.deviceIntegrity;
189
206
  this.readYourWritesDelaysMs =
190
207
  config.readYourWritesDelaysMs ?? [250, 500, 1000, 2000];
191
208
  this.customerIdPromise = this.loadCustomerId(config.customerId);
192
209
  // C7: retry any purchases that failed to register on a previous launch, so
193
210
  // a paying user never stays locked out because a POST didn't land.
194
211
  void this.retryPendingPurchases().catch((err) => this.diag("retryPendingPurchases", err));
212
+ if (config.advertisingId)
213
+ this.reportAdvertisingId(config.advertisingId);
195
214
  const store = getStoreAdapter();
196
215
  if (store) {
197
216
  this.stopStoreObserver = store.observe((tx) => void this.registerStoreTransaction(store, tx).catch((err) => this.diag("storeObserver", err)));
@@ -967,6 +986,7 @@ export class RevnixClient {
967
986
  ...(attribution.campaign ? { campaign: attribution.campaign } : {}),
968
987
  ...(attribution.adGroup ? { adGroup: attribution.adGroup } : {}),
969
988
  ...(attribution.creative ? { creative: attribution.creative } : {}),
989
+ ...(await this.integrityFor(customerId)),
970
990
  });
971
991
  await this.storage.setItem(LAST_ATTRIBUTION_KEY, payload);
972
992
  })
@@ -1000,6 +1020,21 @@ export class RevnixClient {
1000
1020
  })
1001
1021
  .catch((err) => this.diag("setPushToken", err));
1002
1022
  }
1023
+ reportAdvertisingId(source) {
1024
+ void Promise.resolve()
1025
+ .then(source)
1026
+ .then(async (value) => {
1027
+ const id = value || null;
1028
+ const customerId = await this.customerIdPromise;
1029
+ const payload = JSON.stringify({ customerId, id });
1030
+ const last = await this.storage.getItem(LAST_AD_ID_KEY);
1031
+ if (last === payload || (id === null && last === null))
1032
+ return;
1033
+ await this.setAttributes({ gps_adid: id });
1034
+ await this.storage.setItem(LAST_AD_ID_KEY, payload);
1035
+ })
1036
+ .catch((err) => this.diag("advertisingId", err));
1037
+ }
1003
1038
  /** REV-272: the two moments that happen ON a paywall, with the loop guard
1004
1039
  * applied. `fromPaywallId` travels so the server can refuse to hand back
1005
1040
  * the very paywall being dismissed — the backstop for a host that reports
@@ -1222,12 +1257,13 @@ export class RevnixClient {
1222
1257
  if (this.ephemeralAnonId)
1223
1258
  return;
1224
1259
  void this.customerIdPromise
1225
- .then((customerId) => this.request("POST", "/v1/installs", {
1260
+ .then(async (customerId) => this.request("POST", "/v1/installs", {
1226
1261
  customerId,
1227
1262
  occurredAt: Date.now(),
1228
1263
  sdkVersion: SDK_VERSION,
1229
1264
  installReferrer: referrer.slice(0, 1024),
1230
1265
  ...this.installMetadata,
1266
+ ...(await this.integrityFor(customerId)),
1231
1267
  }))
1232
1268
  .then((body) => {
1233
1269
  this.refreshAttribution();
@@ -1262,12 +1298,13 @@ export class RevnixClient {
1262
1298
  if (this.ephemeralAnonId)
1263
1299
  return Promise.resolve(undefined);
1264
1300
  return this.customerIdPromise
1265
- .then((customerId) => this.request("POST", "/v1/installs", {
1301
+ .then(async (customerId) => this.request("POST", "/v1/installs", {
1266
1302
  customerId,
1267
1303
  occurredAt: Date.now(),
1268
1304
  sdkVersion: SDK_VERSION,
1269
1305
  attributionToken: token.slice(0, 2048),
1270
1306
  ...this.installMetadata,
1307
+ ...(await this.integrityFor(customerId)),
1271
1308
  }))
1272
1309
  .then((body) => {
1273
1310
  this.refreshAttribution();
@@ -1600,12 +1637,15 @@ export class RevnixClient {
1600
1637
  /** Fire-and-forget install report (REV-093). Never blocks startup and
1601
1638
  * never throws — the backend absorbs retries idempotently per id. */
1602
1639
  reportInstall(customerId) {
1603
- void this.request("POST", "/v1/installs", {
1640
+ const occurredAt = Date.now();
1641
+ void this.integrityFor(customerId)
1642
+ .then((integrity) => this.request("POST", "/v1/installs", {
1604
1643
  customerId,
1605
- occurredAt: Date.now(),
1644
+ occurredAt,
1606
1645
  sdkVersion: SDK_VERSION,
1607
1646
  ...this.installMetadata,
1608
- })
1647
+ ...integrity,
1648
+ }))
1609
1649
  .then((body) => {
1610
1650
  if (!this.ephemeralAnonId)
1611
1651
  this.refreshAttribution();
@@ -1613,6 +1653,34 @@ export class RevnixClient {
1613
1653
  })
1614
1654
  .catch((err) => this.diag("reportInstall", err));
1615
1655
  }
1656
+ integrityFor(customerId) {
1657
+ const source = this.deviceIntegrity;
1658
+ if (!source)
1659
+ return Promise.resolve({});
1660
+ if (this.integrityRequest?.customerId !== customerId) {
1661
+ let timer;
1662
+ const timeout = new Promise((_, reject) => {
1663
+ timer = setTimeout(() => reject(new Error("device integrity timed out")), INTEGRITY_TIMEOUT_MS);
1664
+ });
1665
+ const body = Promise.race([
1666
+ Promise.resolve().then(() => source(customerId)),
1667
+ timeout,
1668
+ ])
1669
+ .then((evidence) => {
1670
+ const integrity = wellFormedIntegrity(evidence);
1671
+ if (!integrity)
1672
+ throw new Error("device integrity unavailable");
1673
+ return { integrity };
1674
+ })
1675
+ .catch((err) => {
1676
+ this.diag("deviceIntegrity", err);
1677
+ return {};
1678
+ })
1679
+ .finally(() => clearTimeout(timer));
1680
+ this.integrityRequest = { customerId, body };
1681
+ }
1682
+ return this.integrityRequest.body;
1683
+ }
1616
1684
  /** REV-268: assemble and encode the device facts once. Runtime-detected
1617
1685
  * facts first, the app's `device` over them, then the three the SDK owns.
1618
1686
  * `installedAt` is the first launch this storage ever saw — written on
@@ -18,5 +18,12 @@ export const RevnixStore = native
18
18
  finish: missing,
19
19
  observe: () => () => { },
20
20
  };
21
+ /** Pass as `deviceIntegrity` in the Revnix config: App Attest on iOS, Play
22
+ * Integrity on Android, via the RevnixStore native module. Resolves null
23
+ * where the device or build cannot produce evidence. */
24
+ export const nativeDeviceIntegrity = (customerId) => native?.deviceIntegrity?.(customerId) ?? Promise.resolve(null);
25
+ /** Pass as `advertisingId` in the Revnix config: the Google Advertising ID
26
+ * on Android, via the RevnixStore native module. Resolves null on iOS. */
27
+ export const nativeAdvertisingId = () => native?.advertisingId?.() ?? Promise.resolve(null);
21
28
  if (native)
22
29
  setStoreAdapter(RevnixStore);
@@ -17,13 +17,13 @@
17
17
  // RevnixPaywall delegates here whenever a config carries `blocks`, so apps
18
18
  // get designed paywalls without changing a line of their integration.
19
19
  import * as React from "react";
20
- import { ActivityIndicator, Image, Linking, Platform, Pressable, ScrollView, StatusBar, Text, View, } from "react-native";
20
+ import { ActivityIndicator, Image, Linking, Platform, Pressable, ScrollView, StatusBar, Switch, Text, View, } from "react-native";
21
21
  import { gradientFallbackColor, isGradient, parseGradient, resolveColor, surfaceSolid, withAlpha, } from "../blocks/color.js";
22
22
  import { DEFAULT_FONT_SIZE, hairlineColor, inherit, inheritedTextStyle, toTextStyle, toViewStyle, } from "../blocks/style.js";
23
23
  import { backgroundLayers, coverPlacement, } from "../blocks/background.js";
24
24
  import { detectLocale, linkLabels, localizeBlockDoc } from "../blocks/locale.js";
25
- import { resolveTags } from "../blocks/tags.js";
26
- import { canvasMetrics, effectiveStyle, isVisible, packageContext, selectedPackageId, } from "../blocks/selection.js";
25
+ import { resolveTags, timerText } from "../blocks/tags.js";
26
+ import { canvasMetrics, effectiveStyle, isVisible, packageContext, selectedPackageId, toggleSelection, } from "../blocks/selection.js";
27
27
  import { DESIGN_CANVAS, hasCloseAction, } from "../blocks/types.js";
28
28
  /** CSS angle (0 = up, 90 = right) → RN gradient start/end points. */
29
29
  function angleToPoints(angle) {
@@ -259,6 +259,56 @@ function BlockText({ text, style, ctx, inherited, onPress, }) {
259
259
  }
260
260
  return React.createElement(View, { style: toViewStyle(style, styleCtx) }, body);
261
261
  }
262
+ function Timer({ block, style, ctx, inherited, }) {
263
+ const [now, setNow] = React.useState(Date.now);
264
+ React.useEffect(() => {
265
+ const id = setInterval(() => setNow(Date.now()), 1000);
266
+ return () => clearInterval(id);
267
+ }, []);
268
+ const text = timerText(block, ctx.shownAt, now);
269
+ if (text === null)
270
+ return null;
271
+ return React.createElement(BlockText, { text: text, style: style, ctx: ctx, inherited: inherited });
272
+ }
273
+ function Carousel({ pages, autoAdvance, doc, }) {
274
+ const scroller = React.useRef(null);
275
+ const [size, setSize] = React.useState({ width: 0, height: 0 });
276
+ const [page, setPage] = React.useState(0);
277
+ React.useEffect(() => {
278
+ if (!autoAdvance || size.width === 0 || pages.length < 2)
279
+ return;
280
+ const id = setInterval(() => {
281
+ scroller.current?.scrollTo({
282
+ x: ((page + 1) % pages.length) * size.width,
283
+ animated: true,
284
+ });
285
+ }, autoAdvance * 1000);
286
+ return () => clearInterval(id);
287
+ }, [autoAdvance, size.width, page, pages.length]);
288
+ return (React.createElement(View, { style: { flex: 1 }, onLayout: (e) => setSize({
289
+ width: e.nativeEvent.layout.width,
290
+ height: e.nativeEvent.layout.height,
291
+ }) },
292
+ React.createElement(ScrollView, { ref: scroller, horizontal: true, pagingEnabled: true, showsHorizontalScrollIndicator: false, scrollEventThrottle: 16, onScroll: (e) => {
293
+ if (size.width > 0) {
294
+ setPage(Math.round(e.nativeEvent.contentOffset.x / size.width));
295
+ }
296
+ } }, pages.map((node, i) => (React.createElement(View, { key: i, style: size }, node)))),
297
+ pages.length > 1 ? (React.createElement(View, { pointerEvents: "none", style: {
298
+ position: "absolute",
299
+ bottom: 8,
300
+ left: 0,
301
+ right: 0,
302
+ flexDirection: "row",
303
+ justifyContent: "center",
304
+ gap: 6,
305
+ } }, pages.map((_, i) => (React.createElement(View, { key: i, style: {
306
+ width: 6,
307
+ height: 6,
308
+ borderRadius: 3,
309
+ backgroundColor: i === page ? doc.accent : withAlpha(doc.textColor, 0.3),
310
+ } }))))) : null));
311
+ }
262
312
  /** The press handler for a block the design marks as the close, or undefined
263
313
  * when it is not one (or the host wired no dismissal). One helper so text,
264
314
  * image and button blocks cannot drift on what "action: close" means. */
@@ -363,6 +413,11 @@ function gridTracks(block) {
363
413
  }
364
414
  function Block({ block, ctx, pkg, inherited, selection, }) {
365
415
  const { doc } = ctx;
416
+ React.useEffect(() => {
417
+ if (block.type === "video") {
418
+ ctx.onDiagnostic?.("video drawn as its poster: no built-in player");
419
+ }
420
+ }, [block.type]);
366
421
  const styleCtx = { ...ctx.style, fontSize: inherited.fontSize };
367
422
  // A pinned card judges its own context; every other block inherits the
368
423
  // nearest plan card's. (A repeat card is judged per instance below.)
@@ -385,9 +440,27 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
385
440
  switch (block.type) {
386
441
  case "text":
387
442
  return (React.createElement(BlockText, { text: resolveTags(block.text, pkg, ctx.packages), style: style, ctx: ctx, inherited: inherited, onPress: closePress(block, ctx) }));
388
- case "image": {
389
- const url = block.url || ctx.heroImageUrl;
390
- const round = block.shape === "circle";
443
+ case "timer":
444
+ return React.createElement(Timer, { block: block, style: style, ctx: ctx, inherited: inherited });
445
+ case "toggle": {
446
+ const toggle = toggleSelection(block, ctx.packages, ctx.selectedId);
447
+ if (!toggle)
448
+ return null;
449
+ const flip = () => ctx.onSelect(toggle.target);
450
+ return (React.createElement(Pressable, { accessibilityRole: "switch", accessibilityState: { checked: toggle.on }, onPress: flip, style: [{ flexDirection: "row", alignItems: "center", gap: 12 }, view] },
451
+ React.createElement(BlockFill, { block: block, ctx: ctx }),
452
+ React.createElement(Text, { style: [
453
+ { flex: 1 },
454
+ inheritedTextStyle(text, ctx.style),
455
+ textOnly(style, styleCtx),
456
+ ] }, resolveTags(block.label, ctx.packages.find((p) => p.packageId === ctx.selectedId), ctx.packages)),
457
+ React.createElement(Switch, { value: toggle.on, onValueChange: flip, trackColor: { true: doc.accent } })));
458
+ }
459
+ case "image":
460
+ case "video": {
461
+ const image = block.type === "image";
462
+ const url = image ? block.url || ctx.heroImageUrl : block.poster;
463
+ const round = image && block.shape === "circle";
391
464
  const s = style;
392
465
  // A converted design sizes its own slot; the 160pt default is only for
393
466
  // a slot dropped into a flow column, and must not fight it.
@@ -400,7 +473,7 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
400
473
  // An icon the design marks as the close (REV-252). Same rule as text:
401
474
  // the whole box is the target, and the style lands on the Pressable so
402
475
  // a pinned icon keeps its offsets.
403
- const closeImage = closePress(block, ctx);
476
+ const closeImage = image ? closePress(block, ctx) : undefined;
404
477
  const ImageBox = closeImage ? Pressable : View;
405
478
  return (React.createElement(ImageBox, { accessibilityRole: closeImage ? "button" : undefined, accessibilityLabel: closeImage ? "Close" : undefined, hitSlop: closeImage ? 8 : undefined, onPress: closeImage, style: [
406
479
  sized ? null : { height: 160 },
@@ -408,11 +481,11 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
408
481
  view,
409
482
  ] },
410
483
  React.createElement(BlockFill, { block: block, ctx: ctx }),
411
- url ? (React.createElement(Image, { source: { uri: url }, resizeMode: block.fit ?? "cover", style: { width: "100%", height: "100%" } })) : (React.createElement(View, { style: {
484
+ url ? (React.createElement(Image, { source: { uri: url }, resizeMode: block.fit ?? "cover", style: { width: "100%", height: "100%" } })) : image ? (React.createElement(View, { style: {
412
485
  width: "100%",
413
486
  height: "100%",
414
487
  backgroundColor: withAlpha("#7d879b", 0.2),
415
- } }))));
488
+ } })) : null));
416
489
  }
417
490
  case "list":
418
491
  return (React.createElement(View, { style: [{ flexDirection: "column", gap: style?.gap ?? 8 }, view] },
@@ -521,20 +594,23 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
521
594
  return (React.createElement(View, { style: [block.flex ? { flexGrow: 1 } : { height: 16 }, view] }));
522
595
  case "card": {
523
596
  const layout = block.layout ?? "column";
597
+ const carousel = layout === "carousel";
524
598
  const base = layout === "stack"
525
599
  ? { position: "relative" }
526
- : layout === "grid"
527
- ? {
528
- position: "relative",
529
- flexDirection: "row",
530
- flexWrap: "wrap",
531
- gap: style?.gap ?? 10,
532
- }
533
- : {
534
- position: "relative",
535
- flexDirection: layout === "row" ? "row" : "column",
536
- gap: style?.gap ?? 10,
537
- };
600
+ : carousel
601
+ ? { position: "relative", overflow: "hidden" }
602
+ : layout === "grid"
603
+ ? {
604
+ position: "relative",
605
+ flexDirection: "row",
606
+ flexWrap: "wrap",
607
+ gap: style?.gap ?? 10,
608
+ }
609
+ : {
610
+ position: "relative",
611
+ flexDirection: layout === "row" ? "row" : "column",
612
+ gap: style?.gap ?? 10,
613
+ };
538
614
  const tracks = layout === "grid" ? gridTracks(block) : null;
539
615
  const renderChildren = (context, childSelection, childText = text) => block.children.map((child, i) => {
540
616
  const node = (React.createElement(Block, { key: child.id, block: child, ctx: ctx, pkg: context, inherited: childText, selection: childSelection }));
@@ -578,6 +654,9 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
578
654
  base,
579
655
  layout === "stack" ? null : { borderRadius: 16 },
580
656
  view,
657
+ carousel
658
+ ? { height: typeof style?.height === "number" ? style.height : 220 }
659
+ : null,
581
660
  ];
582
661
  const cardPackage = block.packageIndex !== undefined
583
662
  ? (ctx.packages[block.packageIndex] ?? pkg)
@@ -586,7 +665,7 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
586
665
  // how hand-styled plan rows (a highlighted annual beside a plain
587
666
  // monthly) become tappable without a products block.
588
667
  const pinned = block.packageIndex !== undefined && cardPackage !== undefined;
589
- return (React.createElement(CardBox, { style: boxStyle, fill: style?.fill, doc: doc, gradient: ctx.gradient, radius: style?.radius ?? (layout === "stack" ? undefined : 16), onPress: pinned ? () => ctx.onSelect(cardPackage.packageId) : undefined }, renderChildren(cardPackage, context)));
668
+ return (React.createElement(CardBox, { style: boxStyle, fill: style?.fill, doc: doc, gradient: ctx.gradient, radius: style?.radius ?? (layout === "stack" ? undefined : 16), onPress: pinned ? () => ctx.onSelect(cardPackage.packageId) : undefined }, carousel ? (React.createElement(Carousel, { pages: renderChildren(cardPackage, context), autoAdvance: block.autoAdvance, doc: doc })) : (renderChildren(cardPackage, context))));
590
669
  }
591
670
  }
592
671
  }
@@ -598,6 +677,7 @@ export function RevnixBlockPaywall(props) {
598
677
  // re-runs on every selection tap.
599
678
  const doc = React.useMemo(() => localizeBlockDoc(props.doc, props.locale ?? detectLocale()), [props.doc, props.locale]);
600
679
  const [internalSelected, setInternalSelected] = React.useState(undefined);
680
+ const [shownAt] = React.useState(Date.now);
601
681
  // Contract §1: host → own tap → design highlight → first package, each
602
682
  // only when the offering carries it.
603
683
  const selectedId = selectedPackageId(packages, props.selectedPackageId, internalSelected, props.highlightPackageId);
@@ -639,6 +719,7 @@ export function RevnixBlockPaywall(props) {
639
719
  onDiagnostic: props.onDiagnostic,
640
720
  style: styleCtx,
641
721
  loading: props.loading === true,
722
+ shownAt,
642
723
  };
643
724
  const children = doc.blocks.map((block) => (React.createElement(Block, { key: block.id, block: block, ctx: ctx, pkg: packages.find((p) => p.packageId === selectedId) ?? packages[0], inherited: rootText })));
644
725
  // REV-252: the renderer's own close, drawn only for a design that authors
@@ -1,4 +1,4 @@
1
1
  // L3: the single source of the SDK version inside src/. Must match
2
2
  // package.json "version" — version.test.ts fails the build when they drift
3
3
  // (they had: client.ts said 0.7.0 while the package shipped 0.9.0).
4
- export const SDK_VERSION = "1.6.0";
4
+ export const SDK_VERSION = "1.7.0";
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ export { memoryStorage, generateAnonymousId } from "./storage.js";
9
9
  export { SDK_VERSION } from "./version.js";
10
10
  export { DEVICE_HEADER, encodeDeviceHeader } from "./device.js";
11
11
  export { browserLifecycle, DEFAULT_SESSION_TIMEOUT_MS, HOST_CALL_PLACEMENT, IMPLICIT_PLACEMENTS, PREVIEW_PLACEMENT_KEY, type ImplicitPlacement, type ImplicitSkipReason, type ImplicitTrigger, type ImplicitTriggerResponse, type RevnixLifecycle, } from "./implicit.js";
12
- export type { AdRevenue, AliasResult, CustomerEntitlements, DeviceFacts, Entitlement, EntitlementSource, Environment, IdentifyResult, IdentifyStrategy, IdentityInfo, ImplicitResolution, LastDeepLink, MmpAttribution, MmpProvider, PaywallConfig, PaywallInteraction, PaywallPurchaseOutcome, PlacementOffering, PlacementPackage, PlacementPaywall, PlacementProduct, PlacementResolution, RegisterPurchaseInput, RegisterPurchaseResult, RevnixAttribution, RevnixConfig, RevnixDiagnostic, RevnixStorage, Store, } from "./types.js";
12
+ export type { AdRevenue, AliasResult, CustomerEntitlements, DeviceFacts, Entitlement, EntitlementSource, Environment, IdentifyResult, IdentifyStrategy, IdentityInfo, ImplicitResolution, IntegrityEvidence, LastDeepLink, MmpAttribution, MmpProvider, PaywallConfig, PaywallInteraction, PaywallPurchaseOutcome, PlacementOffering, PlacementPackage, PlacementPaywall, PlacementProduct, PlacementResolution, RegisterPurchaseInput, RegisterPurchaseResult, RevnixAttribution, RevnixConfig, RevnixDiagnostic, RevnixStorage, Store, } from "./types.js";
13
13
  /** Singleton facade — configure once at app start, then import `Revnix`
14
14
  * anywhere. Prefer constructing RevnixClient directly in tests. */
15
15
  export declare const Revnix: {
@@ -1,2 +1,10 @@
1
1
  import { type StoreAdapter } from "../store.js";
2
+ import type { RevnixConfig } from "../types.js";
2
3
  export declare const RevnixStore: StoreAdapter;
4
+ /** Pass as `deviceIntegrity` in the Revnix config: App Attest on iOS, Play
5
+ * Integrity on Android, via the RevnixStore native module. Resolves null
6
+ * where the device or build cannot produce evidence. */
7
+ export declare const nativeDeviceIntegrity: NonNullable<RevnixConfig["deviceIntegrity"]>;
8
+ /** Pass as `advertisingId` in the Revnix config: the Google Advertising ID
9
+ * on Android, via the RevnixStore native module. Resolves null on iOS. */
10
+ export declare const nativeAdvertisingId: NonNullable<RevnixConfig["advertisingId"]>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RevnixStore = void 0;
3
+ exports.nativeAdvertisingId = exports.nativeDeviceIntegrity = exports.RevnixStore = void 0;
4
4
  const react_native_1 = require("react-native");
5
5
  const store_js_1 = require("../store.js");
6
6
  const native = react_native_1.NativeModules.RevnixStore;
@@ -21,5 +21,14 @@ exports.RevnixStore = native
21
21
  finish: missing,
22
22
  observe: () => () => { },
23
23
  };
24
+ /** Pass as `deviceIntegrity` in the Revnix config: App Attest on iOS, Play
25
+ * Integrity on Android, via the RevnixStore native module. Resolves null
26
+ * where the device or build cannot produce evidence. */
27
+ const nativeDeviceIntegrity = (customerId) => native?.deviceIntegrity?.(customerId) ?? Promise.resolve(null);
28
+ exports.nativeDeviceIntegrity = nativeDeviceIntegrity;
29
+ /** Pass as `advertisingId` in the Revnix config: the Google Advertising ID
30
+ * on Android, via the RevnixStore native module. Resolves null on iOS. */
31
+ const nativeAdvertisingId = () => native?.advertisingId?.() ?? Promise.resolve(null);
32
+ exports.nativeAdvertisingId = nativeAdvertisingId;
24
33
  if (native)
25
34
  (0, store_js_1.setStoreAdapter)(exports.RevnixStore);
package/dist/types.d.ts CHANGED
@@ -129,7 +129,39 @@ export interface RevnixConfig {
129
129
  * Never throws into your app: an exception from this handler is reported
130
130
  * through `onDiagnostic` and swallowed. */
131
131
  onAttribution?: (attribution: RevnixAttribution) => void;
132
+ /** Opt-in device integrity evidence (Apple App Attest / Google Play
133
+ * Integrity) attached to every install report and `setAttribution` call.
134
+ * Pair it with the dashboard's "Require device integrity" setting, which
135
+ * strips attribution from installs that arrive without evidence. In React
136
+ * Native pass `nativeDeviceIntegrity` from "react-native-revnix/store";
137
+ * it needs iOS 14+ with App Attest, or Google Play services on Android.
138
+ * Simulators and emulators return nothing.
139
+ *
140
+ * Called at most once per customer id and bounded to 10s. A rejection,
141
+ * timeout, or missing/malformed result sends the request without
142
+ * evidence and reports `op: "deviceIntegrity"` through `onDiagnostic`. */
143
+ deviceIntegrity?: (customerId: string) => Promise<IntegrityEvidence | null | undefined>;
144
+ /** Google Advertising ID source, stored on the customer as `gps_adid` at
145
+ * launch and resent only when it changes. In React Native pass
146
+ * `nativeAdvertisingId` from "react-native-revnix/store"; it needs
147
+ * `play-services-ads-identifier` and the `AD_ID` permission in the app and
148
+ * answers null on iOS and on opt-out, and rejects when the id cannot be
149
+ * read. A null answer deletes a previously stored `gps_adid`; a
150
+ * rejection changes nothing and reports `op: "advertisingId"`. */
151
+ advertisingId?: () => Promise<string | null | undefined>;
132
152
  }
153
+ /** Device integrity evidence for `POST /v1/installs` and `/v1/attribution`.
154
+ * iOS: an App Attest key id and its attestation (standard base64) over
155
+ * SHA-256 of the customer id. Android: a classic Play Integrity token whose
156
+ * nonce is base64url (no padding) of SHA-256 of the customer id. */
157
+ export type IntegrityEvidence = {
158
+ platform: "ios";
159
+ keyId: string;
160
+ attestation: string;
161
+ } | {
162
+ platform: "android";
163
+ token: string;
164
+ };
133
165
  /** AT11: the install-attribution verdict — GET
134
166
  * /v1/customers/{id}/attribution. `installMatch: "unknown"` (no install
135
167
  * recorded yet, a normal cold-start race) is never sent to hosts as such;