@sendoracloud/sdk-react-native 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -33,8 +33,10 @@ __export(index_exports, {
33
33
  Auth: () => Auth,
34
34
  AuthError: () => AuthError,
35
35
  EmailAlreadyTakenError: () => EmailAlreadyTakenError,
36
+ Links: () => Links,
36
37
  SendoraCloud: () => SendoraCloud,
37
- default: () => index_default
38
+ default: () => index_default,
39
+ extractShortcodeFromUrl: () => extractShortcodeFromUrl
38
40
  });
39
41
  module.exports = __toCommonJS(index_exports);
40
42
 
@@ -235,21 +237,41 @@ var Auth = class {
235
237
  this.resolveReady = res;
236
238
  });
237
239
  }
238
- /** Re-hydrate session from storage. Called by the parent SDK
239
- * during init() after Storage.hydrate() has run. Marks the
240
- * internal ready promise as resolved so queued auth calls
241
- * unblock. */
240
+ /**
241
+ * Re-hydrate session from storage. Called by the parent SDK during
242
+ * init() after Storage.hydrate() has run. Marks the internal ready
243
+ * promise as resolved so queued auth calls unblock.
244
+ *
245
+ * s58.47 — restore the user even when the cached ACCESS token is
246
+ * missing/expired, as long as USER_KEY + REFRESH_KEY are present.
247
+ * Pre-s58.47 this required BOTH USER_KEY and TOKEN_KEY, which
248
+ * caused a cold-start regression: if the access token rotated out
249
+ * of storage (TTL elapsed, app killed between persists, partial
250
+ * write, AsyncStorage eviction) the user appeared signed out even
251
+ * though their refresh chain was still alive in storage. Hosts
252
+ * then called `signInAnonymously()` on every launch and minted a
253
+ * fresh anon user, fragmenting analytics across sessions
254
+ * (Pulse News symptom). With the refresh token present, the next
255
+ * `getAccessToken()` call will silently refresh + restore a valid
256
+ * access token without disturbing identity.
257
+ */
242
258
  hydrate() {
243
259
  const cachedUser = this.hooks.storage.get(USER_KEY);
244
260
  const cachedToken = this.hooks.storage.get(TOKEN_KEY);
261
+ const cachedRefresh = this.hooks.storage.get(REFRESH_KEY);
245
262
  const cachedExpires = this.hooks.storage.get(TOKEN_EXPIRES_KEY);
246
- if (cachedUser && cachedToken) {
263
+ if (cachedUser && (cachedToken || cachedRefresh)) {
247
264
  try {
248
265
  const parsed = JSON.parse(cachedUser);
249
266
  if (typeof parsed.id !== "string" || parsed.id.length === 0) throw new Error("invalid cache");
250
267
  this.user = parsed;
251
- this.accessToken = cachedToken;
252
- this.accessExpiresAt = cachedExpires ? Number(cachedExpires) : 0;
268
+ if (cachedToken) {
269
+ this.accessToken = cachedToken;
270
+ this.accessExpiresAt = cachedExpires ? Number(cachedExpires) : 0;
271
+ } else {
272
+ this.accessToken = null;
273
+ this.accessExpiresAt = 0;
274
+ }
253
275
  this.hooks.onIdentityChange(this.user.id);
254
276
  } catch {
255
277
  this.hooks.storage.remove(USER_KEY);
@@ -821,6 +843,155 @@ function decodeJwtPayload(token) {
821
843
  }
822
844
  }
823
845
 
846
+ // src/links.ts
847
+ async function unwrap(res, ctx) {
848
+ if (!res) throw new Error(`[sendoracloud] ${ctx}: network error`);
849
+ if (!res.ok) {
850
+ let msg = `HTTP ${res.status}`;
851
+ try {
852
+ const parsed2 = await res.clone().json();
853
+ if (parsed2.error?.code || parsed2.error?.message) {
854
+ msg = `${parsed2.error.code ?? ""}${parsed2.error.code && parsed2.error.message ? ": " : ""}${parsed2.error.message ?? ""}`;
855
+ }
856
+ } catch {
857
+ }
858
+ throw new Error(`[sendoracloud] ${ctx}: ${msg}`);
859
+ }
860
+ const parsed = await res.json();
861
+ return parsed.data ?? null;
862
+ }
863
+ var SHORTCODE_RE = /^[a-z0-9-]{3,20}$/;
864
+ function extractShortcodeFromUrl(url) {
865
+ try {
866
+ const u = new URL(url);
867
+ const segs = u.pathname.split("/").filter(Boolean);
868
+ if (segs.length === 0) return null;
869
+ const tail = segs[segs.length - 1];
870
+ return SHORTCODE_RE.test(tail) ? tail : null;
871
+ } catch {
872
+ return null;
873
+ }
874
+ }
875
+ var Links = class {
876
+ constructor(deps) {
877
+ this.deps = deps;
878
+ this.listeners = [];
879
+ }
880
+ /** Register a callback for warm + deferred deep-link opens. Returns unsubscribe fn. */
881
+ onLinkOpened(handler) {
882
+ this.listeners.push(handler);
883
+ return () => {
884
+ const idx = this.listeners.indexOf(handler);
885
+ if (idx >= 0) this.listeners.splice(idx, 1);
886
+ };
887
+ }
888
+ emit(event) {
889
+ for (const fn of this.listeners) {
890
+ try {
891
+ fn(event);
892
+ } catch (err) {
893
+ if (this.deps.debug) console.warn("[sendoracloud] onLinkOpened handler threw:", err);
894
+ }
895
+ }
896
+ }
897
+ /** Mint a new short link. Returns the share URL + shortcode. */
898
+ async create(input) {
899
+ if (!input.title) throw new Error("[sendoracloud] links.create: title is required");
900
+ if (!input.fallbackUrl) throw new Error("[sendoracloud] links.create: fallbackUrl is required");
901
+ const body = { title: input.title, fallbackUrl: input.fallbackUrl };
902
+ if (input.iosDeepLinkPath) body.iosDeepLinkPath = input.iosDeepLinkPath;
903
+ if (input.androidDeepLinkPath) body.androidDeepLinkPath = input.androidDeepLinkPath;
904
+ if (input.linkData) body.linkData = input.linkData;
905
+ if (input.ogTitle) body.ogTitle = input.ogTitle;
906
+ if (input.ogDescription) body.ogDescription = input.ogDescription;
907
+ if (input.ogImageUrl) body.ogImageUrl = input.ogImageUrl;
908
+ if (input.campaign) body.campaign = input.campaign;
909
+ if (input.source) body.source = input.source;
910
+ if (input.medium) body.medium = input.medium;
911
+ if (input.channel) body.channel = input.channel;
912
+ if (input.tags) body.tags = input.tags;
913
+ if (input.expiresAt) body.expiresAt = input.expiresAt;
914
+ const ios = input.iosBundleId ?? this.deps.iosBundleId;
915
+ const android = input.androidPackageName ?? this.deps.androidPackageName;
916
+ if (ios) body.iosBundleId = ios;
917
+ if (android) body.androidPackageName = android;
918
+ const res = await post(
919
+ { apiUrl: this.deps.apiUrl, publicKey: this.deps.publicKey, debug: this.deps.debug },
920
+ "/api/v1/sdk/links",
921
+ body
922
+ );
923
+ const data = await unwrap(res, "links.create");
924
+ if (!data?.shortcode) {
925
+ throw new Error("[sendoracloud] links.create returned no shortcode");
926
+ }
927
+ return data;
928
+ }
929
+ /**
930
+ * Warm-path: app received a universal/app-link delivery. Resolves the
931
+ * shortcode against the backend and fires `onLinkOpened`. No-op (and
932
+ * returns false) when the URL isn't a Sendora link.
933
+ */
934
+ async handleUniversalLink(url) {
935
+ const shortcode = extractShortcodeFromUrl(url);
936
+ if (!shortcode) return false;
937
+ try {
938
+ const res = await getJson(
939
+ { apiUrl: this.deps.apiUrl, publicKey: this.deps.publicKey, debug: this.deps.debug },
940
+ `/api/v1/sdk/links/${encodeURIComponent(shortcode)}`
941
+ );
942
+ const data = await unwrap(res, "links.handleUniversalLink");
943
+ if (!data) return false;
944
+ this.emit({
945
+ shortcode: data.shortcode,
946
+ linkData: data.linkData ?? {},
947
+ iosDeepLinkPath: data.iosDeepLinkPath,
948
+ androidDeepLinkPath: data.androidDeepLinkPath,
949
+ isDeferred: false
950
+ });
951
+ return true;
952
+ } catch (err) {
953
+ if (this.deps.debug) console.warn("[sendoracloud] handleUniversalLink failed:", err);
954
+ return false;
955
+ }
956
+ }
957
+ /**
958
+ * Cold-launch deferred deep link match. Pass the Android Play Install
959
+ * Referrer string (preferred — 100% accurate) and/or a precomputed
960
+ * fingerprint hash (iOS — probabilistic).
961
+ *
962
+ * SDK fires `onLinkOpened` with `isDeferred: true` on success.
963
+ * Returns the event payload on match, `null` on no match.
964
+ */
965
+ async matchDeferred(input) {
966
+ if (!input.fingerprintHash && !input.installReferrer) {
967
+ throw new Error("[sendoracloud] links.matchDeferred: pass fingerprintHash or installReferrer");
968
+ }
969
+ const body = {};
970
+ if (input.fingerprintHash) body.fingerprintHash = input.fingerprintHash;
971
+ if (input.installReferrer) body.installReferrer = input.installReferrer;
972
+ const ios = input.iosBundleId ?? this.deps.iosBundleId;
973
+ const android = input.androidPackageName ?? this.deps.androidPackageName;
974
+ if (ios) body.iosBundleId = ios;
975
+ if (android) body.androidPackageName = android;
976
+ const res = await post(
977
+ { apiUrl: this.deps.apiUrl, publicKey: this.deps.publicKey, debug: this.deps.debug },
978
+ "/api/v1/sdk/links/match",
979
+ body
980
+ );
981
+ const data = await unwrap(res, "links.matchDeferred");
982
+ if (!data || !data.shortcode) return null;
983
+ const event = {
984
+ shortcode: data.shortcode,
985
+ linkData: data.linkData ?? {},
986
+ iosDeepLinkPath: data.iosDeepLinkPath,
987
+ androidDeepLinkPath: data.androidDeepLinkPath,
988
+ isDeferred: true
989
+ };
990
+ this.emit(event);
991
+ return event;
992
+ }
993
+ };
994
+
824
995
  // src/auto-track.ts
825
996
  var DEFAULT_IDLE_MS = 30 * 60 * 1e3;
826
997
  function resolveFlags(cfg) {
@@ -904,7 +1075,7 @@ function installAutoTrack(args) {
904
1075
  }
905
1076
 
906
1077
  // src/index.ts
907
- var SDK_VERSION = "0.11.0";
1078
+ var SDK_VERSION = "0.15.0";
908
1079
  var DEFAULT_API_URL = "https://api.sendoracloud.com";
909
1080
  var ANON_KEY = "anon_id";
910
1081
  var USER_ID_KEY = "user_id";
@@ -1043,6 +1214,13 @@ var SendoraSDK = class {
1043
1214
  }
1044
1215
  });
1045
1216
  this.auth.hydrate();
1217
+ this.links = new Links({
1218
+ apiUrl: this.config.apiUrl,
1219
+ publicKey: this.config.publicKey,
1220
+ debug: this.debug,
1221
+ iosBundleId: this.config.iosBundleId,
1222
+ androidPackageName: this.config.androidPackageName
1223
+ });
1046
1224
  this.ready = true;
1047
1225
  if (config.autoTrack !== false) {
1048
1226
  this.autoTrackCleanup = installAutoTrack({
@@ -1237,5 +1415,7 @@ var index_default = SendoraCloud;
1237
1415
  Auth,
1238
1416
  AuthError,
1239
1417
  EmailAlreadyTakenError,
1240
- SendoraCloud
1418
+ Links,
1419
+ SendoraCloud,
1420
+ extractShortcodeFromUrl
1241
1421
  });
package/dist/index.d.cts CHANGED
@@ -122,10 +122,24 @@ declare class Auth {
122
122
  private readyPromise;
123
123
  private resolveReady;
124
124
  constructor(hooks: AuthHooks);
125
- /** Re-hydrate session from storage. Called by the parent SDK
126
- * during init() after Storage.hydrate() has run. Marks the
127
- * internal ready promise as resolved so queued auth calls
128
- * unblock. */
125
+ /**
126
+ * Re-hydrate session from storage. Called by the parent SDK during
127
+ * init() after Storage.hydrate() has run. Marks the internal ready
128
+ * promise as resolved so queued auth calls unblock.
129
+ *
130
+ * s58.47 — restore the user even when the cached ACCESS token is
131
+ * missing/expired, as long as USER_KEY + REFRESH_KEY are present.
132
+ * Pre-s58.47 this required BOTH USER_KEY and TOKEN_KEY, which
133
+ * caused a cold-start regression: if the access token rotated out
134
+ * of storage (TTL elapsed, app killed between persists, partial
135
+ * write, AsyncStorage eviction) the user appeared signed out even
136
+ * though their refresh chain was still alive in storage. Hosts
137
+ * then called `signInAnonymously()` on every launch and minted a
138
+ * fresh anon user, fragmenting analytics across sessions
139
+ * (Pulse News symptom). With the refresh token present, the next
140
+ * `getAccessToken()` call will silently refresh + restore a valid
141
+ * access token without disturbing identity.
142
+ */
129
143
  hydrate(): void;
130
144
  getCurrentUser(): AuthUser | null;
131
145
  /**
@@ -290,6 +304,129 @@ declare class Auth {
290
304
  private wipeLocalIdentity;
291
305
  }
292
306
 
307
+ /**
308
+ * Sendora Deep Links surface for React Native.
309
+ *
310
+ * Three core moves:
311
+ * 1. `create(...)` — mint a Sendora short link from inside the app
312
+ * (Pulse-style "share article" UX).
313
+ * 2. `handleUniversalLink(url)` — call when iOS / Android delivers a Universal
314
+ * Link or App Link to the app. SDK resolves the
315
+ * shortcode against backend and fires
316
+ * `onLinkOpened` with the link's `linkData`.
317
+ * 3. `matchDeferred({ ... })` — call once on cold launch after install. If the
318
+ * device fingerprint OR Android Play Install
319
+ * Referrer match a recent click, SDK fires
320
+ * `onLinkOpened` with `isDeferred: true`.
321
+ *
322
+ * The host app supplies the iOS bundle id / Android package via `init()` of
323
+ * the main SDK, OR per-call. Sendora-side `apps` table is the source of
324
+ * truth — a leaked public key + wrong bundle = 400 from the backend.
325
+ */
326
+ interface LinkCreateInput {
327
+ /** Customer-facing title. Shown in dashboard list views. */
328
+ title: string;
329
+ /** In-app deep-link path the host app routes to (e.g. `/articles/123`). */
330
+ iosDeepLinkPath?: string;
331
+ /** In-app deep-link path the host app routes to (e.g. `/articles/123`). */
332
+ androidDeepLinkPath?: string;
333
+ /** Web fallback URL — shown when app is not installed + Play Store fallback. */
334
+ fallbackUrl: string;
335
+ /** Custom JSON delivered to onLinkOpened. Max 2KB serialized. */
336
+ linkData?: Record<string, unknown>;
337
+ /** Optional OG preview overrides for richer share cards. */
338
+ ogTitle?: string;
339
+ ogDescription?: string;
340
+ ogImageUrl?: string;
341
+ /** Attribution tags. */
342
+ campaign?: string;
343
+ source?: string;
344
+ medium?: string;
345
+ channel?: string;
346
+ tags?: string[];
347
+ /** ISO timestamp when the link stops resolving. */
348
+ expiresAt?: string;
349
+ /** Bundle ID / package name for the bundle-id gate. */
350
+ iosBundleId?: string;
351
+ androidPackageName?: string;
352
+ }
353
+ interface LinkCreateResult {
354
+ id: string;
355
+ shortcode: string;
356
+ /** Fully qualified share URL — what you pass to RN `Share.share({ message: ... })`. */
357
+ url: string;
358
+ iosDeepLinkPath: string | null;
359
+ androidDeepLinkPath: string | null;
360
+ fallbackUrl: string;
361
+ linkData: Record<string, unknown>;
362
+ expiresAt: string | null;
363
+ }
364
+ interface LinkOpenedEvent {
365
+ shortcode: string;
366
+ /** Custom JSON the link was created with. Pulse navigates off `articleId` here. */
367
+ linkData: Record<string, unknown>;
368
+ /** Server's iOS deep-link path. Useful when host app's URL parser needs a hint. */
369
+ iosDeepLinkPath: string | null;
370
+ /** Server's Android deep-link path. */
371
+ androidDeepLinkPath: string | null;
372
+ /**
373
+ * `true` when this event came from the deferred deep link path —
374
+ * i.e. user installed the app from the App / Play Store after
375
+ * clicking the link, and the SDK matched their fingerprint /
376
+ * install-referrer to the prior click.
377
+ */
378
+ isDeferred: boolean;
379
+ }
380
+ type LinkOpenedHandler = (event: LinkOpenedEvent) => void;
381
+ interface LinkMatchInput {
382
+ /** Hex-encoded SHA-256 of (ip + ua + screen + tz + locale). Pre-hashed by caller. */
383
+ fingerprintHash?: string;
384
+ /** Raw Play Install Referrer (`PlayInstallReferrerClient.getInstallReferrer().installReferrer`). */
385
+ installReferrer?: string;
386
+ iosBundleId?: string;
387
+ androidPackageName?: string;
388
+ }
389
+ /**
390
+ * Extract a Sendora shortcode from a universal link URL. Accepts either:
391
+ * - https://go.sendoracloud.com/<shortcode>
392
+ * - https://go.sendoracloud.com/link/<shortcode> (Worker rewrites this path)
393
+ * - any custom host the customer pointed at the Sendora Worker (lookup by tail
394
+ * segment matches `SHORTCODE_RE`).
395
+ */
396
+ declare function extractShortcodeFromUrl(url: string): string | null;
397
+ interface LinksDeps {
398
+ apiUrl: string;
399
+ publicKey: string;
400
+ debug: boolean;
401
+ iosBundleId?: string;
402
+ androidPackageName?: string;
403
+ }
404
+ declare class Links {
405
+ private deps;
406
+ private listeners;
407
+ constructor(deps: LinksDeps);
408
+ /** Register a callback for warm + deferred deep-link opens. Returns unsubscribe fn. */
409
+ onLinkOpened(handler: LinkOpenedHandler): () => void;
410
+ private emit;
411
+ /** Mint a new short link. Returns the share URL + shortcode. */
412
+ create(input: LinkCreateInput): Promise<LinkCreateResult>;
413
+ /**
414
+ * Warm-path: app received a universal/app-link delivery. Resolves the
415
+ * shortcode against the backend and fires `onLinkOpened`. No-op (and
416
+ * returns false) when the URL isn't a Sendora link.
417
+ */
418
+ handleUniversalLink(url: string): Promise<boolean>;
419
+ /**
420
+ * Cold-launch deferred deep link match. Pass the Android Play Install
421
+ * Referrer string (preferred — 100% accurate) and/or a precomputed
422
+ * fingerprint hash (iOS — probabilistic).
423
+ *
424
+ * SDK fires `onLinkOpened` with `isDeferred: true` on success.
425
+ * Returns the event payload on match, `null` on no match.
426
+ */
427
+ matchDeferred(input: LinkMatchInput): Promise<LinkOpenedEvent | null>;
428
+ }
429
+
293
430
  interface SendoraConfig {
294
431
  /**
295
432
  * Public API key (`pk_live_…` or `pk_test_…`). Secret keys are refused.
@@ -344,6 +481,17 @@ interface SendoraConfig {
344
481
  };
345
482
  /** Idle threshold (ms) for session expiry; default 30 minutes. */
346
483
  sessionIdleMs?: number;
484
+ /**
485
+ * iOS bundle id (e.g. `com.pulse.news`). Forwarded to backend on
486
+ * `links.create()` / `links.matchDeferred()` for the bundle-id gate —
487
+ * blocks a leaked public key from being used by a different app.
488
+ */
489
+ iosBundleId?: string;
490
+ /**
491
+ * Android package name (e.g. `com.pulse.news`). Same purpose as
492
+ * `iosBundleId` above.
493
+ */
494
+ androidPackageName?: string;
347
495
  }
348
496
  type TraitValue = string | number | boolean | null | undefined;
349
497
  interface IdentifyTraits {
@@ -416,6 +564,8 @@ declare class SendoraSDK {
416
564
  private autoTrackCleanup;
417
565
  /** Lazy-initialised in init() once we know the apiUrl + publicKey. */
418
566
  auth: Auth;
567
+ /** Lazy-initialised in init(). Deep-link surface: create + handleUniversalLink + matchDeferred. */
568
+ links: Links;
419
569
  /** Initialize the SDK. Must be awaited at app startup before identify/track. */
420
570
  init(config: SendoraConfig): Promise<void>;
421
571
  /** Current logical session id, if auto-track is enabled. */
@@ -462,4 +612,4 @@ declare class SendoraSDK {
462
612
  }
463
613
  declare const SendoraCloud: SendoraSDK;
464
614
 
465
- export { Auth, AuthError, type AuthTokens, type AuthUser, EmailAlreadyTakenError, type IdentifyTraits, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, SendoraCloud as default };
615
+ export { Auth, AuthError, type AuthTokens, type AuthUser, EmailAlreadyTakenError, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, SendoraCloud as default, extractShortcodeFromUrl };
package/dist/index.d.ts CHANGED
@@ -122,10 +122,24 @@ declare class Auth {
122
122
  private readyPromise;
123
123
  private resolveReady;
124
124
  constructor(hooks: AuthHooks);
125
- /** Re-hydrate session from storage. Called by the parent SDK
126
- * during init() after Storage.hydrate() has run. Marks the
127
- * internal ready promise as resolved so queued auth calls
128
- * unblock. */
125
+ /**
126
+ * Re-hydrate session from storage. Called by the parent SDK during
127
+ * init() after Storage.hydrate() has run. Marks the internal ready
128
+ * promise as resolved so queued auth calls unblock.
129
+ *
130
+ * s58.47 — restore the user even when the cached ACCESS token is
131
+ * missing/expired, as long as USER_KEY + REFRESH_KEY are present.
132
+ * Pre-s58.47 this required BOTH USER_KEY and TOKEN_KEY, which
133
+ * caused a cold-start regression: if the access token rotated out
134
+ * of storage (TTL elapsed, app killed between persists, partial
135
+ * write, AsyncStorage eviction) the user appeared signed out even
136
+ * though their refresh chain was still alive in storage. Hosts
137
+ * then called `signInAnonymously()` on every launch and minted a
138
+ * fresh anon user, fragmenting analytics across sessions
139
+ * (Pulse News symptom). With the refresh token present, the next
140
+ * `getAccessToken()` call will silently refresh + restore a valid
141
+ * access token without disturbing identity.
142
+ */
129
143
  hydrate(): void;
130
144
  getCurrentUser(): AuthUser | null;
131
145
  /**
@@ -290,6 +304,129 @@ declare class Auth {
290
304
  private wipeLocalIdentity;
291
305
  }
292
306
 
307
+ /**
308
+ * Sendora Deep Links surface for React Native.
309
+ *
310
+ * Three core moves:
311
+ * 1. `create(...)` — mint a Sendora short link from inside the app
312
+ * (Pulse-style "share article" UX).
313
+ * 2. `handleUniversalLink(url)` — call when iOS / Android delivers a Universal
314
+ * Link or App Link to the app. SDK resolves the
315
+ * shortcode against backend and fires
316
+ * `onLinkOpened` with the link's `linkData`.
317
+ * 3. `matchDeferred({ ... })` — call once on cold launch after install. If the
318
+ * device fingerprint OR Android Play Install
319
+ * Referrer match a recent click, SDK fires
320
+ * `onLinkOpened` with `isDeferred: true`.
321
+ *
322
+ * The host app supplies the iOS bundle id / Android package via `init()` of
323
+ * the main SDK, OR per-call. Sendora-side `apps` table is the source of
324
+ * truth — a leaked public key + wrong bundle = 400 from the backend.
325
+ */
326
+ interface LinkCreateInput {
327
+ /** Customer-facing title. Shown in dashboard list views. */
328
+ title: string;
329
+ /** In-app deep-link path the host app routes to (e.g. `/articles/123`). */
330
+ iosDeepLinkPath?: string;
331
+ /** In-app deep-link path the host app routes to (e.g. `/articles/123`). */
332
+ androidDeepLinkPath?: string;
333
+ /** Web fallback URL — shown when app is not installed + Play Store fallback. */
334
+ fallbackUrl: string;
335
+ /** Custom JSON delivered to onLinkOpened. Max 2KB serialized. */
336
+ linkData?: Record<string, unknown>;
337
+ /** Optional OG preview overrides for richer share cards. */
338
+ ogTitle?: string;
339
+ ogDescription?: string;
340
+ ogImageUrl?: string;
341
+ /** Attribution tags. */
342
+ campaign?: string;
343
+ source?: string;
344
+ medium?: string;
345
+ channel?: string;
346
+ tags?: string[];
347
+ /** ISO timestamp when the link stops resolving. */
348
+ expiresAt?: string;
349
+ /** Bundle ID / package name for the bundle-id gate. */
350
+ iosBundleId?: string;
351
+ androidPackageName?: string;
352
+ }
353
+ interface LinkCreateResult {
354
+ id: string;
355
+ shortcode: string;
356
+ /** Fully qualified share URL — what you pass to RN `Share.share({ message: ... })`. */
357
+ url: string;
358
+ iosDeepLinkPath: string | null;
359
+ androidDeepLinkPath: string | null;
360
+ fallbackUrl: string;
361
+ linkData: Record<string, unknown>;
362
+ expiresAt: string | null;
363
+ }
364
+ interface LinkOpenedEvent {
365
+ shortcode: string;
366
+ /** Custom JSON the link was created with. Pulse navigates off `articleId` here. */
367
+ linkData: Record<string, unknown>;
368
+ /** Server's iOS deep-link path. Useful when host app's URL parser needs a hint. */
369
+ iosDeepLinkPath: string | null;
370
+ /** Server's Android deep-link path. */
371
+ androidDeepLinkPath: string | null;
372
+ /**
373
+ * `true` when this event came from the deferred deep link path —
374
+ * i.e. user installed the app from the App / Play Store after
375
+ * clicking the link, and the SDK matched their fingerprint /
376
+ * install-referrer to the prior click.
377
+ */
378
+ isDeferred: boolean;
379
+ }
380
+ type LinkOpenedHandler = (event: LinkOpenedEvent) => void;
381
+ interface LinkMatchInput {
382
+ /** Hex-encoded SHA-256 of (ip + ua + screen + tz + locale). Pre-hashed by caller. */
383
+ fingerprintHash?: string;
384
+ /** Raw Play Install Referrer (`PlayInstallReferrerClient.getInstallReferrer().installReferrer`). */
385
+ installReferrer?: string;
386
+ iosBundleId?: string;
387
+ androidPackageName?: string;
388
+ }
389
+ /**
390
+ * Extract a Sendora shortcode from a universal link URL. Accepts either:
391
+ * - https://go.sendoracloud.com/<shortcode>
392
+ * - https://go.sendoracloud.com/link/<shortcode> (Worker rewrites this path)
393
+ * - any custom host the customer pointed at the Sendora Worker (lookup by tail
394
+ * segment matches `SHORTCODE_RE`).
395
+ */
396
+ declare function extractShortcodeFromUrl(url: string): string | null;
397
+ interface LinksDeps {
398
+ apiUrl: string;
399
+ publicKey: string;
400
+ debug: boolean;
401
+ iosBundleId?: string;
402
+ androidPackageName?: string;
403
+ }
404
+ declare class Links {
405
+ private deps;
406
+ private listeners;
407
+ constructor(deps: LinksDeps);
408
+ /** Register a callback for warm + deferred deep-link opens. Returns unsubscribe fn. */
409
+ onLinkOpened(handler: LinkOpenedHandler): () => void;
410
+ private emit;
411
+ /** Mint a new short link. Returns the share URL + shortcode. */
412
+ create(input: LinkCreateInput): Promise<LinkCreateResult>;
413
+ /**
414
+ * Warm-path: app received a universal/app-link delivery. Resolves the
415
+ * shortcode against the backend and fires `onLinkOpened`. No-op (and
416
+ * returns false) when the URL isn't a Sendora link.
417
+ */
418
+ handleUniversalLink(url: string): Promise<boolean>;
419
+ /**
420
+ * Cold-launch deferred deep link match. Pass the Android Play Install
421
+ * Referrer string (preferred — 100% accurate) and/or a precomputed
422
+ * fingerprint hash (iOS — probabilistic).
423
+ *
424
+ * SDK fires `onLinkOpened` with `isDeferred: true` on success.
425
+ * Returns the event payload on match, `null` on no match.
426
+ */
427
+ matchDeferred(input: LinkMatchInput): Promise<LinkOpenedEvent | null>;
428
+ }
429
+
293
430
  interface SendoraConfig {
294
431
  /**
295
432
  * Public API key (`pk_live_…` or `pk_test_…`). Secret keys are refused.
@@ -344,6 +481,17 @@ interface SendoraConfig {
344
481
  };
345
482
  /** Idle threshold (ms) for session expiry; default 30 minutes. */
346
483
  sessionIdleMs?: number;
484
+ /**
485
+ * iOS bundle id (e.g. `com.pulse.news`). Forwarded to backend on
486
+ * `links.create()` / `links.matchDeferred()` for the bundle-id gate —
487
+ * blocks a leaked public key from being used by a different app.
488
+ */
489
+ iosBundleId?: string;
490
+ /**
491
+ * Android package name (e.g. `com.pulse.news`). Same purpose as
492
+ * `iosBundleId` above.
493
+ */
494
+ androidPackageName?: string;
347
495
  }
348
496
  type TraitValue = string | number | boolean | null | undefined;
349
497
  interface IdentifyTraits {
@@ -416,6 +564,8 @@ declare class SendoraSDK {
416
564
  private autoTrackCleanup;
417
565
  /** Lazy-initialised in init() once we know the apiUrl + publicKey. */
418
566
  auth: Auth;
567
+ /** Lazy-initialised in init(). Deep-link surface: create + handleUniversalLink + matchDeferred. */
568
+ links: Links;
419
569
  /** Initialize the SDK. Must be awaited at app startup before identify/track. */
420
570
  init(config: SendoraConfig): Promise<void>;
421
571
  /** Current logical session id, if auto-track is enabled. */
@@ -462,4 +612,4 @@ declare class SendoraSDK {
462
612
  }
463
613
  declare const SendoraCloud: SendoraSDK;
464
614
 
465
- export { Auth, AuthError, type AuthTokens, type AuthUser, EmailAlreadyTakenError, type IdentifyTraits, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, SendoraCloud as default };
615
+ export { Auth, AuthError, type AuthTokens, type AuthUser, EmailAlreadyTakenError, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, SendoraCloud as default, extractShortcodeFromUrl };
package/dist/index.js CHANGED
@@ -195,21 +195,41 @@ var Auth = class {
195
195
  this.resolveReady = res;
196
196
  });
197
197
  }
198
- /** Re-hydrate session from storage. Called by the parent SDK
199
- * during init() after Storage.hydrate() has run. Marks the
200
- * internal ready promise as resolved so queued auth calls
201
- * unblock. */
198
+ /**
199
+ * Re-hydrate session from storage. Called by the parent SDK during
200
+ * init() after Storage.hydrate() has run. Marks the internal ready
201
+ * promise as resolved so queued auth calls unblock.
202
+ *
203
+ * s58.47 — restore the user even when the cached ACCESS token is
204
+ * missing/expired, as long as USER_KEY + REFRESH_KEY are present.
205
+ * Pre-s58.47 this required BOTH USER_KEY and TOKEN_KEY, which
206
+ * caused a cold-start regression: if the access token rotated out
207
+ * of storage (TTL elapsed, app killed between persists, partial
208
+ * write, AsyncStorage eviction) the user appeared signed out even
209
+ * though their refresh chain was still alive in storage. Hosts
210
+ * then called `signInAnonymously()` on every launch and minted a
211
+ * fresh anon user, fragmenting analytics across sessions
212
+ * (Pulse News symptom). With the refresh token present, the next
213
+ * `getAccessToken()` call will silently refresh + restore a valid
214
+ * access token without disturbing identity.
215
+ */
202
216
  hydrate() {
203
217
  const cachedUser = this.hooks.storage.get(USER_KEY);
204
218
  const cachedToken = this.hooks.storage.get(TOKEN_KEY);
219
+ const cachedRefresh = this.hooks.storage.get(REFRESH_KEY);
205
220
  const cachedExpires = this.hooks.storage.get(TOKEN_EXPIRES_KEY);
206
- if (cachedUser && cachedToken) {
221
+ if (cachedUser && (cachedToken || cachedRefresh)) {
207
222
  try {
208
223
  const parsed = JSON.parse(cachedUser);
209
224
  if (typeof parsed.id !== "string" || parsed.id.length === 0) throw new Error("invalid cache");
210
225
  this.user = parsed;
211
- this.accessToken = cachedToken;
212
- this.accessExpiresAt = cachedExpires ? Number(cachedExpires) : 0;
226
+ if (cachedToken) {
227
+ this.accessToken = cachedToken;
228
+ this.accessExpiresAt = cachedExpires ? Number(cachedExpires) : 0;
229
+ } else {
230
+ this.accessToken = null;
231
+ this.accessExpiresAt = 0;
232
+ }
213
233
  this.hooks.onIdentityChange(this.user.id);
214
234
  } catch {
215
235
  this.hooks.storage.remove(USER_KEY);
@@ -781,6 +801,155 @@ function decodeJwtPayload(token) {
781
801
  }
782
802
  }
783
803
 
804
+ // src/links.ts
805
+ async function unwrap(res, ctx) {
806
+ if (!res) throw new Error(`[sendoracloud] ${ctx}: network error`);
807
+ if (!res.ok) {
808
+ let msg = `HTTP ${res.status}`;
809
+ try {
810
+ const parsed2 = await res.clone().json();
811
+ if (parsed2.error?.code || parsed2.error?.message) {
812
+ msg = `${parsed2.error.code ?? ""}${parsed2.error.code && parsed2.error.message ? ": " : ""}${parsed2.error.message ?? ""}`;
813
+ }
814
+ } catch {
815
+ }
816
+ throw new Error(`[sendoracloud] ${ctx}: ${msg}`);
817
+ }
818
+ const parsed = await res.json();
819
+ return parsed.data ?? null;
820
+ }
821
+ var SHORTCODE_RE = /^[a-z0-9-]{3,20}$/;
822
+ function extractShortcodeFromUrl(url) {
823
+ try {
824
+ const u = new URL(url);
825
+ const segs = u.pathname.split("/").filter(Boolean);
826
+ if (segs.length === 0) return null;
827
+ const tail = segs[segs.length - 1];
828
+ return SHORTCODE_RE.test(tail) ? tail : null;
829
+ } catch {
830
+ return null;
831
+ }
832
+ }
833
+ var Links = class {
834
+ constructor(deps) {
835
+ this.deps = deps;
836
+ this.listeners = [];
837
+ }
838
+ /** Register a callback for warm + deferred deep-link opens. Returns unsubscribe fn. */
839
+ onLinkOpened(handler) {
840
+ this.listeners.push(handler);
841
+ return () => {
842
+ const idx = this.listeners.indexOf(handler);
843
+ if (idx >= 0) this.listeners.splice(idx, 1);
844
+ };
845
+ }
846
+ emit(event) {
847
+ for (const fn of this.listeners) {
848
+ try {
849
+ fn(event);
850
+ } catch (err) {
851
+ if (this.deps.debug) console.warn("[sendoracloud] onLinkOpened handler threw:", err);
852
+ }
853
+ }
854
+ }
855
+ /** Mint a new short link. Returns the share URL + shortcode. */
856
+ async create(input) {
857
+ if (!input.title) throw new Error("[sendoracloud] links.create: title is required");
858
+ if (!input.fallbackUrl) throw new Error("[sendoracloud] links.create: fallbackUrl is required");
859
+ const body = { title: input.title, fallbackUrl: input.fallbackUrl };
860
+ if (input.iosDeepLinkPath) body.iosDeepLinkPath = input.iosDeepLinkPath;
861
+ if (input.androidDeepLinkPath) body.androidDeepLinkPath = input.androidDeepLinkPath;
862
+ if (input.linkData) body.linkData = input.linkData;
863
+ if (input.ogTitle) body.ogTitle = input.ogTitle;
864
+ if (input.ogDescription) body.ogDescription = input.ogDescription;
865
+ if (input.ogImageUrl) body.ogImageUrl = input.ogImageUrl;
866
+ if (input.campaign) body.campaign = input.campaign;
867
+ if (input.source) body.source = input.source;
868
+ if (input.medium) body.medium = input.medium;
869
+ if (input.channel) body.channel = input.channel;
870
+ if (input.tags) body.tags = input.tags;
871
+ if (input.expiresAt) body.expiresAt = input.expiresAt;
872
+ const ios = input.iosBundleId ?? this.deps.iosBundleId;
873
+ const android = input.androidPackageName ?? this.deps.androidPackageName;
874
+ if (ios) body.iosBundleId = ios;
875
+ if (android) body.androidPackageName = android;
876
+ const res = await post(
877
+ { apiUrl: this.deps.apiUrl, publicKey: this.deps.publicKey, debug: this.deps.debug },
878
+ "/api/v1/sdk/links",
879
+ body
880
+ );
881
+ const data = await unwrap(res, "links.create");
882
+ if (!data?.shortcode) {
883
+ throw new Error("[sendoracloud] links.create returned no shortcode");
884
+ }
885
+ return data;
886
+ }
887
+ /**
888
+ * Warm-path: app received a universal/app-link delivery. Resolves the
889
+ * shortcode against the backend and fires `onLinkOpened`. No-op (and
890
+ * returns false) when the URL isn't a Sendora link.
891
+ */
892
+ async handleUniversalLink(url) {
893
+ const shortcode = extractShortcodeFromUrl(url);
894
+ if (!shortcode) return false;
895
+ try {
896
+ const res = await getJson(
897
+ { apiUrl: this.deps.apiUrl, publicKey: this.deps.publicKey, debug: this.deps.debug },
898
+ `/api/v1/sdk/links/${encodeURIComponent(shortcode)}`
899
+ );
900
+ const data = await unwrap(res, "links.handleUniversalLink");
901
+ if (!data) return false;
902
+ this.emit({
903
+ shortcode: data.shortcode,
904
+ linkData: data.linkData ?? {},
905
+ iosDeepLinkPath: data.iosDeepLinkPath,
906
+ androidDeepLinkPath: data.androidDeepLinkPath,
907
+ isDeferred: false
908
+ });
909
+ return true;
910
+ } catch (err) {
911
+ if (this.deps.debug) console.warn("[sendoracloud] handleUniversalLink failed:", err);
912
+ return false;
913
+ }
914
+ }
915
+ /**
916
+ * Cold-launch deferred deep link match. Pass the Android Play Install
917
+ * Referrer string (preferred — 100% accurate) and/or a precomputed
918
+ * fingerprint hash (iOS — probabilistic).
919
+ *
920
+ * SDK fires `onLinkOpened` with `isDeferred: true` on success.
921
+ * Returns the event payload on match, `null` on no match.
922
+ */
923
+ async matchDeferred(input) {
924
+ if (!input.fingerprintHash && !input.installReferrer) {
925
+ throw new Error("[sendoracloud] links.matchDeferred: pass fingerprintHash or installReferrer");
926
+ }
927
+ const body = {};
928
+ if (input.fingerprintHash) body.fingerprintHash = input.fingerprintHash;
929
+ if (input.installReferrer) body.installReferrer = input.installReferrer;
930
+ const ios = input.iosBundleId ?? this.deps.iosBundleId;
931
+ const android = input.androidPackageName ?? this.deps.androidPackageName;
932
+ if (ios) body.iosBundleId = ios;
933
+ if (android) body.androidPackageName = android;
934
+ const res = await post(
935
+ { apiUrl: this.deps.apiUrl, publicKey: this.deps.publicKey, debug: this.deps.debug },
936
+ "/api/v1/sdk/links/match",
937
+ body
938
+ );
939
+ const data = await unwrap(res, "links.matchDeferred");
940
+ if (!data || !data.shortcode) return null;
941
+ const event = {
942
+ shortcode: data.shortcode,
943
+ linkData: data.linkData ?? {},
944
+ iosDeepLinkPath: data.iosDeepLinkPath,
945
+ androidDeepLinkPath: data.androidDeepLinkPath,
946
+ isDeferred: true
947
+ };
948
+ this.emit(event);
949
+ return event;
950
+ }
951
+ };
952
+
784
953
  // src/auto-track.ts
785
954
  var DEFAULT_IDLE_MS = 30 * 60 * 1e3;
786
955
  function resolveFlags(cfg) {
@@ -864,7 +1033,7 @@ function installAutoTrack(args) {
864
1033
  }
865
1034
 
866
1035
  // src/index.ts
867
- var SDK_VERSION = "0.11.0";
1036
+ var SDK_VERSION = "0.15.0";
868
1037
  var DEFAULT_API_URL = "https://api.sendoracloud.com";
869
1038
  var ANON_KEY = "anon_id";
870
1039
  var USER_ID_KEY = "user_id";
@@ -1003,6 +1172,13 @@ var SendoraSDK = class {
1003
1172
  }
1004
1173
  });
1005
1174
  this.auth.hydrate();
1175
+ this.links = new Links({
1176
+ apiUrl: this.config.apiUrl,
1177
+ publicKey: this.config.publicKey,
1178
+ debug: this.debug,
1179
+ iosBundleId: this.config.iosBundleId,
1180
+ androidPackageName: this.config.androidPackageName
1181
+ });
1006
1182
  this.ready = true;
1007
1183
  if (config.autoTrack !== false) {
1008
1184
  this.autoTrackCleanup = installAutoTrack({
@@ -1196,6 +1372,8 @@ export {
1196
1372
  Auth,
1197
1373
  AuthError,
1198
1374
  EmailAlreadyTakenError,
1375
+ Links,
1199
1376
  SendoraCloud,
1200
- index_default as default
1377
+ index_default as default,
1378
+ extractShortcodeFromUrl
1201
1379
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth. Expo Go compatible, no native modules beyond AsyncStorage.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",