@sendoracloud/sdk-react-native 0.4.0 → 0.5.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,262 @@ 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
+ bearerHeaders() {
451
+ if (!this.accessToken) {
452
+ throw new AuthError("NOT_SIGNED_IN", "Not signed in");
453
+ }
454
+ return { Authorization: `Bearer ${this.accessToken}` };
455
+ }
456
+ async unwrap(res, errCode) {
457
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
458
+ const parsed = await res.json().catch(() => ({}));
459
+ if (!res.ok || !parsed.success) {
460
+ throw new AuthError(parsed.error?.code ?? errCode, parsed.error?.message ?? `HTTP ${res.status}`);
461
+ }
462
+ return parsed.data;
463
+ }
464
+ /**
465
+ * Sign out: wipe local identity FIRST so the user is logged out on
466
+ * device even if the revoke network call hangs (airplane mode, 5xx).
467
+ * Revoke is fire-and-forget after the wipe.
468
+ */
469
+ signOut() {
470
+ return this.serialize(async () => {
471
+ const refresh = this.hooks.storage.get(REFRESH_KEY);
472
+ await this.wipeLocalIdentity();
473
+ if (refresh) {
474
+ void post(
475
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
269
476
  "/api/v1/auth-service/token/revoke",
270
477
  { refreshToken: refresh }
271
- );
272
- } catch {
478
+ ).catch(() => void 0);
273
479
  }
274
- }
275
- await this.wipeLocalIdentity();
480
+ });
276
481
  }
277
482
  // --- internals ---
483
+ serialize(op) {
484
+ const next = this.inflight.then(op, op);
485
+ this.inflight = next.catch(() => void 0);
486
+ return next;
487
+ }
488
+ async refreshAccessToken() {
489
+ if (this.refreshInflight) return this.refreshInflight;
490
+ const refresh = this.hooks.storage.get(REFRESH_KEY);
491
+ if (!refresh) return null;
492
+ this.refreshInflight = (async () => {
493
+ try {
494
+ const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
495
+ refreshToken: refresh
496
+ }).catch(() => null);
497
+ if (!data) return null;
498
+ this.accessToken = data.tokens.accessToken;
499
+ this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
500
+ this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
501
+ this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
502
+ this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
503
+ return data.tokens.accessToken;
504
+ } finally {
505
+ this.refreshInflight = null;
506
+ }
507
+ })();
508
+ return this.refreshInflight;
509
+ }
278
510
  async callAuth(path, body) {
279
511
  const res = await post(
280
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey },
512
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
281
513
  path,
282
514
  body
283
515
  );
@@ -290,7 +522,7 @@ var Auth = class {
290
522
  } catch {
291
523
  throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
292
524
  }
