@sendoracloud/sdk-react-native 0.4.0 → 0.7.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
@@ -98,18 +98,31 @@ var Storage = class {
98
98
  });
99
99
  }
100
100
  /**
101
- * Remove every sendora_*-prefixed key we know about from both
102
- * memory and AsyncStorage. Used by reset() so no stale identity
103
- * survives a logout, including keys future SDK versions might add.
101
+ * Remove every sendora_*-prefixed key from both memory AND
102
+ * AsyncStorage. Uses getAllKeys() to find keys this process never
103
+ * cached (e.g. orphaned writes from a prior SDK version that added
104
+ * a key not in our hydrate list). Falls back to cache enumeration
105
+ * if the AsyncStorage backend doesn't expose getAllKeys.
104
106
  */
105
107
  async clearAll() {
106
- const keys = Array.from(this.cache.keys());
107
108
  this.cache.clear();
108
109
  const store = await getAsyncStorage();
109
110
  if (!store) return;
111
+ let keys = [];
112
+ if (typeof store.getAllKeys === "function") {
113
+ try {
114
+ const all = await store.getAllKeys();
115
+ keys = all.filter((k) => k.startsWith(PREFIX));
116
+ } catch {
117
+ keys = [];
118
+ }
119
+ }
120
+ if (keys.length === 0) {
121
+ keys = Array.from(this.cache.keys()).map((k) => PREFIX + k);
122
+ }
110
123
  for (const k of keys) {
111
124
  try {
112
- await store.removeItem(PREFIX + k);
125
+ await store.removeItem(k);
113
126
  } catch {
114
127
  }
115
128
  }
@@ -117,11 +130,20 @@ var Storage = class {
117
130
  };
118
131
 
119
132
  // src/http.ts
120
- async function post(opts, path, body) {
133
+ async function post(opts, path, body, extraHeaders) {
134
+ return request(opts, "POST", path, body, extraHeaders);
135
+ }
136
+ async function getJson(opts, path, extraHeaders) {
137
+ return request(opts, "GET", path, void 0, extraHeaders);
138
+ }
139
+ async function del(opts, path, extraHeaders) {
140
+ return request(opts, "DELETE", path, void 0, extraHeaders);
141
+ }
142
+ async function request(opts, method, path, body, extraHeaders) {
121
143
  let res;
122
144
  try {
123
145
  res = await fetch(`${opts.apiUrl}${path}`, {
124
- method: "POST",
146
+ method,
125
147
  headers: {
126
148
  "Content-Type": "application/json",
127
149
  // Backend auth middleware reads `x-api-key`. A previous build
@@ -129,41 +151,62 @@ async function post(opts, path, body) {
129
151
  // only accepts for SuperTokens session tokens — every event
130
152
  // silently 401'd. Do not change this header without updating
131
153
  // apps/backend/src/middleware/auth.ts in lockstep.
132
- "x-api-key": opts.publicKey
154
+ "x-api-key": opts.publicKey,
155
+ ...extraHeaders ?? {}
133
156
  },
134
- body: JSON.stringify(body)
157
+ body: body !== void 0 ? JSON.stringify(body) : void 0
135
158
  });
136
159
  } catch (err) {
137
160
  console.warn(
138
- `[sendoracloud] POST ${path} failed at the network layer:`,
161
+ `[sendoracloud] ${method} ${path} failed at the network layer:`,
139
162
  err instanceof Error ? err.message : String(err)
140
163
  );
141
164
  return null;
142
165
  }
143
166
  if (!res.ok) {
144
- let detail = "";
145
- let fields = "";
146
- try {
147
- const parsed = await res.clone().json();
148
- if (parsed?.error?.code || parsed?.error?.message) {
149
- detail = ` \u2014 ${parsed.error.code ?? ""}${parsed.error.code && parsed.error.message ? ": " : ""}${parsed.error.message ?? ""}`;
150
- }
151
- const fe = parsed?.error?.details?.fieldErrors;
152
- if (fe) {
153
- const pieces = Object.entries(fe).map(([k, v]) => `${k}: ${(v ?? []).join(", ")}`).filter(Boolean);
154
- if (pieces.length) fields = ` (${pieces.join("; ")})`;
167
+ const isAuthPath = path.startsWith("/api/v1/auth-service/");
168
+ if (isAuthPath && !opts.debug) {
169
+ console.warn(`[sendoracloud] ${method} ${path} \u2192 HTTP ${res.status}`);
170
+ } else {
171
+ let detail = "";
172
+ let fields = "";
173
+ try {
174
+ const parsed = await res.clone().json();
175
+ if (parsed?.error?.code || parsed?.error?.message) {
176
+ detail = ` \u2014 ${parsed.error.code ?? ""}${parsed.error.code && parsed.error.message ? ": " : ""}${parsed.error.message ?? ""}`;
177
+ }
178
+ const fe = parsed?.error?.details?.fieldErrors;
179
+ if (fe) {
180
+ const pieces = Object.entries(fe).map(([k, v]) => `${k}: ${(v ?? []).join(", ")}`).filter(Boolean);
181
+ if (pieces.length) fields = ` (${pieces.join("; ")})`;
182
+ }
183
+ } catch {
155
184
  }
156
- } catch {
185
+ console.warn(`[sendoracloud] ${method} ${path} \u2192 HTTP ${res.status}${detail}${fields}`);
157
186
  }
158
- console.warn(`[sendoracloud] POST ${path} \u2192 HTTP ${res.status}${detail}${fields}`);
159
187
  }
160
188
  return res;
161
189
  }
162
190
 
163
191
  // src/auth.ts
164
192
  var TOKEN_KEY = "auth_access_token";
193
+ var TOKEN_EXPIRES_KEY = "auth_access_expires";
165
194
  var REFRESH_KEY = "auth_refresh_token";
166
195
  var USER_KEY = "auth_user";
196
+ var REFRESH_SAFETY_MS = 3e4;
197
+ function isAuthApiResponse(v) {
198
+ if (!v || typeof v !== "object") return false;
199
+ const r = v;
200
+ const user = r.user;
201
+ const tokens = r.tokens;
202
+ if (!user || typeof user.id !== "string" || user.id.length === 0) return false;
203
+ if (typeof user.isAnonymous !== "boolean") return false;
204
+ if (!tokens) return false;
205
+ if (typeof tokens.accessToken !== "string" || tokens.accessToken.length === 0) return false;
206
+ if (typeof tokens.refreshToken !== "string" || tokens.refreshToken.length === 0) return false;
207
+ if (typeof tokens.expiresIn !== "number" || tokens.expiresIn <= 0) return false;
208
+ return true;
209
+ }
167
210
  var EmailAlreadyTakenError = class extends Error {
168
211
  constructor(message = "An account with this email already exists") {
169
212
  super(message);
@@ -181,21 +224,29 @@ var Auth = class {
181
224
  constructor(hooks) {
182
225
  this.user = null;
183
226
  this.accessToken = null;
227
+ this.accessExpiresAt = 0;
228
+ this.inflight = Promise.resolve();
229
+ this.refreshInflight = null;
184
230
  this.hooks = hooks;
185
231
  }
186
- /** Re-hydrate session from AsyncStorage. Called by the parent SDK
232
+ /** Re-hydrate session from storage. Called by the parent SDK
187
233
  * during init() after Storage.hydrate() has run. */
188
234
  hydrate() {
189
235
  const cachedUser = this.hooks.storage.get(USER_KEY);
190
236
  const cachedToken = this.hooks.storage.get(TOKEN_KEY);
237
+ const cachedExpires = this.hooks.storage.get(TOKEN_EXPIRES_KEY);
191
238
  if (cachedUser && cachedToken) {
192
239
  try {
193
- this.user = JSON.parse(cachedUser);
240
+ const parsed = JSON.parse(cachedUser);
241
+ if (typeof parsed.id !== "string" || parsed.id.length === 0) throw new Error("invalid cache");
242
+ this.user = parsed;
194
243
  this.accessToken = cachedToken;
244
+ this.accessExpiresAt = cachedExpires ? Number(cachedExpires) : 0;
195
245
  this.hooks.onIdentityChange(this.user.id);
196
246
  } catch {
197
247
  this.hooks.storage.remove(USER_KEY);
198
248
  this.hooks.storage.remove(TOKEN_KEY);
249
+ this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
199
250
  this.hooks.storage.remove(REFRESH_KEY);
200
251
  }
201
252
  }
@@ -203,81 +254,339 @@ var Auth = class {
203
254
  getCurrentUser() {
204
255
  return this.user;
205
256
  }
206
- getAccessToken() {
257
+ /**
258
+ * Returns a non-expired access token or null. If the cached token
259
+ * is expired but a refresh token exists, transparently refreshes
260
+ * (single-flight — concurrent callers share one network call).
261
+ */
262
+ async getAccessToken() {
263
+ if (!this.accessToken) return null;
264
+ if (this.accessExpiresAt && Date.now() < this.accessExpiresAt - REFRESH_SAFETY_MS) {
265
+ return this.accessToken;
266
+ }
267
+ return this.refreshAccessToken();
268
+ }
269
+ /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
270
+ getAccessTokenSync() {
207
271
  return this.accessToken;
208
272
  }
209
- async signInAnonymously(opts) {
210
- const data = await this.callAuth("/api/v1/auth-service/anonymous", {
211
- name: opts?.name,
212
- metadata: opts?.metadata
273
+ signInAnonymously(opts) {
274
+ return this.serialize(async () => {
275
+ const data = await this.callAuth("/api/v1/auth-service/anonymous", {
276
+ name: opts?.name,
277
+ metadata: opts?.metadata
278
+ });
279
+ this.persist(data);
280
+ return data.user;
213
281
  });
214
- this.persist(data);
215
- return data.user;
216
282
  }
217
- async signUp(email, password, opts) {
218
- const refresh = this.hooks.storage.get(REFRESH_KEY);
219
- const isAnonymous = this.user?.isAnonymous === true;
220
- if (isAnonymous && refresh) {
283
+ signUp(email, password, opts) {
284
+ return this.serialize(async () => {
285
+ const refresh = this.hooks.storage.get(REFRESH_KEY);
286
+ const isAnonymous = this.user?.isAnonymous === true;
287
+ if (isAnonymous && refresh) {
288
+ try {
289
+ const data = await this.callAuth("/api/v1/auth-service/upgrade", {
290
+ refreshToken: refresh,
291
+ email,
292
+ password,
293
+ name: opts?.name
294
+ });
295
+ this.persist(data);
296
+ return data.user;
297
+ } catch (err) {
298
+ if (err instanceof AuthError && (err.code === "CONFLICT" || err.code === "EMAIL_ALREADY_TAKEN")) {
299
+ throw new EmailAlreadyTakenError(err.message);
300
+ }
301
+ throw err;
302
+ }
303
+ }
304
+ const wasIdentified = this.user !== null && this.user.isAnonymous === false;
305
+ if (wasIdentified) await this.wipeLocalIdentity();
221
306
  try {
222
- const data = await this.callAuth("/api/v1/auth-service/upgrade", {
223
- refreshToken: refresh,
307
+ const data = await this.callAuth("/api/v1/auth-service/signup", {
224
308
  email,
225
309
  password,
226
- name: opts?.name
310
+ name: opts?.name,
311
+ metadata: opts?.metadata
227
312
  });
228
313
  this.persist(data);
229
314
  return data.user;
230
315
  } catch (err) {
231
- if (err instanceof AuthError && (err.code === "CONFLICT" || err.code === "EMAIL_ALREADY_TAKEN")) {
316
+ if (err instanceof AuthError && err.code === "CONFLICT") {
232
317
  throw new EmailAlreadyTakenError(err.message);
233
318
  }
234
319
  throw err;
235
320
  }
236
- }
237
- try {
238
- const data = await this.callAuth("/api/v1/auth-service/signup", {
239
- email,
240
- password,
241
- name: opts?.name,
242
- metadata: opts?.metadata
321
+ });
322
+ }
323
+ /**
324
+ * Sign in with email + password. When the user has confirmed MFA,
325
+ * returns `{mfaRequired, mfaChallengeToken}` instead of an
326
+ * `AuthUser`; caller must follow up with `challengeMfa()`.
327
+ */
328
+ signIn(email, password) {
329
+ return this.serialize(async () => {
330
+ if (this.user !== null) await this.wipeLocalIdentity();
331
+ const res = await post(
332
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
333
+ "/api/v1/auth-service/login",
334
+ { email, password }
335
+ );
336
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
337
+ let parsed;
338
+ try {
339
+ parsed = await res.json();
340
+ } catch {
341
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
342
+ }
343
+ if (!res.ok || !parsed.success || !parsed.data) {
344
+ throw new AuthError(parsed.error?.code ?? `HTTP_${res.status}`, parsed.error?.message ?? `HTTP ${res.status}`);
345
+ }
346
+ const r = parsed.data;
347
+ if (r.mfaRequired === true && typeof r.mfaChallengeToken === "string") {
348
+ const u = r.user;
349
+ return { mfaRequired: true, mfaChallengeToken: r.mfaChallengeToken, userId: u?.id ?? "" };
350
+ }
351
+ if (!isAuthApiResponse(parsed.data)) {
352
+ throw new AuthError(parsed.error?.code ?? "AUTH_ERROR", parsed.error?.message ?? "Login failed");
353
+ }
354
+ this.persist(parsed.data);
355
+ return parsed.data.user;
356
+ });
357
+ }
358
+ /**
359
+ * Exchange the MFA challenge token from `signIn` for a real session
360
+ * by submitting a TOTP or recovery code from the user's authenticator.
361
+ */
362
+ challengeMfa(mfaChallengeToken, code) {
363
+ return this.serialize(async () => {
364
+ const data = await this.callAuth("/api/v1/auth-service/mfa/challenge", {
365
+ challengeToken: mfaChallengeToken,
366
+ code
243
367
  });
244
368
  this.persist(data);
245
369
  return data.user;
246
- } catch (err) {
247
- if (err instanceof AuthError && err.code === "CONFLICT") {
248
- throw new EmailAlreadyTakenError(err.message);
249
- }
250
- throw err;
370
+ });
371
+ }
372
+ // ============================================================
373
+ // MAGIC LINK
374
+ // ============================================================
375
+ async sendMagicLink(email) {
376
+ const res = await post(
377
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
378
+ "/api/v1/auth-service/magic-link/request",
379
+ { email }
380
+ );
381
+ if (!res || !res.ok) {
382
+ throw new AuthError("MAGIC_LINK_REQUEST_FAILED", "Failed to send link");
251
383
  }
252
384
  }
253
- async signIn(email, password) {
254
- const wasAnonymous = this.user?.isAnonymous === true;
255
- const data = await this.callAuth("/api/v1/auth-service/login", {
256
- email,
257
- password
385
+ verifyMagicLink(token) {
386
+ return this.serialize(async () => {
387
+ if (this.user !== null) await this.wipeLocalIdentity();
388
+ const data = await this.callAuth("/api/v1/auth-service/magic-link/verify", { token });
389
+ this.persist(data);
390
+ return data.user;
258
391
  });
259
- if (wasAnonymous) await this.wipeLocalIdentity();
260
- this.persist(data);
261
- return data.user;
262
392
  }
263
- async signOut() {
264
- const refresh = this.hooks.storage.get(REFRESH_KEY);
265
- if (refresh) {
266
- try {
267
- await post(
268
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey },
393
+ // ============================================================
394
+ // TOTP MFA — enrollment management (Bearer-authenticated)
395
+ // ============================================================
396
+ async enrollMfa() {
397
+ const res = await post(
398
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
399
+ "/api/v1/auth-service/mfa/enroll/start",
400
+ {},
401
+ this.bearerHeaders()
402
+ );
403
+ return await this.unwrap(res, "MFA_ENROLL_FAILED");
404
+ }
405
+ async confirmMfa(code) {
406
+ const res = await post(
407
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
408
+ "/api/v1/auth-service/mfa/enroll/confirm",
409
+ { code },
410
+ this.bearerHeaders()
411
+ );
412
+ const data = await this.unwrap(res, "MFA_CONFIRM_FAILED");
413
+ return Boolean(data.confirmed);
414
+ }
415
+ async disableMfa() {
416
+ await post(
417
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
418
+ "/api/v1/auth-service/mfa/disable",
419
+ {},
420
+ this.bearerHeaders()
421
+ );
422
+ }
423
+ // ============================================================
424
+ // DEVICE SESSIONS (Bearer-authenticated)
425
+ // ============================================================
426
+ async listMySessions() {
427
+ const res = await getJson(
428
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
429
+ "/api/v1/auth-service/sessions/me",
430
+ this.bearerHeaders()
431
+ );
432
+ if (!res || !res.ok) return [];
433
+ const parsed = await res.json();
434
+ return Array.isArray(parsed.data) ? parsed.data : [];
435
+ }
436
+ async revokeSession(sessionId) {
437
+ await del(
438
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
439
+ `/api/v1/auth-service/sessions/me/${encodeURIComponent(sessionId)}`,
440
+ this.bearerHeaders()
441
+ );
442
+ }
443
+ async revokeAllSessions() {
444
+ await del(
445
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
446
+ "/api/v1/auth-service/sessions/me",
447
+ this.bearerHeaders()
448
+ );
449
+ }
450
+ // ============================================================
451
+ // OIDC SSO
452
+ // ============================================================
453
+ /**
454
+ * Start OIDC sign-in. Returns the IdP's authorization URL — caller
455
+ * opens it via React Native's `Linking.openURL()` (or
456
+ * `WebBrowser.openAuthSessionAsync` from `expo-web-browser` when
457
+ * inside Expo). The IdP redirects through Sendora's callback to the
458
+ * `returnTo` URL with `#sendora_oidc_token=...` in the fragment.
459
+ *
460
+ * On the customer's app side, `returnTo` MUST be a custom URL scheme
461
+ * registered in Info.plist (iOS) / AndroidManifest.xml intent-filter
462
+ * (Android), e.g. `myapp://oidc-callback`. When the deep link fires,
463
+ * call `consumeSsoFragment(url)` on the captured URL.
464
+ */
465
+ async startSso(returnTo) {
466
+ const res = await post(
467
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
468
+ "/api/v1/auth-service/sso/oidc/start",
469
+ { returnTo }
470
+ );
471
+ if (!res || !res.ok) throw new AuthError("SSO_START_FAILED", `HTTP ${res?.status ?? "network"}`);
472
+ const parsed = await res.json();
473
+ if (!parsed.success || !parsed.data?.authorizationUrl) {
474
+ throw new AuthError("SSO_START_FAILED", "Missing authorizationUrl");
475
+ }
476
+ return { authorizationUrl: parsed.data.authorizationUrl };
477
+ }
478
+ /**
479
+ * Consume an OIDC callback URL captured via React Native's
480
+ * `Linking.addEventListener('url', ...)`. Reads the fragment for
481
+ * `sendora_oidc_token`, exchanges it for a session, and persists.
482
+ * Throws AuthError on `sendora_oidc_error=...` fragment.
483
+ */
484
+ consumeSsoFragment(callbackUrl) {
485
+ return this.serialize(async () => {
486
+ if (this.user !== null) await this.wipeLocalIdentity();
487
+ const hashIdx = callbackUrl.indexOf("#");
488
+ const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
489
+ const params = new URLSearchParams(fragment);
490
+ const err = params.get("sendora_oidc_error");
491
+ if (err) throw new AuthError("SSO_FAILED", decodeURIComponent(err));
492
+ const refresh = params.get("sendora_oidc_token");
493
+ if (!refresh) throw new AuthError("SSO_NO_TOKEN", "Callback URL has no sendora_oidc_token");
494
+ const res = await post(
495
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
496
+ "/api/v1/auth-service/token/refresh",
497
+ { refreshToken: refresh }
498
+ );
499
+ if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`);
500
+ const parsed = await res.json();
501
+ const access = parsed.data?.accessToken;
502
+ const newRefresh = parsed.data?.refreshToken;
503
+ const expiresIn = parsed.data?.expiresIn;
504
+ if (!parsed.success || !access || !newRefresh || !expiresIn || expiresIn <= 0) {
505
+ throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token");
506
+ }
507
+ const claims = decodeJwtPayload(access);
508
+ const user = {
509
+ id: claims?.sub ?? "",
510
+ email: claims?.email ?? null,
511
+ emailVerified: claims?.email_verified ?? true,
512
+ name: claims?.name ?? null,
513
+ isAnonymous: false
514
+ };
515
+ this.persist({
516
+ user,
517
+ tokens: {
518
+ accessToken: access,
519
+ refreshToken: newRefresh,
520
+ expiresIn,
521
+ tokenType: "Bearer"
522
+ }
523
+ });
524
+ return user;
525
+ });
526
+ }
527
+ bearerHeaders() {
528
+ if (!this.accessToken) {
529
+ throw new AuthError("NOT_SIGNED_IN", "Not signed in");
530
+ }
531
+ return { Authorization: `Bearer ${this.accessToken}` };
532
+ }
533
+ async unwrap(res, errCode) {
534
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
535
+ const parsed = await res.json().catch(() => ({}));
536
+ if (!res.ok || !parsed.success) {
537
+ throw new AuthError(parsed.error?.code ?? errCode, parsed.error?.message ?? `HTTP ${res.status}`);
538
+ }
539
+ return parsed.data;
540
+ }
541
+ /**
542
+ * Sign out: wipe local identity FIRST so the user is logged out on
543
+ * device even if the revoke network call hangs (airplane mode, 5xx).
544
+ * Revoke is fire-and-forget after the wipe.
545
+ */
546
+ signOut() {
547
+ return this.serialize(async () => {
548
+ const refresh = this.hooks.storage.get(REFRESH_KEY);
549
+ await this.wipeLocalIdentity();
550
+ if (refresh) {
551
+ void post(
552
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
269
553
  "/api/v1/auth-service/token/revoke",
270
554
  { refreshToken: refresh }
271
- );
272
- } catch {
555
+ ).catch(() => void 0);
273
556
  }
274
- }
275
- await this.wipeLocalIdentity();
557
+ });
276
558
  }
277
559
  // --- internals ---
560
+ serialize(op) {
561
+ const next = this.inflight.then(op, op);
562
+ this.inflight = next.catch(() => void 0);
563
+ return next;
564
+ }
565
+ async refreshAccessToken() {
566
+ if (this.refreshInflight) return this.refreshInflight;
567
+ const refresh = this.hooks.storage.get(REFRESH_KEY);
568
+ if (!refresh) return null;
569
+ this.refreshInflight = (async () => {
570
+ try {
571
+ const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
572
+ refreshToken: refresh
573
+ }).catch(() => null);
574
+ if (!data) return null;
575
+ this.accessToken = data.tokens.accessToken;
576
+ this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
577
+ this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
578
+ this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
579
+ this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
580
+ return data.tokens.accessToken;
581
+ } finally {
582
+ this.refreshInflight = null;
583
+ }
584
+ })();
585
+ return this.refreshInflight;
586
+ }
278
587
  async callAuth(path, body) {
279
588
  const res = await post(
280
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey },
589
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
281
590
  path,
282
591
  body
283
592
  );
@@ -290,7 +599,7 @@ var Auth = class {
290
599
  } catch {
291
600
  throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
292
601
  }
293
- if (!res.ok || !parsed.success || !parsed.data) {
602
+ if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
294
603
  throw new AuthError(
295
604
  parsed.error?.code ?? `HTTP_${res.status}`,
296
605
  parsed.error?.message ?? `HTTP ${res.status}`
@@ -301,37 +610,64 @@ var Auth = class {
301
610
  persist(data) {
302
611
  this.user = data.user;
303
612
  this.accessToken = data.tokens.accessToken;
613
+ this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
304
614
  this.hooks.storage.set(USER_KEY, JSON.stringify(data.user));
305
615
  this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
616
+ this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
306
617
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
307
618
  this.hooks.onIdentityChange(data.user.id);
308
619
  }
309
620
  async wipeLocalIdentity() {
310
621
  this.user = null;
311
622
  this.accessToken = null;
623
+ this.accessExpiresAt = 0;
312
624
  this.hooks.storage.remove(USER_KEY);
313
625
  this.hooks.storage.remove(TOKEN_KEY);
626
+ this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
314
627
  this.hooks.storage.remove(REFRESH_KEY);
315
628
  await this.hooks.onAnonymousWipe();
316
629
  }
317
630
  };
631
+ function decodeJwtPayload(token) {
632
+ try {
633
+ const parts = token.split(".");
634
+ if (parts.length !== 3) return null;
635
+ const padded = parts[1].replace(/-/g, "+").replace(/_/g, "/");
636
+ const pad = (4 - padded.length % 4) % 4;
637
+ if (typeof atob !== "function") return null;
638
+ const json = atob(padded + "===".slice(0, pad));
639
+ return JSON.parse(json);
640
+ } catch {
641
+ return null;
642
+ }
643
+ }
318
644
 
319
645
  // src/index.ts
646
+ var SDK_VERSION = "0.4.0";
320
647
  var DEFAULT_API_URL = "https://api.sendoracloud.com";
321
648
  var ANON_KEY = "anon_id";
322
649
  var USER_ID_KEY = "user_id";
323
- var AUTH_HYDRATE_KEYS = ["auth_access_token", "auth_refresh_token", "auth_user"];
650
+ var AUTH_HYDRATE_KEYS = [
651
+ "auth_access_token",
652
+ "auth_access_expires",
653
+ "auth_refresh_token",
654
+ "auth_user"
655
+ ];
324
656
  var PUBLIC_KEY_RE = /^pk_(prod|staging|dev|live|test)_[A-Za-z0-9]{16,}$/;
325
657
  var MAX_PUSH_TOKEN_BYTES = 4096;
326
658
  var MAX_PUSH_METADATA_BYTES = 4096;
659
+ var MAX_USER_ID_LEN = 256;
660
+ var MAX_TRAITS_BYTES = 32 * 1024;
661
+ var MAX_PROPERTIES_BYTES = 32 * 1024;
327
662
  function uuid() {
328
663
  const bytes = new Uint8Array(16);
329
664
  const g = globalThis;
330
- if (g.crypto?.getRandomValues) {
331
- g.crypto.getRandomValues(bytes);
332
- } else {
333
- for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
665
+ if (!g.crypto?.getRandomValues) {
666
+ throw new Error(
667
+ "[sendoracloud] crypto.getRandomValues unavailable \u2014 refusing to mint IDs without a CSPRNG. Upgrade Hermes / your RN runtime."
668
+ );
334
669
  }
670
+ g.crypto.getRandomValues(bytes);
335
671
  bytes[6] = bytes[6] & 15 | 64;
336
672
  bytes[8] = bytes[8] & 63 | 128;
337
673
  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
@@ -351,6 +687,8 @@ var SendoraSDK = class {
351
687
  this.storage = new Storage();
352
688
  this.anonId = "";
353
689
  this.userId = null;
690
+ this.consentGranted = true;
691
+ this.debug = false;
354
692
  }
355
693
  /** Initialize the SDK. Must be awaited at app startup before identify/track. */
356
694
  async init(config) {
@@ -379,6 +717,11 @@ var SendoraSDK = class {
379
717
  if (err instanceof Error && err.message.startsWith("[sendoracloud]")) throw err;
380
718
  throw new Error(`[sendoracloud] apiUrl is not a valid URL: ${apiUrl}`);
381
719
  }
720
+ if (apiUrl !== DEFAULT_API_URL) {
721
+ console.warn(
722
+ `[sendoracloud] apiUrl override active: ${apiUrl} \u2014 confirm this is intentional. Tokens + events will be sent here.`
723
+ );
724
+ }
382
725
  await this.storage.hydrate([ANON_KEY, USER_ID_KEY, ...AUTH_HYDRATE_KEYS]);
383
726
  let anon = this.storage.get(ANON_KEY);
384
727
  if (!anon) {
@@ -387,6 +730,8 @@ var SendoraSDK = class {
387
730
  }
388
731
  this.anonId = anon;
389
732
  this.userId = this.storage.get(USER_ID_KEY);
733
+ this.consentGranted = config.consentedByDefault !== false;
734
+ this.debug = config.debug === true;
390
735
  this.config = {
391
736
  apiUrl,
392
737
  environment: "prod",
@@ -398,6 +743,7 @@ var SendoraSDK = class {
398
743
  storage: this.storage,
399
744
  apiUrl: this.config.apiUrl,
400
745
  publicKey: this.config.publicKey,
746
+ debug: this.debug,
401
747
  onIdentityChange: (userId) => {
402
748
  this.userId = userId;
403
749
  if (userId) this.storage.set(USER_ID_KEY, userId);
@@ -422,9 +768,22 @@ var SendoraSDK = class {
422
768
  getUserId() {
423
769
  return this.userId;
424
770
  }
771
+ /** Toggle consent at runtime — when false, events drop instead of fire. */
772
+ setConsent(granted) {
773
+ this.consentGranted = granted;
774
+ }
425
775
  /** Associate a user id + traits with the current anonymous install. */
426
776
  identify(userId, traits) {
427
777
  this.assertReady();
778
+ if (typeof userId !== "string" || userId.length === 0 || userId.length > MAX_USER_ID_LEN) {
779
+ throw new Error(`[sendoracloud] userId must be a 1-${MAX_USER_ID_LEN} char string.`);
780
+ }
781
+ if (traits) {
782
+ const bytes = byteLength(JSON.stringify(traits));
783
+ if (bytes > MAX_TRAITS_BYTES) {
784
+ throw new Error(`[sendoracloud] traits exceed ${MAX_TRAITS_BYTES} bytes.`);
785
+ }
786
+ }
428
787
  this.userId = userId;
429
788
  this.storage.set(USER_ID_KEY, userId);
430
789
  void this.sendEvent("identify", {
@@ -435,6 +794,15 @@ var SendoraSDK = class {
435
794
  /** Fire a custom product event. */
436
795
  track(eventType, properties) {
437
796
  this.assertReady();
797
+ if (typeof eventType !== "string" || eventType.length === 0 || eventType.length > 256) {
798
+ throw new Error("[sendoracloud] eventType must be a 1-256 char string.");
799
+ }
800
+ if (properties) {
801
+ const bytes = byteLength(JSON.stringify(properties));
802
+ if (bytes > MAX_PROPERTIES_BYTES) {
803
+ throw new Error(`[sendoracloud] properties exceed ${MAX_PROPERTIES_BYTES} bytes.`);
804
+ }
805
+ }
438
806
  void this.sendEvent("track", {
439
807
  eventType,
440
808
  properties: properties ?? {}
@@ -443,6 +811,9 @@ var SendoraSDK = class {
443
811
  /** RN equivalent of page view — call on route / screen change. */
444
812
  screen(name, properties) {
445
813
  this.assertReady();
814
+ if (typeof name !== "string" || name.length === 0 || name.length > 256) {
815
+ throw new Error("[sendoracloud] screen name must be a 1-256 char string.");
816
+ }
446
817
  void this.sendEvent("screen", {
447
818
  eventType: "screen.viewed",
448
819
  properties: { screenName: name, ...properties ?? {} }
@@ -467,7 +838,7 @@ var SendoraSDK = class {
467
838
  }
468
839
  }
469
840
  await post(
470
- { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey },
841
+ { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
471
842
  "/api/v1/push/tokens",
472
843
  {
473
844
  orgId: this.config.orgId,
@@ -493,8 +864,9 @@ var SendoraSDK = class {
493
864
  }
494
865
  async sendEvent(kind, payload) {
495
866
  if (!this.config) return;
867
+ if (!this.consentGranted) return;
496
868
  await post(
497
- { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey },
869
+ { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
498
870
  "/api/v1/events",
499
871
  {
500
872
  orgId: this.config.orgId,
@@ -507,6 +879,7 @@ var SendoraSDK = class {
507
879
  properties: payload.properties ?? {},
508
880
  context: {
509
881
  platform: "react-native",
882
+ sdk: { name: "@sendoracloud/sdk-react-native", version: SDK_VERSION },
510
883
  ...this.config.environment ? { environment: this.config.environment } : {},
511
884
  ...kind === "identify" ? { traits: payload.traits } : {}
512
885
  }