@sendoracloud/sdk-react-native 0.17.1 → 0.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/README.md +34 -1
- package/dist/index.cjs +118 -0
- package/dist/index.d.cts +89 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +118 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ await SendoraCloud.reset();
|
|
|
88
88
|
| `track(event, props?)` | Fire a custom product event. |
|
|
89
89
|
| `screen(name, props?)` | Track a screen view. |
|
|
90
90
|
| `registerPushToken(reg)` | Register an APNs or FCM push token. |
|
|
91
|
-
| `auth.*` | Sign-in / sign-up / MFA / passkeys / OIDC / SSO. See `auth.ts`. |
|
|
91
|
+
| `auth.*` | Sign-in / sign-up / MFA / passkeys / OIDC / SSO + `auth.getRefreshToken()` + `auth.merge(anonRefresh, targetRefresh?)`. See `auth.ts`. |
|
|
92
92
|
| `links.create<T>(input, opts?)` | Mint a Sendora deep link. Generic `T` for typed `linkData`. |
|
|
93
93
|
| `links.prewarm<T>(input, opts?)` | Background-mint + cache so share-tap is instant. |
|
|
94
94
|
| `links.handleUniversalLink(url)` | Resolve a warm-path Universal Link / App Link delivery. |
|
|
@@ -183,6 +183,39 @@ Full paste-ready recipes live in [`examples/links-share.tsx`](./examples/links-s
|
|
|
183
183
|
|
|
184
184
|
`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).
|
|
185
185
|
|
|
186
|
+
### Anonymous → identified lifecycle
|
|
187
|
+
|
|
188
|
+
The SDK creates an anonymous user on first `init()`. Every `track()` / `identify()` attaches to that `user_id` until the user signs up or signs in. Two transitions exist; both preserve analytics continuity, but the second one requires one extra line of code from your app:
|
|
189
|
+
|
|
190
|
+
**Case 1 — fresh signup.** `auth.signUp(email, password)` detects the anonymous refresh token and calls `/auth-service/upgrade` first, which flips `is_anonymous=false` + sets the password on the SAME row. The `user_id` is preserved — no funnel break. Falls back to a fresh `/signup` if the bound user is already identified (rare race).
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
const { user } = await SendoraCloud.auth.signUp(email, password);
|
|
194
|
+
// user.id === the same id the anon user had. Done.
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**Case 2 — email already exists.** `signUp()` throws `EmailAlreadyTakenError` without consuming the anon refresh. You fall back to `signIn()` — but `signIn()` wipes the local anon identity, so anon events stay orphaned on the backend unless you transfer them with `auth.merge(anonRefresh)`. Capture the anon refresh BEFORE the signIn:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
try {
|
|
201
|
+
const { user } = await SendoraCloud.auth.signUp(email, password);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (!(err instanceof EmailAlreadyTakenError)) throw err;
|
|
204
|
+
|
|
205
|
+
const anonRefresh = await SendoraCloud.auth.getRefreshToken();
|
|
206
|
+
const { user } = await SendoraCloud.auth.signIn(email, password);
|
|
207
|
+
|
|
208
|
+
// Best-effort, silent-fail-tolerant — anon row may be already drained.
|
|
209
|
+
if (anonRefresh) {
|
|
210
|
+
SendoraCloud.auth.merge(anonRefresh).catch(() => undefined);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**Why merge isn't automatic on signIn.** Two people sharing a device (browser kiosk, family iPad) would silently get their anon-session events combined with a third party's real account. Forcing the merge call into your app keeps the data-mixing decision explicit. Wrap `signIn()` with the snippet above if you want auto behaviour.
|
|
216
|
+
|
|
217
|
+
Full reference + edge-case writeup: [/docs/quickstart-react-native#anon-existing-email](https://sendoracloud.com/docs/quickstart-react-native#anon-existing-email).
|
|
218
|
+
|
|
186
219
|
## Config
|
|
187
220
|
|
|
188
221
|
```ts
|
package/dist/index.cjs
CHANGED
|
@@ -785,6 +785,75 @@ var Auth = class {
|
|
|
785
785
|
})();
|
|
786
786
|
return this.refreshInflight;
|
|
787
787
|
}
|
|
788
|
+
/**
|
|
789
|
+
* Returns the currently-stored refresh token (or null when signed out).
|
|
790
|
+
* Primary use: capture the anonymous refresh token BEFORE calling
|
|
791
|
+
* `signIn()` so it can be transferred via `merge()` after the existing
|
|
792
|
+
* account is authenticated. See the "Anonymous → identified" section
|
|
793
|
+
* of the SDK README + /docs/quickstart-react-native#anon-existing-email.
|
|
794
|
+
*/
|
|
795
|
+
getRefreshToken() {
|
|
796
|
+
return this.hooks.storage.get(REFRESH_KEY) ?? null;
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Transfer every event + trait from an anonymous user to the
|
|
800
|
+
* currently-signed-in (target) user, then delete the anonymous row.
|
|
801
|
+
* `targetRefreshToken` defaults to the SDK's currently-stored refresh
|
|
802
|
+
* token — call this AFTER a successful `signIn()` so the storage
|
|
803
|
+
* already holds the target's refresh.
|
|
804
|
+
*
|
|
805
|
+
* Throws AuthError when:
|
|
806
|
+
* - `anonymousRefreshToken` is invalid / expired / revoked,
|
|
807
|
+
* - the bound source row is not anonymous (CONFLICT — use upgrade),
|
|
808
|
+
* - the target is anonymous (CONFLICT — sign in first),
|
|
809
|
+
* - both tokens resolve to the same user (CONFLICT — no-op).
|
|
810
|
+
*
|
|
811
|
+
* Mirrors Auth0 `accounts.link` + Firebase manual transfer semantics.
|
|
812
|
+
*/
|
|
813
|
+
async merge(anonymousRefreshToken, targetRefreshToken) {
|
|
814
|
+
const target = targetRefreshToken ?? this.getRefreshToken();
|
|
815
|
+
if (!target) {
|
|
816
|
+
throw new AuthError(
|
|
817
|
+
"NO_TARGET_REFRESH",
|
|
818
|
+
"merge() needs a target refresh token. Call signIn() first or pass targetRefreshToken explicitly."
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
const res = await post(
|
|
822
|
+
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
823
|
+
"/api/v1/auth-service/merge",
|
|
824
|
+
{ anonymousRefreshToken, targetRefreshToken: target }
|
|
825
|
+
);
|
|
826
|
+
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
827
|
+
let parsed;
|
|
828
|
+
try {
|
|
829
|
+
parsed = await res.json();
|
|
830
|
+
} catch {
|
|
831
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
|
|
832
|
+
}
|
|
833
|
+
if (!res.ok || !parsed.success || !parsed.data) {
|
|
834
|
+
throw new AuthError(
|
|
835
|
+
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
836
|
+
parsed.error?.message ?? `HTTP ${res.status}`
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
const t = parsed.data.tokens;
|
|
840
|
+
if (t?.accessToken && t.refreshToken && typeof t.expiresIn === "number") {
|
|
841
|
+
this.accessToken = t.accessToken;
|
|
842
|
+
this.accessExpiresAt = Date.now() + t.expiresIn * 1e3;
|
|
843
|
+
this.hooks.storage.set(TOKEN_KEY, t.accessToken);
|
|
844
|
+
this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
|
|
845
|
+
this.hooks.storage.set(REFRESH_KEY, t.refreshToken);
|
|
846
|
+
}
|
|
847
|
+
return {
|
|
848
|
+
mergedUserId: parsed.data.mergedUserId ?? "",
|
|
849
|
+
deletedAnonymousId: parsed.data.deletedAnonymousId ?? "",
|
|
850
|
+
eventsReassigned: parsed.data.eventsReassigned ?? 0,
|
|
851
|
+
profilesReassigned: parsed.data.profilesReassigned ?? 0,
|
|
852
|
+
profilesDropped: parsed.data.profilesDropped ?? 0,
|
|
853
|
+
entitiesReassigned: parsed.data.entitiesReassigned ?? 0,
|
|
854
|
+
entitiesDropped: parsed.data.entitiesDropped ?? 0
|
|
855
|
+
};
|
|
856
|
+
}
|
|
788
857
|
async callAuth(path, body) {
|
|
789
858
|
const res = await post(
|
|
790
859
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
@@ -1771,6 +1840,55 @@ var SendoraSDK = class {
|
|
|
1771
1840
|
}
|
|
1772
1841
|
};
|
|
1773
1842
|
}
|
|
1843
|
+
/**
|
|
1844
|
+
* Attribution namespace — mobile install + deferred deep-link reporting.
|
|
1845
|
+
* Mirrors the backend's `/attribution/{install,deferred}` SDK routes.
|
|
1846
|
+
*
|
|
1847
|
+
* Backend resolves `orgId` + `projectId` from the API key, so the SDK
|
|
1848
|
+
* helper accepts only the device-side context (fingerprint, OS, app
|
|
1849
|
+
* version). Both methods are safe to call on every cold-start —
|
|
1850
|
+
* backend deduplicates installs by `deviceId` within a 24h window so
|
|
1851
|
+
* a paranoid app calling `reportInstall()` on every launch sees
|
|
1852
|
+
* `deduplicated: true` after the first match.
|
|
1853
|
+
*
|
|
1854
|
+
* Typical pairing:
|
|
1855
|
+
* await SendoraCloud.attribution.reportInstall({
|
|
1856
|
+
* deviceId,
|
|
1857
|
+
* fingerprintHash: await computeDeviceFingerprint(),
|
|
1858
|
+
* appVersion,
|
|
1859
|
+
* });
|
|
1860
|
+
* const deferred = await SendoraCloud.attribution.checkDeferred();
|
|
1861
|
+
* if (deferred?.deepLinkPath) router.replace(deferred.deepLinkPath);
|
|
1862
|
+
*/
|
|
1863
|
+
get attribution() {
|
|
1864
|
+
const self = this;
|
|
1865
|
+
return {
|
|
1866
|
+
reportInstall: async (input) => {
|
|
1867
|
+
if (!self.config) throw new Error("[sendoracloud] init() must complete before attribution.reportInstall().");
|
|
1868
|
+
const res = await post(
|
|
1869
|
+
{ apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
|
|
1870
|
+
"/attribution/install",
|
|
1871
|
+
input ?? {}
|
|
1872
|
+
);
|
|
1873
|
+
if (!res || !res.ok) throw new Error(`[sendoracloud] attribution.reportInstall failed (${res?.status ?? "network"})`);
|
|
1874
|
+
const parsed = await res.json();
|
|
1875
|
+
if (!parsed.success || !parsed.data) throw new Error("[sendoracloud] attribution.reportInstall: bad response");
|
|
1876
|
+
return parsed.data;
|
|
1877
|
+
},
|
|
1878
|
+
checkDeferred: async (input) => {
|
|
1879
|
+
if (!self.config) throw new Error("[sendoracloud] init() must complete before attribution.checkDeferred().");
|
|
1880
|
+
const res = await post(
|
|
1881
|
+
{ apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
|
|
1882
|
+
"/attribution/deferred",
|
|
1883
|
+
input ?? {}
|
|
1884
|
+
);
|
|
1885
|
+
if (!res || !res.ok) throw new Error(`[sendoracloud] attribution.checkDeferred failed (${res?.status ?? "network"})`);
|
|
1886
|
+
const parsed = await res.json();
|
|
1887
|
+
if (!parsed.success) throw new Error("[sendoracloud] attribution.checkDeferred: bad response");
|
|
1888
|
+
return parsed.data ?? null;
|
|
1889
|
+
}
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1774
1892
|
/**
|
|
1775
1893
|
* Clear identity + rotate the anonymous id. Call on logout. Awaits
|
|
1776
1894
|
* the AsyncStorage writes so a caller who kills the app immediately
|
package/dist/index.d.cts
CHANGED
|
@@ -299,6 +299,38 @@ declare class Auth {
|
|
|
299
299
|
* surface NOT_SIGNED_IN to the host app instead of looping.
|
|
300
300
|
*/
|
|
301
301
|
private refreshAccessToken;
|
|
302
|
+
/**
|
|
303
|
+
* Returns the currently-stored refresh token (or null when signed out).
|
|
304
|
+
* Primary use: capture the anonymous refresh token BEFORE calling
|
|
305
|
+
* `signIn()` so it can be transferred via `merge()` after the existing
|
|
306
|
+
* account is authenticated. See the "Anonymous → identified" section
|
|
307
|
+
* of the SDK README + /docs/quickstart-react-native#anon-existing-email.
|
|
308
|
+
*/
|
|
309
|
+
getRefreshToken(): string | null;
|
|
310
|
+
/**
|
|
311
|
+
* Transfer every event + trait from an anonymous user to the
|
|
312
|
+
* currently-signed-in (target) user, then delete the anonymous row.
|
|
313
|
+
* `targetRefreshToken` defaults to the SDK's currently-stored refresh
|
|
314
|
+
* token — call this AFTER a successful `signIn()` so the storage
|
|
315
|
+
* already holds the target's refresh.
|
|
316
|
+
*
|
|
317
|
+
* Throws AuthError when:
|
|
318
|
+
* - `anonymousRefreshToken` is invalid / expired / revoked,
|
|
319
|
+
* - the bound source row is not anonymous (CONFLICT — use upgrade),
|
|
320
|
+
* - the target is anonymous (CONFLICT — sign in first),
|
|
321
|
+
* - both tokens resolve to the same user (CONFLICT — no-op).
|
|
322
|
+
*
|
|
323
|
+
* Mirrors Auth0 `accounts.link` + Firebase manual transfer semantics.
|
|
324
|
+
*/
|
|
325
|
+
merge(anonymousRefreshToken: string, targetRefreshToken?: string): Promise<{
|
|
326
|
+
mergedUserId: string;
|
|
327
|
+
deletedAnonymousId: string;
|
|
328
|
+
eventsReassigned: number;
|
|
329
|
+
profilesReassigned: number;
|
|
330
|
+
profilesDropped: number;
|
|
331
|
+
entitiesReassigned: number;
|
|
332
|
+
entitiesDropped: number;
|
|
333
|
+
}>;
|
|
302
334
|
private callAuth;
|
|
303
335
|
private persist;
|
|
304
336
|
private wipeLocalIdentity;
|
|
@@ -719,6 +751,63 @@ declare class SendoraSDK {
|
|
|
719
751
|
registerToken: (reg: PushTokenRegistration) => Promise<PushTokenReceipt>;
|
|
720
752
|
trackOpen: (sendId: string, clickAction?: string) => Promise<void>;
|
|
721
753
|
};
|
|
754
|
+
/**
|
|
755
|
+
* Attribution namespace — mobile install + deferred deep-link reporting.
|
|
756
|
+
* Mirrors the backend's `/attribution/{install,deferred}` SDK routes.
|
|
757
|
+
*
|
|
758
|
+
* Backend resolves `orgId` + `projectId` from the API key, so the SDK
|
|
759
|
+
* helper accepts only the device-side context (fingerprint, OS, app
|
|
760
|
+
* version). Both methods are safe to call on every cold-start —
|
|
761
|
+
* backend deduplicates installs by `deviceId` within a 24h window so
|
|
762
|
+
* a paranoid app calling `reportInstall()` on every launch sees
|
|
763
|
+
* `deduplicated: true` after the first match.
|
|
764
|
+
*
|
|
765
|
+
* Typical pairing:
|
|
766
|
+
* await SendoraCloud.attribution.reportInstall({
|
|
767
|
+
* deviceId,
|
|
768
|
+
* fingerprintHash: await computeDeviceFingerprint(),
|
|
769
|
+
* appVersion,
|
|
770
|
+
* });
|
|
771
|
+
* const deferred = await SendoraCloud.attribution.checkDeferred();
|
|
772
|
+
* if (deferred?.deepLinkPath) router.replace(deferred.deepLinkPath);
|
|
773
|
+
*/
|
|
774
|
+
get attribution(): {
|
|
775
|
+
reportInstall: (input?: {
|
|
776
|
+
deviceId?: string;
|
|
777
|
+
fingerprintHash?: string;
|
|
778
|
+
appVersion?: string;
|
|
779
|
+
os?: string;
|
|
780
|
+
osVersion?: string;
|
|
781
|
+
country?: string;
|
|
782
|
+
}) => Promise<{
|
|
783
|
+
installId: string;
|
|
784
|
+
deduplicated: boolean;
|
|
785
|
+
attributed: boolean;
|
|
786
|
+
result?: {
|
|
787
|
+
matchType: string;
|
|
788
|
+
confidence?: number;
|
|
789
|
+
clickId?: string;
|
|
790
|
+
linkId?: string;
|
|
791
|
+
campaign?: string;
|
|
792
|
+
source?: string;
|
|
793
|
+
medium?: string;
|
|
794
|
+
deepLinkData?: Record<string, unknown>;
|
|
795
|
+
deepLinkPath?: string;
|
|
796
|
+
};
|
|
797
|
+
}>;
|
|
798
|
+
checkDeferred: (input?: {
|
|
799
|
+
fingerprintHash?: string;
|
|
800
|
+
deviceId?: string;
|
|
801
|
+
}) => Promise<{
|
|
802
|
+
matchType: string;
|
|
803
|
+
confidence?: number;
|
|
804
|
+
campaign?: string;
|
|
805
|
+
source?: string;
|
|
806
|
+
medium?: string;
|
|
807
|
+
deepLinkData?: Record<string, unknown>;
|
|
808
|
+
deepLinkPath?: string;
|
|
809
|
+
} | null>;
|
|
810
|
+
};
|
|
722
811
|
/**
|
|
723
812
|
* Clear identity + rotate the anonymous id. Call on logout. Awaits
|
|
724
813
|
* the AsyncStorage writes so a caller who kills the app immediately
|
package/dist/index.d.ts
CHANGED
|
@@ -299,6 +299,38 @@ declare class Auth {
|
|
|
299
299
|
* surface NOT_SIGNED_IN to the host app instead of looping.
|
|
300
300
|
*/
|
|
301
301
|
private refreshAccessToken;
|
|
302
|
+
/**
|
|
303
|
+
* Returns the currently-stored refresh token (or null when signed out).
|
|
304
|
+
* Primary use: capture the anonymous refresh token BEFORE calling
|
|
305
|
+
* `signIn()` so it can be transferred via `merge()` after the existing
|
|
306
|
+
* account is authenticated. See the "Anonymous → identified" section
|
|
307
|
+
* of the SDK README + /docs/quickstart-react-native#anon-existing-email.
|
|
308
|
+
*/
|
|
309
|
+
getRefreshToken(): string | null;
|
|
310
|
+
/**
|
|
311
|
+
* Transfer every event + trait from an anonymous user to the
|
|
312
|
+
* currently-signed-in (target) user, then delete the anonymous row.
|
|
313
|
+
* `targetRefreshToken` defaults to the SDK's currently-stored refresh
|
|
314
|
+
* token — call this AFTER a successful `signIn()` so the storage
|
|
315
|
+
* already holds the target's refresh.
|
|
316
|
+
*
|
|
317
|
+
* Throws AuthError when:
|
|
318
|
+
* - `anonymousRefreshToken` is invalid / expired / revoked,
|
|
319
|
+
* - the bound source row is not anonymous (CONFLICT — use upgrade),
|
|
320
|
+
* - the target is anonymous (CONFLICT — sign in first),
|
|
321
|
+
* - both tokens resolve to the same user (CONFLICT — no-op).
|
|
322
|
+
*
|
|
323
|
+
* Mirrors Auth0 `accounts.link` + Firebase manual transfer semantics.
|
|
324
|
+
*/
|
|
325
|
+
merge(anonymousRefreshToken: string, targetRefreshToken?: string): Promise<{
|
|
326
|
+
mergedUserId: string;
|
|
327
|
+
deletedAnonymousId: string;
|
|
328
|
+
eventsReassigned: number;
|
|
329
|
+
profilesReassigned: number;
|
|
330
|
+
profilesDropped: number;
|
|
331
|
+
entitiesReassigned: number;
|
|
332
|
+
entitiesDropped: number;
|
|
333
|
+
}>;
|
|
302
334
|
private callAuth;
|
|
303
335
|
private persist;
|
|
304
336
|
private wipeLocalIdentity;
|
|
@@ -719,6 +751,63 @@ declare class SendoraSDK {
|
|
|
719
751
|
registerToken: (reg: PushTokenRegistration) => Promise<PushTokenReceipt>;
|
|
720
752
|
trackOpen: (sendId: string, clickAction?: string) => Promise<void>;
|
|
721
753
|
};
|
|
754
|
+
/**
|
|
755
|
+
* Attribution namespace — mobile install + deferred deep-link reporting.
|
|
756
|
+
* Mirrors the backend's `/attribution/{install,deferred}` SDK routes.
|
|
757
|
+
*
|
|
758
|
+
* Backend resolves `orgId` + `projectId` from the API key, so the SDK
|
|
759
|
+
* helper accepts only the device-side context (fingerprint, OS, app
|
|
760
|
+
* version). Both methods are safe to call on every cold-start —
|
|
761
|
+
* backend deduplicates installs by `deviceId` within a 24h window so
|
|
762
|
+
* a paranoid app calling `reportInstall()` on every launch sees
|
|
763
|
+
* `deduplicated: true` after the first match.
|
|
764
|
+
*
|
|
765
|
+
* Typical pairing:
|
|
766
|
+
* await SendoraCloud.attribution.reportInstall({
|
|
767
|
+
* deviceId,
|
|
768
|
+
* fingerprintHash: await computeDeviceFingerprint(),
|
|
769
|
+
* appVersion,
|
|
770
|
+
* });
|
|
771
|
+
* const deferred = await SendoraCloud.attribution.checkDeferred();
|
|
772
|
+
* if (deferred?.deepLinkPath) router.replace(deferred.deepLinkPath);
|
|
773
|
+
*/
|
|
774
|
+
get attribution(): {
|
|
775
|
+
reportInstall: (input?: {
|
|
776
|
+
deviceId?: string;
|
|
777
|
+
fingerprintHash?: string;
|
|
778
|
+
appVersion?: string;
|
|
779
|
+
os?: string;
|
|
780
|
+
osVersion?: string;
|
|
781
|
+
country?: string;
|
|
782
|
+
}) => Promise<{
|
|
783
|
+
installId: string;
|
|
784
|
+
deduplicated: boolean;
|
|
785
|
+
attributed: boolean;
|
|
786
|
+
result?: {
|
|
787
|
+
matchType: string;
|
|
788
|
+
confidence?: number;
|
|
789
|
+
clickId?: string;
|
|
790
|
+
linkId?: string;
|
|
791
|
+
campaign?: string;
|
|
792
|
+
source?: string;
|
|
793
|
+
medium?: string;
|
|
794
|
+
deepLinkData?: Record<string, unknown>;
|
|
795
|
+
deepLinkPath?: string;
|
|
796
|
+
};
|
|
797
|
+
}>;
|
|
798
|
+
checkDeferred: (input?: {
|
|
799
|
+
fingerprintHash?: string;
|
|
800
|
+
deviceId?: string;
|
|
801
|
+
}) => Promise<{
|
|
802
|
+
matchType: string;
|
|
803
|
+
confidence?: number;
|
|
804
|
+
campaign?: string;
|
|
805
|
+
source?: string;
|
|
806
|
+
medium?: string;
|
|
807
|
+
deepLinkData?: Record<string, unknown>;
|
|
808
|
+
deepLinkPath?: string;
|
|
809
|
+
} | null>;
|
|
810
|
+
};
|
|
722
811
|
/**
|
|
723
812
|
* Clear identity + rotate the anonymous id. Call on logout. Awaits
|
|
724
813
|
* the AsyncStorage writes so a caller who kills the app immediately
|
package/dist/index.js
CHANGED
|
@@ -742,6 +742,75 @@ var Auth = class {
|
|
|
742
742
|
})();
|
|
743
743
|
return this.refreshInflight;
|
|
744
744
|
}
|
|
745
|
+
/**
|
|
746
|
+
* Returns the currently-stored refresh token (or null when signed out).
|
|
747
|
+
* Primary use: capture the anonymous refresh token BEFORE calling
|
|
748
|
+
* `signIn()` so it can be transferred via `merge()` after the existing
|
|
749
|
+
* account is authenticated. See the "Anonymous → identified" section
|
|
750
|
+
* of the SDK README + /docs/quickstart-react-native#anon-existing-email.
|
|
751
|
+
*/
|
|
752
|
+
getRefreshToken() {
|
|
753
|
+
return this.hooks.storage.get(REFRESH_KEY) ?? null;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Transfer every event + trait from an anonymous user to the
|
|
757
|
+
* currently-signed-in (target) user, then delete the anonymous row.
|
|
758
|
+
* `targetRefreshToken` defaults to the SDK's currently-stored refresh
|
|
759
|
+
* token — call this AFTER a successful `signIn()` so the storage
|
|
760
|
+
* already holds the target's refresh.
|
|
761
|
+
*
|
|
762
|
+
* Throws AuthError when:
|
|
763
|
+
* - `anonymousRefreshToken` is invalid / expired / revoked,
|
|
764
|
+
* - the bound source row is not anonymous (CONFLICT — use upgrade),
|
|
765
|
+
* - the target is anonymous (CONFLICT — sign in first),
|
|
766
|
+
* - both tokens resolve to the same user (CONFLICT — no-op).
|
|
767
|
+
*
|
|
768
|
+
* Mirrors Auth0 `accounts.link` + Firebase manual transfer semantics.
|
|
769
|
+
*/
|
|
770
|
+
async merge(anonymousRefreshToken, targetRefreshToken) {
|
|
771
|
+
const target = targetRefreshToken ?? this.getRefreshToken();
|
|
772
|
+
if (!target) {
|
|
773
|
+
throw new AuthError(
|
|
774
|
+
"NO_TARGET_REFRESH",
|
|
775
|
+
"merge() needs a target refresh token. Call signIn() first or pass targetRefreshToken explicitly."
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
const res = await post(
|
|
779
|
+
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
780
|
+
"/api/v1/auth-service/merge",
|
|
781
|
+
{ anonymousRefreshToken, targetRefreshToken: target }
|
|
782
|
+
);
|
|
783
|
+
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
784
|
+
let parsed;
|
|
785
|
+
try {
|
|
786
|
+
parsed = await res.json();
|
|
787
|
+
} catch {
|
|
788
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
|
|
789
|
+
}
|
|
790
|
+
if (!res.ok || !parsed.success || !parsed.data) {
|
|
791
|
+
throw new AuthError(
|
|
792
|
+
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
793
|
+
parsed.error?.message ?? `HTTP ${res.status}`
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
const t = parsed.data.tokens;
|
|
797
|
+
if (t?.accessToken && t.refreshToken && typeof t.expiresIn === "number") {
|
|
798
|
+
this.accessToken = t.accessToken;
|
|
799
|
+
this.accessExpiresAt = Date.now() + t.expiresIn * 1e3;
|
|
800
|
+
this.hooks.storage.set(TOKEN_KEY, t.accessToken);
|
|
801
|
+
this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
|
|
802
|
+
this.hooks.storage.set(REFRESH_KEY, t.refreshToken);
|
|
803
|
+
}
|
|
804
|
+
return {
|
|
805
|
+
mergedUserId: parsed.data.mergedUserId ?? "",
|
|
806
|
+
deletedAnonymousId: parsed.data.deletedAnonymousId ?? "",
|
|
807
|
+
eventsReassigned: parsed.data.eventsReassigned ?? 0,
|
|
808
|
+
profilesReassigned: parsed.data.profilesReassigned ?? 0,
|
|
809
|
+
profilesDropped: parsed.data.profilesDropped ?? 0,
|
|
810
|
+
entitiesReassigned: parsed.data.entitiesReassigned ?? 0,
|
|
811
|
+
entitiesDropped: parsed.data.entitiesDropped ?? 0
|
|
812
|
+
};
|
|
813
|
+
}
|
|
745
814
|
async callAuth(path, body) {
|
|
746
815
|
const res = await post(
|
|
747
816
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
@@ -1733,6 +1802,55 @@ var SendoraSDK = class {
|
|
|
1733
1802
|
}
|
|
1734
1803
|
};
|
|
1735
1804
|
}
|
|
1805
|
+
/**
|
|
1806
|
+
* Attribution namespace — mobile install + deferred deep-link reporting.
|
|
1807
|
+
* Mirrors the backend's `/attribution/{install,deferred}` SDK routes.
|
|
1808
|
+
*
|
|
1809
|
+
* Backend resolves `orgId` + `projectId` from the API key, so the SDK
|
|
1810
|
+
* helper accepts only the device-side context (fingerprint, OS, app
|
|
1811
|
+
* version). Both methods are safe to call on every cold-start —
|
|
1812
|
+
* backend deduplicates installs by `deviceId` within a 24h window so
|
|
1813
|
+
* a paranoid app calling `reportInstall()` on every launch sees
|
|
1814
|
+
* `deduplicated: true` after the first match.
|
|
1815
|
+
*
|
|
1816
|
+
* Typical pairing:
|
|
1817
|
+
* await SendoraCloud.attribution.reportInstall({
|
|
1818
|
+
* deviceId,
|
|
1819
|
+
* fingerprintHash: await computeDeviceFingerprint(),
|
|
1820
|
+
* appVersion,
|
|
1821
|
+
* });
|
|
1822
|
+
* const deferred = await SendoraCloud.attribution.checkDeferred();
|
|
1823
|
+
* if (deferred?.deepLinkPath) router.replace(deferred.deepLinkPath);
|
|
1824
|
+
*/
|
|
1825
|
+
get attribution() {
|
|
1826
|
+
const self = this;
|
|
1827
|
+
return {
|
|
1828
|
+
reportInstall: async (input) => {
|
|
1829
|
+
if (!self.config) throw new Error("[sendoracloud] init() must complete before attribution.reportInstall().");
|
|
1830
|
+
const res = await post(
|
|
1831
|
+
{ apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
|
|
1832
|
+
"/attribution/install",
|
|
1833
|
+
input ?? {}
|
|
1834
|
+
);
|
|
1835
|
+
if (!res || !res.ok) throw new Error(`[sendoracloud] attribution.reportInstall failed (${res?.status ?? "network"})`);
|
|
1836
|
+
const parsed = await res.json();
|
|
1837
|
+
if (!parsed.success || !parsed.data) throw new Error("[sendoracloud] attribution.reportInstall: bad response");
|
|
1838
|
+
return parsed.data;
|
|
1839
|
+
},
|
|
1840
|
+
checkDeferred: async (input) => {
|
|
1841
|
+
if (!self.config) throw new Error("[sendoracloud] init() must complete before attribution.checkDeferred().");
|
|
1842
|
+
const res = await post(
|
|
1843
|
+
{ apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
|
|
1844
|
+
"/attribution/deferred",
|
|
1845
|
+
input ?? {}
|
|
1846
|
+
);
|
|
1847
|
+
if (!res || !res.ok) throw new Error(`[sendoracloud] attribution.checkDeferred failed (${res?.status ?? "network"})`);
|
|
1848
|
+
const parsed = await res.json();
|
|
1849
|
+
if (!parsed.success) throw new Error("[sendoracloud] attribution.checkDeferred: bad response");
|
|
1850
|
+
return parsed.data ?? null;
|
|
1851
|
+
}
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1736
1854
|
/**
|
|
1737
1855
|
* Clear identity + rotate the anonymous id. Call on logout. Awaits
|
|
1738
1856
|
* the AsyncStorage writes so a caller who kills the app immediately
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendoracloud/sdk-react-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.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",
|