293
- if (!res.ok || !parsed.success || !parsed.data) {
525
+ if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
294
526
  throw new AuthError(
295
527
  parsed.error?.code ?? `HTTP_${res.status}`,
296
528
  parsed.error?.message ?? `HTTP ${res.status}`
@@ -301,37 +533,51 @@ var Auth = class {
301
533
  persist(data) {
302
534
  this.user = data.user;
303
535
  this.accessToken = data.tokens.accessToken;
536
+ this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
304
537
  this.hooks.storage.set(USER_KEY, JSON.stringify(data.user));
305
538
  this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
539
+ this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
306
540
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
307
541
  this.hooks.onIdentityChange(data.user.id);
308
542
  }
309
543
  async wipeLocalIdentity() {
310
544
  this.user = null;
311
545
  this.accessToken = null;
546
+ this.accessExpiresAt = 0;
312
547
  this.hooks.storage.remove(USER_KEY);
313
548
  this.hooks.storage.remove(TOKEN_KEY);
549
+ this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
314
550
  this.hooks.storage.remove(REFRESH_KEY);
315
551
  await this.hooks.onAnonymousWipe();
316
552
  }
317
553
  };
318
554
 
319
555
  // src/index.ts
556
+ var SDK_VERSION = "0.4.0";
320
557
  var DEFAULT_API_URL = "https://api.sendoracloud.com";
321
558
  var ANON_KEY = "anon_id";
322
559
  var USER_ID_KEY = "user_id";
323
- var AUTH_HYDRATE_KEYS = ["auth_access_token", "auth_refresh_token", "auth_user"];
560
+ var AUTH_HYDRATE_KEYS = [
561
+ "auth_access_token",
562
+ "auth_access_expires",
563
+ "auth_refresh_token",
564
+ "auth_user"
565
+ ];
324
566
  var PUBLIC_KEY_RE = /^pk_(prod|staging|dev|live|test)_[A-Za-z0-9]{16,}$/;
325
567
  var MAX_PUSH_TOKEN_BYTES = 4096;
326
568
  var MAX_PUSH_METADATA_BYTES = 4096;
569
+ var MAX_USER_ID_LEN = 256;
570
+ var MAX_TRAITS_BYTES = 32 * 1024;
571
+ var MAX_PROPERTIES_BYTES = 32 * 1024;
327
572
  function uuid() {
328
573
  const bytes = new Uint8Array(16);
329
574
  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);
575
+ if (!g.crypto?.getRandomValues) {
576
+ throw new Error(
577
+ "[sendoracloud] crypto.getRandomValues unavailable \u2014 refusing to mint IDs without a CSPRNG. Upgrade Hermes / your RN runtime."
578
+ );
334
579
  }
580
+ g.crypto.getRandomValues(bytes);
335
581
  bytes[6] = bytes[6] & 15 | 64;
336
582
  bytes[8] = bytes[8] & 63 | 128;
337
583
  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
@@ -351,6 +597,8 @@ var SendoraSDK = class {
351
597
  this.storage = new Storage();
352
598
  this.anonId = "";
353
599
  this.userId = null;
600
+ this.consentGranted = true;
601
+ this.debug = false;
354
602
  }
355
603
  /** Initialize the SDK. Must be awaited at app startup before identify/track. */
356
604
  async init(config) {
@@ -379,6 +627,11 @@ var SendoraSDK = class {
379
627
  if (err instanceof Error && err.message.startsWith("[sendoracloud]")) throw err;
380
628
  throw new Error(`[sendoracloud] apiUrl is not a valid URL: ${apiUrl}`);
381
629
  }
630
+ if (apiUrl !== DEFAULT_API_URL) {
631
+ console.warn(
632
+ `[sendoracloud] apiUrl override active: ${apiUrl} \u2014 confirm this is intentional. Tokens + events will be sent here.`
633
+ );
634
+ }
382
635
  await this.storage.hydrate([ANON_KEY, USER_ID_KEY, ...AUTH_HYDRATE_KEYS]);
383
636
  let anon = this.storage.get(ANON_KEY);
384
637
  if (!anon) {
@@ -387,6 +640,8 @@ var SendoraSDK = class {
387
640
  }
388
641
  this.anonId = anon;
389
642
  this.userId = this.storage.get(USER_ID_KEY);
643
+ this.consentGranted = config.consentedByDefault !== false;
644
+ this.debug = config.debug === true;
390
645
  this.config = {
391
646
  apiUrl,
392
647
  environment: "prod",
@@ -398,6 +653,7 @@ var SendoraSDK = class {
398
653
  storage: this.storage,
399
654
  apiUrl: this.config.apiUrl,
400
655
  publicKey: this.config.publicKey,
656
+ debug: this.debug,
401
657
  onIdentityChange: (userId) => {
402
658
  this.userId = userId;
403
659
  if (userId) this.storage.set(USER_ID_KEY, userId);
@@ -422,9 +678,22 @@ var SendoraSDK = class {
422
678
  getUserId() {
423
679
  return this.userId;
424
680
  }
681
+ /** Toggle consent at runtime — when false, events drop instead of fire. */
682
+ setConsent(granted) {
683
+ this.consentGranted = granted;
684
+ }
425
685
  /** Associate a user id + traits with the current anonymous install. */
426
686
  identify(userId, traits) {
427
687
  this.assertReady();
688
+ if (typeof userId !== "string" || userId.length === 0 || userId.length > MAX_USER_ID_LEN) {
689
+ throw new Error(`[sendoracloud] userId must be a 1-${MAX_USER_ID_LEN} char string.`);
690
+ }
691
+ if (traits) {
692
+ const bytes = byteLength(JSON.stringify(traits));
693
+ if (bytes > MAX_TRAITS_BYTES) {
694
+ throw new Error(`[sendoracloud] traits exceed ${MAX_TRAITS_BYTES} bytes.`);
695
+ }
696
+ }
428
697
  this.userId = userId;
429
698
  this.storage.set(USER_ID_KEY, userId);
430
699
  void this.sendEvent("identify", {
@@ -435,6 +704,15 @@ var SendoraSDK = class {
435
704
  /** Fire a custom product event. */
436
705
  track(eventType, properties) {
437
706
  this.assertReady();
707
+ if (typeof eventType !== "string" || eventType.length === 0 || eventType.length > 256) {
708
+ throw new Error("[sendoracloud] eventType must be a 1-256 char string.");
709
+ }
710
+ if (properties) {
711
+ const bytes = byteLength(JSON.stringify(properties));
712
+ if (bytes > MAX_PROPERTIES_BYTES) {
713
+ throw new Error(`[sendoracloud] properties exceed ${MAX_PROPERTIES_BYTES} bytes.`);
714
+ }
715
+ }
438
716
  void this.sendEvent("track", {
439
717
  eventType,
440
718
  properties: properties ?? {}
@@ -443,6 +721,9 @@ var SendoraSDK = class {
443
721
  /** RN equivalent of page view — call on route / screen change. */
444
722
  screen(name, properties) {
445
723
  this.assertReady();
724
+ if (typeof name !== "string" || name.length === 0 || name.length > 256) {
725
+ throw new Error("[sendoracloud] screen name must be a 1-256 char string.");
726
+ }
446
727
  void this.sendEvent("screen", {
447
728
  eventType: "screen.viewed",
448
729
  properties: { screenName: name, ...properties ?? {} }
@@ -467,7 +748,7 @@ var SendoraSDK = class {
467
748
  }
468
749
  }
469
750
  await post(
470
- { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey },
751
+ { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
471
752
  "/api/v1/push/tokens",
472
753
  {
473
754
  orgId: this.config.orgId,
@@ -493,8 +774,9 @@ var SendoraSDK = class {
493
774
  }
494
775
  async sendEvent(kind, payload) {
495
776
  if (!this.config) return;
777
+ if (!this.consentGranted) return;
496
778
  await post(
497
- { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey },
779
+ { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
498
780
  "/api/v1/events",
499
781
  {
500
782
  orgId: this.config.orgId,
@@ -507,6 +789,7 @@ var SendoraSDK = class {
507
789
  properties: payload.properties ?? {},
508
790
  context: {
509
791
  platform: "react-native",
792
+ sdk: { name: "@sendoracloud/sdk-react-native", version: SDK_VERSION },
510
793
  ...this.config.environment ? { environment: this.config.environment } : {},
511
794
  ...kind === "identify" ? { traits: payload.traits } : {}
512
795
  }