@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.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,339 @@ 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
|
+
// ============================================================
|
|
411
|
+
// OIDC SSO
|
|
412
|
+
// ============================================================
|
|
413
|
+
/**
|
|
414
|
+
* Start OIDC sign-in. Returns the IdP's authorization URL — caller
|
|
415
|
+
* opens it via React Native's `Linking.openURL()` (or
|
|
416
|
+
* `WebBrowser.openAuthSessionAsync` from `expo-web-browser` when
|
|
417
|
+
* inside Expo). The IdP redirects through Sendora's callback to the
|
|
418
|
+
* `returnTo` URL with `#sendora_oidc_token=...` in the fragment.
|
|
419
|
+
*
|
|
420
|
+
* On the customer's app side, `returnTo` MUST be a custom URL scheme
|
|
421
|
+
* registered in Info.plist (iOS) / AndroidManifest.xml intent-filter
|
|
422
|
+
* (Android), e.g. `myapp://oidc-callback`. When the deep link fires,
|
|
423
|
+
* call `consumeSsoFragment(url)` on the captured URL.
|
|
424
|
+
*/
|
|
425
|
+
async startSso(returnTo) {
|
|
426
|
+
const res = await post(
|
|
427
|
+
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
428
|
+
"/api/v1/auth-service/sso/oidc/start",
|
|
429
|
+
{ returnTo }
|
|
430
|
+
);
|
|
431
|
+
if (!res || !res.ok) throw new AuthError("SSO_START_FAILED", `HTTP ${res?.status ?? "network"}`);
|
|
432
|
+
const parsed = await res.json();
|
|
433
|
+
if (!parsed.success || !parsed.data?.authorizationUrl) {
|
|
434
|
+
throw new AuthError("SSO_START_FAILED", "Missing authorizationUrl");
|
|
435
|
+
}
|
|
436
|
+
return { authorizationUrl: parsed.data.authorizationUrl };
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Consume an OIDC callback URL captured via React Native's
|
|
440
|
+
* `Linking.addEventListener('url', ...)`. Reads the fragment for
|
|
441
|
+
* `sendora_oidc_token`, exchanges it for a session, and persists.
|
|
442
|
+
* Throws AuthError on `sendora_oidc_error=...` fragment.
|
|
443
|
+
*/
|
|
444
|
+
consumeSsoFragment(callbackUrl) {
|
|
445
|
+
return this.serialize(async () => {
|
|
446
|
+
if (this.user !== null) await this.wipeLocalIdentity();
|
|
447
|
+
const hashIdx = callbackUrl.indexOf("#");
|
|
448
|
+
const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
|
|
449
|
+
const params = new URLSearchParams(fragment);
|
|
450
|
+
const err = params.get("sendora_oidc_error");
|
|
451
|
+
if (err) throw new AuthError("SSO_FAILED", decodeURIComponent(err));
|
|
452
|
+
const refresh = params.get("sendora_oidc_token");
|
|
453
|
+
if (!refresh) throw new AuthError("SSO_NO_TOKEN", "Callback URL has no sendora_oidc_token");
|
|
454
|
+
const res = await post(
|
|
455
|
+
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
456
|
+
"/api/v1/auth-service/token/refresh",
|
|
457
|
+
{ refreshToken: refresh }
|
|
458
|
+
);
|
|
459
|
+
if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`);
|
|
460
|
+
const parsed = await res.json();
|
|
461
|
+
const access = parsed.data?.accessToken;
|
|
462
|
+
const newRefresh = parsed.data?.refreshToken;
|
|
463
|
+
const expiresIn = parsed.data?.expiresIn;
|
|
464
|
+
if (!parsed.success || !access || !newRefresh || !expiresIn || expiresIn <= 0) {
|
|
465
|
+
throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token");
|
|
466
|
+
}
|
|
467
|
+
const claims = decodeJwtPayload(access);
|
|
468
|
+
const user = {
|
|
469
|
+
id: claims?.sub ?? "",
|
|
470
|
+
email: claims?.email ?? null,
|
|
471
|
+
emailVerified: claims?.email_verified ?? true,
|
|
472
|
+
name: claims?.name ?? null,
|
|
473
|
+
isAnonymous: false
|
|
474
|
+
};
|
|
475
|
+
this.persist({
|
|
476
|
+
user,
|
|
477
|
+
tokens: {
|
|
478
|
+
accessToken: access,
|
|
479
|
+
refreshToken: newRefresh,
|
|
480
|
+
expiresIn,
|
|
481
|
+
tokenType: "Bearer"
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
return user;
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
bearerHeaders() {
|
|
488
|
+
if (!this.accessToken) {
|
|
489
|
+
throw new AuthError("NOT_SIGNED_IN", "Not signed in");
|
|
490
|
+
}
|
|
491
|
+
return { Authorization: `Bearer ${this.accessToken}` };
|
|
492
|
+
}
|
|
493
|
+
async unwrap(res, errCode) {
|
|
494
|
+
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
495
|
+
const parsed = await res.json().catch(() => ({}));
|
|
496
|
+
if (!res.ok || !parsed.success) {
|
|
497
|
+
throw new AuthError(parsed.error?.code ?? errCode, parsed.error?.message ?? `HTTP ${res.status}`);
|
|
498
|
+
}
|
|
499
|
+
return parsed.data;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Sign out: wipe local identity FIRST so the user is logged out on
|
|
503
|
+
* device even if the revoke network call hangs (airplane mode, 5xx).
|
|
504
|
+
* Revoke is fire-and-forget after the wipe.
|
|
505
|
+
*/
|
|
506
|
+
signOut() {
|
|
507
|
+
return this.serialize(async () => {
|
|
508
|
+
const refresh = this.hooks.storage.get(REFRESH_KEY);
|
|
509
|
+
await this.wipeLocalIdentity();
|
|
510
|
+
if (refresh) {
|
|
511
|
+
void post(
|
|
512
|
+
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
229
513
|
"/api/v1/auth-service/token/revoke",
|
|
230
514
|
{ refreshToken: refresh }
|
|
231
|
-
);
|
|
232
|
-
} catch {
|
|
515
|
+
).catch(() => void 0);
|
|
233
516
|
}
|
|
234
|
-
}
|
|
235
|
-
await this.wipeLocalIdentity();
|
|
517
|
+
});
|
|
236
518
|
}
|
|
237
519
|
// --- internals ---
|
|
520
|
+
serialize(op) {
|
|
521
|
+
const next = this.inflight.then(op, op);
|
|
522
|
+
this.inflight = next.catch(() => void 0);
|
|
523
|
+
return next;
|
|
524
|
+
}
|
|
525
|
+
async refreshAccessToken() {
|
|
526
|
+
if (this.refreshInflight) return this.refreshInflight;
|
|
527
|
+
const refresh = this.hooks.storage.get(REFRESH_KEY);
|
|
528
|
+
if (!refresh) return null;
|
|
529
|
+
this.refreshInflight = (async () => {
|
|
530
|
+
try {
|
|
531
|
+
const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
|
|
532
|
+
refreshToken: refresh
|
|
533
|
+
}).catch(() => null);
|
|
534
|
+
if (!data) return null;
|
|
535
|
+
this.accessToken = data.tokens.accessToken;
|
|
536
|
+
this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
|
|
537
|
+
this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
|
|
538
|
+
this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
|
|
539
|
+
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
540
|
+
return data.tokens.accessToken;
|
|
541
|
+
} finally {
|
|
542
|
+
this.refreshInflight = null;
|
|
543
|
+
}
|
|
544
|
+
})();
|
|
545
|
+
return this.refreshInflight;
|
|
546
|
+
}
|
|
238
547
|
async callAuth(path, body) {
|
|
239
548
|
const res = await post(
|
|
240
|
-
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey },
|
|
549
|
+
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
241
550
|
path,
|
|
242
551
|
body
|
|
243
552
|
);
|
|
@@ -250,7 +559,7 @@ var Auth = class {
|
|
|
250
559
|
} catch {
|
|
251
560
|
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
|
|
252
561
|
}
|
|
253
|
-
if (!res.ok || !parsed.success || !parsed.data) {
|
|
562
|
+
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
254
563
|
throw new AuthError(
|
|
255
564
|
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
256
565
|
parsed.error?.message ?? `HTTP ${res.status}`
|
|
@@ -261,37 +570,64 @@ var Auth = class {
|
|
|
261
570
|
persist(data) {
|
|
262
571
|
this.user = data.user;
|
|
263
572
|
this.accessToken = data.tokens.accessToken;
|
|
573
|
+
this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
|
|
264
574
|
this.hooks.storage.set(USER_KEY, JSON.stringify(data.user));
|
|
265
575
|
this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
|
|
576
|
+
this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
|
|
266
577
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
267
578
|
this.hooks.onIdentityChange(data.user.id);
|
|
268
579
|
}
|
|
269
580
|
async wipeLocalIdentity() {
|
|
270
581
|
this.user = null;
|
|
271
582
|
this.accessToken = null;
|
|
583
|
+
this.accessExpiresAt = 0;
|
|
272
584
|
this.hooks.storage.remove(USER_KEY);
|
|
273
585
|
this.hooks.storage.remove(TOKEN_KEY);
|
|
586
|
+
this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
|
|
274
587
|
this.hooks.storage.remove(REFRESH_KEY);
|
|
275
588
|
await this.hooks.onAnonymousWipe();
|
|
276
589
|
}
|
|
277
590
|
};
|
|
591
|
+
function decodeJwtPayload(token) {
|
|
592
|
+
try {
|
|
593
|
+
const parts = token.split(".");
|
|
594
|
+
if (parts.length !== 3) return null;
|
|
595
|
+
const padded = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
596
|
+
const pad = (4 - padded.length % 4) % 4;
|
|
597
|
+
if (typeof atob !== "function") return null;
|
|
598
|
+
const json = atob(padded + "===".slice(0, pad));
|
|
599
|
+
return JSON.parse(json);
|
|
600
|
+
} catch {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
278
604
|
|
|
279
605
|
// src/index.ts
|
|
606
|
+
var SDK_VERSION = "0.4.0";
|
|
280
607
|
var DEFAULT_API_URL = "https://api.sendoracloud.com";
|
|
281
608
|
var ANON_KEY = "anon_id";
|
|
282
609
|
var USER_ID_KEY = "user_id";
|
|
283
|
-
var AUTH_HYDRATE_KEYS = [
|
|
610
|
+
var AUTH_HYDRATE_KEYS = [
|
|
611
|
+
"auth_access_token",
|
|
612
|
+
"auth_access_expires",
|
|
613
|
+
"auth_refresh_token",
|
|
614
|
+
"auth_user"
|
|
615
|
+
];
|
|
284
616
|
var PUBLIC_KEY_RE = /^pk_(prod|staging|dev|live|test)_[A-Za-z0-9]{16,}$/;
|
|
285
617
|
var MAX_PUSH_TOKEN_BYTES = 4096;
|
|
286
618
|
var MAX_PUSH_METADATA_BYTES = 4096;
|
|
619
|
+
var MAX_USER_ID_LEN = 256;
|
|
620
|
+
var MAX_TRAITS_BYTES = 32 * 1024;
|
|
621
|
+
var MAX_PROPERTIES_BYTES = 32 * 1024;
|
|
287
622
|
function uuid() {
|
|
288
623
|
const bytes = new Uint8Array(16);
|
|
289
624
|
const g = globalThis;
|
|
290
|
-
if (g.crypto?.getRandomValues) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
625
|
+
if (!g.crypto?.getRandomValues) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
"[sendoracloud] crypto.getRandomValues unavailable \u2014 refusing to mint IDs without a CSPRNG. Upgrade Hermes / your RN runtime."
|
|
628
|
+
);
|
|
294
629
|
}
|
|
630
|
+
g.crypto.getRandomValues(bytes);
|
|
295
631
|
bytes[6] = bytes[6] & 15 | 64;
|
|
296
632
|
bytes[8] = bytes[8] & 63 | 128;
|
|
297
633
|
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -311,6 +647,8 @@ var SendoraSDK = class {
|
|
|
311
647
|
this.storage = new Storage();
|
|
312
648
|
this.anonId = "";
|
|
313
649
|
this.userId = null;
|
|
650
|
+
this.consentGranted = true;
|
|
651
|
+
this.debug = false;
|
|
314
652
|
}
|
|
315
653
|
/** Initialize the SDK. Must be awaited at app startup before identify/track. */
|
|
316
654
|
async init(config) {
|
|
@@ -339,6 +677,11 @@ var SendoraSDK = class {
|
|
|
339
677
|
if (err instanceof Error && err.message.startsWith("[sendoracloud]")) throw err;
|
|
340
678
|
throw new Error(`[sendoracloud] apiUrl is not a valid URL: ${apiUrl}`);
|
|
341
679
|
}
|
|
680
|
+
if (apiUrl !== DEFAULT_API_URL) {
|
|
681
|
+
console.warn(
|
|
682
|
+
`[sendoracloud] apiUrl override active: ${apiUrl} \u2014 confirm this is intentional. Tokens + events will be sent here.`
|
|
683
|
+
);
|
|
684
|
+
}
|
|
342
685
|
await this.storage.hydrate([ANON_KEY, USER_ID_KEY, ...AUTH_HYDRATE_KEYS]);
|
|
343
686
|
let anon = this.storage.get(ANON_KEY);
|
|
344
687
|
if (!anon) {
|
|
@@ -347,6 +690,8 @@ var SendoraSDK = class {
|
|
|
347
690
|
}
|
|
348
691
|
this.anonId = anon;
|
|
349
692
|
this.userId = this.storage.get(USER_ID_KEY);
|
|
693
|
+
this.consentGranted = config.consentedByDefault !== false;
|
|
694
|
+
this.debug = config.debug === true;
|
|
350
695
|
this.config = {
|
|
351
696
|
apiUrl,
|
|
352
697
|
environment: "prod",
|
|
@@ -358,6 +703,7 @@ var SendoraSDK = class {
|
|
|
358
703
|
storage: this.storage,
|
|
359
704
|
apiUrl: this.config.apiUrl,
|
|
360
705
|
publicKey: this.config.publicKey,
|
|
706
|
+
debug: this.debug,
|
|
361
707
|
onIdentityChange: (userId) => {
|
|
362
708
|
this.userId = userId;
|
|
363
709
|
if (userId) this.storage.set(USER_ID_KEY, userId);
|
|
@@ -382,9 +728,22 @@ var SendoraSDK = class {
|
|
|
382
728
|
getUserId() {
|
|
383
729
|
return this.userId;
|
|
384
730
|
}
|
|
731
|
+
/** Toggle consent at runtime — when false, events drop instead of fire. */
|
|
732
|
+
setConsent(granted) {
|
|
733
|
+
this.consentGranted = granted;
|
|
734
|
+
}
|
|
385
735
|
/** Associate a user id + traits with the current anonymous install. */
|
|
386
736
|
identify(userId, traits) {
|
|
387
737
|
this.assertReady();
|
|
738
|
+
if (typeof userId !== "string" || userId.length === 0 || userId.length > MAX_USER_ID_LEN) {
|
|
739
|
+
throw new Error(`[sendoracloud] userId must be a 1-${MAX_USER_ID_LEN} char string.`);
|
|
740
|
+
}
|
|
741
|
+
if (traits) {
|
|
742
|
+
const bytes = byteLength(JSON.stringify(traits));
|
|
743
|
+
if (bytes > MAX_TRAITS_BYTES) {
|
|
744
|
+
throw new Error(`[sendoracloud] traits exceed ${MAX_TRAITS_BYTES} bytes.`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
388
747
|
this.userId = userId;
|
|
389
748
|
this.storage.set(USER_ID_KEY, userId);
|
|
390
749
|
void this.sendEvent("identify", {
|
|
@@ -395,6 +754,15 @@ var SendoraSDK = class {
|
|
|
395
754
|
/** Fire a custom product event. */
|
|
396
755
|
track(eventType, properties) {
|
|
397
756
|
this.assertReady();
|
|
757
|
+
if (typeof eventType !== "string" || eventType.length === 0 || eventType.length > 256) {
|
|
758
|
+
throw new Error("[sendoracloud] eventType must be a 1-256 char string.");
|
|
759
|
+
}
|
|
760
|
+
if (properties) {
|
|
761
|
+
const bytes = byteLength(JSON.stringify(properties));
|
|
762
|
+
if (bytes > MAX_PROPERTIES_BYTES) {
|
|
763
|
+
throw new Error(`[sendoracloud] properties exceed ${MAX_PROPERTIES_BYTES} bytes.`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
398
766
|
void this.sendEvent("track", {
|
|
399
767
|
eventType,
|
|
400
768
|
properties: properties ?? {}
|
|
@@ -403,6 +771,9 @@ var SendoraSDK = class {
|
|
|
403
771
|
/** RN equivalent of page view — call on route / screen change. */
|
|
404
772
|
screen(name, properties) {
|
|
405
773
|
this.assertReady();
|
|
774
|
+
if (typeof name !== "string" || name.length === 0 || name.length > 256) {
|
|
775
|
+
throw new Error("[sendoracloud] screen name must be a 1-256 char string.");
|
|
776
|
+
}
|
|
406
777
|
void this.sendEvent("screen", {
|
|
407
778
|
eventType: "screen.viewed",
|
|
408
779
|
properties: { screenName: name, ...properties ?? {} }
|
|
@@ -427,7 +798,7 @@ var SendoraSDK = class {
|
|
|
427
798
|
}
|
|
428
799
|
}
|
|
429
800
|
await post(
|
|
430
|
-
{ apiUrl: this.config.apiUrl, publicKey: this.config.publicKey },
|
|
801
|
+
{ apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
|
|
431
802
|
"/api/v1/push/tokens",
|
|
432
803
|
{
|
|
433
804
|
orgId: this.config.orgId,
|
|
@@ -453,8 +824,9 @@ var SendoraSDK = class {
|
|
|
453
824
|
}
|
|
454
825
|
async sendEvent(kind, payload) {
|
|
455
826
|
if (!this.config) return;
|
|
827
|
+
if (!this.consentGranted) return;
|
|
456
828
|
await post(
|
|
457
|
-
{ apiUrl: this.config.apiUrl, publicKey: this.config.publicKey },
|
|
829
|
+
{ apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
|
|
458
830
|
"/api/v1/events",
|
|
459
831
|
{
|
|
460
832
|
orgId: this.config.orgId,
|
|
@@ -467,6 +839,7 @@ var SendoraSDK = class {
|
|
|
467
839
|
properties: payload.properties ?? {},
|
|
468
840
|
context: {
|
|
469
841
|
platform: "react-native",
|
|
842
|
+
sdk: { name: "@sendoracloud/sdk-react-native", version: SDK_VERSION },
|
|
470
843
|
...this.config.environment ? { environment: this.config.environment } : {},
|
|
471
844
|
...kind === "identify" ? { traits: payload.traits } : {}
|
|
472
845
|
}
|