@sendoracloud/sdk-react-native 0.15.0 → 0.16.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/README.md CHANGED
@@ -22,6 +22,14 @@ import "react-native-get-random-values";
22
22
 
23
23
  If you forget, `init()` throws with a paste-ready remediation block — it never fails silently.
24
24
 
25
+ ### Optional peers (deep links)
26
+
27
+ | Peer | When to install |
28
+ | --- | --- |
29
+ | `expo-localization` | Improves device-fingerprint locale accuracy. Most Expo apps already have it. |
30
+ | `expo-crypto` | Hardens the SHA-256 used by `computeDeviceFingerprint()`. Auto-used when installed; SDK falls back to web crypto and then a pure-JS impl if not. |
31
+ | `react-native-play-install-referrer` | Android-only. Enables 100%-accurate deferred deep-link matching via Play Install Referrer. SDK auto-probes — no-op on iOS or when uninstalled. |
32
+
25
33
  ## Quick start
26
34
 
27
35
  ```ts
@@ -29,41 +37,31 @@ import SendoraCloud from "@sendoracloud/sdk-react-native";
29
37
 
30
38
  // App.tsx or wherever you initialise app-level services
31
39
  await SendoraCloud.init({
32
- publicKey: "pk_live_...", // from Settings → API Keys in the SendoraCloud dashboard
33
- orgId: "YOUR_ORG_UUID",
40
+ apiKey: "pk_prod_...", // from Settings → API Keys in the dashboard
41
+ iosBundleId: "com.yourapp", // bundle-id gate — required for SDK link mint
42
+ androidPackageName: "com.yourapp",
43
+ // linkDomain: "pulse.link", // optional — only if you use a custom share host
34
44
  });
35
45
 
36
46
  // Identify the signed-in user
37
- SendoraCloud.identify("user-123", {
38
- email: "user@example.com",
39
- name: "Ada Lovelace",
40
- });
47
+ SendoraCloud.identify("user-123", { email: "user@example.com" });
41
48
 
42
- // Track a product event
49
+ // Track product events + screen views
43
50
  SendoraCloud.track("signup.completed", { plan: "growth" });
44
-
45
- // Track screen views (RN equivalent of page views)
46
51
  SendoraCloud.screen("Pricing");
47
52
 
48
- // Register the device push token (get it from expo-notifications)
49
- import * as Notifications from "expo-notifications";
50
- const { data } = await Notifications.getExpoPushTokenAsync();
53
+ // Push tokens
51
54
  const receipt = await SendoraCloud.registerPushToken({
52
- token: data,
55
+ token: expoPushToken,
53
56
  platform: Platform.OS as "ios" | "android",
54
- // bundleId is OPTIONAL but strongly recommended for multi-env apps:
55
- // Sendora uses it to route to the right APNs / FCM credentials so a
56
- // dev token never receives a prod push.
57
- bundleId: "com.yourcompany.app",
57
+ bundleId: "com.yourapp",
58
58
  });
59
- // Returns { tokenId, created } — log it for debugging fan-out issues.
60
- console.log("registered:", receipt.tokenId, "new?", receipt.created);
61
59
 
62
60
  // On logout
63
- SendoraCloud.reset();
61
+ await SendoraCloud.reset();
64
62
  ```
65
63
 
66
- ## API
64
+ ## API surface
67
65
 
68
66
  | Method | Description |
69
67
  | --- | --- |
@@ -72,38 +70,122 @@ SendoraCloud.reset();
72
70
  | `track(event, props?)` | Fire a custom product event. |
73
71
  | `screen(name, props?)` | Track a screen view. |
74
72
  | `registerPushToken(reg)` | Register an APNs or FCM push token. |
73
+ | `auth.*` | Sign-in / sign-up / MFA / passkeys / OIDC / SSO. See `auth.ts`. |
74
+ | `links.create<T>(input, opts?)` | Mint a Sendora deep link. Generic `T` for typed `linkData`. |
75
+ | `links.prewarm<T>(input, opts?)` | Background-mint + cache so share-tap is instant. |
76
+ | `links.handleUniversalLink(url)` | Resolve a warm-path Universal Link / App Link delivery. |
77
+ | `links.matchDeferred(input?)` | Cold-launch deferred match. Auto-probes Play Install Referrer + fingerprint. |
78
+ | `links.attachLinkingApi({ onLegacyUrl })` | One-call replacement for `Linking.getInitialURL` + `addEventListener` + extract-pre-check. |
79
+ | `links.onLinkOpened<T>(cb)` | Register a callback fired for both warm + deferred opens. |
80
+ | `links.revoke(shortcode)` | Soft-delete a link (private-content unsend). Idempotent. |
81
+ | `links.getStats(shortcode)` | `{ totalClicks, uniqueClicks, deferredMatches, byOs, byCountry, byDevice }`. |
82
+ | `computeDeviceFingerprint()` | Standalone — returns canonical hex SHA-256. Identical recipe across all 3 mobile SDKs. |
75
83
  | `reset()` | Clear identity + rotate anonymous id. Call on logout. |
76
84
  | `getAnonymousId()` | Stable device id (persisted across restarts). |
