@poly-x/react 0.1.0-alpha.17 → 0.1.0-alpha.19

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 CHANGED
@@ -407,6 +407,40 @@ var BffSessionStore = class {
407
407
  this.loading = false;
408
408
  }
409
409
  }
410
+ /**
411
+ * v2 (FR-LIVE-1/2): re-read server truth and update claims, resolving whether **authorization
412
+ * actually changed**. Drives the live-authz orchestrator (`startLiveAuthz`): a `true` means a
413
+ * consumer that gates on `useUser()` should re-guard. Never flickers to `resolving`; a definite
414
+ * signed-out answer signs the user out (revoked), a network blip keeps the last-known claims.
415
+ * The orchestrator single-flights callers, so this needs no internal in-flight guard.
416
+ */
417
+ refresh = async () => {
418
+ try {
419
+ const response = await fetch(SESSION_ENDPOINT, {
420
+ credentials: "same-origin",
421
+ headers: { accept: "application/json" }
422
+ });
423
+ const body = response.ok ? await response.json() : null;
424
+ if (!Boolean(body?.isSignedIn && body.claims)) {
425
+ const changed = this.state.status !== "signed-out";
426
+ if (changed) this.set({
427
+ status: "signed-out",
428
+ reason: "revoked"
429
+ });
430
+ return changed;
431
+ }
432
+ const prevClaims = this.state.status === "authenticated" ? JSON.stringify(this.state.session.claims) : null;
433
+ const nextClaims = body.claims;
434
+ const changed = prevClaims !== JSON.stringify(nextClaims);
435
+ if (changed) this.set({
436
+ status: "authenticated",
437
+ session: { claims: nextClaims }
438
+ });
439
+ return changed;
440
+ } catch {
441
+ return false;
442
+ }
443
+ };
410
444
  set(next) {
411
445
  this.state = next;
412
446
  for (const listener of this.listeners) listener();
@@ -578,6 +612,23 @@ async function capturePreciseLocation(consented, opts = {}) {
578
612
  return (await attemptCapture(geo, false, lowAccuracyMs)).result;
579
613
  }
580
614
  //#endregion
615
+ //#region src/device-context.ts
616
+ /** Capture the browser's timezone + screen resolution. Never throws; omits anything unavailable. */
617
+ function captureDeviceContext() {
618
+ const context = {};
619
+ try {
620
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
621
+ if (typeof timezone === "string" && timezone) context.timezone = timezone;
622
+ } catch {}
623
+ try {
624
+ if (typeof window !== "undefined" && window.screen) {
625
+ const { width, height } = window.screen;
626
+ if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) context.screenResolution = `${Math.round(width)}x${Math.round(height)}`;
627
+ }
628
+ } catch {}
629
+ return context;
630
+ }
631
+ //#endregion
581
632
  //#region src/components/sign-in.tsx
