@sendoracloud/sdk-react-native 1.16.0 → 1.19.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 +122 -11
- package/dist/index.d.cts +223 -126
- package/dist/index.d.ts +223 -126
- package/dist/index.js +122 -11
- package/package.json +4 -2
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
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
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
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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.
|
|
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.
|
|
203
|
+
version: "1.19.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,10 +2341,54 @@ 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;
|
|
2280
2360
|
}
|
|
2361
|
+
/**
|
|
2362
|
+
* Non-throwing, synchronous read of the verified auth principal. Returns
|
|
2363
|
+
* the persisted `AuthUser` (`{ id, email, emailVerified, name, isAnonymous }`)
|
|
2364
|
+
* or `null` when no session is hydrated yet. Zero I/O — a pure in-memory read
|
|
2365
|
+
* of the session restored during `init()` — so it is safe on the very first
|
|
2366
|
+
* API call at cold start: await `init()` once (disk-only, no network), then
|
|
2367
|
+
* read this synchronously, even offline.
|
|
2368
|
+
*
|
|
2369
|
+
* `id` is the JWT `sub` — the durable, server-verified identity to key your
|
|
2370
|
+
* backend data on. Do NOT use `getAnonymousIdSync()` for that (it's a
|
|
2371
|
+
* rotating analytics id). Pair with `isReady()` to tell "still restoring"
|
|
2372
|
+
* apart from "signed out": `null` here while `isReady()` is `false` = init
|
|
2373
|
+
* hasn't finished; `null` while `isReady()` is `true` = genuinely no session.
|
|
2374
|
+
* `isAnonymous` is optional on the wire — test `=== false`, not truthiness.
|
|
2375
|
+
* The access token is re-verified server-side, so reading identity here
|
|
2376
|
+
* (client-untrusted) for UI/routing is safe; never use it as your own
|
|
2377
|
+
* authorization decision.
|
|
2378
|
+
*/
|
|
2379
|
+
getUserSync() {
|
|
2380
|
+
return this.ready ? this.auth.getCurrentUser() : null;
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2383
|
+
* Non-throwing, synchronous readiness flag. `true` once `init()` has
|
|
2384
|
+
* resolved (storage hydrated + auth session restored). Gate a cold-start
|
|
2385
|
+
* identity read on this so you never mistake "still restoring the session"
|
|
2386
|
+
* for "signed out" — the loading gate every mobile auth SDK expects
|
|
2387
|
+
* (Firebase's init-null caveat, Clerk's `isLoaded`).
|
|
2388
|
+
*/
|
|
2389
|
+
isReady() {
|
|
2390
|
+
return this.ready;
|
|
2391
|
+
}
|
|
2281
2392
|
/** Toggle consent at runtime — when false, events drop instead of fire. */
|
|
2282
2393
|
setConsent(granted) {
|
|
2283
2394
|
this.consentGranted = granted;
|
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
|
|
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
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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,8 +972,46 @@ 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;
|
|
988
|
+
/**
|
|
989
|
+
* Non-throwing, synchronous read of the verified auth principal. Returns
|
|
990
|
+
* the persisted `AuthUser` (`{ id, email, emailVerified, name, isAnonymous }`)
|
|
991
|
+
* or `null` when no session is hydrated yet. Zero I/O — a pure in-memory read
|
|
992
|
+
* of the session restored during `init()` — so it is safe on the very first
|
|
993
|
+
* API call at cold start: await `init()` once (disk-only, no network), then
|
|
994
|
+
* read this synchronously, even offline.
|
|
995
|
+
*
|
|
996
|
+
* `id` is the JWT `sub` — the durable, server-verified identity to key your
|
|
997
|
+
* backend data on. Do NOT use `getAnonymousIdSync()` for that (it's a
|
|
998
|
+
* rotating analytics id). Pair with `isReady()` to tell "still restoring"
|
|
999
|
+
* apart from "signed out": `null` here while `isReady()` is `false` = init
|
|
1000
|
+
* hasn't finished; `null` while `isReady()` is `true` = genuinely no session.
|
|
1001
|
+
* `isAnonymous` is optional on the wire — test `=== false`, not truthiness.
|
|
1002
|
+
* The access token is re-verified server-side, so reading identity here
|
|
1003
|
+
* (client-untrusted) for UI/routing is safe; never use it as your own
|
|
1004
|
+
* authorization decision.
|
|
1005
|
+
*/
|
|
1006
|
+
getUserSync(): AuthUser | null;
|
|
1007
|
+
/**
|
|
1008
|
+
* Non-throwing, synchronous readiness flag. `true` once `init()` has
|
|
1009
|
+
* resolved (storage hydrated + auth session restored). Gate a cold-start
|
|
1010
|
+
* identity read on this so you never mistake "still restoring the session"
|
|
1011
|
+
* for "signed out" — the loading gate every mobile auth SDK expects
|
|
1012
|
+
* (Firebase's init-null caveat, Clerk's `isLoaded`).
|
|
1013
|
+
*/
|
|
1014
|
+
isReady(): boolean;
|
|
918
1015
|
/** Toggle consent at runtime — when false, events drop instead of fire. */
|
|
919
1016
|
setConsent(granted: boolean): void;
|
|
920
1017
|
/** Associate a user id + traits with the current anonymous install. */
|
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
|
|
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
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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,8 +972,46 @@ 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;
|
|
988
|
+
/**
|
|
989
|
+
* Non-throwing, synchronous read of the verified auth principal. Returns
|
|
990
|
+
* the persisted `AuthUser` (`{ id, email, emailVerified, name, isAnonymous }`)
|
|
991
|
+
* or `null` when no session is hydrated yet. Zero I/O — a pure in-memory read
|
|
992
|
+
* of the session restored during `init()` — so it is safe on the very first
|
|
993
|
+
* API call at cold start: await `init()` once (disk-only, no network), then
|
|
994
|
+
* read this synchronously, even offline.
|
|
995
|
+
*
|
|
996
|
+
* `id` is the JWT `sub` — the durable, server-verified identity to key your
|
|
997
|
+
* backend data on. Do NOT use `getAnonymousIdSync()` for that (it's a
|
|
998
|
+
* rotating analytics id). Pair with `isReady()` to tell "still restoring"
|
|
999
|
+
* apart from "signed out": `null` here while `isReady()` is `false` = init
|
|
1000
|
+
* hasn't finished; `null` while `isReady()` is `true` = genuinely no session.
|
|
1001
|
+
* `isAnonymous` is optional on the wire — test `=== false`, not truthiness.
|
|
1002
|
+
* The access token is re-verified server-side, so reading identity here
|
|
1003
|
+
* (client-untrusted) for UI/routing is safe; never use it as your own
|
|
1004
|
+
* authorization decision.
|
|
1005
|
+
*/
|
|
1006
|
+
getUserSync(): AuthUser | null;
|
|
1007
|
+
/**
|
|
1008
|
+
* Non-throwing, synchronous readiness flag. `true` once `init()` has
|
|
1009
|
+
* resolved (storage hydrated + auth session restored). Gate a cold-start
|
|
1010
|
+
* identity read on this so you never mistake "still restoring the session"
|
|
1011
|
+
* for "signed out" — the loading gate every mobile auth SDK expects
|
|
1012
|
+
* (Firebase's init-null caveat, Clerk's `isLoaded`).
|
|
1013
|
+
*/
|
|
1014
|
+
isReady(): boolean;
|
|
918
1015
|
/** Toggle consent at runtime — when false, events drop instead of fire. */
|
|
919
1016
|
setConsent(granted: boolean): void;
|
|
920
1017
|
/** Associate a user id + traits with the current anonymous install. */
|
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
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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.
|
|
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.
|
|
160
|
+
version: "1.19.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,10 +2303,54 @@ 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;
|
|
2242
2322
|
}
|
|
2323
|
+
/**
|
|
2324
|
+
* Non-throwing, synchronous read of the verified auth principal. Returns
|
|
2325
|
+
* the persisted `AuthUser` (`{ id, email, emailVerified, name, isAnonymous }`)
|
|
2326
|
+
* or `null` when no session is hydrated yet. Zero I/O — a pure in-memory read
|
|
2327
|
+
* of the session restored during `init()` — so it is safe on the very first
|
|
2328
|
+
* API call at cold start: await `init()` once (disk-only, no network), then
|
|
2329
|
+
* read this synchronously, even offline.
|
|
2330
|
+
*
|
|
2331
|
+
* `id` is the JWT `sub` — the durable, server-verified identity to key your
|
|
2332
|
+
* backend data on. Do NOT use `getAnonymousIdSync()` for that (it's a
|
|
2333
|
+
* rotating analytics id). Pair with `isReady()` to tell "still restoring"
|
|
2334
|
+
* apart from "signed out": `null` here while `isReady()` is `false` = init
|
|
2335
|
+
* hasn't finished; `null` while `isReady()` is `true` = genuinely no session.
|
|
2336
|
+
* `isAnonymous` is optional on the wire — test `=== false`, not truthiness.
|
|
2337
|
+
* The access token is re-verified server-side, so reading identity here
|
|
2338
|
+
* (client-untrusted) for UI/routing is safe; never use it as your own
|
|
2339
|
+
* authorization decision.
|
|
2340
|
+
*/
|
|
2341
|
+
getUserSync() {
|
|
2342
|
+
return this.ready ? this.auth.getCurrentUser() : null;
|
|
2343
|
+
}
|
|
2344
|
+
/**
|
|
2345
|
+
* Non-throwing, synchronous readiness flag. `true` once `init()` has
|
|
2346
|
+
* resolved (storage hydrated + auth session restored). Gate a cold-start
|
|
2347
|
+
* identity read on this so you never mistake "still restoring the session"
|
|
2348
|
+
* for "signed out" — the loading gate every mobile auth SDK expects
|
|
2349
|
+
* (Firebase's init-null caveat, Clerk's `isLoaded`).
|
|
2350
|
+
*/
|
|
2351
|
+
isReady() {
|
|
2352
|
+
return this.ready;
|
|
2353
|
+
}
|
|
2243
2354
|
/** Toggle consent at runtime — when false, events drop instead of fire. */
|
|
2244
2355
|
setConsent(granted) {
|
|
2245
2356
|
this.consentGranted = granted;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendoracloud/sdk-react-native",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.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",
|