@sendoracloud/sdk-react-native 0.4.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +452 -79
- package/dist/index.d.cts +111 -15
- package/dist/index.d.ts +111 -15
- package/dist/index.js +452 -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,339 @@ 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
|
+
// ============================================================
|
|
451
|
+
// OIDC SSO
|
|
452
|
+
// ============================================================
|
|
453
|
+
/**
|
|
454
|
+
* Start OIDC sign-in. Returns the IdP's authorization URL — caller
|
|
455
|
+
* opens it via React Native's `Linking.openURL()` (or
|
|
456
|
+
* `WebBrowser.openAuthSessionAsync` from `expo-web-browser` when
|
|
457
|
+
* inside Expo). The IdP redirects through Sendora's callback to the
|
|
458
|
+
* `returnTo` URL with `#sendora_oidc_token=...` in the fragment.
|
|
459
|
+
*
|
|
460
|
+
* On the customer's app side, `returnTo` MUST be a custom URL scheme
|
|
461
|
+
* registered in Info.plist (iOS) / AndroidManifest.xml intent-filter
|
|
462
|
+
* (Android), e.g. `myapp://oidc-callback`. When the deep link fires,
|
|
463
|
+
* call `consumeSsoFragment(url)` on the captured URL.
|
|
464
|
+
*/
|
|
465
|
+
async startSso(returnTo) {
|
|
466
|
+
const res = await post(
|
|
467
|
+
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
468
|
+
"/api/v1/auth-service/sso/oidc/start",
|
|
469
|
+
{ returnTo }
|
|
470
|
+
);
|
|
471
|
+
if (!res || !res.ok) throw new AuthError("SSO_START_FAILED", `HTTP ${res?.status ?? "network"}`);
|
|
472
|
+
const parsed = await res.json();
|
|
473
|
+
if (!parsed.success || !parsed.data?.authorizationUrl) {
|
|
474
|
+
throw new AuthError("SSO_START_FAILED", "Missing authorizationUrl");
|
|
475
|
+
}
|
|
476
|
+
return { authorizationUrl: parsed.data.authorizationUrl };
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Consume an OIDC callback URL captured via React Native's
|
|
480
|
+
* `Linking.addEventListener('url', ...)`. Reads the fragment for
|
|
481
|
+
* `sendora_oidc_token`, exchanges it for a session, and persists.
|
|
482
|
+
* Throws AuthError on `sendora_oidc_error=...` fragment.
|
|
483
|
+
*/
|
|
484
|
+
consumeSsoFragment(callbackUrl) {
|
|
485
|
+
return this.serialize(async () => {
|
|
486
|
+
if (this.user !== null) await this.wipeLocalIdentity();
|
|
487
|
+
const hashIdx = callbackUrl.indexOf("#");
|
|
488
|
+
const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
|
|
489
|
+
const params = new URLSearchParams(fragment);
|
|
490
|
+
const err = params.get("sendora_oidc_error");
|
|
491
|
+
if (err) throw new AuthError("SSO_FAILED", decodeURIComponent(err));
|
|
492
|
+
const refresh = params.get("sendora_oidc_token");
|
|
493
|
+
if (!refresh) throw new AuthError("SSO_NO_TOKEN", "Callback URL has no sendora_oidc_token");
|
|
494
|
+
const res = await post(
|
|
495
|
+
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
496
|
+
"/api/v1/auth-service/token/refresh",
|
|
497
|
+
{ refreshToken: refresh }
|
|
498
|
+
);
|
|
499
|
+
if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`);
|
|
500
|
+
const parsed = await res.json();
|
|
501
|
+
const access = parsed.data?.accessToken;
|
|
502
|
+
const newRefresh = parsed.data?.refreshToken;
|
|
503
|
+
const expiresIn = parsed.data?.expiresIn;
|
|
504
|
+
if (!parsed.success || !access || !newRefresh || !expiresIn || expiresIn <= 0) {
|
|
505
|
+
throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token");
|
|
506
|
+
}
|
|
507
|
+
const claims = decodeJwtPayload(access);
|
|
508
|
+
const user = {
|
|
509
|
+
id: claims?.sub ?? "",
|
|
510
|
+
email: claims?.email ?? null,
|
|
511
|
+
emailVerified: claims?.email_verified ?? true,
|
|
512
|
+
name: claims?.name ?? null,
|
|
513
|
+
isAnonymous: false
|
|
514
|
+
};
|
|
515
|
+
this.persist({
|
|
516
|
+
user,
|
|
517
|
+
tokens: {
|
|
518
|
+
accessToken: access,
|
|
519
|
+
refreshToken: newRefresh,
|
|
520
|
+
expiresIn,
|
|
521
|
+
tokenType: "Bearer"
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
return user;
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
bearerHeaders() {
|
|
528
|
+
if (!this.accessToken) {
|
|
529
|
+
throw new AuthError("NOT_SIGNED_IN", "Not signed in");
|
|
530
|
+
}
|
|
531
|
+
return { Authorization: `Bearer ${this.accessToken}` };
|
|
532
|
+
}
|
|
533
|
+
async unwrap(res, errCode) {
|
|
534
|
+
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
535
|
+
const parsed = await res.json().catch(() => ({}));
|
|
536
|
+
if (!res.ok || !parsed.success) {
|
|
537
|
+
throw new AuthError(parsed.error?.code ?? errCode, parsed.error?.message ?? `HTTP ${res.status}`);
|
|
538
|
+
}
|
|
539
|
+
return parsed.data;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Sign out: wipe local identity FIRST so the user is logged out on
|
|
543
|
+
* device even if the revoke network call hangs (airplane mode, 5xx).
|
|
544
|
+
* Revoke is fire-and-forget after the wipe.
|
|
545
|
+
*/
|
|
546
|
+
signOut() {
|
|
547
|
+
return this.serialize(async () => {
|
|
548
|
+
const refresh = this.hooks.storage.get(REFRESH_KEY);
|
|
549
|
+
await this.wipeLocalIdentity();
|
|
550
|
+
if (refresh) {
|
|
551
|
+
void post(
|
|
552
|
+
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
269
553
|
"/api/v1/auth-service/token/revoke",
|
|
270
554
|
{ refreshToken: refresh }
|
|
271
|
-
);
|
|
272
|
-
} catch {
|
|
555
|
+
).catch(() => void 0);
|
|
273
556
|
}
|
|
274
|
-
}
|
|
275
|
-
await this.wipeLocalIdentity();
|
|
557
|
+
});
|
|
276
558
|
}
|
|
277
559
|
// --- internals ---
|
|
560
|
+
serialize(op) {
|
|
561
|
+
const next = this.inflight.then(op, op);
|
|
562
|
+
this.inflight = next.catch(() => void 0);
|
|
563
|
+
return next;
|
|
564
|
+
}
|
|
565
|
+
async refreshAccessToken() {
|
|
566
|
+
if (this.refreshInflight) return this.refreshInflight;
|
|
567
|
+
const refresh = this.hooks.storage.get(REFRESH_KEY);
|
|
568
|
+
if (!refresh) return null;
|
|
569
|
+
this.refreshInflight = (async () => {
|
|
570
|
+
try {
|
|
571
|
+
const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
|
|
572
|
+
refreshToken: refresh
|
|
573
|
+
}).catch(() => null);
|
|
574
|
+
if (!data) return null;
|
|
575
|
+
this.accessToken = data.tokens.accessToken;
|
|
576
|
+
this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
|
|
577
|
+
this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
|
|
578
|
+
this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
|
|
579
|
+
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
580
|
+
return data.tokens.accessToken;
|
|
581
|
+
} finally {
|
|
582
|
+
this.refreshInflight = null;
|
|
583
|
+
}
|
|
584
|
+
})();
|
|
585
|
+
return this.refreshInflight;
|
|
586
|
+
}
|
|
278
587
|
async callAuth(path, body) {
|
|
279
588
|
const res = await post(
|
|
280
|
-
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey },
|
|
589
|
+
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
281
590
|
path,
|
|
282
591
|
body
|
|
283
592
|
);
|
|
@@ -290,7 +599,7 @@ var Auth = class {
|
|
|
290
599
|
} catch {
|
|
291
600
|
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
|
|
292
601
|
}
|
|
293
|
-
if (!res.ok || !parsed.success || !parsed.data) {
|
|
602
|
+
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
294
603
|
throw new AuthError(
|
|
295
604
|
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
296
605
|
parsed.error?.message ?? `HTTP ${res.status}`
|
|
@@ -301,37 +610,64 @@ var Auth = class {
|
|
|
301
610
|
persist(data) {
|
|
302
611
|
this.user = data.user;
|
|
303
612
|
this.accessToken = data.tokens.accessToken;
|
|
613
|
+
this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
|
|
304
614
|
this.hooks.storage.set(USER_KEY, JSON.stringify(data.user));
|
|
305
615
|
this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
|
|
616
|
+
this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
|
|
306
617
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
307
618
|
this.hooks.onIdentityChange(data.user.id);
|
|
308
619
|
}
|
|
309
620
|
async wipeLocalIdentity() {
|
|
310
621
|
this.user = null;
|
|
311
622
|
this.accessToken = null;
|
|
623
|
+
this.accessExpiresAt = 0;
|
|
312
624
|
this.hooks.storage.remove(USER_KEY);
|
|
313
625
|
this.hooks.storage.remove(TOKEN_KEY);
|
|
626
|
+
this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
|
|
314
627
|
this.hooks.storage.remove(REFRESH_KEY);
|
|
315
628
|
await this.hooks.onAnonymousWipe();
|
|
316
629
|
}
|
|
317
630
|
};
|
|
631
|
+
function decodeJwtPayload(token) {
|
|
632
|
+
try {
|
|
633
|
+
const parts = token.split(".");
|
|
634
|
+
if (parts.length !== 3) return null;
|
|
635
|
+
const padded = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
636
|
+
const pad = (4 - padded.length % 4) % 4;
|
|
637
|
+
if (typeof atob !== "function") return null;
|
|
638
|
+
const json = atob(padded + "===".slice(0, pad));
|
|
639
|
+
return JSON.parse(json);
|
|
640
|
+
} catch {
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
318
644
|
|
|
319
645
|
// src/index.ts
|
|
646
|
+
var SDK_VERSION = "0.4.0";
|
|
320
647
|
var DEFAULT_API_URL = "https://api.sendoracloud.com";
|
|
321
648
|
var ANON_KEY = "anon_id";
|
|
322
649
|
var USER_ID_KEY = "user_id";
|
|
323
|
-
var AUTH_HYDRATE_KEYS = [
|
|
650
|
+
var AUTH_HYDRATE_KEYS = [
|
|
651
|
+
"auth_access_token",
|
|
652
|
+
"auth_access_expires",
|
|
653
|
+
"auth_refresh_token",
|
|
654
|
+
"auth_user"
|
|
655
|
+
];
|
|
324
656
|
var PUBLIC_KEY_RE = /^pk_(prod|staging|dev|live|test)_[A-Za-z0-9]{16,}$/;
|
|
325
657
|
var MAX_PUSH_TOKEN_BYTES = 4096;
|
|
326
658
|
var MAX_PUSH_METADATA_BYTES = 4096;
|
|
659
|
+
var MAX_USER_ID_LEN = 256;
|
|
660
|
+
var MAX_TRAITS_BYTES = 32 * 1024;
|
|
661
|
+
var MAX_PROPERTIES_BYTES = 32 * 1024;
|
|
327
662
|
function uuid() {
|
|
328
663
|
const bytes = new Uint8Array(16);
|
|
329
664
|
const g = globalThis;
|
|
330
|
-
if (g.crypto?.getRandomValues) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
665
|
+
if (!g.crypto?.getRandomValues) {
|
|
666
|
+
throw new Error(
|
|
667
|
+
"[sendoracloud] crypto.getRandomValues unavailable \u2014 refusing to mint IDs without a CSPRNG. Upgrade Hermes / your RN runtime."
|
|
668
|
+
);
|
|
334
669
|
}
|
|
670
|
+
g.crypto.getRandomValues(bytes);
|
|
335
671
|
bytes[6] = bytes[6] & 15 | 64;
|
|
336
672
|
bytes[8] = bytes[8] & 63 | 128;
|
|
337
673
|
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -351,6 +687,8 @@ var SendoraSDK = class {
|
|
|
351
687
|
this.storage = new Storage();
|
|
352
688
|
this.anonId = "";
|
|
353
689
|
this.userId = null;
|
|
690
|
+
this.consentGranted = true;
|
|
691
|
+
this.debug = false;
|
|
354
692
|
}
|
|
355
693
|
/** Initialize the SDK. Must be awaited at app startup before identify/track. */
|
|
356
694
|
async init(config) {
|
|
@@ -379,6 +717,11 @@ var SendoraSDK = class {
|
|
|
379
717
|
if (err instanceof Error && err.message.startsWith("[sendoracloud]")) throw err;
|
|
380
718
|
throw new Error(`[sendoracloud] apiUrl is not a valid URL: ${apiUrl}`);
|
|
381
719
|
}
|
|
720
|
+
if (apiUrl !== DEFAULT_API_URL) {
|
|
721
|
+
console.warn(
|
|
722
|
+
`[sendoracloud] apiUrl override active: ${apiUrl} \u2014 confirm this is intentional. Tokens + events will be sent here.`
|
|
723
|
+
);
|
|
724
|
+
}
|
|
382
725
|
await this.storage.hydrate([ANON_KEY, USER_ID_KEY, ...AUTH_HYDRATE_KEYS]);
|
|
383
726
|
let anon = this.storage.get(ANON_KEY);
|
|
384
727
|
if (!anon) {
|
|
@@ -387,6 +730,8 @@ var SendoraSDK = class {
|
|
|
387
730
|
}
|
|
388
731
|
this.anonId = anon;
|
|
389
732
|
this.userId = this.storage.get(USER_ID_KEY);
|
|
733
|
+
this.consentGranted = config.consentedByDefault !== false;
|
|
734
|
+
this.debug = config.debug === true;
|
|
390
735
|
this.config = {
|
|
391
736
|
apiUrl,
|
|
392
737
|
environment: "prod",
|
|
@@ -398,6 +743,7 @@ var SendoraSDK = class {
|
|
|
398
743
|
storage: this.storage,
|
|
399
744
|
apiUrl: this.config.apiUrl,
|
|
400
745
|
publicKey: this.config.publicKey,
|
|
746
|
+
debug: this.debug,
|
|
401
747
|
onIdentityChange: (userId) => {
|
|
402
748
|
this.userId = userId;
|
|
403
749
|
if (userId) this.storage.set(USER_ID_KEY, userId);
|
|
@@ -422,9 +768,22 @@ var SendoraSDK = class {
|
|
|
422
768
|
getUserId() {
|
|
423
769
|
return this.userId;
|
|
424
770
|
}
|
|
771
|
+
/** Toggle consent at runtime — when false, events drop instead of fire. */
|
|
772
|
+
setConsent(granted) {
|
|
773
|
+
this.consentGranted = granted;
|
|
774
|
+
}
|
|
425
775
|
/** Associate a user id + traits with the current anonymous install. */
|
|
426
776
|
identify(userId, traits) {
|
|
427
777
|
this.assertReady();
|
|
778
|
+
if (typeof userId !== "string" || userId.length === 0 || userId.length > MAX_USER_ID_LEN) {
|
|
779
|
+
throw new Error(`[sendoracloud] userId must be a 1-${MAX_USER_ID_LEN} char string.`);
|
|
780
|
+
}
|
|
781
|
+
if (traits) {
|
|
782
|
+
const bytes = byteLength(JSON.stringify(traits));
|
|
783
|
+
if (bytes > MAX_TRAITS_BYTES) {
|
|
784
|
+
throw new Error(`[sendoracloud] traits exceed ${MAX_TRAITS_BYTES} bytes.`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
428
787
|
this.userId = userId;
|
|
429
788
|
this.storage.set(USER_ID_KEY, userId);
|
|
430
789
|
void this.sendEvent("identify", {
|
|
@@ -435,6 +794,15 @@ var SendoraSDK = class {
|
|
|
435
794
|
/** Fire a custom product event. */
|
|
436
795
|
track(eventType, properties) {
|
|
437
796
|
this.assertReady();
|
|
797
|
+
if (typeof eventType !== "string" || eventType.length === 0 || eventType.length > 256) {
|
|
798
|
+
throw new Error("[sendoracloud] eventType must be a 1-256 char string.");
|
|
799
|
+
}
|
|
800
|
+
if (properties) {
|
|
801
|
+
const bytes = byteLength(JSON.stringify(properties));
|
|
802
|
+
if (bytes > MAX_PROPERTIES_BYTES) {
|
|
803
|
+
throw new Error(`[sendoracloud] properties exceed ${MAX_PROPERTIES_BYTES} bytes.`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
438
806
|
void this.sendEvent("track", {
|
|
439
807
|
eventType,
|
|
440
808
|
properties: properties ?? {}
|
|
@@ -443,6 +811,9 @@ var SendoraSDK = class {
|
|
|
443
811
|
/** RN equivalent of page view — call on route / screen change. */
|
|
444
812
|
screen(name, properties) {
|
|
445
813
|
this.assertReady();
|
|
814
|
+
if (typeof name !== "string" || name.length === 0 || name.length > 256) {
|
|
815
|
+
throw new Error("[sendoracloud] screen name must be a 1-256 char string.");
|
|
816
|
+
}
|
|
446
817
|
void this.sendEvent("screen", {
|
|
447
818
|
eventType: "screen.viewed",
|
|
448
819
|
properties: { screenName: name, ...properties ?? {} }
|
|
@@ -467,7 +838,7 @@ var SendoraSDK = class {
|
|
|
467
838
|
}
|
|
468
839
|
}
|
|
469
840
|
await post(
|
|
470
|
-
{ apiUrl: this.config.apiUrl, publicKey: this.config.publicKey },
|
|
841
|
+
{ apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
|
|
471
842
|
"/api/v1/push/tokens",
|
|
472
843
|
{
|
|
473
844
|
orgId: this.config.orgId,
|
|
@@ -493,8 +864,9 @@ var SendoraSDK = class {
|
|
|
493
864
|
}
|
|
494
865
|
async sendEvent(kind, payload) {
|
|
495
866
|
if (!this.config) return;
|
|
867
|
+
if (!this.consentGranted) return;
|
|
496
868
|
await post(
|
|
497
|
-
{ apiUrl: this.config.apiUrl, publicKey: this.config.publicKey },
|
|
869
|
+
{ apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
|
|
498
870
|
"/api/v1/events",
|
|
499
871
|
{
|
|
500
872
|
orgId: this.config.orgId,
|
|
@@ -507,6 +879,7 @@ var SendoraSDK = class {
|
|
|
507
879
|
properties: payload.properties ?? {},
|
|
508
880
|
context: {
|
|
509
881
|
platform: "react-native",
|
|
882
|
+
sdk: { name: "@sendoracloud/sdk-react-native", version: SDK_VERSION },
|
|
510
883
|
...this.config.environment ? { environment: this.config.environment } : {},
|
|
511
884
|
...kind === "identify" ? { traits: payload.traits } : {}
|
|
512
885
|
}
|