582
633
  const DEFAULT_LABELS$3 = {
583
634
  heading: "Sign in",
@@ -738,6 +789,7 @@ function SignIn(props) {
738
789
  setError(null);
739
790
  try {
740
791
  const geo = props.captureLocation ? await capturePreciseLocation(true) : void 0;
792
+ const deviceContext = captureDeviceContext();
741
793
  const body = await (await fetch(action, {
742
794
  method: "POST",
743
795
  headers: { "content-type": "application/json" },
@@ -747,6 +799,7 @@ function SignIn(props) {
747
799
  organizationCode: props.organizationCode,
748
800
  tenantId: options.tenantId,
749
801
  ...geo ? { geo } : {},
802
+ ...Object.keys(deviceContext).length ? { deviceContext } : {},
750
803
  ...options.passwords
751
804
  })
752
805
  })).json().catch(() => ({}));
@@ -1927,6 +1980,138 @@ const UserButton = Object.assign(UserButtonRoot, {
1927
1980
  Header
1928
1981
  });
1929
1982
  //#endregion
1983
+ //#region src/live-authz.ts
1984
+ const DEFAULT_POLL_MS = 6e4;
1985
+ const DEFAULT_FOCUS_THROTTLE_MS = 5e3;
1986
+ /** Wire the triggers and return a handle. Safe to call with no window/document (SSR → no-op wiring). */
1987
+ function startLiveAuthz(opts) {
1988
+ const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
1989
+ const throttleMs = opts.focusThrottleMs ?? DEFAULT_FOCUS_THROTTLE_MS;
1990
+ const win = opts.win ?? (typeof window !== "undefined" ? window : void 0);
1991
+ const doc = opts.doc ?? (typeof document !== "undefined" ? document : void 0);
1992
+ let stopped = false;
1993
+ let inFlight = false;
1994
+ let pending = false;
1995
+ let lastRefreshAt = -Infinity;
1996
+ /**
1997
+ * Single-flight refresh (FR-LIVE-5). `coalesce` = queue exactly one follow-up run if a refresh
1998
+ * is already in flight — used by the explicit push signal so a change mid-refresh isn't missed.
1999
+ * Ambient triggers (focus/online/poll) pass `false`: if a refresh is already running, drop —
2000
+ * they carry no new information the in-flight run won't already pick up.
2001
+ */
2002
+ async function run(coalesce) {
2003
+ if (stopped) return;
2004
+ if (inFlight) {
2005
+ if (coalesce) pending = true;
2006
+ return;
2007
+ }
2008
+ inFlight = true;
2009
+ try {
2010
+ let changed = false;
2011
+ try {
2012
+ changed = await opts.refresh();
2013
+ } catch {}
2014
+ lastRefreshAt = Date.now();
2015
+ if (changed && !stopped) opts.onChanged?.();
2016
+ } finally {
2017
+ inFlight = false;
2018
+ if (pending && !stopped) {
2019
+ pending = false;
2020
+ run(true);
2021
+ }
2022
+ }
2023
+ }
2024
+ /** Throttled, non-queuing refresh for ambient triggers (focus/visible/online). */
2025
+ function throttledRun() {
2026
+ if (Date.now() - lastRefreshAt >= throttleMs) run(false);
2027
+ }
2028
+ const isVisible = () => !doc || doc.visibilityState !== "hidden";
2029
+ const onFocus = () => throttledRun();
2030
+ const onOnline = () => throttledRun();
2031
+ const onVisibility = () => {
2032
+ if (isVisible()) throttledRun();
2033
+ };
2034
+ if (win) {
2035
+ win.addEventListener("focus", onFocus);
2036
+ win.addEventListener("online", onOnline);
2037
+ }
2038
+ if (doc) doc.addEventListener("visibilitychange", onVisibility);
2039
+ const unsubscribeSignal = opts.subscribeSignal?.(() => void run(true));
2040
+ let pollTimer;
2041
+ if (pollMs > 0) pollTimer = setInterval(() => {
2042
+ if (isVisible()) run(false);
2043
+ }, pollMs);
2044
+ return {
2045
+ refreshNow: () => void run(true),
2046
+ stop: () => {
2047
+ if (stopped) return;
2048
+ stopped = true;
2049
+ if (win) {
2050
+ win.removeEventListener("focus", onFocus);
2051
+ win.removeEventListener("online", onOnline);
2052
+ }
2053
+ if (doc) doc.removeEventListener("visibilitychange", onVisibility);
2054
+ unsubscribeSignal?.();
2055
+ if (pollTimer !== void 0) clearInterval(pollTimer);
2056
+ }
2057
+ };
2058
+ }
2059
+ //#endregion
2060
+ //#region src/live-session.ts
2061
+ /**
2062
+ * @poly-x/react/live-session — the React glue that keeps the BFF session live (SDK v2).
2063
+ *
2064
+ * `useLiveAuthz` starts the framework-agnostic orchestrator (`startLiveAuthz`, F013) bound to the
2065
+ * BFF session store's `refresh()`: the platform `authz:changed` signal (when a `subscribeSignal`
2066
+ * transport is supplied) + window focus/reconnect + a ~60s safety poll → a single-flight
2067
+ * re-introspect that updates reactive `useUser()`. `useAuthzChanged` lets an app with its own guard
2068
+ * layer react to a change (FR-REACT-1). Apps that gate on `useUser()` re-guard automatically
2069
+ * (FR-REACT-2) — they need no callback.
2070
+ *
2071
+ * The `subscribeSignal` transport is optional: without it the passive triggers still converge
2072
+ * (FR-SIG-4). The default poly-alert transport is provided separately (a later slice) once the
2073
+ * subscription-credential custody decision lands.
2074
+ */
2075
+ const changeListeners = /* @__PURE__ */ new Set();
2076
+ function emitChanged() {
2077
+ for (const listener of changeListeners) listener();
2078
+ }
2079
+ /**
2080
+ * Start the live-authz orchestrator for this app's BFF session. Mount once (e.g. in the app root).
2081
+ * Returns nothing; drives `useUser()` reactivity + fires `useAuthzChanged` callbacks on a change.
2082
+ */
2083
+ function useLiveAuthz(options = {}) {
2084
+ const { subscribeSignal, pollMs, focusThrottleMs } = options;
2085
+ (0, react.useEffect)(() => {
2086
+ const store = getBffSessionStore();
2087
+ const handle = startLiveAuthz({
2088
+ refresh: () => store.refresh(),
2089
+ onChanged: emitChanged,
2090
+ subscribeSignal,
2091
+ pollMs,
2092
+ focusThrottleMs
2093
+ });
2094
+ return () => handle.stop();
2095
+ }, [
2096
+ subscribeSignal,
2097
+ pollMs,
2098
+ focusThrottleMs
2099
+ ]);
2100
+ }
2101
+ /**
2102
+ * Register a callback fired when the user's authorization changes (FR-REACT-1) — for apps that keep
2103
+ * their own ability/route model and need to re-run their guard or refetch view data. Apps that gate
2104
+ * on `useUser()` do not need this. Requires `useLiveAuthz` to be mounted somewhere in the tree.
2105
+ */
2106
+ function useAuthzChanged(callback) {
2107
+ (0, react.useEffect)(() => {
2108
+ changeListeners.add(callback);
2109
+ return () => {
2110
+ changeListeners.delete(callback);
2111
+ };
2112
+ }, [callback]);
2113
+ }
2114
+ //#endregion
1930
2115
  //#region src/index.ts
1931
2116
  /**
1932
2117
  * @poly-x/react — PolyX SDK for React SPAs. Sign-in UI + orchestration land in
@@ -1955,8 +2140,11 @@ exports.capturePreciseLocation = capturePreciseLocation;
1955
2140
  exports.createLoginController = createLoginController;
1956
2141
  exports.initialsFrom = initialsFrom;
1957
2142
  exports.resolveSide = resolveSide;
2143
+ exports.startLiveAuthz = startLiveAuthz;
1958
2144
  exports.useAuth = useAuth;
1959
2145
  exports.useAuthCallback = useAuthCallback;
2146
+ exports.useAuthzChanged = useAuthzChanged;
2147
+ exports.useLiveAuthz = useLiveAuthz;
1960
2148
  exports.useSession = useSession;
1961
2149
  exports.useUser = useUser;
1962
2150
  exports.version = version;
package/dist/index.d.cts CHANGED
@@ -631,6 +631,68 @@ declare function UserAvatar({
631
631
  className
632
632
  }: UserAvatarProps): ReactNode;
633
633
  //#endregion
634
+ //#region src/live-authz.d.ts
635
+ /**
636
+ * @poly-x/react/live-authz — keeps live claims current (SDK v2: FR-LIVE, FR-SIG, FR-TRIG).
637
+ *
638
+ * Orchestrates the refresh triggers behind ADR-0008/0009: the platform `authz:changed` signal
639
+ * (instant), window focus / tab-visible, network reconnect, and a **hidden-pausing safety poll**
640
+ * (~60s) as the staleness ceiling. Every refresh coalesces through a **single-flight** guard;
641
+ * focus/reconnect are throttled so churn never storms the platform; the push signal is never
642
+ * throttled (it's an explicit change). The injected `refresh` reports whether claims actually
643
+ * changed, so `onChanged` (which drives `useAuthzChanged`) fires only on a real change (FR-REACT-5).
644
+ *
645
+ * Pure of React — unit-testable with injected window/document + fake timers.
646
+ */
647
+ interface LiveAuthzOptions {
648
+ /** Bring claims current; resolves `true` if the claims actually changed. Errors are swallowed
649
+ * (keep last-known — FR-LIVE-1 / EC-LIVE-1). */
650
+ refresh: () => Promise<boolean>;
651
+ /** Subscribe to the platform `authz:changed` signal; return an unsubscribe fn (ADR-0008). */
652
+ subscribeSignal?: (onSignal: () => void) => () => void;
653
+ /** Fired after a triggered refresh that changed claims (drives `useAuthzChanged`). */
654
+ onChanged?: () => void;
655
+ /** Background safety-poll interval in ms. Default 60000; `<= 0` disables the poll. */
656
+ pollMs?: number;
657
+ /** Throttle window (ms) for focus/reconnect/visibility refreshes. Default 5000. */
658
+ focusThrottleMs?: number;
659
+ /** Injectable window (tests). Defaults to the global `window` when present. */
660
+ win?: Pick<Window, "addEventListener" | "removeEventListener">;
661
+ /** Injectable document (tests). Defaults to the global `document` when present. */
662
+ doc?: Pick<Document, "addEventListener" | "removeEventListener"> & {
663
+ readonly visibilityState: DocumentVisibilityState;
664
+ };
665
+ }
666
+ interface LiveAuthzHandle {
667
+ /** Trigger a coalesced refresh immediately (used by the push signal). */
668
+ refreshNow: () => void;
669
+ /** Detach every listener + timer. Idempotent. */
670
+ stop: () => void;
671
+ }
672
+ /** Wire the triggers and return a handle. Safe to call with no window/document (SSR → no-op wiring). */
673
+ declare function startLiveAuthz(opts: LiveAuthzOptions): LiveAuthzHandle;
674
+ //#endregion
675
+ //#region src/live-session.d.ts
676
+ interface UseLiveAuthzOptions {
677
+ /** Optional push transport (ADR-0008). Omit to run passive-only (focus/reconnect/poll). */
678
+ subscribeSignal?: LiveAuthzOptions["subscribeSignal"];
679
+ /** Safety-poll interval (ms). Default 60000; `<= 0` disables. */
680
+ pollMs?: number;
681
+ /** Throttle window (ms) for focus/reconnect refreshes. Default 5000. */
682
+ focusThrottleMs?: number;
683
+ }
684
+ /**
685
+ * Start the live-authz orchestrator for this app's BFF session. Mount once (e.g. in the app root).
686
+ * Returns nothing; drives `useUser()` reactivity + fires `useAuthzChanged` callbacks on a change.
687
+ */
688
+ declare function useLiveAuthz(options?: UseLiveAuthzOptions): void;
689
+ /**
690
+ * Register a callback fired when the user's authorization changes (FR-REACT-1) — for apps that keep
691
+ * their own ability/route model and need to re-run their guard or refetch view data. Apps that gate
692
+ * on `useUser()` do not need this. Requires `useLiveAuthz` to be mounted somewhere in the tree.
693
+ */
694
+ declare function useAuthzChanged(callback: () => void): void;
695
+ //#endregion
634
696
  //#region src/index.d.ts
635
697
  /**
636
698
  * @poly-x/react — PolyX SDK for React SPAs. Sign-in UI + orchestration land in
@@ -639,4 +701,4 @@ declare function UserAvatar({
639
701
  declare const PACKAGE_NAME = "@poly-x/react";
640
702
  declare const version = "0.0.0";
641
703
  //#endregion
642
- export { type Align, AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, BroadcastAuthChannel, BroadcastChannelSessionChannel, type BrowserBridge, type CallbackParams, type CaptureOptions, type CapturedGeo, ForgotPassword, type ForgotPasswordLabels, type ForgotPasswordProps, type GeolocationLike, type LoginController, type LoginControllerDeps, PACKAGE_NAME, PolyXContext, type PolyXContextValue, PolyXProvider, type PolyXProviderProps, PopupCancelledError, type PopupHandle, Protected, type ProtectedProps, ResetPassword, type ResetPasswordLabels, type ResetPasswordProps, SessionStoragePkceStore, type Side, type SideOption, SignIn, type SignInLabels, type SignInOptions, type SignInProps, type SignInVariant, type UseAuth, UserAvatar, type UserAvatarProps, UserButton, type UserButtonActionProps, type UserButtonLabels, type UserButtonLinkProps, type UserButtonProps, type UserButtonSlotProps, WebLocksLock, WindowBrowserBridge, capturePreciseLocation, createLoginController, initialsFrom, resolveSide, useAuth, useAuthCallback, useSession, useUser, version };
704
+ export { type Align, AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, BroadcastAuthChannel, BroadcastChannelSessionChannel, type BrowserBridge, type CallbackParams, type CaptureOptions, type CapturedGeo, ForgotPassword, type ForgotPasswordLabels, type ForgotPasswordProps, type GeolocationLike, type LiveAuthzHandle, type LiveAuthzOptions, type LoginController, type LoginControllerDeps, PACKAGE_NAME, PolyXContext, type PolyXContextValue, PolyXProvider, type PolyXProviderProps, PopupCancelledError, type PopupHandle, Protected, type ProtectedProps, ResetPassword, type ResetPasswordLabels, type ResetPasswordProps, SessionStoragePkceStore, type Side, type SideOption, SignIn, type SignInLabels, type SignInOptions, type SignInProps, type SignInVariant, type UseAuth, type UseLiveAuthzOptions, UserAvatar, type UserAvatarProps, UserButton, type UserButtonActionProps, type UserButtonLabels, type UserButtonLinkProps, type UserButtonProps, type UserButtonSlotProps, WebLocksLock, WindowBrowserBridge, capturePreciseLocation, createLoginController, initialsFrom, resolveSide, startLiveAuthz, useAuth, useAuthCallback, useAuthzChanged, useLiveAuthz, useSession, useUser, version };
package/dist/index.d.mts CHANGED
@@ -631,6 +631,68 @@ declare function UserAvatar({
631
631
  className
632
632
  }: UserAvatarProps): ReactNode;
633
633
  //#endregion
634
+ //#region src/live-authz.d.ts
635
+ /**
636
+ * @poly-x/react/live-authz — keeps live claims current (SDK v2: FR-LIVE, FR-SIG, FR-TRIG).
637
+ *
638
+ * Orchestrates the refresh triggers behind ADR-0008/0009: the platform `authz:changed` signal
639
+ * (instant), window focus / tab-visible, network reconnect, and a **hidden-pausing safety poll**
640
+ * (~60s) as the staleness ceiling. Every refresh coalesces through a **single-flight** guard;
641
+ * focus/reconnect are throttled so churn never storms the platform; the push signal is never
642
+ * throttled (it's an explicit change). The injected `refresh` reports whether claims actually
643
+ * changed, so `onChanged` (which drives `useAuthzChanged`) fires only on a real change (FR-REACT-5).
644
+ *
645
+ * Pure of React — unit-testable with injected window/document + fake timers.
646
+ */
647
+ interface LiveAuthzOptions {
648
+ /** Bring claims current; resolves `true` if the claims actually changed. Errors are swallowed
649
+ * (keep last-known — FR-LIVE-1 / EC-LIVE-1). */
650
+ refresh: () => Promise<boolean>;
651
+ /** Subscribe to the platform `authz:changed` signal; return an unsubscribe fn (ADR-0008). */
652
+ subscribeSignal?: (onSignal: () => void) => () => void;
653
+ /** Fired after a triggered refresh that changed claims (drives `useAuthzChanged`). */
654
+ onChanged?: () => void;
655
+ /** Background safety-poll interval in ms. Default 60000; `<= 0` disables the poll. */
656
+ pollMs?: number;
657
+ /** Throttle window (ms) for focus/reconnect/visibility refreshes. Default 5000. */
658
+ focusThrottleMs?: number;
659
+ /** Injectable window (tests). Defaults to the global `window` when present. */
660
+ win?: Pick<Window, "addEventListener" | "removeEventListener">;
661
+ /** Injectable document (tests). Defaults to the global `document` when present. */
662
+ doc?: Pick<Document, "addEventListener" | "removeEventListener"> & {
663
+ readonly visibilityState: DocumentVisibilityState;
664
+ };
665
+ }
666
+ interface LiveAuthzHandle {
667
+ /** Trigger a coalesced refresh immediately (used by the push signal). */
668
+ refreshNow: () => void;
669
+ /** Detach every listener + timer. Idempotent. */
670
+ stop: () => void;
671
+ }
672
+ /** Wire the triggers and return a handle. Safe to call with no window/document (SSR → no-op wiring). */
673
+ declare function startLiveAuthz(opts: LiveAuthzOptions): LiveAuthzHandle;
674
+ //#endregion
675
+ //#region src/live-session.d.ts
676
+ interface UseLiveAuthzOptions {
677
+ /** Optional push transport (ADR-0008). Omit to run passive-only (focus/reconnect/poll). */
678
+ subscribeSignal?: LiveAuthzOptions["subscribeSignal"];
679
+ /** Safety-poll interval (ms). Default 60000; `<= 0` disables. */
680
+ pollMs?: number;
681
+ /** Throttle window (ms) for focus/reconnect refreshes. Default 5000. */
682
+ focusThrottleMs?: number;
683
+ }
684
+ /**
685
+ * Start the live-authz orchestrator for this app's BFF session. Mount once (e.g. in the app root).
686
+ * Returns nothing; drives `useUser()` reactivity + fires `useAuthzChanged` callbacks on a change.
687
+ */
688
+ declare function useLiveAuthz(options?: UseLiveAuthzOptions): void;
689
+ /**
690
+ * Register a callback fired when the user's authorization changes (FR-REACT-1) — for apps that keep
691
+ * their own ability/route model and need to re-run their guard or refetch view data. Apps that gate
692
+ * on `useUser()` do not need this. Requires `useLiveAuthz` to be mounted somewhere in the tree.
693
+ */
694
+ declare function useAuthzChanged(callback: () => void): void;
695
+ //#endregion
634
696
  //#region src/index.d.ts
635
697
  /**
636
698
  * @poly-x/react — PolyX SDK for React SPAs. Sign-in UI + orchestration land in
@@ -639,4 +701,4 @@ declare function UserAvatar({
639
701
  declare const PACKAGE_NAME = "@poly-x/react";
640
702
  declare const version = "0.0.0";
641
703
  //#endregion
642
- export { type Align, AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, BroadcastAuthChannel, BroadcastChannelSessionChannel, type BrowserBridge, type CallbackParams, type CaptureOptions, type CapturedGeo, ForgotPassword, type ForgotPasswordLabels, type ForgotPasswordProps, type GeolocationLike, type LoginController, type LoginControllerDeps, PACKAGE_NAME, PolyXContext, type PolyXContextValue, PolyXProvider, type PolyXProviderProps, PopupCancelledError, type PopupHandle, Protected, type ProtectedProps, ResetPassword, type ResetPasswordLabels, type ResetPasswordProps, SessionStoragePkceStore, type Side, type SideOption, SignIn, type SignInLabels, type SignInOptions, type SignInProps, type SignInVariant, type UseAuth, UserAvatar, type UserAvatarProps, UserButton, type UserButtonActionProps, type UserButtonLabels, type UserButtonLinkProps, type UserButtonProps, type UserButtonSlotProps, WebLocksLock, WindowBrowserBridge, capturePreciseLocation, createLoginController, initialsFrom, resolveSide, useAuth, useAuthCallback, useSession, useUser, version };
704
+ export { type Align, AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, BroadcastAuthChannel, BroadcastChannelSessionChannel, type BrowserBridge, type CallbackParams, type CaptureOptions, type CapturedGeo, ForgotPassword, type ForgotPasswordLabels, type ForgotPasswordProps, type GeolocationLike, type LiveAuthzHandle, type LiveAuthzOptions, type LoginController, type LoginControllerDeps, PACKAGE_NAME, PolyXContext, type PolyXContextValue, PolyXProvider, type PolyXProviderProps, PopupCancelledError, type PopupHandle, Protected, type ProtectedProps, ResetPassword, type ResetPasswordLabels, type ResetPasswordProps, SessionStoragePkceStore, type Side, type SideOption, SignIn, type SignInLabels, type SignInOptions, type SignInProps, type SignInVariant, type UseAuth, type UseLiveAuthzOptions, UserAvatar, type UserAvatarProps, UserButton, type UserButtonActionProps, type UserButtonLabels, type UserButtonLinkProps, type UserButtonProps, type UserButtonSlotProps, WebLocksLock, WindowBrowserBridge, capturePreciseLocation, createLoginController, initialsFrom, resolveSide, startLiveAuthz, useAuth, useAuthCallback, useAuthzChanged, useLiveAuthz, useSession, useUser, version };
package/dist/index.mjs CHANGED
@@ -406,6 +406,40 @@ var BffSessionStore = class {
406
406
  this.loading = false;
407
407
  }
408
408
  }
409
+ /**
410
+ * v2 (FR-LIVE-1/2): re-read server truth and update claims, resolving whether **authorization
411
+ * actually changed**. Drives the live-authz orchestrator (`startLiveAuthz`): a `true` means a
412
+ * consumer that gates on `useUser()` should re-guard. Never flickers to `resolving`; a definite
413
+ * signed-out answer signs the user out (revoked), a network blip keeps the last-known claims.
414
+ * The orchestrator single-flights callers, so this needs no internal in-flight guard.
415
+ */
416
+ refresh = async () => {
417
+ try {
418
+ const response = await fetch(SESSION_ENDPOINT, {
419
+ credentials: "same-origin",
420
+ headers: { accept: "application/json" }
421
+ });
422
+ const body = response.ok ? await response.json() : null;
423
+ if (!Boolean(body?.isSignedIn && body.claims)) {
424
+ const changed = this.state.status !== "signed-out";
425
+ if (changed) this.set({
426
+ status: "signed-out",
427
+ reason: "revoked"
428
+ });
429
+ return changed;
430
+ }
431
+ const prevClaims = this.state.status === "authenticated" ? JSON.stringify(this.state.session.claims) : null;
432
+ const nextClaims = body.claims;
433
+ const changed = prevClaims !== JSON.stringify(nextClaims);
434
+ if (changed) this.set({
435
+ status: "authenticated",
436
+ session: { claims: nextClaims }
437
+ });
438
+ return changed;
439
+ } catch {
440
+ return false;
441
+ }
442
+ };
409
443
  set(next) {
410
444
  this.state = next;
411
445
  for (const listener of this.listeners) listener();
@@ -577,6 +611,23 @@ async function capturePreciseLocation(consented, opts = {}) {
577
611
  return (await attemptCapture(geo, false, lowAccuracyMs)).result;
578
612
  }
579
613
  //#endregion
614
+ //#region src/device-context.ts
615
+ /** Capture the browser's timezone + screen resolution. Never throws; omits anything unavailable. */
616
+ function captureDeviceContext() {
617
+ const context = {};
618
+ try {
619
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
620
+ if (typeof timezone === "string" && timezone) context.timezone = timezone;
621
+ } catch {}
622
+ try {
623
+ if (typeof window !== "undefined" && window.screen) {
624
+ const { width, height } = window.screen;
625
+ if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) context.screenResolution = `${Math.round(width)}x${Math.round(height)}`;
626
+ }
627
+ } catch {}
628
+ return context;
629
+ }
630
+ //#endregion
580
631
  //#region src/components/sign-in.tsx
581
632
  const DEFAULT_LABELS$3 = {
582
633
  heading: "Sign in",
@@ -737,6 +788,7 @@ function SignIn(props) {
737
788
  setError(null);
738
789
  try {
739
790
  const geo = props.captureLocation ? await capturePreciseLocation(true) : void 0;
791
+ const deviceContext = captureDeviceContext();
740
792
  const body = await (await fetch(action, {
741
793
  method: "POST",
742
794
  headers: { "content-type": "application/json" },
@@ -746,6 +798,7 @@ function SignIn(props) {
746
798
  organizationCode: props.organizationCode,
747
799
  tenantId: options.tenantId,
748
800
  ...geo ? { geo } : {},
801
+ ...Object.keys(deviceContext).length ? { deviceContext } : {},
749
802
  ...options.passwords
750
803
  })
751
804
  })).json().catch(() => ({}));
@@ -1926,6 +1979,138 @@ const UserButton = Object.assign(UserButtonRoot, {
1926
1979
  Header
1927
1980
  });
1928
1981
  //#endregion
1982
+ //#region src/live-authz.ts
1983
+ const DEFAULT_POLL_MS = 6e4;
1984
+ const DEFAULT_FOCUS_THROTTLE_MS = 5e3;
1985
+ /** Wire the triggers and return a handle. Safe to call with no window/document (SSR → no-op wiring). */
1986
+ function startLiveAuthz(opts) {
1987
+ const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
1988
+ const throttleMs = opts.focusThrottleMs ?? DEFAULT_FOCUS_THROTTLE_MS;
1989
+ const win = opts.win ?? (typeof window !== "undefined" ? window : void 0);
1990
+ const doc = opts.doc ?? (typeof document !== "undefined" ? document : void 0);
1991
+ let stopped = false;
1992
+ let inFlight = false;
1993
+ let pending = false;
1994
+ let lastRefreshAt = -Infinity;
1995
+ /**
1996
+ * Single-flight refresh (FR-LIVE-5). `coalesce` = queue exactly one follow-up run if a refresh
1997
+ * is already in flight — used by the explicit push signal so a change mid-refresh isn't missed.
1998
+ * Ambient triggers (focus/online/poll) pass `false`: if a refresh is already running, drop —
1999
+ * they carry no new information the in-flight run won't already pick up.
2000
+ */
2001
+ async function run(coalesce) {
2002
+ if (stopped) return;
2003
+ if (inFlight) {
2004
+ if (coalesce) pending = true;
2005
+ return;
2006
+ }
2007
+ inFlight = true;
2008
+ try {
2009
+ let changed = false;
2010
+ try {
2011
+ changed = await opts.refresh();
2012
+ } catch {}
2013
+ lastRefreshAt = Date.now();
2014
+ if (changed && !stopped) opts.onChanged?.();
2015
+ } finally {
2016
+ inFlight = false;
2017
+ if (pending && !stopped) {
2018
+ pending = false;
2019
+ run(true);
2020
+ }
2021
+ }
2022
+ }
2023
+ /** Throttled, non-queuing refresh for ambient triggers (focus/visible/online). */
2024
+ function throttledRun() {
2025
+ if (Date.now() - lastRefreshAt >= throttleMs) run(false);
2026
+ }
2027
+ const isVisible = () => !doc || doc.visibilityState !== "hidden";
2028
+ const onFocus = () => throttledRun();
2029
+ const onOnline = () => throttledRun();
2030
+ const onVisibility = () => {
2031
+ if (isVisible()) throttledRun();
2032
+ };
2033
+ if (win) {
2034
+ win.addEventListener("focus", onFocus);
2035
+ win.addEventListener("online", onOnline);
2036
+ }
2037
+ if (doc) doc.addEventListener("visibilitychange", onVisibility);
2038
+ const unsubscribeSignal = opts.subscribeSignal?.(() => void run(true));
2039
+ let pollTimer;
2040
+ if (pollMs > 0) pollTimer = setInterval(() => {
2041
+ if (isVisible()) run(false);
2042
+ }, pollMs);
2043
+ return {
2044
+ refreshNow: () => void run(true),
2045
+ stop: () => {
2046
+ if (stopped) return;
2047
+ stopped = true;
2048
+ if (win) {
2049
+ win.removeEventListener("focus", onFocus);
2050
+ win.removeEventListener("online", onOnline);
2051
+ }
2052
+ if (doc) doc.removeEventListener("visibilitychange", onVisibility);
2053
+ unsubscribeSignal?.();
2054
+ if (pollTimer !== void 0) clearInterval(pollTimer);
2055
+ }
2056
+ };
2057
+ }
2058
+ //#endregion
2059
+ //#region src/live-session.ts
2060
+ /**
2061
+ * @poly-x/react/live-session — the React glue that keeps the BFF session live (SDK v2).
2062
+ *
2063
+ * `useLiveAuthz` starts the framework-agnostic orchestrator (`startLiveAuthz`, F013) bound to the
2064
+ * BFF session store's `refresh()`: the platform `authz:changed` signal (when a `subscribeSignal`
2065
+ * transport is supplied) + window focus/reconnect + a ~60s safety poll → a single-flight
2066
+ * re-introspect that updates reactive `useUser()`. `useAuthzChanged` lets an app with its own guard
2067
+ * layer react to a change (FR-REACT-1). Apps that gate on `useUser()` re-guard automatically
2068
+ * (FR-REACT-2) — they need no callback.
2069
+ *
2070
+ * The `subscribeSignal` transport is optional: without it the passive triggers still converge
2071
+ * (FR-SIG-4). The default poly-alert transport is provided separately (a later slice) once the
2072
+ * subscription-credential custody decision lands.
2073
+ */
2074
+ const changeListeners = /* @__PURE__ */ new Set();
2075
+ function emitChanged() {
2076
+ for (const listener of changeListeners) listener();
2077
+ }
2078
+ /**
2079
+ * Start the live-authz orchestrator for this app's BFF session. Mount once (e.g. in the app root).
2080
+ * Returns nothing; drives `useUser()` reactivity + fires `useAuthzChanged` callbacks on a change.
2081
+ */
2082
+ function useLiveAuthz(options = {}) {
2083
+ const { subscribeSignal, pollMs, focusThrottleMs } = options;
2084
+ useEffect(() => {
2085
+ const store = getBffSessionStore();
2086
+ const handle = startLiveAuthz({
2087
+ refresh: () => store.refresh(),
2088
+ onChanged: emitChanged,
2089
+ subscribeSignal,
2090
+ pollMs,
2091
+ focusThrottleMs
2092
+ });
2093
+ return () => handle.stop();
2094
+ }, [
2095
+ subscribeSignal,
2096
+ pollMs,
2097
+ focusThrottleMs
2098
+ ]);
2099
+ }
2100
+ /**
2101
+ * Register a callback fired when the user's authorization changes (FR-REACT-1) — for apps that keep
2102
+ * their own ability/route model and need to re-run their guard or refetch view data. Apps that gate
2103
+ * on `useUser()` do not need this. Requires `useLiveAuthz` to be mounted somewhere in the tree.
2104
+ */
2105
+ function useAuthzChanged(callback) {
2106
+ useEffect(() => {
2107
+ changeListeners.add(callback);
2108
+ return () => {
2109
+ changeListeners.delete(callback);
2110
+ };
2111
+ }, [callback]);
2112
+ }
2113
+ //#endregion
1929
2114
  //#region src/index.ts
1930
2115
  /**
1931
2116
  * @poly-x/react — PolyX SDK for React SPAs. Sign-in UI + orchestration land in
@@ -1934,4 +2119,4 @@ const UserButton = Object.assign(UserButtonRoot, {
1934
2119
  const PACKAGE_NAME = "@poly-x/react";
1935
2120
  const version = "0.0.0";
1936
2121
  //#endregion
1937
- export { AuthCallback, BroadcastAuthChannel, BroadcastChannelSessionChannel, ForgotPassword, PACKAGE_NAME, PolyXContext, PolyXProvider, PopupCancelledError, Protected, ResetPassword, SessionStoragePkceStore, SignIn, UserAvatar, UserButton, WebLocksLock, WindowBrowserBridge, capturePreciseLocation, createLoginController, initialsFrom, resolveSide, useAuth, useAuthCallback, useSession, useUser, version };
2122
+ export { AuthCallback, BroadcastAuthChannel, BroadcastChannelSessionChannel, ForgotPassword, PACKAGE_NAME, PolyXContext, PolyXProvider, PopupCancelledError, Protected, ResetPassword, SessionStoragePkceStore, SignIn, UserAvatar, UserButton, WebLocksLock, WindowBrowserBridge, capturePreciseLocation, createLoginController, initialsFrom, resolveSide, startLiveAuthz, useAuth, useAuthCallback, useAuthzChanged, useLiveAuthz, useSession, useUser, version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poly-x/react",
3
- "version": "0.1.0-alpha.17",
3
+ "version": "0.1.0-alpha.19",
4
4
  "description": "PolyX SDK for React SPAs - provider, hooks, drop-in auth UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "module": "./dist/index.mjs",
28
28
  "types": "./dist/index.d.cts",
29
29
  "dependencies": {
30
- "@poly-x/core": "0.1.0-alpha.17"
30
+ "@poly-x/core": "0.1.0-alpha.19"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "react": ">=18",