@wireai/activation 0.10.0 → 0.12.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 (61) hide show
  1. package/AGENTS.md +51 -0
  2. package/CHANGELOG.md +111 -1
  3. package/INTEGRATION_PROMPT.md +13 -1
  4. package/README.md +106 -4
  5. package/dist/analytics/index.d.mts +35 -6
  6. package/dist/analytics/index.d.ts +35 -6
  7. package/dist/analytics/index.js +222 -94
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +214 -95
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/{currentSession-D0Vq7_VE.d.ts → currentSession-D6RiVtc8.d.ts} +187 -29
  12. package/dist/{currentSession-DdDkprpM.d.mts → currentSession-DsSDHqor.d.mts} +187 -29
  13. package/dist/index.d.mts +236 -82
  14. package/dist/index.d.ts +236 -82
  15. package/dist/index.js +330 -60
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +314 -61
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/questionnaire/index.d.mts +1 -1
  20. package/dist/questionnaire/index.d.ts +1 -1
  21. package/dist/questionnaire/index.js +59 -8
  22. package/dist/questionnaire/index.js.map +1 -1
  23. package/dist/questionnaire/index.mjs +59 -8
  24. package/dist/questionnaire/index.mjs.map +1 -1
  25. package/dist/reviews/index.d.mts +2 -2
  26. package/dist/reviews/index.d.ts +2 -2
  27. package/dist/reviews/index.js +97 -17
  28. package/dist/reviews/index.js.map +1 -1
  29. package/dist/reviews/index.mjs +97 -17
  30. package/dist/reviews/index.mjs.map +1 -1
  31. package/dist/{transport-Bzb-bcB2.d.mts → transport-CF_eHwzC.d.mts} +15 -16
  32. package/dist/{transport-B31G0Cib.d.ts → transport-DsRe4epC.d.ts} +15 -16
  33. package/llms.txt +9 -0
  34. package/package.json +1 -1
  35. package/src/activation/useWireActivation.ts +12 -1
  36. package/src/activation/wireActivation.ts +36 -24
  37. package/src/analytics/analyticsFacade.ts +113 -29
  38. package/src/analytics/currentSession.ts +83 -0
  39. package/src/analytics/eventQueue.ts +20 -11
  40. package/src/analytics/index.ts +29 -1
  41. package/src/analytics/reportClientEvent.ts +50 -2
  42. package/src/analytics/screenTracking.ts +6 -1
  43. package/src/analytics/useAnalytics.ts +22 -1
  44. package/src/context/deviceId.ts +109 -0
  45. package/src/context/userContext.ts +73 -0
  46. package/src/identity/userIdentity.ts +10 -0
  47. package/src/index.ts +50 -2
  48. package/src/questionnaire/runtime.ts +12 -2
  49. package/src/questionnaire/transport.ts +5 -1
  50. package/src/questionnaire/useQuestionnaireGate.ts +9 -7
  51. package/src/revenuecat/index.ts +55 -0
  52. package/src/revenuecat/purchaseEvents.ts +167 -0
  53. package/src/revenuecat/revenueCatBridge.ts +221 -0
  54. package/src/revenuecat/types.ts +95 -0
  55. package/src/reviews/decision.ts +8 -1
  56. package/src/reviews/runtime.ts +92 -1
  57. package/src/reviews/transport.ts +27 -10
  58. package/src/reviews/useReviewGate.ts +12 -7
  59. package/src/session-analytics/lifecycle.ts +15 -13
  60. package/src/session-analytics/reportSessionStart.ts +15 -4
  61. package/src/session-analytics/useLifecycleEvents.ts +68 -28
