@sendoracloud/sdk-react-native 1.15.0 → 1.18.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/dist/index.cjs CHANGED
@@ -64,20 +64,30 @@ async function getAsyncStorage() {
64
64
  return asyncStoragePromise;
65
65
  }
66
66
  var Storage = class {
67
- constructor() {
67
+ constructor(opts = {}) {
68
68
  this.cache = /* @__PURE__ */ new Map();
69
69
  this.hydrated = false;
70
+ this.secure = opts.secureAdapter ?? null;
71
+ this.secureKeys = new Set(opts.secureKeys ?? []);
72
+ }
73
+ isSecure(key) {
74
+ return this.secure !== null && this.secureKeys.has(key);
70
75
  }
71
76
  /**
72
- * Populate the memory cache from AsyncStorage. Must be awaited
77
+ * Populate the memory cache from persistent storage. Must be awaited
73
78
  * once at SDK init — after that, `get` returns synchronously from
74
- * the cache and `set` fire-and-forgets to AsyncStorage.
79
+ * the cache and `set` fire-and-forgets. For secure keys, reads the
80
+ * adapter first, then falls back to (and migrates from) any legacy
81
+ * plaintext AsyncStorage value written by a pre-secure SDK version —
82
+ * so enabling secure storage never signs an existing user out.
75
83
  */
76
84
  async hydrate(keys) {
77
85
  if (this.hydrated) return;
78
86
  const store = await getAsyncStorage();
79
- if (store) {
80
- for (const k of keys) {
87
+ for (const k of keys) {
88
+ if (this.isSecure(k)) {
89
+ await this.hydrateSecureKey(k, store);
90
+ } else if (store) {
81
91
  try {
82
92
  const v = await store.getItem(PREFIX + k);
83
93
  if (v !== null) this.cache.set(k, v);
@@ -87,11 +97,50 @@ var Storage = class {
87
97
  }
88
98
  this.hydrated = true;
89
99
  }
100
+ /** Read a secure key: adapter first, else migrate legacy plaintext. Never throws. */
101
+ async hydrateSecureKey(k, store) {
102
+ try {
103
+ const secureVal = await this.secure.getItem(k);
104
+ if (secureVal !== null) {
105
+ this.cache.set(k, secureVal);
106
+ return;
107
+ }
108
+ if (store) {
109
+ const legacy = await store.getItem(PREFIX + k).catch(() => null);
110
+ if (legacy !== null) {
111
+ this.cache.set(k, legacy);
112
+ try {
113
+ await this.secure.setItem(k, legacy);
114
+ await store.removeItem(PREFIX + k).catch(() => {
115
+ });
116
+ } catch {
117
+ }
118
+ }
119
+ }
120
+ } catch {
121
+ if (store) {
122
+ try {
123
+ const legacy = await store.getItem(PREFIX + k);
124
+ if (legacy !== null) this.cache.set(k, legacy);
125
+ } catch {
126
+ }
127
+ }
128
+ }
129
+ }
90
130
  get(key) {
91
131
  return this.cache.get(key) ?? null;
92
132
  }
93
133
  set(key, value) {
94
134
  this.cache.set(key, value);
135
+ if (this.isSecure(key)) {
136
+ void this.secure.setItem(key, value).catch(() => {
137
+ void getAsyncStorage().then((store) => {
138
+ if (store) store.setItem(PREFIX + key, value).catch(() => {
139
+ });
140
+ });
141
+ });
142
+ return;
143
+ }
95
144
  void getAsyncStorage().then((store) => {
96
145
  if (store) store.setItem(PREFIX + key, value).catch(() => {
97
146
  });
@@ -99,6 +148,10 @@ var Storage = class {
99
148
  }
100
149
  remove(key) {
101
150
  this.cache.delete(key);
151
+ if (this.isSecure(key)) {
152
+ void this.secure.removeItem(key).catch(() => {
153
+ });
154
+ }
102
155
  void getAsyncStorage().then((store) => {
103
156
  if (store) store.removeItem(PREFIX + key).catch(() => {
104
157
  });
@@ -106,13 +159,21 @@ var Storage = class {
106
159
  }
107
160
  /**
108
161
  * Remove every sendora_*-prefixed key from both memory AND
109
- * AsyncStorage. Uses getAllKeys() to find keys this process never
110
- * cached (e.g. orphaned writes from a prior SDK version that added
111
- * a key not in our hydrate list). Falls back to cache enumeration
162
+ * AsyncStorage, plus every secure key from the adapter. Uses
163
+ * getAllKeys() to find keys this process never cached (e.g. orphaned
164
+ * writes from a prior SDK version). Falls back to cache enumeration
112
165
  * if the AsyncStorage backend doesn't expose getAllKeys.
113
166
  */
114
167
  async clearAll() {
115
168
  this.cache.clear();
169
+ if (this.secure) {
170
+ for (const k of this.secureKeys) {
171
+ try {
172
+ await this.secure.removeItem(k);
173
+ } catch {
174
+ }
175
+ }
176
+ }
116
177
  const store = await getAsyncStorage();
117
178
  if (!store) return;
118
179
  let keys = [];
@@ -125,7 +186,7 @@ var Storage = class {
125
186
  }
126
187
  }
127
188
  if (keys.length === 0) {
128
- keys = Array.from(this.cache.keys()).map((k) => PREFIX + k);
189
+ keys = Array.from(this.secureKeys).map((k) => PREFIX + k);
129
190
  }
130
191
  for (const k of keys) {
131
192
  try {
@@ -139,7 +200,7 @@ var Storage = class {
139
200
  // package.json
140
201
  var package_default = {
141
202
  name: "@sendoracloud/sdk-react-native",
142
- version: "1.15.0",
203
+ version: "1.18.0",
143
204
  description: "Sendora Cloud React Native + Expo SDK \u2014 analytics, identity, push-token registration, auth, deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push token registration requires a Dev Client (EAS Build or `npx expo prebuild`).",
144
205
  type: "module",
145
206
  main: "./dist/index.cjs",
@@ -192,13 +253,15 @@ var package_default = {
192
253
  },
193
254
  scripts: {
194
255
  build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
195
- typecheck: "tsc --noEmit"
256
+ typecheck: "tsc --noEmit",
257
+ test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts"
196
258
  },
197
259
  devDependencies: {
198
260
  "@types/react": "^19.2.16",
199
261
  "react-native": "^0.85.3",
200
262
  "react-native-webview": "^13.16.1",
201
263
  tsup: "^8.0.0",
264
+ tsx: "^4.21.0",
202
265
  typescript: "^5.0.0"
203
266
  },
204
267
  license: "MIT",
@@ -1999,6 +2062,7 @@ var AUTH_HYDRATE_KEYS = [
1999
2062
  "auth_refresh_token",
2000
2063
  "auth_user"
2001
2064
  ];
2065
+ var SECURE_KEYS = ["auth_refresh_token", "auth_access_token", "auth_user"];
2002
2066
  var PUBLIC_KEY_RE = /^pk_(prod|staging|dev|live|test)_[A-Za-z0-9]{16,}$/;
2003
2067
  var MAX_PUSH_TOKEN_BYTES = 4096;
2004
2068
  var MAX_PUSH_METADATA_BYTES = 4096;
@@ -2046,7 +2110,7 @@ function byteLength(s) {
2046
2110
  return s.length * 4;
2047
2111
  }
2048
2112
  }
2049
- function buildDeviceContext(appVersion) {
2113
+ function buildDeviceContext(appVersion, appBuild) {
2050
2114
  try {
2051
2115
  const os = import_react_native4.Platform?.OS;
2052
2116
  if (os !== "ios" && os !== "android") return void 0;
@@ -2056,7 +2120,8 @@ function buildDeviceContext(appVersion) {
2056
2120
  os: os === "ios" ? "iOS" : "Android",
2057
2121
  osVersion: String(import_react_native4.Platform.Version ?? ""),
2058
2122
  model: expoDeviceModel(),
2059
- appVersion: resolveAppVersion(appVersion)
2123
+ appVersion: resolveAppVersion(appVersion),
2124
+ appBuild: resolveAppBuild(appBuild)
2060
2125
  };
2061
2126
  } catch {
2062
2127
  return void 0;
@@ -2122,6 +2187,18 @@ function resolveAppVersion(configAppVersion) {
2122
2187
  const g = globalThis.__SENDORA_APP_VERSION__;
2123
2188
  return typeof g === "string" ? g : "";
2124
2189
  }
2190
+ function expoApplicationBuild() {
2191
+ const a = expoModule("ExpoApplication");
2192
+ const v = a?.nativeBuildVersion;
2193
+ return typeof v === "string" && v ? v : "";
2194
+ }
2195
+ function resolveAppBuild(configAppBuild) {
2196
+ if (configAppBuild) return configAppBuild;
2197
+ const appMod = expoApplicationBuild();
2198
+ if (appMod) return appMod;
2199
+ const g = globalThis.__SENDORA_APP_BUILD__;
2200
+ return typeof g === "string" ? g : "";
2201
+ }
2125
2202
  var SendoraSDK = class {
2126
2203
  constructor() {
2127
2204
  this.config = null;
@@ -2180,6 +2257,9 @@ var SendoraSDK = class {
2180
2257
  `[sendoracloud] apiUrl override active: ${apiUrl} \u2014 confirm this is intentional. Tokens + events will be sent here.`
2181
2258
  );
2182
2259
  }
2260
+ if (config.secureStorage) {
2261
+ this.storage = new Storage({ secureAdapter: config.secureStorage, secureKeys: SECURE_KEYS });
2262
+ }
2183
2263
  await this.storage.hydrate([
2184
2264
  ANON_KEY,
2185
2265
  USER_ID_KEY,
@@ -2261,6 +2341,19 @@ var SendoraSDK = class {
2261
2341
  this.assertReady();
2262
2342
  return this.anonId;
2263
2343
  }
2344
+ /**
2345
+ * Non-throwing, synchronous read of the anonymous id. Returns `null`
2346
+ * until `init()` has resolved — the id lives in AsyncStorage and is
2347
+ * hydrated (or minted) during `init()`, so there is nothing to read
2348
+ * before that. Unlike `getAnonymousId()`, this never throws, so an
2349
+ * offline-first caller can read-or-fallback at cold start without a
2350
+ * try/catch. `init()` itself needs no network (disk-only hydrate), so
2351
+ * awaiting it once at boot makes this return a value even offline.
2352
+ * Once ready it returns the same stable id as `getAnonymousId()`.
2353
+ */
2354
+ getAnonymousIdSync() {
2355
+ return this.anonId || null;
2356
+ }
2264
2357
  /** Current identified user id, if any. */
2265
2358
  getUserId() {
2266
2359
  return this.userId;
@@ -2932,7 +3025,7 @@ var SendoraSDK = class {
2932
3025
  async sendEvent(kind, payload) {
2933
3026
  if (!this.config) return;
2934
3027
  if (!this.consentGranted) return;
2935
- const device = buildDeviceContext(this.config.appVersion);
3028
+ const device = buildDeviceContext(this.config.appVersion, this.config.appBuild);
2936
3029
  await post(
2937
3030
  { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
2938
3031
  "/api/v1/events",
package/dist/index.d.cts CHANGED
@@ -1,3 +1,156 @@
1
+ interface SendoraConfig {
2
+ /**
3
+ * Public API key (`pk_live_…` or `pk_test_…`). Secret keys are refused.
4
+ * Canonical name as of 0.10.0 — pre-0.10.0 callers using `publicKey`
5
+ * still work via the deprecated alias below.
6
+ */
7
+ apiKey?: string;
8
+ /**
9
+ * @deprecated Use `apiKey` instead (0.10.0+ canonical name across
10
+ * sdk-web / sdk-ios / sdk-android). Kept for back-compat — when
11
+ * both are set, `apiKey` wins.
12
+ */
13
+ publicKey?: string;
14
+ /** Backend base URL. Defaults to Sendora's production API. */
15
+ apiUrl?: string;
16
+ /** `"prod"` | `"staging"` | `"dev"` routing hint. For API-key auth this
17
+ * is IGNORED — the backend forcibly tags events with the environment
18
+ * encoded in the key prefix (pk_prod_* / pk_staging_* / pk_dev_*). See
19
+ * ADR-014. Kept for session-authed callers and test harnesses that
20
+ * don't use API keys. Defaults to `"prod"`. */
21
+ environment?: "prod" | "staging" | "dev";
22
+ /** Optional project id scoping. */
23
+ projectId?: string;
24
+ /** If false, events drop until setConsent(true) is called. Default true (mobile apps usually collect OS-level consent). */
25
+ consentedByDefault?: boolean;
26
+ /** Verbose error logging (auth-error codes + Zod field errors). Default false. */
27
+ debug?: boolean;
28
+ /**
29
+ * Auto-collect lifecycle events. Mirrors Firebase Analytics' auto-collected
30
+ * surface. `true` = all enabled (default). Pass an object for granular
31
+ * control or `false` to disable.
32
+ *
33
+ * - `appOpen` — fire `app.opened` on init. Default true.
34
+ * - `sessionStart` — emit `session.start` once per logical session
35
+ * (idle >`sessionIdleMs` closes session). Default true.
36
+ * - `appBackground` — emit `app.backgrounded`/`app.foregrounded` on
37
+ * AppState transitions. Default true.
38
+ * - `engagement` — emit `app.engagement` with foreground-only `durationMs`
39
+ * per screen on the next `screen()` call + on app-background (GA4
40
+ * user_engagement parity). Default true. Only measures named screens —
41
+ * it has no effect until you start calling `screen()`.
42
+ *
43
+ * Note: screen view tracking is NOT auto — RN has no canonical router.
44
+ * Call `SendoraCloud.screen(name)` from your navigation listener.
45
+ */
46
+ autoTrack?: boolean | {
47
+ appOpen?: boolean;
48
+ sessionStart?: boolean;
49
+ appBackground?: boolean;
50
+ engagement?: boolean;
51
+ };
52
+ /** Idle threshold (ms) for session expiry; default 30 minutes. */
53
+ sessionIdleMs?: number;
54
+ /**
55
+ * iOS bundle id (e.g. `com.pulse.news`). Forwarded to backend on
56
+ * `links.create()` / `links.matchDeferred()` for the bundle-id gate —
57
+ * blocks a leaked public key from being used by a different app.
58
+ */
59
+ iosBundleId?: string;
60
+ /**
61
+ * Android package name (e.g. `com.pulse.news`). Same purpose as
62
+ * `iosBundleId` above.
63
+ */
64
+ androidPackageName?: string;
65
+ /**
66
+ * Optional share-link host (e.g. `pulse.link`). When set, the deep
67
+ * links module's URL extractor only treats Sendora-routed URLs whose
68
+ * host matches this domain (or a subdomain). Leave unset on apps that
69
+ * use the default Sendora-hosted share domain (`go.sendoracloud.com`).
70
+ * Added 0.16.0.
71
+ */
72
+ linkDomain?: string;
73
+ /**
74
+ * Your app's version (e.g. "2.4.0"). Attached to every event's
75
+ * `context.device.appVersion` so the dashboard can break analytics down by
76
+ * release (ADR-022). Optional — RN has no zero-dep way to auto-read the
77
+ * host app version, so supply it (e.g. from `expo-constants` /
78
+ * `react-native-device-info`) if you want app-version analytics.
79
+ */
80
+ appVersion?: string;
81
+ /**
82
+ * App build number (iOS `CFBundleVersion` / Android `versionCode`). Auto-read
83
+ * from expo-application's `nativeBuildVersion` when present; supply here to
84
+ * override or for non-Expo apps. Shown as "version (build)" on the dashboard.
85
+ */
86
+ appBuild?: string;
87
+ /**
88
+ * Optional encrypted-at-rest storage for sensitive auth material (refresh
89
+ * token, access token, cached user). When provided, the SDK routes ONLY those
90
+ * keys through this adapter instead of plain AsyncStorage; everything else
91
+ * (analytics anon id, session flags) stays in AsyncStorage. Existing plaintext
92
+ * tokens are migrated into the adapter transparently on the first launch after
93
+ * you enable it (dual-read), so no one is signed out.
94
+ *
95
+ * Pass a Keychain/Keystore-backed adapter — e.g. an `expo-secure-store`
96
+ * hybrid (AES key in SecureStore encrypting the value in AsyncStorage, the
97
+ * "LargeSecureStore" pattern). The SDK does NOT depend on `expo-secure-store`
98
+ * itself — you supply the adapter, mirroring Supabase's `auth.storage` model —
99
+ * so bare-RN apps stay Metro-safe. Omit to keep the default AsyncStorage
100
+ * behaviour (OWASP MASVS-L1). See docs/backend-identity + docs/sdks.
101
+ *
102
+ * Guidance: this is a defense-in-depth (OWASP L2) upgrade — refresh tokens are
103
+ * long-lived and high-value, so encrypt them on a rooted/jailbroken-device
104
+ * threat model. It is opt-in: adding it never changes an app that doesn't pass it.
105
+ */
106
+ secureStorage?: SecureStorageAdapter;
107
+ }
108
+ /**
109
+ * Async key/value adapter for encrypted-at-rest auth storage. Compatible with a
110
+ * thin wrapper over `expo-secure-store` or `react-native-keychain`. All methods
111
+ * are async; the SDK awaits them during `init()` hydrate and fire-and-forgets on
112
+ * write (identical to its AsyncStorage handling). A throwing adapter degrades
113
+ * gracefully to AsyncStorage — the SDK never loses a session on adapter failure.
114
+ */
115
+ interface SecureStorageAdapter {
116
+ getItem(key: string): Promise<string | null>;
117
+ setItem(key: string, value: string): Promise<void>;
118
+ removeItem(key: string): Promise<void>;
119
+ }
120
+ type TraitValue = string | number | boolean | null | undefined;
121
+ interface IdentifyTraits {
122
+ email?: string;
123
+ name?: string;
124
+ avatarUrl?: string;
125
+ [key: string]: TraitValue;
126
+ }
127
+ interface TrackProperties {
128
+ [key: string]: TraitValue | TraitValue[];
129
+ }
130
+ type PushPlatform = "ios" | "android";
131
+ interface PushTokenRegistration {
132
+ token: string;
133
+ platform: PushPlatform;
134
+ /**
135
+ * App bundle / package identifier, for fan-out to the right credentials.
136
+ * Optional but strongly recommended for multi-env apps so dev / staging
137
+ * tokens never get a prod APNs / FCM push.
138
+ */
139
+ bundleId?: string;
140
+ /** Arbitrary device metadata stored alongside the token. */
141
+ metadata?: Record<string, string>;
142
+ }
143
+ /**
144
+ * Returned by `registerPushToken` so the caller can log a receipt or run
145
+ * a follow-up `testPush(userId)` against the freshly stored token. `created`
146
+ * distinguishes a brand-new token row from an idempotent re-registration of
147
+ * a token Sendora already had on file.
148
+ */
149
+ interface PushTokenReceipt {
150
+ tokenId: string;
151
+ created: boolean;
152
+ }
153
+
1
154
  /**
2
155
  * Hybrid storage for React Native: read-through memory cache backed
3
156
  * by AsyncStorage for persistence. Callers can read synchronously
@@ -7,24 +160,51 @@
7
160
  * AsyncStorage is a required peer dependency — we import it lazily
8
161
  * so the package itself doesn't hard-fail if the user hasn't
9
162
  * installed it yet (they'll see an explicit runtime error instead).
163
+ *
164
+ * Optional encrypted-at-rest storage (1.18.0): the caller may pass a
165
+ * `SecureStorageAdapter` (Keychain/Keystore-backed, e.g. an
166
+ * expo-secure-store hybrid) plus the set of "secure keys" — the
167
+ * sensitive auth material (refresh token, access token, cached user).
168
+ * When present, those keys are routed through the adapter instead of
169
+ * plain AsyncStorage; every other key stays in AsyncStorage. The SDK
170
+ * never depends on expo-secure-store itself — the app supplies the
171
+ * adapter (Supabase `auth.storage` model), so bare-RN stays Metro-safe.
172
+ * Every adapter op is try/catch-guarded: an adapter failure degrades to
173
+ * AsyncStorage rather than losing the session.
10
174
  */
175
+
176
+ type StorageOptions = {
177
+ /** Encrypted-at-rest adapter for the `secureKeys` below. Null/undefined → plain AsyncStorage for everything (default). */
178
+ secureAdapter?: SecureStorageAdapter | null;
179
+ /** Keys routed through `secureAdapter` when it is present. Everything else stays in AsyncStorage. */
180
+ secureKeys?: Iterable<string>;
181
+ };
11
182
  declare class Storage {
12
183
  private cache;
13
184
  private hydrated;
185
+ private readonly secure;
186
+ private readonly secureKeys;
187
+ constructor(opts?: StorageOptions);
188
+ private isSecure;
14
189
  /**
15
- * Populate the memory cache from AsyncStorage. Must be awaited
190
+ * Populate the memory cache from persistent storage. Must be awaited
16
191
  * once at SDK init — after that, `get` returns synchronously from
17
- * the cache and `set` fire-and-forgets to AsyncStorage.
192
+ * the cache and `set` fire-and-forgets. For secure keys, reads the
193
+ * adapter first, then falls back to (and migrates from) any legacy
194
+ * plaintext AsyncStorage value written by a pre-secure SDK version —
195
+ * so enabling secure storage never signs an existing user out.
18
196
  */
19
197
  hydrate(keys: string[]): Promise<void>;
198
+ /** Read a secure key: adapter first, else migrate legacy plaintext. Never throws. */
199
+ private hydrateSecureKey;
20
200
  get(key: string): string | null;
21
201
  set(key: string, value: string): void;
22
202
  remove(key: string): void;
23
203
  /**
24
204
  * Remove every sendora_*-prefixed key from both memory AND
25
- * AsyncStorage. Uses getAllKeys() to find keys this process never
26
- * cached (e.g. orphaned writes from a prior SDK version that added
27
- * a key not in our hydrate list). Falls back to cache enumeration
205
+ * AsyncStorage, plus every secure key from the adapter. Uses
206
+ * getAllKeys() to find keys this process never cached (e.g. orphaned
207
+ * writes from a prior SDK version). Falls back to cache enumeration
28
208
  * if the AsyncStorage backend doesn't expose getAllKeys.
29
209
  */
30
210
  clearAll(): Promise<void>;
@@ -744,121 +924,6 @@ declare class Links {
744
924
  attachLinkingApi(opts?: AttachLinkingApiOptions): Promise<() => void>;
745
925
  }
746
926
 
747
- interface SendoraConfig {
748
- /**
749
- * Public API key (`pk_live_…` or `pk_test_…`). Secret keys are refused.
750
- * Canonical name as of 0.10.0 — pre-0.10.0 callers using `publicKey`
751
- * still work via the deprecated alias below.
752
- */
753
- apiKey?: string;
754
- /**
755
- * @deprecated Use `apiKey` instead (0.10.0+ canonical name across
756
- * sdk-web / sdk-ios / sdk-android). Kept for back-compat — when
757
- * both are set, `apiKey` wins.
758
- */
759
- publicKey?: string;
760
- /** Backend base URL. Defaults to Sendora's production API. */
761
- apiUrl?: string;
762
- /** `"prod"` | `"staging"` | `"dev"` routing hint. For API-key auth this
763
- * is IGNORED — the backend forcibly tags events with the environment
764
- * encoded in the key prefix (pk_prod_* / pk_staging_* / pk_dev_*). See
765
- * ADR-014. Kept for session-authed callers and test harnesses that
766
- * don't use API keys. Defaults to `"prod"`. */
767
- environment?: "prod" | "staging" | "dev";
768
- /** Optional project id scoping. */
769
- projectId?: string;
770
- /** If false, events drop until setConsent(true) is called. Default true (mobile apps usually collect OS-level consent). */
771
- consentedByDefault?: boolean;
772
- /** Verbose error logging (auth-error codes + Zod field errors). Default false. */
773
- debug?: boolean;
774
- /**
775
- * Auto-collect lifecycle events. Mirrors Firebase Analytics' auto-collected
776
- * surface. `true` = all enabled (default). Pass an object for granular
777
- * control or `false` to disable.
778
- *
779
- * - `appOpen` — fire `app.opened` on init. Default true.
780
- * - `sessionStart` — emit `session.start` once per logical session
781
- * (idle >`sessionIdleMs` closes session). Default true.
782
- * - `appBackground` — emit `app.backgrounded`/`app.foregrounded` on
783
- * AppState transitions. Default true.
784
- * - `engagement` — emit `app.engagement` with foreground-only `durationMs`
785
- * per screen on the next `screen()` call + on app-background (GA4
786
- * user_engagement parity). Default true. Only measures named screens —
787
- * it has no effect until you start calling `screen()`.
788
- *
789
- * Note: screen view tracking is NOT auto — RN has no canonical router.
790
- * Call `SendoraCloud.screen(name)` from your navigation listener.
791
- */
792
- autoTrack?: boolean | {
793
- appOpen?: boolean;
794
- sessionStart?: boolean;
795
- appBackground?: boolean;
796
- engagement?: boolean;
797
- };
798
- /** Idle threshold (ms) for session expiry; default 30 minutes. */
799
- sessionIdleMs?: number;
800
- /**
801
- * iOS bundle id (e.g. `com.pulse.news`). Forwarded to backend on
802
- * `links.create()` / `links.matchDeferred()` for the bundle-id gate —
803
- * blocks a leaked public key from being used by a different app.
804
- */
805
- iosBundleId?: string;
806
- /**
807
- * Android package name (e.g. `com.pulse.news`). Same purpose as
808
- * `iosBundleId` above.
809
- */
810
- androidPackageName?: string;
811
- /**
812
- * Optional share-link host (e.g. `pulse.link`). When set, the deep
813
- * links module's URL extractor only treats Sendora-routed URLs whose
814
- * host matches this domain (or a subdomain). Leave unset on apps that
815
- * use the default Sendora-hosted share domain (`go.sendoracloud.com`).
816
- * Added 0.16.0.
817
- */
818
- linkDomain?: string;
819
- /**
820
- * Your app's version (e.g. "2.4.0"). Attached to every event's
821
- * `context.device.appVersion` so the dashboard can break analytics down by
822
- * release (ADR-022). Optional — RN has no zero-dep way to auto-read the
823
- * host app version, so supply it (e.g. from `expo-constants` /
824
- * `react-native-device-info`) if you want app-version analytics.
825
- */
826
- appVersion?: string;
827
- }
828
- type TraitValue = string | number | boolean | null | undefined;
829
- interface IdentifyTraits {
830
- email?: string;
831
- name?: string;
832
- avatarUrl?: string;
833
- [key: string]: TraitValue;
834
- }
835
- interface TrackProperties {
836
- [key: string]: TraitValue | TraitValue[];
837
- }
838
- type PushPlatform = "ios" | "android";
839
- interface PushTokenRegistration {
840
- token: string;
841
- platform: PushPlatform;
842
- /**
843
- * App bundle / package identifier, for fan-out to the right credentials.
844
- * Optional but strongly recommended for multi-env apps so dev / staging
845
- * tokens never get a prod APNs / FCM push.
846
- */
847
- bundleId?: string;
848
- /** Arbitrary device metadata stored alongside the token. */
849
- metadata?: Record<string, string>;
850
- }
851
- /**
852
- * Returned by `registerPushToken` so the caller can log a receipt or run
853
- * a follow-up `testPush(userId)` against the freshly stored token. `created`
854
- * distinguishes a brand-new token row from an idempotent re-registration of
855
- * a token Sendora already had on file.
856
- */
857
- interface PushTokenReceipt {
858
- tokenId: string;
859
- created: boolean;
860
- }
861
-
862
927
  /**
863
928
  * @sendoracloud/sdk-react-native — React Native + Expo SDK.
864
929
  *
@@ -907,6 +972,17 @@ declare class SendoraSDK {
907
972
  getSessionId(): string;
908
973
  /** Stable anonymous identifier for this install — survives app restarts via AsyncStorage. */
909
974
  getAnonymousId(): string;
975
+ /**
976
+ * Non-throwing, synchronous read of the anonymous id. Returns `null`
977
+ * until `init()` has resolved — the id lives in AsyncStorage and is
978
+ * hydrated (or minted) during `init()`, so there is nothing to read
979
+ * before that. Unlike `getAnonymousId()`, this never throws, so an
980
+ * offline-first caller can read-or-fallback at cold start without a
981
+ * try/catch. `init()` itself needs no network (disk-only hydrate), so
982
+ * awaiting it once at boot makes this return a value even offline.
983
+ * Once ready it returns the same stable id as `getAnonymousId()`.
984
+ */
985
+ getAnonymousIdSync(): string | null;
910
986
  /** Current identified user id, if any. */
911
987
  getUserId(): string | null;
912
988
  /** Toggle consent at runtime — when false, events drop instead of fire. */
package/dist/index.d.ts CHANGED
@@ -1,3 +1,156 @@
1
+ interface SendoraConfig {
2
+ /**
3
+ * Public API key (`pk_live_…` or `pk_test_…`). Secret keys are refused.
4
+ * Canonical name as of 0.10.0 — pre-0.10.0 callers using `publicKey`
5
+ * still work via the deprecated alias below.
6
+ */
7
+ apiKey?: string;
8
+ /**
9
+ * @deprecated Use `apiKey` instead (0.10.0+ canonical name across
10
+ * sdk-web / sdk-ios / sdk-android). Kept for back-compat — when
11
+ * both are set, `apiKey` wins.
12
+ */
13
+ publicKey?: string;
14
+ /** Backend base URL. Defaults to Sendora's production API. */
15
+ apiUrl?: string;
16
+ /** `"prod"` | `"staging"` | `"dev"` routing hint. For API-key auth this
17
+ * is IGNORED — the backend forcibly tags events with the environment
18
+ * encoded in the key prefix (pk_prod_* / pk_staging_* / pk_dev_*). See
19
+ * ADR-014. Kept for session-authed callers and test harnesses that
20
+ * don't use API keys. Defaults to `"prod"`. */
21
+ environment?: "prod" | "staging" | "dev";
22
+ /** Optional project id scoping. */
23
+ projectId?: string;
24
+ /** If false, events drop until setConsent(true) is called. Default true (mobile apps usually collect OS-level consent). */
25
+ consentedByDefault?: boolean;
26
+ /** Verbose error logging (auth-error codes + Zod field errors). Default false. */
27
+ debug?: boolean;
28
+ /**
29
+ * Auto-collect lifecycle events. Mirrors Firebase Analytics' auto-collected
30
+ * surface. `true` = all enabled (default). Pass an object for granular
31
+ * control or `false` to disable.
32
+ *
33
+ * - `appOpen` — fire `app.opened` on init. Default true.
34
+ * - `sessionStart` — emit `session.start` once per logical session
35
+ * (idle >`sessionIdleMs` closes session). Default true.
36
+ * - `appBackground` — emit `app.backgrounded`/`app.foregrounded` on
37
+ * AppState transitions. Default true.
38
+ * - `engagement` — emit `app.engagement` with foreground-only `durationMs`
39
+ * per screen on the next `screen()` call + on app-background (GA4
40
+ * user_engagement parity). Default true. Only measures named screens —
41
+ * it has no effect until you start calling `screen()`.
42
+ *
43
+ * Note: screen view tracking is NOT auto — RN has no canonical router.
44
+ * Call `SendoraCloud.screen(name)` from your navigation listener.
45
+ */
46
+ autoTrack?: boolean | {
47
+ appOpen?: boolean;
48
+ sessionStart?: boolean;
49
+ appBackground?: boolean;
50
+ engagement?: boolean;
51
+ };
52
+ /** Idle threshold (ms) for session expiry; default 30 minutes. */
53
+ sessionIdleMs?: number;
54
+ /**
55
+ * iOS bundle id (e.g. `com.pulse.news`). Forwarded to backend on
56
+ * `links.create()` / `links.matchDeferred()` for the bundle-id gate —
57
+ * blocks a leaked public key from being used by a different app.
58
+ */
59
+ iosBundleId?: string;
60
+ /**
61
+ * Android package name (e.g. `com.pulse.news`). Same purpose as
62
+ * `iosBundleId` above.
63
+ */
64
+ androidPackageName?: string;
65
+ /**
66
+ * Optional share-link host (e.g. `pulse.link`). When set, the deep
67
+ * links module's URL extractor only treats Sendora-routed URLs whose
68
+ * host matches this domain (or a subdomain). Leave unset on apps that
69
+ * use the default Sendora-hosted share domain (`go.sendoracloud.com`).
70
+ * Added 0.16.0.
71
+ */
72
+ linkDomain?: string;
73
+ /**
74
+ * Your app's version (e.g. "2.4.0"). Attached to every event's
75
+ * `context.device.appVersion` so the dashboard can break analytics down by
76
+ * release (ADR-022). Optional — RN has no zero-dep way to auto-read the
77
+ * host app version, so supply it (e.g. from `expo-constants` /
78
+ * `react-native-device-info`) if you want app-version analytics.
79
+ */
80
+ appVersion?: string;
81
+ /**
82
+ * App build number (iOS `CFBundleVersion` / Android `versionCode`). Auto-read
83
+ * from expo-application's `nativeBuildVersion` when present; supply here to
84
+ * override or for non-Expo apps. Shown as "version (build)" on the dashboard.
85
+ */
86
+ appBuild?: string;
87
+ /**
88
+ * Optional encrypted-at-rest storage for sensitive auth material (refresh
89
+ * token, access token, cached user). When provided, the SDK routes ONLY those
90
+ * keys through this adapter instead of plain AsyncStorage; everything else
91
+ * (analytics anon id, session flags) stays in AsyncStorage. Existing plaintext
92
+ * tokens are migrated into the adapter transparently on the first launch after
93
+ * you enable it (dual-read), so no one is signed out.
94
+ *
95
+ * Pass a Keychain/Keystore-backed adapter — e.g. an `expo-secure-store`
96
+ * hybrid (AES key in SecureStore encrypting the value in AsyncStorage, the
97
+ * "LargeSecureStore" pattern). The SDK does NOT depend on `expo-secure-store`
98
+ * itself — you supply the adapter, mirroring Supabase's `auth.storage` model —
99
+ * so bare-RN apps stay Metro-safe. Omit to keep the default AsyncStorage
100
+ * behaviour (OWASP MASVS-L1). See docs/backend-identity + docs/sdks.
101
+ *
102
+ * Guidance: this is a defense-in-depth (OWASP L2) upgrade — refresh tokens are
103
+ * long-lived and high-value, so encrypt them on a rooted/jailbroken-device
104
+ * threat model. It is opt-in: adding it never changes an app that doesn't pass it.
105
+ */
106
+ secureStorage?: SecureStorageAdapter;
107
+ }
108
+ /**
109
+ * Async key/value adapter for encrypted-at-rest auth storage. Compatible with a
110
+ * thin wrapper over `expo-secure-store` or `react-native-keychain`. All methods
111
+ * are async; the SDK awaits them during `init()` hydrate and fire-and-forgets on
112
+ * write (identical to its AsyncStorage handling). A throwing adapter degrades
113
+ * gracefully to AsyncStorage — the SDK never loses a session on adapter failure.
114
+ */
115
+ interface SecureStorageAdapter {
116
+ getItem(key: string): Promise<string | null>;
117
+ setItem(key: string, value: string): Promise<void>;
118
+ removeItem(key: string): Promise<void>;
119
+ }
120
+ type TraitValue = string | number | boolean | null | undefined;
121
+ interface IdentifyTraits {
122
+ email?: string;
123
+ name?: string;
124
+ avatarUrl?: string;
125
+ [key: string]: TraitValue;
126
+ }
127
+ interface TrackProperties {
128
+ [key: string]: TraitValue | TraitValue[];
129
+ }
130
+ type PushPlatform = "ios" | "android";
131
+ interface PushTokenRegistration {
132
+ token: string;
133
+ platform: PushPlatform;
134
+ /**
135
+ * App bundle / package identifier, for fan-out to the right credentials.
136
+ * Optional but strongly recommended for multi-env apps so dev / staging
137
+ * tokens never get a prod APNs / FCM push.
138
+ */
139
+ bundleId?: string;
140
+ /** Arbitrary device metadata stored alongside the token. */
141
+ metadata?: Record<string, string>;
142
+ }
143
+ /**
144
+ * Returned by `registerPushToken` so the caller can log a receipt or run
145
+ * a follow-up `testPush(userId)` against the freshly stored token. `created`
146
+ * distinguishes a brand-new token row from an idempotent re-registration of
147
+ * a token Sendora already had on file.
148
+ */
149
+ interface PushTokenReceipt {
150
+ tokenId: string;
151
+ created: boolean;
152
+ }
153
+
1
154
  /**
2
155
  * Hybrid storage for React Native: read-through memory cache backed
3
156
  * by AsyncStorage for persistence. Callers can read synchronously
@@ -7,24 +160,51 @@
7
160
  * AsyncStorage is a required peer dependency — we import it lazily
8
161
  * so the package itself doesn't hard-fail if the user hasn't
9
162
  * installed it yet (they'll see an explicit runtime error instead).
163
+ *
164
+ * Optional encrypted-at-rest storage (1.18.0): the caller may pass a
165
+ * `SecureStorageAdapter` (Keychain/Keystore-backed, e.g. an
166
+ * expo-secure-store hybrid) plus the set of "secure keys" — the
167
+ * sensitive auth material (refresh token, access token, cached user).
168
+ * When present, those keys are routed through the adapter instead of
169
+ * plain AsyncStorage; every other key stays in AsyncStorage. The SDK
170
+ * never depends on expo-secure-store itself — the app supplies the
171
+ * adapter (Supabase `auth.storage` model), so bare-RN stays Metro-safe.
172
+ * Every adapter op is try/catch-guarded: an adapter failure degrades to
173
+ * AsyncStorage rather than losing the session.
10
174
  */
175
+
176
+ type StorageOptions = {
177
+ /** Encrypted-at-rest adapter for the `secureKeys` below. Null/undefined → plain AsyncStorage for everything (default). */
178
+ secureAdapter?: SecureStorageAdapter | null;
179
+ /** Keys routed through `secureAdapter` when it is present. Everything else stays in AsyncStorage. */
180
+ secureKeys?: Iterable<string>;
181
+ };
11
182
  declare class Storage {
12
183
  private cache;
13
184
  private hydrated;
185
+ private readonly secure;
186
+ private readonly secureKeys;
187
+ constructor(opts?: StorageOptions);
188
+ private isSecure;
14
189
  /**
15
- * Populate the memory cache from AsyncStorage. Must be awaited
190
+ * Populate the memory cache from persistent storage. Must be awaited
16
191
  * once at SDK init — after that, `get` returns synchronously from
17
- * the cache and `set` fire-and-forgets to AsyncStorage.
192
+ * the cache and `set` fire-and-forgets. For secure keys, reads the
193
+ * adapter first, then falls back to (and migrates from) any legacy
194
+ * plaintext AsyncStorage value written by a pre-secure SDK version —
195
+ * so enabling secure storage never signs an existing user out.
18
196
  */
19
197
  hydrate(keys: string[]): Promise<void>;
198
+ /** Read a secure key: adapter first, else migrate legacy plaintext. Never throws. */
199
+ private hydrateSecureKey;
20
200
  get(key: string): string | null;
21
201
  set(key: string, value: string): void;
22
202
  remove(key: string): void;
23
203
  /**
24
204
  * Remove every sendora_*-prefixed key from both memory AND
25
- * AsyncStorage. Uses getAllKeys() to find keys this process never
26
- * cached (e.g. orphaned writes from a prior SDK version that added
27
- * a key not in our hydrate list). Falls back to cache enumeration
205
+ * AsyncStorage, plus every secure key from the adapter. Uses
206
+ * getAllKeys() to find keys this process never cached (e.g. orphaned
207
+ * writes from a prior SDK version). Falls back to cache enumeration
28
208
  * if the AsyncStorage backend doesn't expose getAllKeys.
29
209
  */
30
210
  clearAll(): Promise<void>;
@@ -744,121 +924,6 @@ declare class Links {
744
924
  attachLinkingApi(opts?: AttachLinkingApiOptions): Promise<() => void>;
745
925
  }
746
926
 
747
- interface SendoraConfig {
748
- /**
749
- * Public API key (`pk_live_…` or `pk_test_…`). Secret keys are refused.
750
- * Canonical name as of 0.10.0 — pre-0.10.0 callers using `publicKey`
751
- * still work via the deprecated alias below.
752
- */
753
- apiKey?: string;
754
- /**
755
- * @deprecated Use `apiKey` instead (0.10.0+ canonical name across
756
- * sdk-web / sdk-ios / sdk-android). Kept for back-compat — when
757
- * both are set, `apiKey` wins.
758
- */
759
- publicKey?: string;
760
- /** Backend base URL. Defaults to Sendora's production API. */
761
- apiUrl?: string;
762
- /** `"prod"` | `"staging"` | `"dev"` routing hint. For API-key auth this
763
- * is IGNORED — the backend forcibly tags events with the environment
764
- * encoded in the key prefix (pk_prod_* / pk_staging_* / pk_dev_*). See
765
- * ADR-014. Kept for session-authed callers and test harnesses that
766
- * don't use API keys. Defaults to `"prod"`. */
767
- environment?: "prod" | "staging" | "dev";
768
- /** Optional project id scoping. */
769
- projectId?: string;
770
- /** If false, events drop until setConsent(true) is called. Default true (mobile apps usually collect OS-level consent). */
771
- consentedByDefault?: boolean;
772
- /** Verbose error logging (auth-error codes + Zod field errors). Default false. */
773
- debug?: boolean;
774
- /**
775
- * Auto-collect lifecycle events. Mirrors Firebase Analytics' auto-collected
776
- * surface. `true` = all enabled (default). Pass an object for granular
777
- * control or `false` to disable.
778
- *
779
- * - `appOpen` — fire `app.opened` on init. Default true.
780
- * - `sessionStart` — emit `session.start` once per logical session
781
- * (idle >`sessionIdleMs` closes session). Default true.
782
- * - `appBackground` — emit `app.backgrounded`/`app.foregrounded` on
783
- * AppState transitions. Default true.
784
- * - `engagement` — emit `app.engagement` with foreground-only `durationMs`
785
- * per screen on the next `screen()` call + on app-background (GA4
786
- * user_engagement parity). Default true. Only measures named screens —
787
- * it has no effect until you start calling `screen()`.
788
- *
789
- * Note: screen view tracking is NOT auto — RN has no canonical router.
790
- * Call `SendoraCloud.screen(name)` from your navigation listener.
791
- */
792
- autoTrack?: boolean | {
793
- appOpen?: boolean;
794
- sessionStart?: boolean;
795
- appBackground?: boolean;
796
- engagement?: boolean;
797
- };
798
- /** Idle threshold (ms) for session expiry; default 30 minutes. */
799
- sessionIdleMs?: number;
800
- /**
801
- * iOS bundle id (e.g. `com.pulse.news`). Forwarded to backend on
802
- * `links.create()` / `links.matchDeferred()` for the bundle-id gate —
803
- * blocks a leaked public key from being used by a different app.
804
- */
805
- iosBundleId?: string;
806
- /**
807
- * Android package name (e.g. `com.pulse.news`). Same purpose as
808
- * `iosBundleId` above.
809
- */
810
- androidPackageName?: string;
811
- /**
812
- * Optional share-link host (e.g. `pulse.link`). When set, the deep
813
- * links module's URL extractor only treats Sendora-routed URLs whose
814
- * host matches this domain (or a subdomain). Leave unset on apps that
815
- * use the default Sendora-hosted share domain (`go.sendoracloud.com`).
816
- * Added 0.16.0.
817
- */
818
- linkDomain?: string;
819
- /**
820
- * Your app's version (e.g. "2.4.0"). Attached to every event's
821
- * `context.device.appVersion` so the dashboard can break analytics down by
822
- * release (ADR-022). Optional — RN has no zero-dep way to auto-read the
823
- * host app version, so supply it (e.g. from `expo-constants` /
824
- * `react-native-device-info`) if you want app-version analytics.
825
- */
826
- appVersion?: string;
827
- }
828
- type TraitValue = string | number | boolean | null | undefined;
829
- interface IdentifyTraits {
830
- email?: string;
831
- name?: string;
832
- avatarUrl?: string;
833
- [key: string]: TraitValue;
834
- }
835
- interface TrackProperties {
836
- [key: string]: TraitValue | TraitValue[];
837
- }
838
- type PushPlatform = "ios" | "android";
839
- interface PushTokenRegistration {
840
- token: string;
841
- platform: PushPlatform;
842
- /**
843
- * App bundle / package identifier, for fan-out to the right credentials.
844
- * Optional but strongly recommended for multi-env apps so dev / staging
845
- * tokens never get a prod APNs / FCM push.
846
- */
847
- bundleId?: string;
848
- /** Arbitrary device metadata stored alongside the token. */
849
- metadata?: Record<string, string>;
850
- }
851
- /**
852
- * Returned by `registerPushToken` so the caller can log a receipt or run
853
- * a follow-up `testPush(userId)` against the freshly stored token. `created`
854
- * distinguishes a brand-new token row from an idempotent re-registration of
855
- * a token Sendora already had on file.
856
- */
857
- interface PushTokenReceipt {
858
- tokenId: string;
859
- created: boolean;
860
- }
861
-
862
927
  /**
863
928
  * @sendoracloud/sdk-react-native — React Native + Expo SDK.
864
929
  *
@@ -907,6 +972,17 @@ declare class SendoraSDK {
907
972
  getSessionId(): string;
908
973
  /** Stable anonymous identifier for this install — survives app restarts via AsyncStorage. */
909
974
  getAnonymousId(): string;
975
+ /**
976
+ * Non-throwing, synchronous read of the anonymous id. Returns `null`
977
+ * until `init()` has resolved — the id lives in AsyncStorage and is
978
+ * hydrated (or minted) during `init()`, so there is nothing to read
979
+ * before that. Unlike `getAnonymousId()`, this never throws, so an
980
+ * offline-first caller can read-or-fallback at cold start without a
981
+ * try/catch. `init()` itself needs no network (disk-only hydrate), so
982
+ * awaiting it once at boot makes this return a value even offline.
983
+ * Once ready it returns the same stable id as `getAnonymousId()`.
984
+ */
985
+ getAnonymousIdSync(): string | null;
910
986
  /** Current identified user id, if any. */
911
987
  getUserId(): string | null;
912
988
  /** Toggle consent at runtime — when false, events drop instead of fire. */
package/dist/index.js CHANGED
@@ -21,20 +21,30 @@ async function getAsyncStorage() {
21
21
  return asyncStoragePromise;
22
22
  }
23
23
  var Storage = class {
24
- constructor() {
24
+ constructor(opts = {}) {
25
25
  this.cache = /* @__PURE__ */ new Map();
26
26
  this.hydrated = false;
27
+ this.secure = opts.secureAdapter ?? null;
28
+ this.secureKeys = new Set(opts.secureKeys ?? []);
29
+ }
30
+ isSecure(key) {
31
+ return this.secure !== null && this.secureKeys.has(key);
27
32
  }
28
33
  /**
29
- * Populate the memory cache from AsyncStorage. Must be awaited
34
+ * Populate the memory cache from persistent storage. Must be awaited
30
35
  * once at SDK init — after that, `get` returns synchronously from
31
- * the cache and `set` fire-and-forgets to AsyncStorage.
36
+ * the cache and `set` fire-and-forgets. For secure keys, reads the
37
+ * adapter first, then falls back to (and migrates from) any legacy
38
+ * plaintext AsyncStorage value written by a pre-secure SDK version —
39
+ * so enabling secure storage never signs an existing user out.
32
40
  */
33
41
  async hydrate(keys) {
34
42
  if (this.hydrated) return;
35
43
  const store = await getAsyncStorage();
36
- if (store) {
37
- for (const k of keys) {
44
+ for (const k of keys) {
45
+ if (this.isSecure(k)) {
46
+ await this.hydrateSecureKey(k, store);
47
+ } else if (store) {
38
48
  try {
39
49
  const v = await store.getItem(PREFIX + k);
40
50
  if (v !== null) this.cache.set(k, v);
@@ -44,11 +54,50 @@ var Storage = class {
44
54
  }
45
55
  this.hydrated = true;
46
56
  }
57
+ /** Read a secure key: adapter first, else migrate legacy plaintext. Never throws. */
58
+ async hydrateSecureKey(k, store) {
59
+ try {
60
+ const secureVal = await this.secure.getItem(k);
61
+ if (secureVal !== null) {
62
+ this.cache.set(k, secureVal);
63
+ return;
64
+ }
65
+ if (store) {
66
+ const legacy = await store.getItem(PREFIX + k).catch(() => null);
67
+ if (legacy !== null) {
68
+ this.cache.set(k, legacy);
69
+ try {
70
+ await this.secure.setItem(k, legacy);
71
+ await store.removeItem(PREFIX + k).catch(() => {
72
+ });
73
+ } catch {
74
+ }
75
+ }
76
+ }
77
+ } catch {
78
+ if (store) {
79
+ try {
80
+ const legacy = await store.getItem(PREFIX + k);
81
+ if (legacy !== null) this.cache.set(k, legacy);
82
+ } catch {
83
+ }
84
+ }
85
+ }
86
+ }
47
87
  get(key) {
48
88
  return this.cache.get(key) ?? null;
49
89
  }
50
90
  set(key, value) {
51
91
  this.cache.set(key, value);
92
+ if (this.isSecure(key)) {
93
+ void this.secure.setItem(key, value).catch(() => {
94
+ void getAsyncStorage().then((store) => {
95
+ if (store) store.setItem(PREFIX + key, value).catch(() => {
96
+ });
97
+ });
98
+ });
99
+ return;
100
+ }
52
101
  void getAsyncStorage().then((store) => {
53
102
  if (store) store.setItem(PREFIX + key, value).catch(() => {
54
103
  });
@@ -56,6 +105,10 @@ var Storage = class {
56
105
  }
57
106
  remove(key) {
58
107
  this.cache.delete(key);
108
+ if (this.isSecure(key)) {
109
+ void this.secure.removeItem(key).catch(() => {
110
+ });
111
+ }
59
112
  void getAsyncStorage().then((store) => {
60
113
  if (store) store.removeItem(PREFIX + key).catch(() => {
61
114
  });
@@ -63,13 +116,21 @@ var Storage = class {
63
116
  }
64
117
  /**
65
118
  * Remove every sendora_*-prefixed key from both memory AND
66
- * AsyncStorage. Uses getAllKeys() to find keys this process never
67
- * cached (e.g. orphaned writes from a prior SDK version that added
68
- * a key not in our hydrate list). Falls back to cache enumeration
119
+ * AsyncStorage, plus every secure key from the adapter. Uses
120
+ * getAllKeys() to find keys this process never cached (e.g. orphaned
121
+ * writes from a prior SDK version). Falls back to cache enumeration
69
122
  * if the AsyncStorage backend doesn't expose getAllKeys.
70
123
  */
71
124
  async clearAll() {
72
125
  this.cache.clear();
126
+ if (this.secure) {
127
+ for (const k of this.secureKeys) {
128
+ try {
129
+ await this.secure.removeItem(k);
130
+ } catch {
131
+ }
132
+ }
133
+ }
73
134
  const store = await getAsyncStorage();
74
135
  if (!store) return;
75
136
  let keys = [];
@@ -82,7 +143,7 @@ var Storage = class {
82
143
  }
83
144
  }
84
145
  if (keys.length === 0) {
85
- keys = Array.from(this.cache.keys()).map((k) => PREFIX + k);
146
+ keys = Array.from(this.secureKeys).map((k) => PREFIX + k);
86
147
  }
87
148
  for (const k of keys) {
88
149
  try {
@@ -96,7 +157,7 @@ var Storage = class {
96
157
  // package.json
97
158
  var package_default = {
98
159
  name: "@sendoracloud/sdk-react-native",
99
- version: "1.15.0",
160
+ version: "1.18.0",
100
161
  description: "Sendora Cloud React Native + Expo SDK \u2014 analytics, identity, push-token registration, auth, deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push token registration requires a Dev Client (EAS Build or `npx expo prebuild`).",
101
162
  type: "module",
102
163
  main: "./dist/index.cjs",
@@ -149,13 +210,15 @@ var package_default = {
149
210
  },
150
211
  scripts: {
151
212
  build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
152
- typecheck: "tsc --noEmit"
213
+ typecheck: "tsc --noEmit",
214
+ test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts"
153
215
  },
154
216
  devDependencies: {
155
217
  "@types/react": "^19.2.16",
156
218
  "react-native": "^0.85.3",
157
219
  "react-native-webview": "^13.16.1",
158
220
  tsup: "^8.0.0",
221
+ tsx: "^4.21.0",
159
222
  typescript: "^5.0.0"
160
223
  },
161
224
  license: "MIT",
@@ -1961,6 +2024,7 @@ var AUTH_HYDRATE_KEYS = [
1961
2024
  "auth_refresh_token",
1962
2025
  "auth_user"
1963
2026
  ];
2027
+ var SECURE_KEYS = ["auth_refresh_token", "auth_access_token", "auth_user"];
1964
2028
  var PUBLIC_KEY_RE = /^pk_(prod|staging|dev|live|test)_[A-Za-z0-9]{16,}$/;
1965
2029
  var MAX_PUSH_TOKEN_BYTES = 4096;
1966
2030
  var MAX_PUSH_METADATA_BYTES = 4096;
@@ -2008,7 +2072,7 @@ function byteLength(s) {
2008
2072
  return s.length * 4;
2009
2073
  }
2010
2074
  }
2011
- function buildDeviceContext(appVersion) {
2075
+ function buildDeviceContext(appVersion, appBuild) {
2012
2076
  try {
2013
2077
  const os = Platform?.OS;
2014
2078
  if (os !== "ios" && os !== "android") return void 0;
@@ -2018,7 +2082,8 @@ function buildDeviceContext(appVersion) {
2018
2082
  os: os === "ios" ? "iOS" : "Android",
2019
2083
  osVersion: String(Platform.Version ?? ""),
2020
2084
  model: expoDeviceModel(),
2021
- appVersion: resolveAppVersion(appVersion)
2085
+ appVersion: resolveAppVersion(appVersion),
2086
+ appBuild: resolveAppBuild(appBuild)
2022
2087
  };
2023
2088
  } catch {
2024
2089
  return void 0;
@@ -2084,6 +2149,18 @@ function resolveAppVersion(configAppVersion) {
2084
2149
  const g = globalThis.__SENDORA_APP_VERSION__;
2085
2150
  return typeof g === "string" ? g : "";
2086
2151
  }
2152
+ function expoApplicationBuild() {
2153
+ const a = expoModule("ExpoApplication");
2154
+ const v = a?.nativeBuildVersion;
2155
+ return typeof v === "string" && v ? v : "";
2156
+ }
2157
+ function resolveAppBuild(configAppBuild) {
2158
+ if (configAppBuild) return configAppBuild;
2159
+ const appMod = expoApplicationBuild();
2160
+ if (appMod) return appMod;
2161
+ const g = globalThis.__SENDORA_APP_BUILD__;
2162
+ return typeof g === "string" ? g : "";
2163
+ }
2087
2164
  var SendoraSDK = class {
2088
2165
  constructor() {
2089
2166
  this.config = null;
@@ -2142,6 +2219,9 @@ var SendoraSDK = class {
2142
2219
  `[sendoracloud] apiUrl override active: ${apiUrl} \u2014 confirm this is intentional. Tokens + events will be sent here.`
2143
2220
  );
2144
2221
  }
2222
+ if (config.secureStorage) {
2223
+ this.storage = new Storage({ secureAdapter: config.secureStorage, secureKeys: SECURE_KEYS });
2224
+ }
2145
2225
  await this.storage.hydrate([
2146
2226
  ANON_KEY,
2147
2227
  USER_ID_KEY,
@@ -2223,6 +2303,19 @@ var SendoraSDK = class {
2223
2303
  this.assertReady();
2224
2304
  return this.anonId;
2225
2305
  }
2306
+ /**
2307
+ * Non-throwing, synchronous read of the anonymous id. Returns `null`
2308
+ * until `init()` has resolved — the id lives in AsyncStorage and is
2309
+ * hydrated (or minted) during `init()`, so there is nothing to read
2310
+ * before that. Unlike `getAnonymousId()`, this never throws, so an
2311
+ * offline-first caller can read-or-fallback at cold start without a
2312
+ * try/catch. `init()` itself needs no network (disk-only hydrate), so
2313
+ * awaiting it once at boot makes this return a value even offline.
2314
+ * Once ready it returns the same stable id as `getAnonymousId()`.
2315
+ */
2316
+ getAnonymousIdSync() {
2317
+ return this.anonId || null;
2318
+ }
2226
2319
  /** Current identified user id, if any. */
2227
2320
  getUserId() {
2228
2321
  return this.userId;
@@ -2894,7 +2987,7 @@ var SendoraSDK = class {
2894
2987
  async sendEvent(kind, payload) {
2895
2988
  if (!this.config) return;
2896
2989
  if (!this.consentGranted) return;
2897
- const device = buildDeviceContext(this.config.appVersion);
2990
+ const device = buildDeviceContext(this.config.appVersion, this.config.appBuild);
2898
2991
  await post(
2899
2992
  { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
2900
2993
  "/api/v1/events",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "1.15.0",
3
+ "version": "1.18.0",
4
4
  "description": "Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth, deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push token registration requires a Dev Client (EAS Build or `npx expo prebuild`).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -53,13 +53,15 @@
53
53
  },
54
54
  "scripts": {
55
55
  "build": "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
56
- "typecheck": "tsc --noEmit"
56
+ "typecheck": "tsc --noEmit",
57
+ "test": "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts"
57
58
  },
58
59
  "devDependencies": {
59
60
  "@types/react": "^19.2.16",
60
61
  "react-native": "^0.85.3",
61
62
  "react-native-webview": "^13.16.1",
62
63
  "tsup": "^8.0.0",
64
+ "tsx": "^4.21.0",
63
65
  "typescript": "^5.0.0"
64
66
  },
65
67
  "license": "MIT",