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