@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 +362 -79
- package/dist/index.d.cts +89 -15
- package/dist/index.d.ts +89 -15
- package/dist/index.js +362 -79
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -98,18 +98,31 @@ var Storage = class {
|
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
100
|
/**
|
|
101
|
-
* Remove every sendora_*-prefixed key
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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(
|
|
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
|
|
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]
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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/
|
|
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 &&
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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 = [
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
}
|