@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.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,262 @@ 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
+ bearerHeaders() {
411
+ if (!this.accessToken) {
412
+ throw new AuthError("NOT_SIGNED_IN", "Not signed in");
413
+ }
414
+ return { Authorization: `Bearer ${this.accessToken}` };
415
+ }
416
+ async unwrap(res, errCode) {
417
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
418
+ const parsed = await res.json().catch(() => ({}));
419
+ if (!res.ok || !parsed.success) {
420
+ throw new AuthError(parsed.error?.code ?? errCode, parsed.error?.message ?? `HTTP ${res.status}`);
421
+ }
422
+ return parsed.data;
423
+ }
424
+ /**
425
+ * Sign out: wipe local identity FIRST so the user is logged out on
426
+ * device even if the revoke network call hangs (airplane mode, 5xx).
427
+ * Revoke is fire-and-forget after the wipe.
428
+ */
429
+ signOut() {
430
+ return this.serialize(async () => {
431
+ const refresh = this.hooks.storage.get(REFRESH_KEY);
432
+ await this.wipeLocalIdentity();
433
+ if (refresh) {
434
+ void post(
435
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
229
436
  "/api/v1/auth-service/token/revoke",
230
437
  { refreshToken: refresh }
231
- );
232
- } catch {
438
+ ).catch(() => void 0);
233
439
  }
234
- }
235
- await this.wipeLocalIdentity();
440
+ });
236
441
  }
237
442
  // --- internals ---
443
+ serialize(op) {
444
+ const next = this.inflight.then(op, op);
445
+ this.inflight = next.catch(() => void 0);
446
+ return next;
447
+ }
448
+ async refreshAccessToken() {
449
+ if (this.refreshInflight) return this.refreshInflight;
450
+ const refresh = this.hooks.storage.get(REFRESH_KEY);
451
+ if (!refresh) return null;
452
+ this.refreshInflight = (async () => {
453
+ try {
454
+ const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
455
+ refreshToken: refresh
456
+ }).catch(() => null);
457
+ if (!data) return null;
458
+ this.accessToken = data.tokens.accessToken;
459
+ this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
460
+ this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
461
+ this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
462
+ this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
463
+ return data.tokens.accessToken;
464
+ } finally {
465
+ this.refreshInflight = null;
466
+ }
467
+ })();
468
+ return this.refreshInflight;
469
+ }
238
470
  async callAuth(path, body) {
239
471
  const res = await post(
240
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey },
472
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
241
473
  path,
242
474
  body
243
475
  );
@@ -250,7 +482,7 @@ var Auth = class {
250
482
  } catch {
251
483
  throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
252
484
  }
