@sendoracloud/sdk-react-native 1.16.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.16.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;
@@ -2193,6 +2257,9 @@ var SendoraSDK = class {
2193
2257
  `[sendoracloud] apiUrl override active: ${apiUrl} \u2014 confirm this is intentional. Tokens + events will be sent here.`
2194
2258
  );
2195
2259
  }
2260
+ if (config.secureStorage) {
2261
+ this.storage = new Storage({ secureAdapter: config.secureStorage, secureKeys: SECURE_KEYS });
2262
+ }
2196
2263
  await this.storage.hydrate([
2197
2264
  ANON_KEY,
2198
2265
  USER_ID_KEY,
@@ -2274,6 +2341,19 @@ var SendoraSDK = class {
2274
2341
  this.assertReady();
2275
2342
  return this.anonId;
2276
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
+ }
2277
2357
  /** Current identified user id, if any. */
2278
2358
  getUserId() {
2279
2359
  return this.userId;
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,127 +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
- * App build number (iOS `CFBundleVersion` / Android `versionCode`). Auto-read
829
- * from expo-application's `nativeBuildVersion` when present; supply here to
830
- * override or for non-Expo apps. Shown as "version (build)" on the dashboard.
831
- */
832
- appBuild?: string;
833
- }
834
- type TraitValue = string | number | boolean | null | undefined;
835
- interface IdentifyTraits {
836
- email?: string;
837
- name?: string;
838
- avatarUrl?: string;
839
- [key: string]: TraitValue;
840
- }
841
- interface TrackProperties {
842
- [key: string]: TraitValue | TraitValue[];
843
- }
844
- type PushPlatform = "ios" | "android";
845
- interface PushTokenRegistration {
846
- token: string;
847
- platform: PushPlatform;
848
- /**
849
- * App bundle / package identifier, for fan-out to the right credentials.
850
- * Optional but strongly recommended for multi-env apps so dev / staging
851
- * tokens never get a prod APNs / FCM push.
852
- */
853
- bundleId?: string;
854
- /** Arbitrary device metadata stored alongside the token. */
855
- metadata?: Record<string, string>;
856
- }
857
- /**
858
- * Returned by `registerPushToken` so the caller can log a receipt or run
859
- * a follow-up `testPush(userId)` against the freshly stored token. `created`
860
- * distinguishes a brand-new token row from an idempotent re-registration of
861
- * a token Sendora already had on file.
862
- */
863
- interface PushTokenReceipt {
864
- tokenId: string;
865
- created: boolean;
866
- }
867
-
868
927
  /**
869
928
  * @sendoracloud/sdk-react-native — React Native + Expo SDK.
870
929
  *
@@ -913,6 +972,17 @@ declare class SendoraSDK {
913
972
  getSessionId(): string;
914
973
  /** Stable anonymous identifier for this install — survives app restarts via AsyncStorage. */
915
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;
916
986
  /** Current identified user id, if any. */
917
987
  getUserId(): string | null;
918
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,127 +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
- * App build number (iOS `CFBundleVersion` / Android `versionCode`). Auto-read
829
- * from expo-application's `nativeBuildVersion` when present; supply here to
830
- * override or for non-Expo apps. Shown as "version (build)" on the dashboard.
831
- */
832
- appBuild?: string;
833
- }
834
- type TraitValue = string | number | boolean | null | undefined;
835
- interface IdentifyTraits {
836
- email?: string;
837
- name?: string;
838
- avatarUrl?: string;
839
- [key: string]: TraitValue;
840
- }
841
- interface TrackProperties {
842
- [key: string]: TraitValue | TraitValue[];
843
- }
844
- type PushPlatform = "ios" | "android";
845
- interface PushTokenRegistration {
846
- token: string;
847
- platform: PushPlatform;
848
- /**
849
- * App bundle / package identifier, for fan-out to the right credentials.
850
- * Optional but strongly recommended for multi-env apps so dev / staging
851
- * tokens never get a prod APNs / FCM push.
852
- */
853
- bundleId?: string;
854
- /** Arbitrary device metadata stored alongside the token. */
855
- metadata?: Record<string, string>;
856
- }
857
- /**
858
- * Returned by `registerPushToken` so the caller can log a receipt or run
859
- * a follow-up `testPush(userId)` against the freshly stored token. `created`
860
- * distinguishes a brand-new token row from an idempotent re-registration of
861
- * a token Sendora already had on file.
862
- */
863
- interface PushTokenReceipt {
864
- tokenId: string;
865
- created: boolean;
866
- }
867
-
868
927
  /**
869
928
  * @sendoracloud/sdk-react-native — React Native + Expo SDK.
870
929
  *
@@ -913,6 +972,17 @@ declare class SendoraSDK {
913
972
  getSessionId(): string;
914
973
  /** Stable anonymous identifier for this install — survives app restarts via AsyncStorage. */
915
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;
916
986
  /** Current identified user id, if any. */
917
987
  getUserId(): string | null;
918
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.16.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;
@@ -2155,6 +2219,9 @@ var SendoraSDK = class {
2155
2219
  `[sendoracloud] apiUrl override active: ${apiUrl} \u2014 confirm this is intentional. Tokens + events will be sent here.`
2156
2220
  );
2157
2221
  }
2222
+ if (config.secureStorage) {
2223
+ this.storage = new Storage({ secureAdapter: config.secureStorage, secureKeys: SECURE_KEYS });
2224
+ }
2158
2225
  await this.storage.hydrate([
2159
2226
  ANON_KEY,
2160
2227
  USER_ID_KEY,
@@ -2236,6 +2303,19 @@ var SendoraSDK = class {
2236
2303
  this.assertReady();
2237
2304
  return this.anonId;
2238
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
+ }
2239
2319
  /** Current identified user id, if any. */
2240
2320
  getUserId() {
2241
2321
  return this.userId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "1.16.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",