@userbox/react 0.0.0-stage → 1.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1199 @@
1
+ import * as React from 'react';
2
+ import { createContext, useMemo, useState, useRef, useEffect, useCallback, useContext } from 'react';
3
+ import { jsx } from 'react/jsx-runtime';
4
+
5
+ // src/react/UserboxProvider.tsx
6
+
7
+ // src/core/errors.ts
8
+ var ERROR_KINDS = /* @__PURE__ */ new Set([
9
+ "missing_credentials",
10
+ "auth_failed",
11
+ "refresh_failed",
12
+ "permission_fetch_failed",
13
+ "network",
14
+ "misconfigured"
15
+ ]);
16
+ function isUserboxError(value) {
17
+ if (typeof value !== "object" || value === null) return false;
18
+ const candidate = value;
19
+ return typeof candidate.kind === "string" && ERROR_KINDS.has(candidate.kind) && typeof candidate.message === "string";
20
+ }
21
+ function missingCredentials(message, requestId) {
22
+ return requestId ? { kind: "missing_credentials", message, requestId } : { kind: "missing_credentials", message };
23
+ }
24
+ function authFailed(message, httpStatus, code, requestId) {
25
+ return requestId ? { kind: "auth_failed", message, httpStatus, code, requestId } : { kind: "auth_failed", message, httpStatus, code };
26
+ }
27
+ function refreshFailed(message, httpStatus, code, requestId) {
28
+ return requestId ? { kind: "refresh_failed", message, httpStatus, code, requestId } : { kind: "refresh_failed", message, httpStatus, code };
29
+ }
30
+ function permissionFetchFailed(message, httpStatus, code, workspaceId, requestId) {
31
+ return requestId ? {
32
+ kind: "permission_fetch_failed",
33
+ message,
34
+ httpStatus,
35
+ code,
36
+ workspaceId,
37
+ requestId
38
+ } : { kind: "permission_fetch_failed", message, httpStatus, code, workspaceId };
39
+ }
40
+ function networkError(message) {
41
+ return { kind: "network", message };
42
+ }
43
+ function misconfigured(field, message) {
44
+ return { kind: "misconfigured", message, field };
45
+ }
46
+
47
+ // src/core/permission-cache.ts
48
+ var PermissionCache = class {
49
+ entries = /* @__PURE__ */ new Map();
50
+ get(workspaceId) {
51
+ return this.entries.get(workspaceId);
52
+ }
53
+ has(workspaceId) {
54
+ return this.entries.has(workspaceId);
55
+ }
56
+ setLoading(workspaceId, promise) {
57
+ this.entries.set(workspaceId, {
58
+ state: "loading",
59
+ promise,
60
+ startedAt: Date.now()
61
+ });
62
+ }
63
+ setLoaded(workspaceId, permissions) {
64
+ const frozen = freezeSet(permissions);
65
+ this.entries.set(workspaceId, {
66
+ state: "loaded",
67
+ permissions: frozen,
68
+ fetchedAt: Date.now()
69
+ });
70
+ }
71
+ setError(workspaceId, error) {
72
+ const prior = this.entries.get(workspaceId);
73
+ const lastGood = prior && prior.state === "loaded" ? prior.permissions : void 0;
74
+ this.entries.set(workspaceId, lastGood !== void 0 ? { state: "error", error, failedAt: Date.now(), lastGood } : { state: "error", error, failedAt: Date.now() });
75
+ }
76
+ clear() {
77
+ this.entries.clear();
78
+ }
79
+ snapshot() {
80
+ return new Map(this.entries);
81
+ }
82
+ };
83
+ function freezeSet(values) {
84
+ const s = /* @__PURE__ */ new Set();
85
+ for (const v of values) {
86
+ if (typeof v === "string") s.add(v);
87
+ }
88
+ return s;
89
+ }
90
+
91
+ // src/core/redirect.ts
92
+ var SLUG_REGEX = /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/;
93
+ var MAX_RETURN_TO_LENGTH = 2048;
94
+ function assertValidSlug(slug) {
95
+ if (typeof slug !== "string" || slug.length === 0) {
96
+ throw misconfiguredField("tenantSlug", "tenantSlug is required");
97
+ }
98
+ if (!SLUG_REGEX.test(slug)) {
99
+ throw misconfiguredField(
100
+ "tenantSlug",
101
+ "tenantSlug must match /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/"
102
+ );
103
+ }
104
+ }
105
+ function deriveAuthOrigin(baseUrl, tenantSlug) {
106
+ let url;
107
+ try {
108
+ url = new URL(baseUrl);
109
+ } catch {
110
+ throw misconfiguredField("baseUrl", "baseUrl must be a valid absolute URL");
111
+ }
112
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
113
+ throw misconfiguredField("baseUrl", "baseUrl must be http(s)");
114
+ }
115
+ const host = url.hostname;
116
+ const dotIdx = host.indexOf(".");
117
+ if (dotIdx === -1) {
118
+ throw misconfiguredField(
119
+ "baseUrl",
120
+ "baseUrl host must have at least two DNS labels (e.g. api.example.localhost)"
121
+ );
122
+ }
123
+ const parent = host.substring(dotIdx + 1);
124
+ const port = url.port ? `:${url.port}` : "";
125
+ return `${url.protocol}//${tenantSlug}.auth.${parent}${port}`;
126
+ }
127
+ function buildAuthSurfaceUrl(input) {
128
+ assertValidSlug(input.tenantSlug);
129
+ assertValidReturnTo(input.returnTo);
130
+ const origin = input.baseAuthUrl ?? deriveAuthOrigin(input.baseUrl, input.tenantSlug);
131
+ const trimmed = origin.replace(/\/+$/, "");
132
+ const u = new URL(trimmed + input.path);
133
+ u.searchParams.set("return_to", input.returnTo);
134
+ u.searchParams.set("state", input.state);
135
+ u.searchParams.set("flow_id", input.flowId);
136
+ return u.toString();
137
+ }
138
+ function assertValidReturnTo(returnTo, currentOrigin) {
139
+ if (typeof returnTo !== "string" || returnTo.length === 0) {
140
+ throw misconfiguredField("callbackUrl", "returnTo is required");
141
+ }
142
+ if (returnTo.length > MAX_RETURN_TO_LENGTH) {
143
+ throw misconfiguredField(
144
+ "callbackUrl",
145
+ `returnTo exceeds maximum length (${MAX_RETURN_TO_LENGTH})`
146
+ );
147
+ }
148
+ let parsed;
149
+ try {
150
+ parsed = new URL(returnTo);
151
+ } catch {
152
+ throw misconfiguredField("callbackUrl", "returnTo must be an absolute URL");
153
+ }
154
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
155
+ throw misconfiguredField("callbackUrl", "returnTo must be http(s)");
156
+ }
157
+ const origin = safeWindowOrigin();
158
+ if (origin && parsed.origin !== origin) {
159
+ throw misconfiguredField(
160
+ "callbackUrl",
161
+ "returnTo must be same-origin with the calling app"
162
+ );
163
+ }
164
+ }
165
+ function safeWindowOrigin() {
166
+ if (typeof window === "undefined") return null;
167
+ if (typeof window.location?.origin !== "string") return null;
168
+ return window.location.origin;
169
+ }
170
+ function misconfiguredField(field, message) {
171
+ return misconfigured(field, message);
172
+ }
173
+
174
+ // src/core/http.ts
175
+ async function request(input) {
176
+ const headers = {
177
+ "X-Tenant-ID": input.tenantSlug,
178
+ Accept: "application/json",
179
+ ...input.headers
180
+ };
181
+ if (input.accessToken) {
182
+ headers.Authorization = `Bearer ${input.accessToken}`;
183
+ }
184
+ const init = {
185
+ method: input.method ?? "GET",
186
+ headers
187
+ };
188
+ if (input.body !== void 0) {
189
+ headers["Content-Type"] = "application/json";
190
+ init.body = JSON.stringify(input.body);
191
+ }
192
+ if (input.credentials !== void 0) {
193
+ init.credentials = input.credentials;
194
+ }
195
+ if (input.signal) init.signal = input.signal;
196
+ let response;
197
+ try {
198
+ response = await fetch(input.url, init);
199
+ } catch (err) {
200
+ return {
201
+ ok: false,
202
+ error: networkError(humaniseNetworkError(err))
203
+ };
204
+ }
205
+ const requestId = response.headers.get("X-Request-ID") ?? void 0;
206
+ if (response.status === 204) {
207
+ return { ok: true, data: void 0, requestId };
208
+ }
209
+ let parsed = null;
210
+ const text = await response.text();
211
+ if (text.length > 0) {
212
+ try {
213
+ parsed = JSON.parse(text);
214
+ } catch {
215
+ parsed = null;
216
+ }
217
+ }
218
+ if (response.ok) {
219
+ return { ok: true, data: parsed, requestId };
220
+ }
221
+ const shape = parsed ?? {};
222
+ const code = typeof shape.code === "string" ? shape.code : "UNKNOWN";
223
+ const message = typeof shape.error === "string" ? shape.error : `HTTP ${response.status}`;
224
+ const error = mapServerError(input, response.status, code, message, requestId);
225
+ return { ok: false, error };
226
+ }
227
+ function mapServerError(input, status, code, message, requestId) {
228
+ switch (input.errorKind) {
229
+ case "refresh":
230
+ return refreshFailed(message, status, code, requestId);
231
+ case "permissions":
232
+ return permissionFetchFailed(
233
+ message,
234
+ status,
235
+ code,
236
+ input.workspaceId ?? "",
237
+ requestId
238
+ );
239
+ case "auth":
240
+ default:
241
+ return authFailed(message, status, code, requestId);
242
+ }
243
+ }
244
+ function humaniseNetworkError(err) {
245
+ if (err instanceof DOMException && err.name === "AbortError") {
246
+ return "Request aborted";
247
+ }
248
+ if (err instanceof Error) return err.message || "Network error";
249
+ return "Network error";
250
+ }
251
+
252
+ // src/core/token.ts
253
+ function base64UrlDecode(input) {
254
+ let s = input.replace(/-/g, "+").replace(/_/g, "/");
255
+ while (s.length % 4 !== 0) s += "=";
256
+ if (typeof atob === "function") return atob(s);
257
+ return Buffer.from(s, "base64").toString("binary");
258
+ }
259
+ function decodeJwtClaims(token) {
260
+ const parts = token.split(".");
261
+ if (parts.length !== 3) {
262
+ throw asAuthFailed("Malformed JWT");
263
+ }
264
+ const payload = parts[1];
265
+ if (!payload) throw asAuthFailed("Malformed JWT payload");
266
+ let json;
267
+ try {
268
+ json = base64UrlDecode(payload);
269
+ } catch {
270
+ throw asAuthFailed("Malformed JWT payload");
271
+ }
272
+ let obj;
273
+ try {
274
+ obj = JSON.parse(json);
275
+ } catch {
276
+ throw asAuthFailed("Malformed JWT payload");
277
+ }
278
+ if (typeof obj !== "object" || obj === null) {
279
+ throw asAuthFailed("Malformed JWT payload");
280
+ }
281
+ const claims = obj;
282
+ const sub = typeof claims.sub === "string" ? claims.sub : null;
283
+ const tid = typeof claims.tid === "string" ? claims.tid : null;
284
+ const exp = typeof claims.exp === "number" ? claims.exp : null;
285
+ const jti = typeof claims.jti === "string" ? claims.jti : void 0;
286
+ if (!sub || !tid || exp === null) {
287
+ throw asAuthFailed("JWT missing required claims (sub/tid/exp)");
288
+ }
289
+ return jti ? { sub, tid, exp, jti } : { sub, tid, exp };
290
+ }
291
+ function asAuthFailed(message) {
292
+ return authFailed(message, 401, "MALFORMED_JWT");
293
+ }
294
+
295
+ // src/core/refresh-engine.ts
296
+ var RETRY_DELAYS_MS = [1e3, 3e3, 9e3];
297
+ var PERMANENT_HTTP_STATUSES = /* @__PURE__ */ new Set([400, 401, 403]);
298
+ var CLAIM_WINDOW_MS = 200;
299
+ var RefreshEngine = class {
300
+ options;
301
+ now;
302
+ timer = null;
303
+ inFlight = null;
304
+ failureCount = 0;
305
+ permanentlyFailed = false;
306
+ sessionExpiredFired = false;
307
+ channel = null;
308
+ currentSession = null;
309
+ constructor(options) {
310
+ this.options = options;
311
+ this.now = options.now ?? Date.now;
312
+ this.setupChannel();
313
+ }
314
+ setupChannel() {
315
+ if (!this.options.broadcastChannelName) return;
316
+ if (typeof BroadcastChannel === "undefined") return;
317
+ try {
318
+ this.channel = new BroadcastChannel(
319
+ `userbox-refresh-${this.options.broadcastChannelName}`
320
+ );
321
+ this.channel.addEventListener("message", (evt) => {
322
+ this.handleBroadcast(evt.data);
323
+ });
324
+ } catch {
325
+ this.channel = null;
326
+ }
327
+ }
328
+ handleBroadcast(data) {
329
+ if (!data || typeof data !== "object") return;
330
+ const msg = data;
331
+ if (msg.type !== "session" || typeof msg.session !== "object" || msg.session === null) return;
332
+ const session = msg.session;
333
+ if (typeof session.accessToken !== "string" || typeof session.accessTokenExpiresAt !== "number" || typeof session.refreshExpiresAt !== "number" || typeof session.user !== "object" || session.user === null) {
334
+ return;
335
+ }
336
+ const u = session.user;
337
+ if (typeof u.id !== "string" || typeof u.tenantId !== "string") return;
338
+ const normalised = {
339
+ accessToken: session.accessToken,
340
+ accessTokenExpiresAt: session.accessTokenExpiresAt,
341
+ refreshExpiresAt: session.refreshExpiresAt,
342
+ user: {
343
+ id: u.id,
344
+ tenantId: u.tenantId,
345
+ email: typeof u.email === "string" ? u.email : null,
346
+ fullName: typeof u.fullName === "string" ? u.fullName : null
347
+ }
348
+ };
349
+ this.adoptSession(normalised);
350
+ }
351
+ adoptSession(session) {
352
+ if (this.currentSession && this.currentSession.user.id !== session.user.id) {
353
+ this.permanentlyFailed = true;
354
+ this.clearTimer();
355
+ this.options.onError(authFailed("Session identity changed unexpectedly", 401, "IDENTITY_SWAP"));
356
+ this.fireSessionExpiredOnce();
357
+ return;
358
+ }
359
+ this.currentSession = session;
360
+ this.options.onSession(session);
361
+ this.scheduleNextRefresh();
362
+ }
363
+ /**
364
+ * Adopt the initial session (e.g. from the callback flow). Subsequent
365
+ * refreshes are scheduled automatically.
366
+ */
367
+ primeSession(session) {
368
+ this.permanentlyFailed = false;
369
+ this.sessionExpiredFired = false;
370
+ this.failureCount = 0;
371
+ this.currentSession = session;
372
+ this.scheduleNextRefresh();
373
+ }
374
+ hasSession() {
375
+ return this.currentSession !== null;
376
+ }
377
+ isPermanentlyFailed() {
378
+ return this.permanentlyFailed;
379
+ }
380
+ getSession() {
381
+ return this.currentSession;
382
+ }
383
+ async refresh() {
384
+ if (this.permanentlyFailed) {
385
+ return {
386
+ kind: "permanent_fail",
387
+ error: authFailed("Refresh already permanently failed", 401, "PERMANENT_FAIL")
388
+ };
389
+ }
390
+ if (this.inFlight) return this.inFlight;
391
+ this.inFlight = this.performRefreshWithCoordination().finally(() => {
392
+ this.inFlight = null;
393
+ });
394
+ return this.inFlight;
395
+ }
396
+ async performRefreshWithCoordination() {
397
+ if (this.channel) {
398
+ const winner = await this.claimRefreshWindow();
399
+ if (winner) return winner;
400
+ }
401
+ return this.performRefresh();
402
+ }
403
+ claimRefreshWindow() {
404
+ return new Promise((resolve) => {
405
+ let resolved = false;
406
+ const channel = this.channel;
407
+ if (!channel) {
408
+ resolve(null);
409
+ return;
410
+ }
411
+ const listener = (evt) => {
412
+ if (resolved) return;
413
+ if (!evt.data || typeof evt.data !== "object") return;
414
+ const msg = evt.data;
415
+ if (msg.type !== "session") return;
416
+ const previous = this.currentSession;
417
+ this.handleBroadcast(evt.data);
418
+ if (this.currentSession && this.currentSession !== previous) {
419
+ resolved = true;
420
+ channel.removeEventListener("message", listener);
421
+ resolve({
422
+ kind: "ok",
423
+ session: this.currentSession,
424
+ rawRefreshToken: null,
425
+ nextRefreshIn: this.computeNextRefreshIn(this.currentSession)
426
+ });
427
+ }
428
+ };
429
+ channel.addEventListener("message", listener);
430
+ try {
431
+ channel.postMessage({ type: "claim" });
432
+ } catch {
433
+ }
434
+ setTimeout(() => {
435
+ if (resolved) return;
436
+ channel.removeEventListener("message", listener);
437
+ resolve(null);
438
+ }, CLAIM_WINDOW_MS);
439
+ });
440
+ }
441
+ async performRefresh() {
442
+ const body = this.options.tokenStorage === "memory" ? { refresh_token: this.options.sessionStore.getRefreshToken() ?? "" } : {};
443
+ const res = await request({
444
+ url: this.options.refreshUrl,
445
+ method: "POST",
446
+ tenantSlug: this.options.tenantSlug,
447
+ body,
448
+ // Under cookie mode the refresh cookie may live on a different origin
449
+ // (tenant backend). Same-origin keeps memory mode safe.
450
+ credentials: this.options.tokenStorage === "cookie" ? "include" : "same-origin",
451
+ errorKind: "refresh"
452
+ });
453
+ if (!res.ok) {
454
+ const error = res.error;
455
+ const status = error.kind === "refresh_failed" || error.kind === "auth_failed" ? error.httpStatus : 0;
456
+ if (error.kind === "refresh_failed" && PERMANENT_HTTP_STATUSES.has(status)) {
457
+ return this.transitionPermanent(error);
458
+ }
459
+ return this.transitionTransient(error);
460
+ }
461
+ const data = res.data;
462
+ const accessToken = typeof data.access_token === "string" ? data.access_token : "";
463
+ const expiresIn = typeof data.expires_in === "number" ? data.expires_in : 0;
464
+ const refreshExpiresIn = typeof data.refresh_expires_in === "number" ? data.refresh_expires_in : expiresIn;
465
+ if (!accessToken || expiresIn <= 0) {
466
+ return this.transitionTransient(
467
+ authFailed("Refresh response missing access_token / expires_in", 500, "MALFORMED_RESPONSE")
468
+ );
469
+ }
470
+ let claims;
471
+ try {
472
+ claims = decodeJwtClaims(accessToken);
473
+ } catch (e) {
474
+ const err = e;
475
+ return this.transitionPermanent(err);
476
+ }
477
+ const newRefreshToken = typeof data.refresh_token === "string" ? data.refresh_token : null;
478
+ if (this.options.tokenStorage === "memory" && newRefreshToken) {
479
+ this.options.sessionStore.setRefreshToken(newRefreshToken);
480
+ }
481
+ const previousUserId = this.currentSession?.user.id ?? null;
482
+ if (previousUserId && previousUserId !== claims.sub) {
483
+ const err = authFailed("Session identity changed unexpectedly", 401, "IDENTITY_SWAP");
484
+ return this.transitionPermanent(err);
485
+ }
486
+ const session = {
487
+ accessToken,
488
+ accessTokenExpiresAt: this.now() + expiresIn * 1e3,
489
+ refreshExpiresAt: this.now() + refreshExpiresIn * 1e3,
490
+ user: {
491
+ id: claims.sub,
492
+ tenantId: claims.tid,
493
+ email: this.currentSession?.user.email ?? null,
494
+ fullName: this.currentSession?.user.fullName ?? null
495
+ }
496
+ };
497
+ this.failureCount = 0;
498
+ this.currentSession = session;
499
+ this.options.onSession(session);
500
+ this.scheduleNextRefresh();
501
+ this.broadcast(session);
502
+ return {
503
+ kind: "ok",
504
+ session,
505
+ rawRefreshToken: newRefreshToken,
506
+ nextRefreshIn: this.computeNextRefreshIn(session)
507
+ };
508
+ }
509
+ transitionPermanent(error) {
510
+ this.permanentlyFailed = true;
511
+ this.clearTimer();
512
+ this.options.onError(error);
513
+ this.fireSessionExpiredOnce();
514
+ return { kind: "permanent_fail", error };
515
+ }
516
+ transitionTransient(error) {
517
+ this.failureCount += 1;
518
+ this.options.onError(error);
519
+ if (this.failureCount >= RETRY_DELAYS_MS.length) {
520
+ const escalated = authFailed(
521
+ "Refresh transient failures exceeded retry budget",
522
+ 503,
523
+ "REFRESH_RETRY_BUDGET"
524
+ );
525
+ this.transitionPermanent(escalated);
526
+ return { kind: "transient_fail", error, retryInMs: 0 };
527
+ }
528
+ const retryInMs = RETRY_DELAYS_MS[this.failureCount - 1] ?? 9e3;
529
+ this.scheduleTimer(retryInMs);
530
+ return { kind: "transient_fail", error, retryInMs };
531
+ }
532
+ fireSessionExpiredOnce() {
533
+ if (this.sessionExpiredFired) return;
534
+ this.sessionExpiredFired = true;
535
+ this.options.onSessionExpired();
536
+ }
537
+ computeNextRefreshIn(session) {
538
+ const ttl = session.accessTokenExpiresAt - this.now();
539
+ const reserved = Math.max(
540
+ ttl * this.options.refreshThresholdPct,
541
+ this.options.proactiveSkewMs
542
+ );
543
+ const next = ttl - reserved;
544
+ return Math.max(next, 0);
545
+ }
546
+ scheduleNextRefresh() {
547
+ if (!this.currentSession) return;
548
+ if (this.permanentlyFailed) return;
549
+ const delay = this.computeNextRefreshIn(this.currentSession);
550
+ this.scheduleTimer(delay);
551
+ }
552
+ scheduleTimer(delay) {
553
+ this.clearTimer();
554
+ this.timer = setTimeout(() => {
555
+ this.timer = null;
556
+ void this.refresh();
557
+ }, delay);
558
+ }
559
+ clearTimer() {
560
+ if (this.timer !== null) {
561
+ clearTimeout(this.timer);
562
+ this.timer = null;
563
+ }
564
+ }
565
+ broadcast(session) {
566
+ if (!this.channel) return;
567
+ try {
568
+ this.channel.postMessage({ type: "session", session });
569
+ } catch {
570
+ }
571
+ }
572
+ /** Test/teardown hook. */
573
+ dispose() {
574
+ this.clearTimer();
575
+ if (this.channel) {
576
+ try {
577
+ this.channel.close();
578
+ } catch {
579
+ }
580
+ this.channel = null;
581
+ }
582
+ }
583
+ };
584
+
585
+ // src/core/session-store.ts
586
+ var MemoryStore = class {
587
+ kind = "memory";
588
+ token = null;
589
+ getRefreshToken() {
590
+ return this.token;
591
+ }
592
+ setRefreshToken(token) {
593
+ this.token = token;
594
+ }
595
+ };
596
+ var CookieStore = class {
597
+ kind = "cookie";
598
+ // The browser owns the cookie; the SDK never reads or writes it directly.
599
+ getRefreshToken() {
600
+ return null;
601
+ }
602
+ setRefreshToken(_token) {
603
+ }
604
+ };
605
+ function createSessionStore(kind) {
606
+ return kind === "memory" ? new MemoryStore() : new CookieStore();
607
+ }
608
+ var UserboxContext = createContext(null);
609
+ var DEFAULT_REFRESH_THRESHOLD_PCT = 0.2;
610
+ var DEFAULT_PROACTIVE_SKEW_MS = 5e3;
611
+ function validateConfig(props) {
612
+ if (typeof props.tenantSlug !== "string" || props.tenantSlug.length === 0) {
613
+ throw misconfigured("tenantSlug", "tenantSlug is required");
614
+ }
615
+ assertValidSlug(props.tenantSlug);
616
+ if (typeof props.baseUrl !== "string" || props.baseUrl.length === 0) {
617
+ throw misconfigured("baseUrl", "baseUrl is required");
618
+ }
619
+ if (props.baseUrl.endsWith("/")) {
620
+ throw misconfigured("baseUrl", "baseUrl must not have a trailing slash");
621
+ }
622
+ if (props.tokenStorage !== "memory" && props.tokenStorage !== "cookie") {
623
+ throw misconfigured("tokenStorage", "tokenStorage must be 'memory' or 'cookie'");
624
+ }
625
+ const refreshUrl = props.refreshUrl ?? (props.tokenStorage === "cookie" ? "/api/auth/refresh" : `${props.baseUrl}/v1/auth/refresh`);
626
+ const config = Object.freeze({
627
+ tenantSlug: props.tenantSlug,
628
+ baseUrl: props.baseUrl,
629
+ baseAuthUrl: props.baseAuthUrl,
630
+ tokenStorage: props.tokenStorage,
631
+ refreshUrl,
632
+ refreshThresholdPct: props.refreshThresholdPct ?? DEFAULT_REFRESH_THRESHOLD_PCT,
633
+ proactiveSkewMs: props.proactiveSkewMs ?? DEFAULT_PROACTIVE_SKEW_MS,
634
+ onSessionExpired: props.onSessionExpired,
635
+ onError: props.onError
636
+ });
637
+ return config;
638
+ }
639
+ var UserboxProvider = (props) => {
640
+ const parent = React.useContext(UserboxContext);
641
+ if (parent) {
642
+ throw misconfigured("tenantSlug", "UserboxProvider cannot be nested");
643
+ }
644
+ const config = useMemo(() => validateConfig(props), [
645
+ props.tenantSlug,
646
+ props.baseUrl,
647
+ props.baseAuthUrl,
648
+ props.tokenStorage,
649
+ props.refreshUrl,
650
+ props.refreshThresholdPct,
651
+ props.proactiveSkewMs
652
+ ]);
653
+ const [state, setState] = useState({
654
+ session: null,
655
+ isLoaded: false,
656
+ permissionsVersion: 0
657
+ });
658
+ const cacheRef = useRef(new PermissionCache());
659
+ const sessionStoreRef = useRef(null);
660
+ const engineRef = useRef(null);
661
+ const userMeFetchedRef = useRef(null);
662
+ if (!sessionStoreRef.current) {
663
+ sessionStoreRef.current = createSessionStore(config.tokenStorage);
664
+ }
665
+ if (!engineRef.current) {
666
+ engineRef.current = new RefreshEngine({
667
+ tenantSlug: config.tenantSlug,
668
+ baseUrl: config.baseUrl,
669
+ refreshUrl: config.refreshUrl,
670
+ tokenStorage: config.tokenStorage,
671
+ sessionStore: sessionStoreRef.current,
672
+ refreshThresholdPct: config.refreshThresholdPct,
673
+ proactiveSkewMs: config.proactiveSkewMs,
674
+ onSession: (session) => {
675
+ setState((s) => ({ ...s, session, isLoaded: true }));
676
+ },
677
+ onSessionExpired: () => {
678
+ cacheRef.current.clear();
679
+ setState((s) => ({
680
+ session: null,
681
+ isLoaded: true,
682
+ permissionsVersion: s.permissionsVersion + 1
683
+ }));
684
+ config.onSessionExpired?.();
685
+ },
686
+ onError: (err) => {
687
+ config.onError?.(err);
688
+ },
689
+ broadcastChannelName: config.tenantSlug
690
+ });
691
+ }
692
+ useEffect(() => {
693
+ const engine = engineRef.current;
694
+ let cancelled = false;
695
+ void (async () => {
696
+ const result = await engine.refresh();
697
+ if (cancelled) return;
698
+ if (result.kind !== "ok") {
699
+ setState((s) => ({ ...s, session: null, isLoaded: true }));
700
+ }
701
+ })();
702
+ return () => {
703
+ cancelled = true;
704
+ engine.dispose();
705
+ };
706
+ }, []);
707
+ useEffect(() => {
708
+ const session = state.session;
709
+ if (!session) return;
710
+ if (userMeFetchedRef.current === session.user.id && session.user.email !== null) {
711
+ return;
712
+ }
713
+ let cancelled = false;
714
+ void (async () => {
715
+ const res = await request({
716
+ url: `${config.baseUrl}/v1/users/me`,
717
+ tenantSlug: config.tenantSlug,
718
+ accessToken: session.accessToken,
719
+ errorKind: "auth"
720
+ });
721
+ if (cancelled) return;
722
+ if (!res.ok) {
723
+ return;
724
+ }
725
+ const data = res.data;
726
+ const email = typeof data?.email === "string" ? data.email : null;
727
+ const fullName = typeof data?.full_name === "string" ? data.full_name : null;
728
+ userMeFetchedRef.current = session.user.id;
729
+ setState((s) => {
730
+ if (!s.session || s.session.user.id !== session.user.id) return s;
731
+ const next = {
732
+ ...s.session,
733
+ user: { ...s.session.user, email, fullName }
734
+ };
735
+ return { ...s, session: next };
736
+ });
737
+ })();
738
+ return () => {
739
+ cancelled = true;
740
+ };
741
+ }, [state.session, config.baseUrl, config.tenantSlug]);
742
+ const ensurePermissions = useCallback(
743
+ (workspaceId) => {
744
+ const cache = cacheRef.current;
745
+ if (cache.has(workspaceId)) return;
746
+ const engine = engineRef.current;
747
+ const session = engine.getSession();
748
+ if (!session) return;
749
+ const url = `${config.baseUrl}/v1/access/permissions?user_id=${encodeURIComponent(
750
+ session.user.id
751
+ )}&workspace_id=${encodeURIComponent(workspaceId)}`;
752
+ const promise = (async () => {
753
+ const res = await request({
754
+ url,
755
+ tenantSlug: config.tenantSlug,
756
+ accessToken: session.accessToken,
757
+ errorKind: "permissions",
758
+ workspaceId
759
+ });
760
+ if (!res.ok) {
761
+ cache.setError(workspaceId, res.error);
762
+ config.onError?.(res.error);
763
+ setState((s) => ({ ...s, permissionsVersion: s.permissionsVersion + 1 }));
764
+ return /* @__PURE__ */ new Set();
765
+ }
766
+ const list = Array.isArray(res.data?.permissions) ? res.data.permissions.filter(
767
+ (x) => typeof x === "string"
768
+ ) : [];
769
+ cache.setLoaded(workspaceId, list);
770
+ setState((s) => ({ ...s, permissionsVersion: s.permissionsVersion + 1 }));
771
+ return new Set(list);
772
+ })();
773
+ cache.setLoading(workspaceId, promise);
774
+ },
775
+ [config.baseUrl, config.tenantSlug]
776
+ );
777
+ const adoptCallbackSession = useCallback(
778
+ (session, rawRefreshToken) => {
779
+ const engine = engineRef.current;
780
+ if (config.tokenStorage === "memory" && rawRefreshToken) {
781
+ sessionStoreRef.current?.setRefreshToken(rawRefreshToken);
782
+ }
783
+ engine.primeSession(session);
784
+ setState((s) => ({ ...s, session, isLoaded: true }));
785
+ },
786
+ [config.tokenStorage]
787
+ );
788
+ const navigate = useCallback((url) => {
789
+ if (typeof window !== "undefined") {
790
+ window.location.href = url;
791
+ }
792
+ }, []);
793
+ const signOut = useCallback(
794
+ async (redirectUri) => {
795
+ const engine = engineRef.current;
796
+ const session = engine.getSession();
797
+ const refreshToken = config.tokenStorage === "memory" ? sessionStoreRef.current?.getRefreshToken() ?? null : null;
798
+ sessionStoreRef.current?.setRefreshToken(null);
799
+ cacheRef.current.clear();
800
+ setState((s) => ({
801
+ session: null,
802
+ isLoaded: true,
803
+ permissionsVersion: s.permissionsVersion + 1
804
+ }));
805
+ if (config.tokenStorage === "memory") try {
806
+ const body = { refresh_token: refreshToken ?? "" };
807
+ const res = await request({
808
+ url: `${config.baseUrl}/v1/auth/logout`,
809
+ method: "POST",
810
+ tenantSlug: config.tenantSlug,
811
+ accessToken: session?.accessToken ?? null,
812
+ body,
813
+ credentials: "same-origin",
814
+ errorKind: "auth"
815
+ });
816
+ if (!res.ok) {
817
+ const err = res.error;
818
+ if (!(err.kind === "auth_failed" && err.code === "INVALID_REFRESH_TOKEN")) {
819
+ config.onError?.(err);
820
+ }
821
+ }
822
+ } catch (err) {
823
+ config.onError?.(authFailed("logout failed", 0, "LOGOUT_FAILED"));
824
+ }
825
+ if (config.tokenStorage === "cookie") {
826
+ try {
827
+ await request({
828
+ url: config.refreshUrl,
829
+ method: "POST",
830
+ tenantSlug: config.tenantSlug,
831
+ accessToken: session?.accessToken ?? null,
832
+ body: { action: "logout" },
833
+ credentials: "include",
834
+ errorKind: "auth"
835
+ });
836
+ } catch {
837
+ }
838
+ }
839
+ navigate(redirectUri ?? (typeof window !== "undefined" ? window.location.origin : "/"));
840
+ },
841
+ [config, navigate]
842
+ );
843
+ const contextValue = useMemo(
844
+ () => ({
845
+ session: state.session,
846
+ isLoaded: state.isLoaded,
847
+ permissionCache: cacheRef.current,
848
+ ensurePermissions,
849
+ permissionsVersion: state.permissionsVersion,
850
+ navigate,
851
+ signOut,
852
+ config,
853
+ refreshEngine: engineRef.current,
854
+ adoptCallbackSession
855
+ }),
856
+ [
857
+ state.session,
858
+ state.isLoaded,
859
+ state.permissionsVersion,
860
+ ensurePermissions,
861
+ navigate,
862
+ signOut,
863
+ config,
864
+ adoptCallbackSession
865
+ ]
866
+ );
867
+ return /* @__PURE__ */ jsx(UserboxContext.Provider, { value: contextValue, children: props.children });
868
+ };
869
+ function useSession() {
870
+ const ctx = useContext(UserboxContext);
871
+ if (!ctx) {
872
+ throw misconfigured(
873
+ "tenantSlug",
874
+ "useSession() must be called inside a <UserboxProvider>"
875
+ );
876
+ }
877
+ if (!ctx.isLoaded) {
878
+ return { isLoaded: false, isSignedIn: false, user: null };
879
+ }
880
+ if (!ctx.session) {
881
+ return { isLoaded: true, isSignedIn: false, user: null };
882
+ }
883
+ return { isLoaded: true, isSignedIn: true, user: ctx.session.user };
884
+ }
885
+ function useAccessToken() {
886
+ const ctx = useContext(UserboxContext);
887
+ if (!ctx) {
888
+ throw misconfigured(
889
+ "tenantSlug",
890
+ "useAccessToken() must be called inside a <UserboxProvider>"
891
+ );
892
+ }
893
+ return ctx.session?.accessToken ?? null;
894
+ }
895
+ var EMPTY_SET = /* @__PURE__ */ new Set();
896
+ function usePermissions(workspaceId) {
897
+ const ctx = useContext(UserboxContext);
898
+ if (!ctx) {
899
+ throw misconfigured(
900
+ "tenantSlug",
901
+ "usePermissions() must be called inside a <UserboxProvider>"
902
+ );
903
+ }
904
+ useEffect(() => {
905
+ if (!workspaceId) return;
906
+ if (!ctx.session) return;
907
+ ctx.ensurePermissions(workspaceId);
908
+ }, [workspaceId, ctx.session, ctx]);
909
+ return useMemo(() => {
910
+ if (!workspaceId) {
911
+ return { permissions: EMPTY_SET, isLoaded: false, error: null };
912
+ }
913
+ const entry = ctx.permissionCache.get(workspaceId);
914
+ if (!entry) {
915
+ return { permissions: EMPTY_SET, isLoaded: false, error: null };
916
+ }
917
+ if (entry.state === "loaded") {
918
+ return { permissions: entry.permissions, isLoaded: true, error: null };
919
+ }
920
+ if (entry.state === "error") {
921
+ return {
922
+ permissions: entry.lastGood ?? EMPTY_SET,
923
+ isLoaded: true,
924
+ error: entry.error
925
+ };
926
+ }
927
+ return { permissions: EMPTY_SET, isLoaded: false, error: null };
928
+ }, [workspaceId, ctx.permissionCache, ctx.permissionsVersion]);
929
+ }
930
+
931
+ // src/react/usePermission.ts
932
+ function usePermission(workspaceId, permissionKey) {
933
+ const { permissions, isLoaded } = usePermissions(workspaceId);
934
+ const allowed = isLoaded ? permissions.has(permissionKey) : false;
935
+ return { allowed, isLoaded };
936
+ }
937
+
938
+ // src/core/state-nonce.ts
939
+ var STORAGE_KEY = "ub.auth.state";
940
+ var MAX_FLOW_AGE_MS = 10 * 60 * 1e3;
941
+ function getStorage() {
942
+ if (typeof window === "undefined") return null;
943
+ try {
944
+ return window.sessionStorage;
945
+ } catch {
946
+ return null;
947
+ }
948
+ }
949
+ function readMap(storage) {
950
+ try {
951
+ const raw = storage.getItem(STORAGE_KEY);
952
+ if (!raw) return {};
953
+ const parsed = JSON.parse(raw);
954
+ if (typeof parsed !== "object" || parsed === null) return {};
955
+ const out = {};
956
+ for (const [k, v] of Object.entries(parsed)) {
957
+ if (typeof v !== "object" || v === null) continue;
958
+ const flow = v;
959
+ if (typeof flow.nonce === "string" && typeof flow.createdAt === "number") {
960
+ out[k] = { nonce: flow.nonce, createdAt: flow.createdAt };
961
+ }
962
+ }
963
+ return out;
964
+ } catch {
965
+ return {};
966
+ }
967
+ }
968
+ function writeMap(storage, map) {
969
+ try {
970
+ storage.setItem(STORAGE_KEY, JSON.stringify(map));
971
+ } catch {
972
+ }
973
+ }
974
+ function pruneStale(map, now) {
975
+ const out = {};
976
+ for (const [k, v] of Object.entries(map)) {
977
+ if (now - v.createdAt <= MAX_FLOW_AGE_MS) out[k] = v;
978
+ }
979
+ return out;
980
+ }
981
+ function generateRandom() {
982
+ if (typeof globalThis.crypto !== "undefined" && typeof globalThis.crypto.randomUUID === "function") {
983
+ return globalThis.crypto.randomUUID();
984
+ }
985
+ if (typeof globalThis.crypto !== "undefined" && typeof globalThis.crypto.getRandomValues === "function") {
986
+ const buf = new Uint8Array(16);
987
+ globalThis.crypto.getRandomValues(buf);
988
+ return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
989
+ }
990
+ return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
991
+ }
992
+ function issueNonce() {
993
+ const flowId = generateRandom();
994
+ const nonce = generateRandom();
995
+ const storage = getStorage();
996
+ if (storage) {
997
+ const map = pruneStale(readMap(storage), Date.now());
998
+ map[flowId] = { nonce, createdAt: Date.now() };
999
+ writeMap(storage, map);
1000
+ }
1001
+ return { flowId, nonce };
1002
+ }
1003
+ function consumeNonce(flowId) {
1004
+ const storage = getStorage();
1005
+ if (!storage) return null;
1006
+ const map = pruneStale(readMap(storage), Date.now());
1007
+ const entry = map[flowId];
1008
+ if (!entry) {
1009
+ writeMap(storage, map);
1010
+ return null;
1011
+ }
1012
+ delete map[flowId];
1013
+ writeMap(storage, map);
1014
+ return entry.nonce;
1015
+ }
1016
+
1017
+ // src/react/useSignIn.ts
1018
+ function useSignIn() {
1019
+ const ctx = useContext(UserboxContext);
1020
+ if (!ctx) {
1021
+ throw misconfigured(
1022
+ "tenantSlug",
1023
+ "useSignIn() must be called inside a <UserboxProvider>"
1024
+ );
1025
+ }
1026
+ const signIn = useCallback(
1027
+ (opts) => {
1028
+ const returnTo = opts?.returnTo ?? (typeof window !== "undefined" ? window.location.href : "/");
1029
+ assertValidReturnTo(returnTo);
1030
+ const { flowId, nonce } = issueNonce();
1031
+ const url = buildAuthSurfaceUrl({
1032
+ tenantSlug: ctx.config.tenantSlug,
1033
+ baseUrl: ctx.config.baseUrl,
1034
+ baseAuthUrl: ctx.config.baseAuthUrl,
1035
+ path: "/login",
1036
+ returnTo,
1037
+ state: nonce,
1038
+ flowId
1039
+ });
1040
+ ctx.navigate(url);
1041
+ },
1042
+ [ctx]
1043
+ );
1044
+ return { signIn };
1045
+ }
1046
+ function useSignUp() {
1047
+ const ctx = useContext(UserboxContext);
1048
+ if (!ctx) {
1049
+ throw misconfigured(
1050
+ "tenantSlug",
1051
+ "useSignUp() must be called inside a <UserboxProvider>"
1052
+ );
1053
+ }
1054
+ const signUp = useCallback(
1055
+ (opts) => {
1056
+ const returnTo = opts?.returnTo ?? (typeof window !== "undefined" ? window.location.href : "/");
1057
+ assertValidReturnTo(returnTo);
1058
+ const { flowId, nonce } = issueNonce();
1059
+ const url = buildAuthSurfaceUrl({
1060
+ tenantSlug: ctx.config.tenantSlug,
1061
+ baseUrl: ctx.config.baseUrl,
1062
+ baseAuthUrl: ctx.config.baseAuthUrl,
1063
+ path: "/signup",
1064
+ returnTo,
1065
+ state: nonce,
1066
+ flowId
1067
+ });
1068
+ ctx.navigate(url);
1069
+ },
1070
+ [ctx]
1071
+ );
1072
+ return { signUp };
1073
+ }
1074
+ function useSignOut() {
1075
+ const ctx = useContext(UserboxContext);
1076
+ if (!ctx) {
1077
+ throw misconfigured(
1078
+ "tenantSlug",
1079
+ "useSignOut() must be called inside a <UserboxProvider>"
1080
+ );
1081
+ }
1082
+ const signOut = useCallback(
1083
+ async (opts) => {
1084
+ await ctx.signOut(opts?.redirectUri);
1085
+ },
1086
+ [ctx]
1087
+ );
1088
+ return { signOut };
1089
+ }
1090
+ function parseHash(hash) {
1091
+ const out = {};
1092
+ const raw = hash.startsWith("#") ? hash.slice(1) : hash;
1093
+ if (!raw) return out;
1094
+ for (const piece of raw.split("&")) {
1095
+ const [k, v] = piece.split("=");
1096
+ if (!k) continue;
1097
+ out[decodeURIComponent(k)] = v ? decodeURIComponent(v) : "";
1098
+ }
1099
+ return out;
1100
+ }
1101
+ var UserboxCallback = ({
1102
+ onSuccess,
1103
+ onError
1104
+ }) => {
1105
+ const ctx = useContext(UserboxContext);
1106
+ const ranRef = useRef(false);
1107
+ useEffect(() => {
1108
+ if (ranRef.current) return;
1109
+ ranRef.current = true;
1110
+ if (!ctx) return;
1111
+ if (typeof window === "undefined") return;
1112
+ const hash = window.location.hash;
1113
+ const params = parseHash(hash);
1114
+ if (!hash || Object.keys(params).length === 0) {
1115
+ return;
1116
+ }
1117
+ const stripFragment = () => {
1118
+ const { pathname, search } = window.location;
1119
+ try {
1120
+ window.history.replaceState(null, "", pathname + search);
1121
+ } catch {
1122
+ }
1123
+ };
1124
+ if (params.error) {
1125
+ stripFragment();
1126
+ const err = authFailed(
1127
+ params.error_description || params.error,
1128
+ 400,
1129
+ params.error
1130
+ );
1131
+ onError?.(err);
1132
+ return;
1133
+ }
1134
+ const accessToken = params.access_token;
1135
+ const refreshToken = params.refresh_token;
1136
+ const expiresIn = Number.parseInt(params.expires_in ?? "0", 10);
1137
+ const refreshExpiresIn = Number.parseInt(
1138
+ params.refresh_expires_in ?? params.expires_in ?? "0",
1139
+ 10
1140
+ );
1141
+ const state = params.state;
1142
+ const flowId = params.flow_id;
1143
+ if (!accessToken || !refreshToken || !expiresIn) {
1144
+ onError?.(missingCredentials("Callback URL missing access_token / refresh_token / expires_in"));
1145
+ return;
1146
+ }
1147
+ if (!flowId || !state) {
1148
+ stripFragment();
1149
+ onError?.(authFailed("Callback URL missing state/flow_id", 400, "STATE_MISSING"));
1150
+ return;
1151
+ }
1152
+ const expectedNonce = consumeNonce(flowId);
1153
+ if (expectedNonce === null || expectedNonce !== state) {
1154
+ stripFragment();
1155
+ onError?.(authFailed("Callback state mismatch \u2014 possible CSRF", 400, "STATE_MISMATCH"));
1156
+ return;
1157
+ }
1158
+ let claims;
1159
+ try {
1160
+ claims = decodeJwtClaims(accessToken);
1161
+ } catch (e) {
1162
+ stripFragment();
1163
+ onError?.(e);
1164
+ return;
1165
+ }
1166
+ const session = {
1167
+ accessToken,
1168
+ accessTokenExpiresAt: Date.now() + expiresIn * 1e3,
1169
+ refreshExpiresAt: Date.now() + refreshExpiresIn * 1e3,
1170
+ user: {
1171
+ id: claims.sub,
1172
+ tenantId: claims.tid,
1173
+ email: null,
1174
+ fullName: null
1175
+ }
1176
+ };
1177
+ if (ctx.config.tokenStorage === "cookie") {
1178
+ void (async () => {
1179
+ const res = await request({
1180
+ url: ctx.config.refreshUrl,
1181
+ method: "POST",
1182
+ tenantSlug: ctx.config.tenantSlug,
1183
+ body: { action: "init", refresh_token: refreshToken },
1184
+ credentials: "include",
1185
+ errorKind: "auth"
1186
+ });
1187
+ if (!res.ok) onError?.(res.error);
1188
+ })();
1189
+ }
1190
+ ctx.adoptCallbackSession(session, refreshToken);
1191
+ stripFragment();
1192
+ onSuccess?.({ user: session.user });
1193
+ }, [ctx, onError, onSuccess]);
1194
+ return null;
1195
+ };
1196
+
1197
+ export { UserboxCallback, UserboxProvider, isUserboxError, useAccessToken, usePermission, usePermissions, useSession, useSignIn, useSignOut, useSignUp };
1198
+ //# sourceMappingURL=index.js.map
1199
+ //# sourceMappingURL=index.js.map