@@ -0,0 +1,167 @@
1
+ /**
2
+ * purchaseEvents - the canonical Wire names for a subscription funnel, plus the PURE mappers that
3
+ * turn a RevenueCat snapshot into small, non-PII event props.
4
+ *
5
+ * Same job `analyticsEvent.ts` does for the onboarding funnel: every app was naming these events
6
+ * itself (`PURCHASE`, `purchase_success`, `AI_PAYWALL_VIEW`), so the same funnel read differently
7
+ * per tenant and no cross-app report was possible. These are the ONE set of names; the mappers are
8
+ * the ONE definition of which RevenueCat fields are safe to send.
9
+ *
10
+ * PURE + dependency-free: no React, no React Native, no `react-native-purchases`, no transport.
11
+ * Every function tolerates a malformed or absent snapshot and returns a defined value instead of
12
+ * throwing, because a paywall must never break on an analytics mapping.
13
+ */
14
+ import type {
15
+ RevenueCatCustomerInfoLike,
16
+ RevenueCatEntitlementLike,
17
+ RevenueCatPackageLike,
18
+ } from "./types";
19
+
20
+ /**
21
+ * The canonical purchase-funnel event names. These are `app_event` `question_key` values on the
22
+ * wire, so they are ALSO the exact strings a review / questionnaire firing trigger matches on.
23
+ * Never rename one: a rename silently unfires every trigger configured against the old string.
24
+ */
25
+ export const WIRE_PURCHASE_EVENTS = {
26
+ /** The paywall became visible (offerings loaded). */
27
+ paywallShown: "wire_paywall_shown",
28
+ /** The user tapped buy; the store sheet is about to open. */
29
+ checkoutStarted: "wire_checkout_started",
30
+ /** The store confirmed the purchase AND the entitlement is now active. */
31
+ purchased: "wire_purchase_completed",
32
+ /** The purchase did not land: a user cancel or a real store/network error (see `reason`). */
33
+ purchaseFailed: "wire_purchase_failed",
34
+ /** A restore ran (whether or not it produced an entitlement; see `plan_tier`). */
35
+ restored: "wire_purchase_restored",
36
+ } as const;
37
+
38
+ export type WirePurchaseEventName =
39
+ (typeof WIRE_PURCHASE_EVENTS)[keyof typeof WIRE_PURCHASE_EVENTS];
40
+
41
+ /**
42
+ * The user-context key the bridge writes the tier to. It lands NAMESPACED as
43
+ * `user_context["custom.plan_tier"]` (host extras are always prefixed), which is the flattened
44
+ * dimension the server report can break the funnel down by.
45
+ */
46
+ export const PLAN_TIER_CONTEXT_KEY = "plan_tier";
47
+
48
+ /** The three tiers the kit models. Anything richer belongs in the host's own analytics. */
49
+ export type PlanTier = "paid" | "trial" | "free";
50
+
51
+ /** Read a plain record field defensively (the snapshot comes from a native bridge). */
52
+ const asRecord = (value: unknown): Record<string, unknown> | undefined =>
53
+ typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
54
+
55
+ /**
56
+ * The ACTIVE entitlement matching `entitlementId`, or `undefined`. Returns `undefined` for a
57
+ * missing key, an explicitly inactive entry, and any malformed snapshot.
58
+ */
59
+ export const activeEntitlement = (
60
+ info: RevenueCatCustomerInfoLike | undefined,
61
+ entitlementId: string,
62
+ ): RevenueCatEntitlementLike | undefined => {
63
+ const active = asRecord(asRecord(asRecord(info)?.entitlements)?.active);
64
+ const found = active?.[entitlementId];
65
+ const entitlement = asRecord(found) as RevenueCatEntitlementLike | undefined;
66
+ if (!entitlement) return undefined;
67
+ // `isActive` is always present on the real type; treat an explicit `false` as not entitled and a
68
+ // missing value as entitled, because the key only appears in `entitlements.active` at all when
69
+ // RevenueCat considers it granted.
70
+ return entitlement.isActive === false ? undefined : entitlement;
71
+ };
72
+
73
+ /** True when RevenueCat says this entitlement is a free trial rather than a paid period. */
74
+ const isTrial = (entitlement: RevenueCatEntitlementLike): boolean =>
75
+ typeof entitlement.periodType === "string" && entitlement.periodType.toUpperCase() === "TRIAL";
76
+
77
+ /** `paid` / `trial` / `free` for the configured entitlement. Never throws. */
78
+ export const resolvePlanTier = (
79
+ info: RevenueCatCustomerInfoLike | undefined,
80
+ entitlementId: string,
81
+ ): PlanTier => {
82
+ const entitlement = activeEntitlement(info, entitlementId);
83
+ if (!entitlement) return "free";
84
+ return isTrial(entitlement) ? "trial" : "paid";
85
+ };
86
+
87
+ /**
88
+ * Non-PII props for an offering package: which package, which product, what it costs. Every field
89
+ * is optional on the wire, so a missing one is OMITTED rather than sent empty or invented.
90
+ */
91
+ export const describePackage = (
92
+ pkg: RevenueCatPackageLike | undefined,
93
+ ): Record<string, string | number> => {
94
+ const props: Record<string, string | number> = {};
95
+ if (!pkg) return props;
96
+ if (typeof pkg.identifier === "string") props.package_id = pkg.identifier;
97
+ if (typeof pkg.packageType === "string") props.package_type = pkg.packageType;
98
+ if (typeof pkg.offeringIdentifier === "string") props.offering_id = pkg.offeringIdentifier;
99
+ const product = asRecord(pkg.product) as RevenueCatProductFields | undefined;
100
+ if (product) {
101
+ if (typeof product.identifier === "string") props.product_id = product.identifier;
102
+ if (typeof product.price === "number" && Number.isFinite(product.price)) {
103
+ props.price = product.price;
104
+ }
105
+ if (typeof product.currencyCode === "string") props.currency = product.currencyCode;
106
+ }
107
+ return props;
108
+ };
109
+
110
+ /** The product fields `describePackage` reads, narrowed after the defensive record check. */
111
+ type RevenueCatProductFields = {
112
+ identifier?: unknown;
113
+ price?: unknown;
114
+ currencyCode?: unknown;
115
+ };
116
+
117
+ /**
118
+ * Non-PII props for the entitlement state after a purchase or a restore. Always carries
119
+ * `entitlement` + `plan_tier` so a "restore that granted nothing" is still a queryable row; the
120
+ * rest of the fields only appear when the entitlement is actually active.
121
+ */
122
+ export const describeEntitlement = (
123
+ info: RevenueCatCustomerInfoLike | undefined,
124
+ entitlementId: string,
125
+ ): Record<string, string | number | boolean> => {
126
+ const entitlement = activeEntitlement(info, entitlementId);
127
+ const props: Record<string, string | number | boolean> = {
128
+ entitlement: entitlementId,
129
+ plan_tier: entitlement ? (isTrial(entitlement) ? "trial" : "paid") : "free",
130
+ };
131
+ if (!entitlement) return props;
132
+ if (typeof entitlement.periodType === "string") props.period_type = entitlement.periodType;
133
+ props.is_trial = isTrial(entitlement);
134
+ if (typeof entitlement.willRenew === "boolean") props.will_renew = entitlement.willRenew;
135
+ if (typeof entitlement.store === "string") props.store = entitlement.store;
136
+ if (typeof entitlement.productIdentifier === "string") {
137
+ props.product_id = entitlement.productIdentifier;
138
+ }
139
+ if (typeof entitlement.isSandbox === "boolean") props.is_sandbox = entitlement.isSandbox;
140
+ return props;
141
+ };
142
+
143
+ /** RevenueCat's `PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR`. A STRING enum, value `"1"`. */
144
+ const PURCHASE_CANCELLED_CODE = "1";
145
+
146
+ /**
147
+ * True when the rejection is the user backing out of the store sheet rather than a real failure.
148
+ * Reads BOTH signals the SDK exposes: the current `code` and the deprecated-but-still-populated
149
+ * `userCancelled` flag. Anything else (including a plain `Error`) is a genuine failure.
150
+ */
151
+ export const isUserCancelled = (error: unknown): boolean => {
152
+ const record = asRecord(error);
153
+ if (!record) return false;
154
+ if (record.userCancelled === true) return true;
155
+ return record.code === PURCHASE_CANCELLED_CODE;
156
+ };
157
+
158
+ /**
159
+ * A short, stable, non-PII failure descriptor. The store's `message` is DELIBERATELY dropped: it is
160
+ * localized, unbounded, and occasionally echoes the account it failed for, so it is not something
161
+ * the kit forwards to a shared analytics backend. The RevenueCat `code` is the stable handle.
162
+ */
163
+ export const describeFailure = (error: unknown): { reason: string; code: string } => {
164
+ const record = asRecord(error);
165
+ const code = typeof record?.code === "string" ? record.code : "unknown";
166
+ return { reason: isUserCancelled(error) ? "cancelled" : "error", code };
167
+ };
@@ -0,0 +1,221 @@
1
+ /**
2
+ * revenueCatBridge - the short, first-class path from RevenueCat to Wire activation.
3
+ *
4
+ * const revenuecat = createRevenueCatBridge({ analytics, entitlementId: "pro" });
5
+ * revenuecat.paywallShown(offering, { source: variant });
6
+ * revenuecat.checkoutStarted(pkg, { source: variant });
7
+ * const entitled = revenuecat.purchaseCompleted(customerInfo, pkg, { source: variant });
8
+ *
9
+ * WHAT IT REPLACES: every consumer was hand-rolling the same four things around its paywall,
10
+ * differently: its own event names, its own idea of which RevenueCat fields are safe to send, its
11
+ * own entitlement check duplicated at the purchase and the restore site, and its own plan-tier
12
+ * user property. This owns all four, so adopting the purchase funnel is a constructor plus five
13
+ * call sites.
14
+ *
15
+ * ── THE JOIN KEY (read this before you wire it) ───────────────────────────────────────────────
16
+ * A purchase event is only worth anything if it can be joined to the SAME user's onboarding. The
17
+ * key that does that is `user_context.device_key`, NOT `session_id`:
18
+ *
19
+ * • Purchase side: this bridge reports through a kit sink (`createAnalytics` or
20
+ * `createWireActivation`). Both auto-mint a stable, persisted, per-install `device_key` and
21
+ * stamp it on every event (see `context/deviceId.ts`).
22
+ * • Onboarding side: `<WireOnboarding userContext={activationJoinContext(deviceKey)} />`. The kit
23
+ * forwards `userContext` verbatim into the A2A session-start metadata, and the server records
24
+ * it on the session's `session_started` event, where the SAME `device_key` lookup reads it.
25
+ *
26
+ * Pass the SAME `deviceKey` to both. `session_id` is NOT a join key across these two families: the
27
+ * onboarding session id is the A2A `contextId`, the app-event session id is the per-open id, and
28
+ * they live in disjoint spaces on purpose. Intersecting them yields zero rows, every time.
29
+ *
30
+ * PURE + dependency-free: no React, no React Native, no `react-native-purchases`. Fire-and-forget
31
+ * throughout, so an analytics failure can never take the paywall down with it.
32
+ */
33
+ import type { Analytics } from "../analytics/analyticsFacade";
34
+ import type { WireActivation } from "../activation/wireActivation";
35
+ import {
36
+ PLAN_TIER_CONTEXT_KEY,
37
+ WIRE_PURCHASE_EVENTS,
38
+ describeEntitlement,
39
+ describeFailure,
40
+ describePackage,
41
+ resolvePlanTier,
42
+ type PlanTier,
43
+ } from "./purchaseEvents";
44
+ import type {
45
+ RevenueCatCustomerInfoLike,
46
+ RevenueCatOfferingLike,
47
+ RevenueCatPackageLike,
48
+ RevenueCatSink,
49
+ } from "./types";
50
+
51
+ /**
52
+ * COMPILE-TIME PROOF that both kit sinks satisfy {@link RevenueCatSink}, so a host can pass the
53
+ * analytics instance it already holds with no adapter of its own. Type-only (erased at build), and
54
+ * `tsc --noEmit` fails here the day either surface drifts. This lives in source, not the test file,
55
+ * because `tsconfig.json` excludes tests from the typecheck.
56
+ */
57
+ type AssertIsSink<T extends RevenueCatSink> = T;
58
+ type _AnalyticsIsASink = AssertIsSink<Analytics>;
59
+ type _WireActivationIsASink = AssertIsSink<WireActivation>;
60
+
61
+ /** Small, non-PII extras a host merges onto a purchase event (paywall variant, entry point, ...). */
62
+ export type PurchaseProps = Record<string, string | number | boolean>;
63
+
64
+ /** Inputs for {@link createRevenueCatBridge}. */
65
+ export interface RevenueCatBridgeConfig {
66
+ /**
67
+ * Where events go: the `createAnalytics(...)` instance (offline-first queue) or the
68
+ * `createWireActivation(...)` instance (awaitable POST). Both already carry the `device_key` this
69
+ * funnel joins on, so pass the one the app already holds rather than creating a second.
70
+ */
71
+ analytics: RevenueCatSink;
72
+ /**
73
+ * The entitlement identifier that means "paid" in THIS app, exactly as configured in the
74
+ * RevenueCat dashboard (e.g. `"pro"`). The bridge reads `customerInfo.entitlements.active[id]`.
75
+ */
76
+ entitlementId: string;
77
+ /** Non-PII props merged into EVERY event from this bridge. A per-call prop of the same name wins. */
78
+ commonProps?: PurchaseProps;
79
+ /**
80
+ * Whether entitlement changes also update the bound user context with `plan_tier`
81
+ * (`user_context["custom.plan_tier"]`), so the whole funnel can be sliced paid vs trial vs free.
82
+ * Default `true`. Requires a sink with `setUserContext` (`createAnalytics`); a no-op otherwise.
83
+ */
84
+ writePlanTier?: boolean;
85
+ }
86
+
87
+ /** The bridge surface. Every method is fire-and-forget and never throws. */
88
+ export interface RevenueCatBridge {
89
+ /**
90
+ * The paywall became visible. Pass the RevenueCat offering you just loaded and the bridge derives
91
+ * `offering_id` + `packages_count`. Fires even with no offering, because a paywall that reached
92
+ * the user with nothing to sell is exactly the failure you want in the funnel.
93
+ */
94
+ paywallShown(offering?: RevenueCatOfferingLike, props?: PurchaseProps): void;
95
+ /** The user tapped buy and the store sheet is opening. */
96
+ checkoutStarted(pkg: RevenueCatPackageLike, props?: PurchaseProps): void;
97
+ /**
98
+ * The store returned from a purchase. Fires `wire_purchase_completed` when the configured
99
+ * entitlement is now active, and `wire_purchase_failed` with `reason: "not_entitled"` when the
100
+ * store confirmed but nothing was granted (the case hosts usually drop on the floor). Syncs
101
+ * `plan_tier` either way. Returns whether the user is entitled now, so the caller can navigate.
102
+ */
103
+ purchaseCompleted(
104
+ customerInfo: RevenueCatCustomerInfoLike | undefined,
105
+ pkg?: RevenueCatPackageLike,
106
+ props?: PurchaseProps,
107
+ ): boolean;
108
+ /** The purchase call rejected. Separates a user cancel from a real store error via `reason`. */
109
+ purchaseFailed(error: unknown, pkg?: RevenueCatPackageLike, props?: PurchaseProps): void;
110
+ /** A restore finished. Reports the resulting tier and returns whether the user is entitled now. */
111
+ purchasesRestored(
112
+ customerInfo: RevenueCatCustomerInfoLike | undefined,
113
+ props?: PurchaseProps,
114
+ ): boolean;
115
+ /**
116
+ * Write the current tier to the bound user context WITHOUT emitting an event. Call it once at
117
+ * launch with a `getCustomerInfo()` snapshot so returning subscribers are segmented correctly
118
+ * from their first event of the session. Returns the tier it resolved.
119
+ */
120
+ syncPlanTier(customerInfo: RevenueCatCustomerInfoLike | undefined): PlanTier;
121
+ }
122
+
123
+ /**
124
+ * Create a bridge bound to one sink and one entitlement. Pure, React-free, and safe to create at
125
+ * module scope next to the paywall.
126
+ */
127
+ export const createRevenueCatBridge = (config: RevenueCatBridgeConfig): RevenueCatBridge => {
128
+ const { analytics, entitlementId } = config;
129
+ const writeTier = config.writePlanTier !== false;
130
+
131
+ /**
132
+ * Send one event. Swallows a throwing sink AND a rejected promise from an awaitable one, so an
133
+ * analytics failure is never visible at the paywall and never becomes an unhandled rejection.
134
+ */
135
+ const emit = (event: string, props: Record<string, unknown>): void => {
136
+ try {
137
+ const result = analytics.track(event, { ...config.commonProps, ...props });
138
+ if (result && typeof (result as Promise<boolean>).catch === "function") {
139
+ void (result as Promise<boolean>).catch(() => {});
140
+ }
141
+ } catch {
142
+ /* analytics must never break a purchase */
143
+ }
144
+ };
145
+
146
+ const syncPlanTier = (customerInfo: RevenueCatCustomerInfoLike | undefined): PlanTier => {
147
+ const tier = resolvePlanTier(customerInfo, entitlementId);
148
+ if (!writeTier) return tier;
149
+ try {
150
+ analytics.setUserContext?.({ extra: { [PLAN_TIER_CONTEXT_KEY]: tier } });
151
+ } catch {
152
+ /* a sink without a usable context writer is not a purchase failure */
153
+ }
154
+ return tier;
155
+ };
156
+
157
+ const paywallShown = (offering?: RevenueCatOfferingLike, props?: PurchaseProps): void => {
158
+ const shown: Record<string, unknown> = {
159
+ packages_count: offering?.availablePackages?.length ?? 0,
160
+ };
161
+ if (typeof offering?.identifier === "string") shown.offering_id = offering.identifier;
162
+ emit(WIRE_PURCHASE_EVENTS.paywallShown, { ...shown, ...props });
163
+ };
164
+
165
+ const checkoutStarted = (pkg: RevenueCatPackageLike, props?: PurchaseProps): void => {
166
+ emit(WIRE_PURCHASE_EVENTS.checkoutStarted, { ...describePackage(pkg), ...props });
167
+ };
168
+
169
+ const purchaseCompleted = (
170
+ customerInfo: RevenueCatCustomerInfoLike | undefined,
171
+ pkg?: RevenueCatPackageLike,
172
+ props?: PurchaseProps,
173
+ ): boolean => {
174
+ const tier = syncPlanTier(customerInfo);
175
+ const entitled = tier !== "free";
176
+ if (entitled) {
177
+ emit(WIRE_PURCHASE_EVENTS.purchased, {
178
+ ...describePackage(pkg),
179
+ ...describeEntitlement(customerInfo, entitlementId),
180
+ ...props,
181
+ });
182
+ return true;
183
+ }
184
+ // The store said yes but the entitlement is not active: a product/entitlement mapping problem,
185
+ // the single most common silent paywall defect. Report it rather than returning quietly.
186
+ emit(WIRE_PURCHASE_EVENTS.purchaseFailed, {
187
+ ...describePackage(pkg),
188
+ reason: "not_entitled",
189
+ code: "unknown",
190
+ entitlement: entitlementId,
191
+ ...props,
192
+ });
193
+ return false;
194
+ };
195
+
196
+ const purchaseFailed = (
197
+ error: unknown,
198
+ pkg?: RevenueCatPackageLike,
199
+ props?: PurchaseProps,
200
+ ): void => {
201
+ emit(WIRE_PURCHASE_EVENTS.purchaseFailed, {
202
+ ...describePackage(pkg),
203
+ ...describeFailure(error),
204
+ ...props,
205
+ });
206
+ };
207
+
208
+ const purchasesRestored = (
209
+ customerInfo: RevenueCatCustomerInfoLike | undefined,
210
+ props?: PurchaseProps,
211
+ ): boolean => {
212
+ const tier = syncPlanTier(customerInfo);
213
+ emit(WIRE_PURCHASE_EVENTS.restored, {
214
+ ...describeEntitlement(customerInfo, entitlementId),
215
+ ...props,
216
+ });
217
+ return tier !== "free";
218
+ };
219
+
220
+ return { paywallShown, checkoutStarted, purchaseCompleted, purchaseFailed, purchasesRestored, syncPlanTier };
221
+ };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * types - STRUCTURAL mirrors of the `react-native-purchases` shapes this adapter READS.
3
+ *
4
+ * WHY structural and not an import: `react-native-purchases` is a NATIVE module. Importing it
5
+ * (even as an optional peer) would put a native rebuild in the kit's dependency story, which the
6
+ * kit's hard rule forbids. Every field below is a subset of the real 9.6.x typings
7
+ * (`@revenuecat/purchases-typescript-internal`: `CustomerInfo`, `PurchasesEntitlementInfo`,
8
+ * `PurchasesPackage`, `PurchasesStoreProduct`, `PurchasesError`), declared as plain `string` /
9
+ * `number` / `boolean` so a host can pass its REAL RevenueCat objects straight in: RevenueCat's
10
+ * `Store`, `PACKAGE_TYPE`, `PERIOD_TYPE` and `PURCHASES_ERROR_CODE` are all STRING enums, and a
11
+ * string enum member is assignable to `string`.
12
+ *
13
+ * The adapter only ever reads. It never constructs a RevenueCat object and never calls the SDK.
14
+ */
15
+
16
+ /** One entitlement as RevenueCat reports it (subset of `PurchasesEntitlementInfo`). */
17
+ export interface RevenueCatEntitlementLike {
18
+ /** The entitlement identifier configured in the RevenueCat dashboard, e.g. `"pro"`. */
19
+ readonly identifier: string;
20
+ /** True while the customer has access. */
21
+ readonly isActive: boolean;
22
+ /** `"NORMAL" | "INTRO" | "TRIAL" | "PREPAID"` on the real type; compared case-insensitively. */
23
+ readonly periodType?: string;
24
+ /** True when the underlying subscription renews at the end of the period. */
25
+ readonly willRenew?: boolean;
26
+ /** The store the entitlement was unlocked from, e.g. `"APP_STORE"`. */
27
+ readonly store?: string;
28
+ /** The product id that unlocked the entitlement. */
29
+ readonly productIdentifier?: string;
30
+ /** False when the purchase is a production one. */
31
+ readonly isSandbox?: boolean;
32
+ }
33
+
34
+ /** The customer snapshot RevenueCat returns from a purchase, a restore, or `getCustomerInfo`. */
35
+ export interface RevenueCatCustomerInfoLike {
36
+ readonly entitlements: {
37
+ /** Active entitlements keyed by identifier. Missing key means "not entitled". */
38
+ readonly active: { readonly [key: string]: RevenueCatEntitlementLike | undefined };
39
+ };
40
+ /** Product ids of the customer's active subscriptions. */
41
+ readonly activeSubscriptions?: readonly string[];
42
+ }
43
+
44
+ /** The purchasable product on a package (subset of `PurchasesStoreProduct`). */
45
+ export interface RevenueCatProductLike {
46
+ readonly identifier: string;
47
+ /** Price in the local currency, as a number. */
48
+ readonly price?: number;
49
+ /** ISO currency code of `price`, e.g. `"EUR"`. */
50
+ readonly currencyCode?: string;
51
+ }
52
+
53
+ /** One offering package (subset of `PurchasesPackage`). */
54
+ export interface RevenueCatPackageLike {
55
+ readonly identifier: string;
56
+ /** `"ANNUAL" | "MONTHLY" | ...` on the real type. */
57
+ readonly packageType?: string;
58
+ readonly product: RevenueCatProductLike;
59
+ /** The offering this package belongs to. */
60
+ readonly offeringIdentifier?: string;
61
+ }
62
+
63
+ /** The current offering a paywall renders (subset of `PurchasesOffering`). */
64
+ export interface RevenueCatOfferingLike {
65
+ readonly identifier?: string;
66
+ readonly availablePackages?: readonly RevenueCatPackageLike[];
67
+ }
68
+
69
+ /** The rejected value of a RevenueCat purchase call (subset of `PurchasesError`). */
70
+ export interface RevenueCatErrorLike {
71
+ /** `PURCHASES_ERROR_CODE`, a STRING enum; `"1"` is `PURCHASE_CANCELLED_ERROR`. */
72
+ readonly code?: string;
73
+ readonly message?: string;
74
+ /** Deprecated on the real type but still populated by 9.6.x. */
75
+ readonly userCancelled?: boolean | null;
76
+ }
77
+
78
+ /**
79
+ * The Wire sink the bridge reports through. Deliberately STRUCTURAL so BOTH kit surfaces satisfy
80
+ * it with no adapter of their own:
81
+ * - `createAnalytics(...)` (offline-first queue) matches `track` + `setUserContext`;
82
+ * - `createWireActivation(...)` (awaitable `wire.track`) matches `track`.
83
+ *
84
+ * Both auto-mint and stamp `user_context.device_key` on every event, which is the join key this
85
+ * adapter's whole design rests on (see `joinKey.md` in the module docs / the README section).
86
+ */
87
+ export interface RevenueCatSink {
88
+ /** Report an in-app event. Returns `void` (queued) or `Promise<boolean>` (awaited POST). */
89
+ track(event: string, props?: Record<string, unknown>): void | Promise<boolean>;
90
+ /**
91
+ * Optional: update the bound user context. Present on `createAnalytics`, absent on
92
+ * `createWireActivation` (its config is captured immutably), so entitlement sync is a no-op there.
93
+ */
94
+ setUserContext?(partial: { extra?: Record<string, string | number | boolean> }): void;
95
+ }
@@ -64,7 +64,14 @@ export const resolveRules = (config: {
64
64
  oncePerVersion?: boolean;
65
65
  }): GateRules => ({
66
66
  enabled: config.enabled ?? true,
67
- minSessions: config.minSessions ?? 0,
67
+ // FAIL-CLOSED on the first session (behavior change, 0.11.0). An unconfigured host with no
68
+ // server decision must NOT prompt on the very first mount: `minSessions` defaults to 2 so the
69
+ // gate needs at least a second session before the LOCAL rules can fire. This is the client-side
70
+ // floor only — a server `decision` (from `fetchReviewDecision`) still OVERRIDES everything, and a
71
+ // host that genuinely wants first-session prompting can set `minSessions: 1` (or `0`) explicitly.
72
+ // Mirrors the 2026-07-16 incident: a first-session user, no server decision, got the prompt and
73
+ // left 1 star. See `evaluateGate` (`min_sessions` reason) and the CHANGELOG.
74
+ minSessions: config.minSessions ?? 2,
68
75
  minEvents: config.minEvents ?? 0,
69
76
  cooldownDays: config.cooldownDays ?? 0,
70
77
  oncePerVersion: config.oncePerVersion ?? true,
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import type { CoachmarkStorage } from "../coachmarks/types";
8
8
  import { getCoachmarkStorage } from "../coachmarks/runtime";
9
+ import { getCurrentSessionId } from "../analytics/currentSession";
10
+ import { makeSessionId } from "../analytics/reportClientEvent";
9
11
 
10
12
  /** Once-gate key. Keyed by app version when `oncePerVersion` is on, so a new release re-enables. */
11
13
  export const reviewSeenKey = (id: string, version?: string): string =>
@@ -14,9 +16,12 @@ export const reviewSeenKey = (id: string, version?: string): string =>
14
16
  /** Last-shown timestamp key (epoch ms), for the cooldown rule. */
15
17
  export const reviewLastShownKey = (id: string): string => `wire_review_${id}_last`;
16
18
 
17
- /** Session-count key, incremented once per gate mount, for the min-sessions rule. */
19
+ /** Session-count key, incremented once per APP-OPEN (not per mount), for the min-sessions rule. */
18
20
  export const reviewSessionsKey = (id: string): string => `wire_review_${id}_sessions`;
19
21
 
22
+ /** Companion key holding the open id the counter was LAST incremented for (see {@link bumpSessionCount}). */
23
+ export const reviewSessionOpenKey = (id: string): string => `wire_review_${id}_open`;
24
+
20
25
  /** Resolve the storage to use: an explicit override, else the provider-injected singleton. */
21
26
  export const resolveStorage = (
22
27
  override?: CoachmarkStorage | null,
@@ -43,3 +48,89 @@ export const writeInt = (storage: CoachmarkStorage | null, key: string, value: n
43
48
  // Best-effort.
44
49
  }
45
50
  };
51
+
52
+ /** Read a string from sync storage (undefined on a missing/blank/throwing read). */
53
+ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefined => {
54
+ if (!storage) return undefined;
55
+ try {
56
+ const raw = storage.getItem(key);
57
+ return typeof raw === "string" && raw.length > 0 ? raw : undefined;
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ };
62
+
63
+ // ── The "which app-open is this" id ──────────────────────────────────────────────────────────
64
+ //
65
+ // The gates' `minSessions` rule needs a SESSION, and the counter behind it used to increment once
66
+ // per gate MOUNT. A mount is not a session: navigating away from the home feed and back, a tab that
67
+ // unmounts its screen, or React StrictMode's dev double-invoke of the `useState` initializer all
68
+ // bumped it. So `minSessions: 2` — the fail-closed default added after the 2026-07-16 one-star
69
+ // incident — was satisfiable inside the user's FIRST app open, which is the exact thing it exists
70
+ // to prevent. `sessions` was a count of mounts wearing the name of a session.
71
+ //
72
+ // The correct unit already exists: `getCurrentSessionId()`, the per-open id the server sees on
73
+ // `app.session_started`. When a host wires the lifecycle events, the counter keys off THAT and is
74
+ // exactly "distinct app-opens", the same unit the server's own `min_sessions` uses.
75
+ //
76
+ // When no host wired lifecycle events there is no registered open, so we fall back to a PROCESS-
77
+ // scoped id: one JS process is one app launch, which is still a genuine app-open and is strictly
78
+ // closer to a session than a mount is. It lives in a `globalThis` slot keyed by `Symbol.for(...)`
79
+ // for the same reason `currentSession` does: tsup inlines this module into several bundles and a
80
+ // plain module-local `let` would give each bundle its own "process".
81
+ const PROCESS_OPEN_ID_SLOT: unique symbol = Symbol.for("@wireai/activation:processOpenId");
82
+
83
+ type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: string };
84
+
85
+ const openIdGlobal = globalThis as GlobalWithOpenId;
86
+
87
+ /**
88
+ * The id identifying THIS app-open for gate counting: the live per-open `session_id` when a host
89
+ * wired the lifecycle events, else a stable per-process id. Never empty.
90
+ */
91
+ export const currentOpenId = (): string => {
92
+ const live = getCurrentSessionId();
93
+ if (live) return live;
94
+ const existing = openIdGlobal[PROCESS_OPEN_ID_SLOT];
95
+ if (existing) return existing;
96
+ const created = makeSessionId();
97
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = created;
98
+ return created;
99
+ };
100
+
101
+ /** Test-only: forget the process open id so a unit test starts from a clean launch. */
102
+ export const resetProcessOpenId = (): void => {
103
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = undefined;
104
+ };
105
+
106
+ /**
107
+ * Return the app-open count for the gate, incrementing it AT MOST ONCE per app-open.
108
+ *
109
+ * IDEMPOTENT by construction: the open id that last incremented the counter is stored alongside it,
110
+ * so a second call within the same open (a remount, a StrictMode double-invoke, a second gate render)
111
+ * reads the stored count back instead of bumping it. A new open id bumps exactly once.
112
+ *
113
+ * Storage-less hosts get `1` for the first call and `1` for every later call within the open —
114
+ * degraded but never inflating, which is the safe direction for a fail-closed gate.
115
+ */
116
+ export const bumpSessionCount = (
117
+ storage: CoachmarkStorage | null,
118
+ sessionsKey: string,
119
+ openKey: string,
120
+ openId: string = currentOpenId(),
121
+ ): number => {
122
+ const lastOpen = readStr(storage, openKey);
123
+ const stored = readInt(storage, sessionsKey);
124
+ // Already counted this open → return what we counted, do not bump again.
125
+ if (lastOpen === openId) return stored > 0 ? stored : 1;
126
+ const next = stored + 1;
127
+ writeInt(storage, sessionsKey, next);
128
+ if (storage) {
129
+ try {
130
+ storage.setItem(openKey, openId);
131
+ } catch {
132
+ // Best-effort: a failed write only means this open may be counted twice.
133
+ }
134
+ }
135
+ return next;
136
+ };