253
- if (!res.ok || !parsed.success || !parsed.data) {
485
+ if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
254
486
  throw new AuthError(
255
487
  parsed.error?.code ?? `HTTP_${res.status}`,
256
488
  parsed.error?.message ?? `HTTP ${res.status}`
@@ -261,37 +493,51 @@ var Auth = class {
261
493
  persist(data) {
262
494
  this.user = data.user;
263
495
  this.accessToken = data.tokens.accessToken;
496
+ this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
264
497
  this.hooks.storage.set(USER_KEY, JSON.stringify(data.user));
265
498
  this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
499
+ this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
266
500
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
267
501
  this.hooks.onIdentityChange(data.user.id);
268
502
  }
269
503
  async wipeLocalIdentity() {
270
504
  this.user = null;
271
505
  this.accessToken = null;
506
+ this.accessExpiresAt = 0;
272
507
  this.hooks.storage.remove(USER_KEY);
273
508
  this.hooks.storage.remove(TOKEN_KEY);
509
+ this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
274
510
  this.hooks.storage.remove(REFRESH_KEY);
275
511
  await this.hooks.onAnonymousWipe();
276
512
  }
277
513
  };
278
514
 
279
515
  // src/index.ts
516
+ var SDK_VERSION = "0.4.0";
280
517
  var DEFAULT_API_URL = "https://api.sendoracloud.com";
281
518
  var ANON_KEY = "anon_id";
282
519
  var USER_ID_KEY = "user_id";
283
- var AUTH_HYDRATE_KEYS = ["auth_access_token", "auth_refresh_token", "auth_user"];
520
+ var AUTH_HYDRATE_KEYS = [
521
+ "auth_access_token",
522
+ "auth_access_expires",
523
+ "auth_refresh_token",
524
+ "auth_user"
525
+ ];
284
526
  var PUBLIC_KEY_RE = /^pk_(prod|staging|dev|live|test)_[A-Za-z0-9]{16,}$/;
285
527
  var MAX_PUSH_TOKEN_BYTES = 4096;
286
528
  var MAX_PUSH_METADATA_BYTES = 4096;
529
+ var MAX_USER_ID_LEN = 256;
530
+ var MAX_TRAITS_BYTES = 32 * 1024;
531
+ var MAX_PROPERTIES_BYTES = 32 * 1024;
287
532
  function uuid() {
288
533
  const bytes = new Uint8Array(16);
289
534
  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);
535
+ if (!g.crypto?.getRandomValues) {
536
+ throw new Error(
537
+ "[sendoracloud] crypto.getRandomValues unavailable \u2014 refusing to mint IDs without a CSPRNG. Upgrade Hermes / your RN runtime."
538
+ );
294
539
  }
540
+ g.crypto.getRandomValues(bytes);
295
541
  bytes[6] = bytes[6] & 15 | 64;
296
542
  bytes[8] = bytes[8] & 63 | 128;
297
543
  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
@@ -311,6 +557,8 @@ var SendoraSDK = class {
311
557
  this.storage = new Storage();
312
558
  this.anonId = "";
313
559
  this.userId = null;
560
+ this.consentGranted = true;
561
+ this.debug = false;
314
562
  }
315
563
  /** Initialize the SDK. Must be awaited at app startup before identify/track. */
316
564
  async init(config) {
@@ -339,6 +587,11 @@ var SendoraSDK = class {
339
587
  if (err instanceof Error && err.message.startsWith("[sendoracloud]")) throw err;
340
588
  throw new Error(`[sendoracloud] apiUrl is not a valid URL: ${apiUrl}`);
341
589
  }
590
+ if (apiUrl !== DEFAULT_API_URL) {
591
+ console.warn(
592
+ `[sendoracloud] apiUrl override active: ${apiUrl} \u2014 confirm this is intentional. Tokens + events will be sent here.`
593
+ );
594
+ }
342
595
  await this.storage.hydrate([ANON_KEY, USER_ID_KEY, ...AUTH_HYDRATE_KEYS]);
343
596
  let anon = this.storage.get(ANON_KEY);
344
597
  if (!anon) {
@@ -347,6 +600,8 @@ var SendoraSDK = class {
347
600
  }
348
601
  this.anonId = anon;
349
602
  this.userId = this.storage.get(USER_ID_KEY);
603
+ this.consentGranted = config.consentedByDefault !== false;
604
+ this.debug = config.debug === true;
350
605
  this.config = {
351
606
  apiUrl,
352
607
  environment: "prod",
@@ -358,6 +613,7 @@ var SendoraSDK = class {
358
613
  storage: this.storage,
359
614
  apiUrl: this.config.apiUrl,
360
615
  publicKey: this.config.publicKey,
616
+ debug: this.debug,
361
617
  onIdentityChange: (userId) => {
362
618
  this.userId = userId;
363
619
  if (userId) this.storage.set(USER_ID_KEY, userId);
@@ -382,9 +638,22 @@ var SendoraSDK = class {
382
638
  getUserId() {
383
639
  return this.userId;
384
640
  }
641
+ /** Toggle consent at runtime — when false, events drop instead of fire. */
642
+ setConsent(granted) {
643
+ this.consentGranted = granted;
644
+ }
385
645
  /** Associate a user id + traits with the current anonymous install. */
386
646
  identify(userId, traits) {
387
647
  this.assertReady();
648
+ if (typeof userId !== "string" || userId.length === 0 || userId.length > MAX_USER_ID_LEN) {
649
+ throw new Error(`[sendoracloud] userId must be a 1-${MAX_USER_ID_LEN} char string.`);
650
+ }
651
+ if (traits) {
652
+ const bytes = byteLength(JSON.stringify(traits));
653
+ if (bytes > MAX_TRAITS_BYTES) {
654
+ throw new Error(`[sendoracloud] traits exceed ${MAX_TRAITS_BYTES} bytes.`);
655
+ }
656
+ }
388
657
  this.userId = userId;
389
658
  this.storage.set(USER_ID_KEY, userId);
390
659
  void this.sendEvent("identify", {
@@ -395,6 +664,15 @@ var SendoraSDK = class {
395
664
  /** Fire a custom product event. */
396
665
  track(eventType, properties) {
397
666
  this.assertReady();
667
+ if (typeof eventType !== "string" || eventType.length === 0 || eventType.length > 256) {
668
+ throw new Error("[sendoracloud] eventType must be a 1-256 char string.");
669
+ }
670
+ if (properties) {
671
+ const bytes = byteLength(JSON.stringify(properties));
672
+ if (bytes > MAX_PROPERTIES_BYTES) {
673
+ throw new Error(`[sendoracloud] properties exceed ${MAX_PROPERTIES_BYTES} bytes.`);
674
+ }
675
+ }
398
676
  void this.sendEvent("track", {
399
677
  eventType,
400
678
  properties: properties ?? {}
@@ -403,6 +681,9 @@ var SendoraSDK = class {
403
681
  /** RN equivalent of page view — call on route / screen change. */
404
682
  screen(name, properties) {
405
683
  this.assertReady();
684
+ if (typeof name !== "string" || name.length === 0 || name.length > 256) {
685
+ throw new Error("[sendoracloud] screen name must be a 1-256 char string.");
686
+ }
406
687
  void this.sendEvent("screen", {
407
688
  eventType: "screen.viewed",
408
689
  properties: { screenName: name, ...properties ?? {} }
@@ -427,7 +708,7 @@ var SendoraSDK = class {
427
708
  }
428
709
  }
429
710
  await post(
430
- { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey },
711
+ { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
431
712
  "/api/v1/push/tokens",
432
713
  {
433
714
  orgId: this.config.orgId,
@@ -453,8 +734,9 @@ var SendoraSDK = class {
453
734
  }
454
735
  async sendEvent(kind, payload) {
455
736
  if (!this.config) return;
737
+ if (!this.consentGranted) return;
456
738
  await post(
457
- { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey },
739
+ { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
458
740
  "/api/v1/events",
459
741
  {
460
742
  orgId: this.config.orgId,
@@ -467,6 +749,7 @@ var SendoraSDK = class {
467
749
  properties: payload.properties ?? {},
468
750
  context: {
469
751
  platform: "react-native",
752
+ sdk: { name: "@sendoracloud/sdk-react-native", version: SDK_VERSION },
470
753
  ...this.config.environment ? { environment: this.config.environment } : {},
471
754
  ...kind === "identify" ? { traits: payload.traits } : {}
472
755
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",