@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.js
CHANGED
|
@@ -58,18 +58,31 @@ var Storage = class {
|
|
|
58
58
|
});
|
|
59
59
|
}
|
|
60
60
|
/**
|
|
61
|
-
* Remove every sendora_*-prefixed key
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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(
|
|
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
|
|
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]
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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/
|
|
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 &&
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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 = [
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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.
|
|
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",
|