77
85
  | `getUserId()` | Currently identified user id, or null. |
78
86
 
87
+ ## Deep links (Branch / Firebase Dynamic Links parity)
88
+
89
+ ```ts
90
+ import SendoraCloud, { LinkError } from "@sendoracloud/sdk-react-native";
91
+
92
+ // 1. Typed linkData — define your app's deep-link shape once.
93
+ interface ArticleLink extends Record<string, unknown> {
94
+ type: "article";
95
+ articleId: string;
96
+ category: "tech" | "design" | "policy";
97
+ sharedBy: string;
98
+ }
99
+
100
+ // 2. Prewarm on row mount → share tap completes instantly.
101
+ SendoraCloud.links.prewarm<ArticleLink>(
102
+ {
103
+ title: `Share: ${article.title}`,
104
+ iosDeepLinkPath: `/articles/${article.id}`,
105
+ androidDeepLinkPath: `/articles/${article.id}`,
106
+ ogTitle: article.title,
107
+ linkData: { type: "article", articleId: article.id, category: article.category, sharedBy: currentUser.id },
108
+ // fallbackUrl optional — backend defaults from your project's apps registry.
109
+ },
110
+ { key: `article:${article.id}` },
111
+ );
112
+
113
+ // 3. Mint on tap — returns from prewarm cache when keys match.
114
+ try {
115
+ const link = await SendoraCloud.links.create<ArticleLink>(input, { key: `article:${article.id}` });
116
+ await Share.share({ message: link.url });
117
+ } catch (err) {
118
+ if (err instanceof LinkError) {
119
+ switch (err.code) {
120
+ case "BUNDLE_MISMATCH": /* register bundle in Dashboard → Apps */ break;
121
+ case "PLAN_LIMIT": /* upgrade plan */ break;
122
+ case "RATE_LIMITED": /* back off + retry */ break;
123
+ case "FALLBACK_REQUIRED": /* configure App Store URL in apps registry */ break;
124
+ }
125
+ }
126
+ }
127
+
128
+ // 4. App-root router — one call replaces the Linking boilerplate.
129
+ useEffect(() => {
130
+ const offOpen = SendoraCloud.links.onLinkOpened<ArticleLink>((event) => {
131
+ if (event.linkData.type === "article") navigateToArticle(event.linkData.articleId);
132
+ });
133
+ let detach: (() => void) | undefined;
134
+ void SendoraCloud.links
135
+ .attachLinkingApi({ onLegacyUrl: (url) => myExistingRouter.handle(url) })
136
+ .then((d) => { detach = d; });
137
+ // Cold-launch deferred match — auto-picks installReferrer / fingerprint.
138
+ void SendoraCloud.links.matchDeferred();
139
+ return () => { detach?.(); offOpen(); };
140
+ }, []);
141
+
142
+ // 5. Revoke + stats — no dashboard scraping.
143
+ await SendoraCloud.links.revoke("ab3xk9p");
144
+ const stats = await SendoraCloud.links.getStats("ab3xk9p");
145
+ // → { totalClicks, uniqueClicks, deferredMatches, byOs, byCountry, byDevice }
146
+ ```
147
+
148
+ Full paste-ready recipes live in [`examples/links-share.tsx`](./examples/links-share.tsx) and [`examples/links-router.tsx`](./examples/links-router.tsx).
149
+
150
+ ### Typed errors
151
+
152
+ `LinkError` is thrown for every backend rejection. Branch on `err.code` (a typed `LinkErrorCode`) rather than string-matching `err.message`:
153
+
154
+ ```
155
+ "BUNDLE_MISMATCH" | "DATA_TOO_LARGE" | "EXPIRED" | "NETWORK" | "RATE_LIMITED"
156
+ | "NOT_FOUND" | "UNAUTHORIZED" | "INVALID_INPUT" | "PLAN_LIMIT"
157
+ | "FALLBACK_REQUIRED" | "SERVER" | "UNKNOWN"
158
+ ```
159
+
160
+ ### Bundle-id gate
161
+
162
+ `init()` accepts `iosBundleId` / `androidPackageName`. The SDK auto-forwards them on every `links.create()` / `links.matchDeferred()` call. The backend cross-checks against your project's apps registry (Dashboard → Apps); a leaked public key plus a wrong bundle returns `LinkError(code: "BUNDLE_MISMATCH", statusCode: 422)`. Fail-closed when zero apps are registered — register your iOS bundle id + Android package name + SHA-256 fingerprint before going to prod.
163
+
164
+ ## Auth
165
+
166
+ `SendoraCloud.auth` covers anonymous sign-in, email + password, magic link, email OTP, TOTP MFA, recovery codes, OIDC / SAML SSO, Sign in with Apple, Google, Microsoft, LinkedIn, Facebook, Discord. See [auth quickstart](https://sendoracloud.com/docs/quickstart-react-native#auth).
167
+
79
168
  ## Config
80
169
 
81
170
  ```ts
82
171
  {
83
- publicKey: string; // Required. Must start with "pk_".
84
- orgId?: string; // Your workspace UUID.
172
+ apiKey: string; // Required. pk_(prod|staging|dev)_<16+ alphanumerics>.
85
173
  apiUrl?: string; // Defaults to https://api.sendoracloud.com
86
- environment?: "prod" | "staging" | "dev"; // Routing hint.
87
- projectId?: string; // Optional project scoping.
174
+ environment?: "prod" | "staging" | "dev";
175
+ projectId?: string;
88
176
  consentedByDefault?: boolean; // Defaults to true.
89
- /**
90
- * Auto-collect lifecycle events. Default true. Object form:
91
- * { appOpen?, sessionStart?, appBackground? } for granular control.
92
- * - appOpen — `app.opened` once per init.
93
- * - sessionStart — `session.start` once per session (idle >sessionIdleMs).
94
- * - appBackground — AppState 'change' fires `app.foregrounded` /
95
- * `app.backgrounded`.
96
- * Note: `screen.viewed` is NOT auto — call `SendoraCloud.screen(name)`
97
- * from your navigation listener (RN has no canonical router).
98
- */
99
177
  autoTrack?: boolean | { appOpen?: boolean; sessionStart?: boolean; appBackground?: boolean };
100
- sessionIdleMs?: number; // Idle threshold for session expiry. Default 30 min.
178
+ sessionIdleMs?: number; // Idle threshold for session expiry. Default 30 min.
179
+ iosBundleId?: string; // Bundle-id gate input for links.create / matchDeferred.
180
+ androidPackageName?: string;
181
+ linkDomain?: string; // Custom share host (e.g. "pulse.link"). Defaults to Sendora's.
182
+ debug?: boolean;
101
183
  }
102
184
  ```
103
185
 
104
186
  ## Stability
105
187
 
106
- This SDK is on the **0.10.x line**. Patch bumps (0.10.x → 0.10.y) are backwards-compatible; minor bumps may include breaking changes — always read the [CHANGELOG](https://github.com/sendoracloud/sdk-react-native/blob/main/CHANGELOG.md) before upgrading. The next major (1.0) is targeted once the HttpOnly-cookie SSR auth + auto-trait extraction features have soaked in production for two consecutive months without a schema change. Once 1.0 ships, semver is strict.
188
+ This SDK is on the **0.16.x line**. Patch bumps (0.16.x → 0.16.y) are backwards-compatible; minor bumps may include breaking changes — always read the [CHANGELOG](https://github.com/sendoracloud/sdk-react-native/blob/main/CHANGELOG.md) before upgrading. 1.0 ships once the SDK has soaked 2 consecutive months in production without a schema change.
107
189
 
108
190
  ## JWT verification (custom backend)
109
191
 
@@ -124,15 +206,16 @@ const claims = decodeJwt(token);
124
206
  const { payload } = await jwtVerify(token, getJwks(claims.iss as string));
125
207
  ```
126
208
 
127
- The `iss` claim is a per-org Sendora URL (e.g. `https://api.sendoracloud.com/api/v1/auth-service/<orgId>`); appending `/.well-known/jwks.json` resolves on the same host. This means a customer can rotate signing keys without your code redeploying. **Don't** copy a hardcoded JWKS URL from a tutorial — it'll break in 12 months when an org rotates.
209
+ The `iss` claim is a per-org Sendora URL (e.g. `https://api.sendoracloud.com/api/v1/auth-service/<orgId>`); appending `/.well-known/jwks.json` resolves on the same host. This means a customer can rotate signing keys without your code redeploying.
128
210
 
129
211
  ## Security
130
212
 
131
- - **Secret keys refused on the client.** The SDK only accepts `pk_(live|test)_<alphanumerics>` keys `sk_*` (any case) or malformed values throw at init. Secret keys must stay on your backend.
132
- - **HTTPS enforced** outside local dev. The SDK rejects non-`https:` `apiUrl` values unless the hostname is `localhost` / `127.0.0.1` and `environment` is not `"prod"`.
133
- - **AsyncStorage** backs the anonymous id so a single user has one identity across app restarts, but never across devices. `reset()` awaits the storage writes and clears every SDK-owned key.
134
- - **Use opaque userIds, not emails.** Whatever you pass to `identify(userId)` persists to on-device AsyncStorage in plaintext. Use your internal user UUID, not the user's email. Emails can go in `traits: { email }` — traits aren't persisted to disk, only sent with the identify event.
135
- - **Push token + metadata capped** at 4 KB each to prevent a buggy or hostile host app from flooding the backend.
213
+ - **Secret keys refused on the client.** Only `pk_*` keys accepted; `sk_*` throws at init.
214
+ - **HTTPS enforced** outside local dev. Non-`https:` `apiUrl` values are rejected unless `localhost` / `127.0.0.1` + `environment != "prod"`.
215
+ - **AsyncStorage** backs the anonymous id for cross-restart identity; never cross-device. `reset()` awaits the storage writes.
216
+ - **Use opaque userIds, not emails.** Persisted to AsyncStorage plaintext. Put email in `traits` instead.
217
+ - **Push token + metadata capped** at 4 KB each.
218
+ - **Deep-link bundle-id gate** — a leaked public key cannot mint links pointed at someone else's app.
136
219
 
137
220
  ## License
138
221