@poly-x/react 0.2.0 → 0.3.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/README.md CHANGED
@@ -72,11 +72,107 @@ and it lives in the PolyX console with the permission model and audit trail.
72
72
  Email, username and password are likewise excluded: each has its own verification
73
73
  path that a profile form must not route around.
74
74
 
75
+ ## Signing in silently, and switching workspace
76
+
77
+ Someone already signed into another application on the same domain should not be asked for a
78
+ password again. `useWarmHop()` tries that once when your sign-in screen mounts:
79
+
80
+ ```tsx
81
+ import { useWarmHop, SignIn } from "@poly-x/react";
82
+
83
+ export default function SignInPage() {
84
+ const hop = useWarmHop();
85
+ if (hop.status !== "settled") return null; // never a half-drawn form
86
+ if (hop.result.status === "signed_in") router.replace("/");
87
+ return <SignIn />; // the ordinary path
88
+ }
89
+ ```
90
+
91
+ A failed attempt is invisible on purpose. Someone who is genuinely signed out pays the whole
92
+ cost of this and gains nothing from it, so there is a hard timeout, nothing in the flow
93
+ navigates, and every failure — offline, slow platform, unexpected response — ends at "show the
94
+ form" rather than at an exception or a spinner.
95
+
96
+ The attempt runs **once per tab**. The marker lives in `sessionStorage` rather than component
97
+ state because the fall-back renders the sign-in page, which is a route change in most apps — a
98
+ guard in state would reset at exactly the moment it needs to hold. Call `resetWarmHop()` after
99
+ signing out so the next sign-in may try again.
100
+
101
+ ### Switching workspace
102
+
103
+ Someone with access to several workspaces can move between them without a password:
104
+
105
+ ```tsx
106
+ import { switchWorkspace } from "@poly-x/react";
107
+
108
+ const result = await switchWorkspace(tenantId);
109
+ if (result.status === "signed_in") router.refresh(); // now scoped to that workspace
110
+ if (result.status === "no_access") showNoAccess();
111
+ ```
112
+
113
+ When someone has more than one workspace and none was named, the settled result is
114
+ `{ status: "select_workspace", workspaces }` — the SDK never picks for you, because putting
115
+ someone to work in the wrong tenant without their noticing is worse than asking:
116
+
117
+ ```tsx
118
+ const hop = useWarmHop();
119
+ if (hop.status === "settled" && hop.result.status === "select_workspace") {
120
+ return <WorkspacePicker options={hop.result.workspaces} onPick={switchWorkspace} />;
121
+ }
122
+ ```
123
+
124
+ Entitlement is decided **by the platform at the moment of the switch**, not from the list you are
125
+ holding, so a workspace revoked since you drew the picker never lets them in. What you get back
126
+ depends on what is left: still hold other workspaces and you get `select_workspace` listing them;
127
+ lost them all and you get `no_access`. Either way, not entry — so do not cache the decision in
128
+ front of it.
129
+
130
+ ### When the workspace you asked for is quietly ignored
131
+
132
+ Worth knowing, because success is not always what it looks like. PolyX only honours a workspace
133
+ hint when the person has **more than one** workspace for that application. With exactly one, it
134
+ ignores the hint and seats them there anyway — answering an ordinary success. Nobody gains access
135
+ they are not entitled to, but they can end up **working in the wrong tenant without noticing**,
136
+ which is the failure the picker exists to prevent.
137
+
138
+ The SDK will not relay that as plain success. Pass a **tenant id** and land somewhere else, and you
139
+ get `workspace_mismatch` carrying both:
140
+
141
+ ```tsx
142
+ const result = await switchWorkspace(tenantId);
143
+ if (result.status === "workspace_mismatch") {
144
+ // result.requested — what you asked for
145
+ // result.workspace — where they actually are now
146
+ }
147
+ ```
148
+
149
+ The session is left in place: it is valid, and you may be perfectly happy with it. What to do about
150
+ it is your call, not the SDK's.
151
+
152
+ An **organization code or slug** cannot be compared against what a session carries, so no mismatch
153
+ is claimed for those — guessing would invent failures. Instead **every** successful result names
154
+ the workspace actually entered, so you can check for yourself:
155
+
156
+ ```tsx
157
+ if (result.status === "signed_in" && result.workspace !== expected) { /* … */ }
158
+ ```
159
+
160
+ Switching does not navigate or reload — the SDK cannot know what is unsaved on your screen, so
161
+ what happens next is yours to decide.
162
+
163
+ ### The boundary
164
+
165
+ Silent sign-on works between applications that **share the ecosystem cookie's domain**.
166
+ Browsers block third-party cookies by default, so an application on an unrelated domain falls
167
+ back to ordinary sign-in. That is browser policy, not something to configure around.
168
+ *Switching workspace is unaffected* — it happens inside one application.
169
+
75
170
  ## API
76
171
 
77
172
  `PolyXProvider` · `useAuth` (`isLoaded`, `isSignedIn`, `signIn`, `signOut`,
78
173
  `getToken`) · `useUser` · `useSession` · `<SignIn>` · `<Protected>` ·
79
- `<UserButton>` · `<UserProfile>` · `<AuthCallback>`. Sign-in defaults to a popup;
80
- pass `mode="redirect"` for a full-page flow.
174
+ `<UserButton>` · `<UserProfile>` · `<AuthCallback>` · `useWarmHop` ·
175
+ `attemptWarmHop` · `switchWorkspace` · `resetWarmHop`. Sign-in defaults to a
176
+ popup; pass `mode="redirect"` for a full-page flow.
81
177
 
82
178
  MIT licensed.
package/dist/index.cjs CHANGED
@@ -200,7 +200,7 @@ var BroadcastChannelSessionChannel = class {
200
200
  //#endregion
201
201
  //#region src/seams/session-storage-pkce.ts
202
202
  const KEY_PREFIX = "poly-x.pkce.";
203
- function hasSessionStorage() {
203
+ function hasSessionStorage$1() {
204
204
  try {
205
205
  return typeof sessionStorage !== "undefined";
206
206
  } catch {
@@ -216,7 +216,7 @@ function hasSessionStorage() {
216
216
  var SessionStoragePkceStore = class {
217
217
  fallback;
218
218
  constructor() {
219
- if (!hasSessionStorage()) this.fallback = new _poly_x_core.InMemoryPkceStore();
219
+ if (!hasSessionStorage$1()) this.fallback = new _poly_x_core.InMemoryPkceStore();
220
220
  }
221
221
  save(entry) {
222
222
  if (this.fallback) return this.fallback.save(entry);
@@ -481,6 +481,198 @@ function getBffSessionStore() {
481
481
  return store;
482
482
  }
483
483
  //#endregion
484
+ //#region src/warm-hop.ts
485
+ /**
486
+ * Warm hop — the browser half (F027/F028 · FR-HOP-1…6, FR-FALL-1…3, FR-SWITCH-1…7).
487
+ *
488
+ * The probe is made **from here, by the browser**, and that is not a style choice. The
489
+ * platform resolves the ecosystem session from a cookie on its own domain; a server-side
490
+ * request carries no browser cookie, so it would be told "sign-in required" every time and
491
+ * the whole feature would look built while never working. `@poly-x/core` refuses the
492
+ * server-side call outright for that reason.
493
+ *
494
+ * The three steps, and why the credential stays safe throughout:
495
+ *
496
+ * 1. Ask our own BFF to prepare (`/api/polyx/hop`). It mints PKCE and keeps the verifier
497
+ * in an httpOnly cookie; we receive only a URL.
498
+ * 2. Call that URL with `credentials:"include"` so the browser attaches the ecosystem
499
+ * cookie. This is the one request that must not come from a server.
500
+ * 3. Hand the resulting `code` back to our BFF (`/api/polyx/hop-complete`), which does the
501
+ * exchange. The code is worthless to anyone who intercepts it — the PKCE verifier that
502
+ * redeems it never left the server — so a page holding it briefly is not a custody leak.
503
+ *
504
+ * Nothing here navigates. That matters beyond tidiness: this exact flow produced a redirect
505
+ * loop in a hand-rolled implementation once already, and a fetch cannot loop.
506
+ */
507
+ const HOP_ENDPOINT = "/api/polyx/hop";
508
+ const HOP_COMPLETE_ENDPOINT = "/api/polyx/hop-complete";
509
+ /**
510
+ * How long to wait for the silent attempt before giving up and showing the form.
511
+ *
512
+ * A guess, and labelled as one. The requirement it serves (FR-FALL-2) is that a slow or
513
+ * dead platform must never leave someone watching a spinner — and for that, *any* bound
514
+ * beats none. The person this protects is already signed out and gains nothing from
515
+ * waiting, so the bound is deliberately short. Override it if you have measured yours.
516
+ */
517
+ const WARM_HOP_TIMEOUT_MS = 5e3;
518
+ /**
519
+ * Survives navigation on purpose (FR-FALL-3).
520
+ *
521
+ * The fall-back from a failed attempt renders the sign-in page, which in most apps is a
522
+ * route change — so a guard held in React state or a module variable is reset exactly when
523
+ * it is needed. `sessionStorage` is scoped to the tab and cleared when it closes, which is
524
+ * the same lifetime as "this entry".
525
+ */
526
+ const ATTEMPTED_KEY = "polyx.warmHop.attempted";
527
+ function hasSessionStorage() {
528
+ try {
529
+ return typeof sessionStorage !== "undefined";
530
+ } catch {
531
+ return false;
532
+ }
533
+ }
534
+ /** Has an automatic attempt already run in this tab? */
535
+ function warmHopAttempted() {
536
+ if (!hasSessionStorage()) return false;
537
+ try {
538
+ return sessionStorage.getItem(ATTEMPTED_KEY) === "1";
539
+ } catch {
540
+ return false;
541
+ }
542
+ }
543
+ function markWarmHopAttempted() {
544
+ if (!hasSessionStorage()) return;
545
+ try {
546
+ sessionStorage.setItem(ATTEMPTED_KEY, "1");
547
+ } catch {}
548
+ }
549
+ /** Clear the once-per-entry marker. Exported for sign-out, so the next sign-in may try again. */
550
+ function resetWarmHop() {
551
+ if (!hasSessionStorage()) return;
552
+ try {
553
+ sessionStorage.removeItem(ATTEMPTED_KEY);
554
+ } catch {}
555
+ }
556
+ async function postJson(url, body, signal) {
557
+ return fetch(url, {
558
+ method: "POST",
559
+ headers: { "content-type": "application/json" },
560
+ body: JSON.stringify(body ?? {}),
561
+ signal,
562
+ credentials: "same-origin"
563
+ });
564
+ }
565
+ /**
566
+ * Run one silent attempt and report what the platform said.
567
+ *
568
+ * Never throws: every failure resolves to `failed`, because a caller in the middle of
569
+ * rendering a sign-in page has no better answer than "show the form" and an exception
570
+ * there is how a blank screen happens.
571
+ */
572
+ async function attemptWarmHop(options = {}) {
573
+ if (typeof window === "undefined") return {
574
+ status: "failed",
575
+ reason: "warm hop attempted outside a browser"
576
+ };
577
+ const controller = new AbortController();
578
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 5e3);
579
+ try {
580
+ const prepared = await postJson(HOP_ENDPOINT, options.workspace ? { tenantId: options.workspace } : {}, controller.signal);
581
+ if (!prepared.ok) return {
582
+ status: "failed",
583
+ reason: `hop preparation failed (${prepared.status})`
584
+ };
585
+ const { authorizeUrl } = await prepared.json();
586
+ if (!authorizeUrl) return {
587
+ status: "failed",
588
+ reason: "hop preparation returned no authorize URL"
589
+ };
590
+ const probe = await fetch(authorizeUrl, {
591
+ method: "GET",
592
+ credentials: "include",
593
+ headers: { accept: "application/json" },
594
+ signal: controller.signal
595
+ });
596
+ const body = await probe.json().catch(() => ({}));
597
+ const outcome = (0, _poly_x_core.parseAuthorizeOutcome)(probe.status, body);
598
+ switch (outcome.type) {
599
+ case "login_required": return { status: "sign_in_required" };
600
+ case "no_access": return { status: "no_access" };
601
+ case "select_tenant": return {
602
+ status: "select_workspace",
603
+ workspaces: outcome.tenants
604
+ };
605
+ case "authorized": {
606
+ const completed = await postJson(HOP_COMPLETE_ENDPOINT, {
607
+ code: outcome.code,
608
+ state: outcome.state
609
+ }, controller.signal);
610
+ const settled = await completed.json().catch(() => ({}));
611
+ if (completed.ok) {
612
+ if (settled.status === "workspace_mismatch" && settled.requested && settled.workspace) return {
613
+ status: "workspace_mismatch",
614
+ requested: settled.requested,
615
+ workspace: settled.workspace
616
+ };
617
+ return {
618
+ status: "signed_in",
619
+ ...settled.workspace ? { workspace: settled.workspace } : {}
620
+ };
621
+ }
622
+ return {
623
+ status: "failed",
624
+ reason: settled.status ?? `exchange failed (${completed.status})`
625
+ };
626
+ }
627
+ }
628
+ } catch (error) {
629
+ return {
630
+ status: "failed",
631
+ reason: error instanceof Error ? error.message : "warm hop failed"
632
+ };
633
+ } finally {
634
+ clearTimeout(timeout);
635
+ }
636
+ }
637
+ /**
638
+ * Move to another workspace (F028 / FR-SWITCH-1…6).
639
+ *
640
+ * The same mechanism as the entry attempt, aimed deliberately rather than on arrival — and
641
+ * deliberately **not** subject to the once-per-entry guard, because this one is a thing the
642
+ * person asked for.
643
+ *
644
+ * Eligibility is decided by the platform at this moment, not from any list held here, so a
645
+ * workspace revoked since the picker was drawn can never be entered (FR-SWITCH-4). Which
646
+ * refusal you get depends on what is left, verified against a live platform: still holding
647
+ * other workspaces gives `select_workspace` listing them; having lost them all gives
648
+ * `no_access`. Do not treat `no_access` as the only refusal.
649
+ *
650
+ * On `signed_in` the session cookie now names the new workspace. The caller decides what to
651
+ * do next — the SDK does not reload the page or discard anything on their behalf, because it
652
+ * cannot know what is unsaved on the screen (FR-SWITCH-7).
653
+ */
654
+ async function switchWorkspace(workspace, options = {}) {
655
+ if (!workspace) return {
656
+ status: "failed",
657
+ reason: "no workspace given"
658
+ };
659
+ return attemptWarmHop({
660
+ ...options,
661
+ workspace
662
+ });
663
+ }
664
+ /**
665
+ * The automatic entry attempt: at most once per tab, whatever the caller does (FR-HOP-2).
666
+ *
667
+ * Returns `null` when it declines to attempt — already tried this entry — so a caller can
668
+ * tell "we tried and they must sign in" from "we did not try".
669
+ */
670
+ async function attemptWarmHopOnce(options = {}) {
671
+ if (warmHopAttempted()) return null;
672
+ markWarmHopAttempted();
673
+ return attemptWarmHop(options);
674
+ }
675
+ //#endregion
484
676
  //#region src/hooks.ts
485
677
  function useSessionSource() {
486
678
  const ctx = (0, react.useContext)(PolyXContext);
@@ -550,6 +742,52 @@ function useUser() {
550
742
  const state = useSession();
551
743
  return state.status === "authenticated" || state.status === "refreshing" ? state.session.claims : null;
552
744
  }
745
+ /**
746
+ * Try a silent sign-in once when this component mounts (F027 / FR-HOP-2, FR-FALL-1…3).
747
+ *
748
+ * The contract that matters is D11's: **an attempt that fails must be invisible.** Someone who
749
+ * is genuinely signed out pays the whole cost of this feature and gets nothing from it, so they
750
+ * must not see a flicker, a spinner that outlives their patience, or — worst — a redirect loop.
751
+ * Hence: no navigation anywhere in the flow, a hard timeout, and a guard that survives the route
752
+ * change to the sign-in page (a guard in React state would reset exactly when it is needed).
753
+ *
754
+ * ```tsx
755
+ * const hop = useWarmHop();
756
+ * if (hop.status !== "settled" ) return null; // no half-drawn form
757
+ * if (hop.result.status === "signed_in") return <Redirect/>;
758
+ * return <SignIn />; // the ordinary path
759
+ * ```
760
+ *
761
+ * Strict Mode's double-mount does not produce two attempts: the marker is written before the
762
+ * first request goes out.
763
+ */
764
+ function useWarmHop(options = {}) {
765
+ const { enabled = true, workspace, timeoutMs } = options;
766
+ const [state, setState] = (0, react.useState)({ status: "idle" });
767
+ (0, react.useEffect)(() => {
768
+ if (!enabled) return;
769
+ let live = true;
770
+ setState({ status: "attempting" });
771
+ attemptWarmHopOnce({
772
+ workspace,
773
+ timeoutMs
774
+ }).then((result) => {
775
+ if (!live) return;
776
+ setState({
777
+ status: "settled",
778
+ result: result ?? { status: "sign_in_required" }
779
+ });
780
+ });
781
+ return () => {
782
+ live = false;
783
+ };
784
+ }, [
785
+ enabled,
786
+ workspace,
787
+ timeoutMs
788
+ ]);
789
+ return state;
790
+ }
553
791
  //#endregion
554
792
  //#region src/components/protected.tsx
555
793
  /** Gate content on authentication state without flashing the wrong branch. */
@@ -2681,7 +2919,7 @@ function useAuthzChanged(callback) {
2681
2919
  */
2682
2920
  const PACKAGE_NAME = "@poly-x/react";
2683
2921
  /** The published version of this package, injected at build time. */
2684
- const version = "0.2.0";
2922
+ const version = "0.3.0";
2685
2923
  //#endregion
2686
2924
  exports.AuthCallback = AuthCallback;
2687
2925
  exports.BroadcastAuthChannel = BroadcastAuthChannel;
@@ -2698,18 +2936,25 @@ exports.SignIn = SignIn;
2698
2936
  exports.UserAvatar = UserAvatar;
2699
2937
  exports.UserButton = UserButton;
2700
2938
  exports.UserProfile = UserProfile;
2939
+ exports.WARM_HOP_TIMEOUT_MS = WARM_HOP_TIMEOUT_MS;
2701
2940
  exports.WebLocksLock = WebLocksLock;
2702
2941
  exports.WindowBrowserBridge = WindowBrowserBridge;
2942
+ exports.attemptWarmHop = attemptWarmHop;
2943
+ exports.attemptWarmHopOnce = attemptWarmHopOnce;
2703
2944
  exports.capturePreciseLocation = capturePreciseLocation;
2704
2945
  exports.consumeAuthEvent = consumeAuthEvent;
2705
2946
  exports.createLoginController = createLoginController;
2706
2947
  exports.initialsFrom = initialsFrom;
2948
+ exports.resetWarmHop = resetWarmHop;
2707
2949
  exports.resolveSide = resolveSide;
2708
2950
  exports.startLiveAuthz = startLiveAuthz;
2951
+ exports.switchWorkspace = switchWorkspace;
2709
2952
  exports.useAuth = useAuth;
2710
2953
  exports.useAuthCallback = useAuthCallback;
2711
2954
  exports.useAuthzChanged = useAuthzChanged;
2712
2955
  exports.useLiveAuthz = useLiveAuthz;
2713
2956
  exports.useSession = useSession;
2714
2957
  exports.useUser = useUser;
2958
+ exports.useWarmHop = useWarmHop;
2715
2959
  exports.version = version;
2960
+ exports.warmHopAttempted = warmHopAttempted;
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ReactNode } from "react";
2
- import { AuthClient, Lock, PkceEntry, PkceStore, SessionChannel, SessionChannelEvent, SessionClaims, SessionEngine, SessionState } from "@poly-x/core";
2
+ import { AuthClient, Lock, PkceEntry, PkceStore, SessionChannel, SessionChannelEvent, SessionClaims, SessionEngine, SessionState, TenantOption } from "@poly-x/core";
3
3
 
4
4
  //#region src/provider.d.ts
5
5
  interface PolyXProviderProps {
@@ -122,6 +122,95 @@ declare class PopupCancelledError extends Error {
122
122
  }
123
123
  declare function createLoginController(deps: LoginControllerDeps): LoginController;
124
124
  //#endregion
125
+ //#region src/warm-hop.d.ts
126
+ /**
127
+ * How long to wait for the silent attempt before giving up and showing the form.
128
+ *
129
+ * A guess, and labelled as one. The requirement it serves (FR-FALL-2) is that a slow or
130
+ * dead platform must never leave someone watching a spinner — and for that, *any* bound
131
+ * beats none. The person this protects is already signed out and gains nothing from
132
+ * waiting, so the bound is deliberately short. Override it if you have measured yours.
133
+ */
134
+ declare const WARM_HOP_TIMEOUT_MS = 5000;
135
+ type WarmHopResult = /** Signed in. The session cookie is set; `workspace` names the tenant actually entered. */{
136
+ status: "signed_in";
137
+ workspace?: string;
138
+ }
139
+ /**
140
+ * Signed in — but **not** where you asked.
141
+ *
142
+ * The platform only honours a workspace hint when the person has more than one candidate for
143
+ * that application; with exactly one it seats them there regardless and still answers
144
+ * "authorized". The session is valid and the person is somewhere they are entitled to be, so
145
+ * this is not a failure — but it is not what was requested, and being silently put to work in
146
+ * the wrong tenant is worse than being asked. Decide deliberately: accept it, or send them to
147
+ * a picker.
148
+ */
149
+ | {
150
+ status: "workspace_mismatch";
151
+ requested: string;
152
+ workspace: string;
153
+ } /** Several workspaces are available and none was requested — ask, never guess (D14). */ | {
154
+ status: "select_workspace";
155
+ workspaces: readonly TenantOption[];
156
+ } /** Recognised, but not entitled to this application or workspace (D15). */ | {
157
+ status: "no_access";
158
+ } /** Not recognised. The ordinary signed-out path — not an error. */ | {
159
+ status: "sign_in_required";
160
+ } /** The attempt could not be completed. Treated as signed-out by callers; surfaced for logs. */ | {
161
+ status: "failed";
162
+ reason: string;
163
+ };
164
+ /** Has an automatic attempt already run in this tab? */
165
+ declare function warmHopAttempted(): boolean;
166
+ /** Clear the once-per-entry marker. Exported for sign-out, so the next sign-in may try again. */
167
+ declare function resetWarmHop(): void;
168
+ interface WarmHopOptions {
169
+ /**
170
+ * Pin a workspace by tenant id, organization code or slug.
171
+ *
172
+ * The platform matches this **only within the person's own memberships**, so it can
173
+ * narrow a choice but never widen access — which is why passing it is safe even when the
174
+ * value came from something the page was holding.
175
+ */
176
+ workspace?: string;
177
+ /** Override the fall-back bound. See `WARM_HOP_TIMEOUT_MS`. */
178
+ timeoutMs?: number;
179
+ }
180
+ /**
181
+ * Run one silent attempt and report what the platform said.
182
+ *
183
+ * Never throws: every failure resolves to `failed`, because a caller in the middle of
184
+ * rendering a sign-in page has no better answer than "show the form" and an exception
185
+ * there is how a blank screen happens.
186
+ */
187
+ declare function attemptWarmHop(options?: WarmHopOptions): Promise<WarmHopResult>;
188
+ /**
189
+ * Move to another workspace (F028 / FR-SWITCH-1…6).
190
+ *
191
+ * The same mechanism as the entry attempt, aimed deliberately rather than on arrival — and
192
+ * deliberately **not** subject to the once-per-entry guard, because this one is a thing the
193
+ * person asked for.
194
+ *
195
+ * Eligibility is decided by the platform at this moment, not from any list held here, so a
196
+ * workspace revoked since the picker was drawn can never be entered (FR-SWITCH-4). Which
197
+ * refusal you get depends on what is left, verified against a live platform: still holding
198
+ * other workspaces gives `select_workspace` listing them; having lost them all gives
199
+ * `no_access`. Do not treat `no_access` as the only refusal.
200
+ *
201
+ * On `signed_in` the session cookie now names the new workspace. The caller decides what to
202
+ * do next — the SDK does not reload the page or discard anything on their behalf, because it
203
+ * cannot know what is unsaved on the screen (FR-SWITCH-7).
204
+ */
205
+ declare function switchWorkspace(workspace: string, options?: WarmHopOptions): Promise<WarmHopResult>;
206
+ /**
207
+ * The automatic entry attempt: at most once per tab, whatever the caller does (FR-HOP-2).
208
+ *
209
+ * Returns `null` when it declines to attempt — already tried this entry — so a caller can
210
+ * tell "we tried and they must sign in" from "we did not try".
211
+ */
212
+ declare function attemptWarmHopOnce(options?: WarmHopOptions): Promise<WarmHopResult | null>;
213
+ //#endregion
125
214
  //#region src/hooks.d.ts
126
215
  /** The raw session state machine value — tearing-free under concurrent React. */
127
216
  declare function useSession(): SessionState;
@@ -151,6 +240,37 @@ declare function useAuth(): UseAuth;
151
240
  /** Run this on your callback route to complete a sign-in (see `<AuthCallback>`). */
152
241
  declare function useAuthCallback(): () => Promise<void>;
153
242
  declare function useUser(): SessionClaims | null;
243
+ /** What `useWarmHop` reports while and after it tries. */
244
+ type WarmHopState = {
245
+ status: "idle";
246
+ } | {
247
+ status: "attempting";
248
+ } | {
249
+ status: "settled";
250
+ result: WarmHopResult;
251
+ };
252
+ /**
253
+ * Try a silent sign-in once when this component mounts (F027 / FR-HOP-2, FR-FALL-1…3).
254
+ *
255
+ * The contract that matters is D11's: **an attempt that fails must be invisible.** Someone who
256
+ * is genuinely signed out pays the whole cost of this feature and gets nothing from it, so they
257
+ * must not see a flicker, a spinner that outlives their patience, or — worst — a redirect loop.
258
+ * Hence: no navigation anywhere in the flow, a hard timeout, and a guard that survives the route
259
+ * change to the sign-in page (a guard in React state would reset exactly when it is needed).
260
+ *
261
+ * ```tsx
262
+ * const hop = useWarmHop();
263
+ * if (hop.status !== "settled" ) return null; // no half-drawn form
264
+ * if (hop.result.status === "signed_in") return <Redirect/>;
265
+ * return <SignIn />; // the ordinary path
266
+ * ```
267
+ *
268
+ * Strict Mode's double-mount does not produce two attempts: the marker is written before the
269
+ * first request goes out.
270
+ */
271
+ declare function useWarmHop(options?: WarmHopOptions & {
272
+ enabled?: boolean;
273
+ }): WarmHopState;
154
274
  //#endregion
155
275
  //#region src/context.d.ts
156
276
  interface PolyXContextValue {
@@ -806,4 +926,4 @@ declare const PACKAGE_NAME = "@poly-x/react";
806
926
  /** The published version of this package, injected at build time. */
807
927
  declare const version: string;
808
928
  //#endregion
809
- export { type Align, AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, type AuthEventKind, 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, UserProfile, type UserProfileLabels, type UserProfileProps, WebLocksLock, WindowBrowserBridge, capturePreciseLocation, consumeAuthEvent, createLoginController, initialsFrom, resolveSide, startLiveAuthz, useAuth, useAuthCallback, useAuthzChanged, useLiveAuthz, useSession, useUser, version };
929
+ export { type Align, AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, type AuthEventKind, 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, UserProfile, type UserProfileLabels, type UserProfileProps, WARM_HOP_TIMEOUT_MS, type WarmHopOptions, type WarmHopResult, type WarmHopState, WebLocksLock, WindowBrowserBridge, attemptWarmHop, attemptWarmHopOnce, capturePreciseLocation, consumeAuthEvent, createLoginController, initialsFrom, resetWarmHop, resolveSide, startLiveAuthz, switchWorkspace, useAuth, useAuthCallback, useAuthzChanged, useLiveAuthz, useSession, useUser, useWarmHop, version, warmHopAttempted };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { AuthClient, Lock, PkceEntry, PkceStore, SessionChannel, SessionChannelEvent, SessionClaims, SessionEngine, SessionState } from "@poly-x/core";
1
+ import { AuthClient, Lock, PkceEntry, PkceStore, SessionChannel, SessionChannelEvent, SessionClaims, SessionEngine, SessionState, TenantOption } from "@poly-x/core";
2
2
  import { ReactNode } from "react";
3
3
 
4
4
  //#region src/provider.d.ts
@@ -122,6 +122,95 @@ declare class PopupCancelledError extends Error {
122
122
  }
123
123
  declare function createLoginController(deps: LoginControllerDeps): LoginController;
124
124
  //#endregion
125
+ //#region src/warm-hop.d.ts
126
+ /**
127
+ * How long to wait for the silent attempt before giving up and showing the form.
128
+ *
129
+ * A guess, and labelled as one. The requirement it serves (FR-FALL-2) is that a slow or
130
+ * dead platform must never leave someone watching a spinner — and for that, *any* bound
131
+ * beats none. The person this protects is already signed out and gains nothing from
132
+ * waiting, so the bound is deliberately short. Override it if you have measured yours.
133
+ */
134
+ declare const WARM_HOP_TIMEOUT_MS = 5000;
135
+ type WarmHopResult = /** Signed in. The session cookie is set; `workspace` names the tenant actually entered. */{
136
+ status: "signed_in";
137
+ workspace?: string;
138
+ }
139
+ /**
140
+ * Signed in — but **not** where you asked.
141
+ *
142
+ * The platform only honours a workspace hint when the person has more than one candidate for
143
+ * that application; with exactly one it seats them there regardless and still answers
144
+ * "authorized". The session is valid and the person is somewhere they are entitled to be, so
145
+ * this is not a failure — but it is not what was requested, and being silently put to work in
146
+ * the wrong tenant is worse than being asked. Decide deliberately: accept it, or send them to
147
+ * a picker.
148
+ */
149
+ | {
150
+ status: "workspace_mismatch";
151
+ requested: string;
152
+ workspace: string;
153
+ } /** Several workspaces are available and none was requested — ask, never guess (D14). */ | {
154
+ status: "select_workspace";
155
+ workspaces: readonly TenantOption[];
156
+ } /** Recognised, but not entitled to this application or workspace (D15). */ | {
157
+ status: "no_access";
158
+ } /** Not recognised. The ordinary signed-out path — not an error. */ | {
159
+ status: "sign_in_required";
160
+ } /** The attempt could not be completed. Treated as signed-out by callers; surfaced for logs. */ | {
161
+ status: "failed";
162
+ reason: string;
163
+ };
164
+ /** Has an automatic attempt already run in this tab? */
165
+ declare function warmHopAttempted(): boolean;
166
+ /** Clear the once-per-entry marker. Exported for sign-out, so the next sign-in may try again. */
167
+ declare function resetWarmHop(): void;
168
+ interface WarmHopOptions {
169
+ /**
170
+ * Pin a workspace by tenant id, organization code or slug.
171
+ *
172
+ * The platform matches this **only within the person's own memberships**, so it can
173
+ * narrow a choice but never widen access — which is why passing it is safe even when the
174
+ * value came from something the page was holding.
175
+ */
176
+ workspace?: string;
177
+ /** Override the fall-back bound. See `WARM_HOP_TIMEOUT_MS`. */
178
+ timeoutMs?: number;
179
+ }
180
+ /**
181
+ * Run one silent attempt and report what the platform said.
182
+ *
183
+ * Never throws: every failure resolves to `failed`, because a caller in the middle of
184
+ * rendering a sign-in page has no better answer than "show the form" and an exception
185
+ * there is how a blank screen happens.
186
+ */
187
+ declare function attemptWarmHop(options?: WarmHopOptions): Promise<WarmHopResult>;
188
+ /**
189
+ * Move to another workspace (F028 / FR-SWITCH-1…6).
190
+ *
191
+ * The same mechanism as the entry attempt, aimed deliberately rather than on arrival — and
192
+ * deliberately **not** subject to the once-per-entry guard, because this one is a thing the
193
+ * person asked for.
194
+ *
195
+ * Eligibility is decided by the platform at this moment, not from any list held here, so a
196
+ * workspace revoked since the picker was drawn can never be entered (FR-SWITCH-4). Which
197
+ * refusal you get depends on what is left, verified against a live platform: still holding
198
+ * other workspaces gives `select_workspace` listing them; having lost them all gives
199
+ * `no_access`. Do not treat `no_access` as the only refusal.
200
+ *
201
+ * On `signed_in` the session cookie now names the new workspace. The caller decides what to
202
+ * do next — the SDK does not reload the page or discard anything on their behalf, because it
203
+ * cannot know what is unsaved on the screen (FR-SWITCH-7).
204
+ */
205
+ declare function switchWorkspace(workspace: string, options?: WarmHopOptions): Promise<WarmHopResult>;
206
+ /**
207
+ * The automatic entry attempt: at most once per tab, whatever the caller does (FR-HOP-2).
208
+ *
209
+ * Returns `null` when it declines to attempt — already tried this entry — so a caller can
210
+ * tell "we tried and they must sign in" from "we did not try".
211
+ */
212
+ declare function attemptWarmHopOnce(options?: WarmHopOptions): Promise<WarmHopResult | null>;
213
+ //#endregion
125
214
  //#region src/hooks.d.ts
126
215
  /** The raw session state machine value — tearing-free under concurrent React. */
127
216
  declare function useSession(): SessionState;
@@ -151,6 +240,37 @@ declare function useAuth(): UseAuth;
151
240
  /** Run this on your callback route to complete a sign-in (see `<AuthCallback>`). */
152
241
  declare function useAuthCallback(): () => Promise<void>;
153
242
  declare function useUser(): SessionClaims | null;
243
+ /** What `useWarmHop` reports while and after it tries. */
244
+ type WarmHopState = {
245
+ status: "idle";
246
+ } | {
247
+ status: "attempting";
248
+ } | {
249
+ status: "settled";
250
+ result: WarmHopResult;
251
+ };
252
+ /**
253
+ * Try a silent sign-in once when this component mounts (F027 / FR-HOP-2, FR-FALL-1…3).
254
+ *
255
+ * The contract that matters is D11's: **an attempt that fails must be invisible.** Someone who
256
+ * is genuinely signed out pays the whole cost of this feature and gets nothing from it, so they
257
+ * must not see a flicker, a spinner that outlives their patience, or — worst — a redirect loop.
258
+ * Hence: no navigation anywhere in the flow, a hard timeout, and a guard that survives the route
259
+ * change to the sign-in page (a guard in React state would reset exactly when it is needed).
260
+ *
261
+ * ```tsx
262
+ * const hop = useWarmHop();
263
+ * if (hop.status !== "settled" ) return null; // no half-drawn form
264
+ * if (hop.result.status === "signed_in") return <Redirect/>;
265
+ * return <SignIn />; // the ordinary path
266
+ * ```
267
+ *
268
+ * Strict Mode's double-mount does not produce two attempts: the marker is written before the
269
+ * first request goes out.
270
+ */
271
+ declare function useWarmHop(options?: WarmHopOptions & {
272
+ enabled?: boolean;
273
+ }): WarmHopState;
154
274
  //#endregion
155
275
  //#region src/context.d.ts
156
276
  interface PolyXContextValue {
@@ -806,4 +926,4 @@ declare const PACKAGE_NAME = "@poly-x/react";
806
926
  /** The published version of this package, injected at build time. */
807
927
  declare const version: string;
808
928
  //#endregion
809
- export { type Align, AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, type AuthEventKind, 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, UserProfile, type UserProfileLabels, type UserProfileProps, WebLocksLock, WindowBrowserBridge, capturePreciseLocation, consumeAuthEvent, createLoginController, initialsFrom, resolveSide, startLiveAuthz, useAuth, useAuthCallback, useAuthzChanged, useLiveAuthz, useSession, useUser, version };
929
+ export { type Align, AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, type AuthEventKind, 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, UserProfile, type UserProfileLabels, type UserProfileProps, WARM_HOP_TIMEOUT_MS, type WarmHopOptions, type WarmHopResult, type WarmHopState, WebLocksLock, WindowBrowserBridge, attemptWarmHop, attemptWarmHopOnce, capturePreciseLocation, consumeAuthEvent, createLoginController, initialsFrom, resetWarmHop, resolveSide, startLiveAuthz, switchWorkspace, useAuth, useAuthCallback, useAuthzChanged, useLiveAuthz, useSession, useUser, useWarmHop, version, warmHopAttempted };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { AuthClient, FetchTransport, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, PROFILE_FIELDS, buildProfileUpdate, conflictField, createSessionEngine, generatePkce, randomState, resolveConfig, validateProfileDraft, validateProfileImage } from "@poly-x/core";
2
+ import { AuthClient, FetchTransport, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, PROFILE_FIELDS, buildProfileUpdate, conflictField, createSessionEngine, generatePkce, parseAuthorizeOutcome, randomState, resolveConfig, validateProfileDraft, validateProfileImage } from "@poly-x/core";
3
3
  import { Children, Fragment, createContext, isValidElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
5
5
  import { createPortal } from "react-dom";
@@ -199,7 +199,7 @@ var BroadcastChannelSessionChannel = class {
199
199
  //#endregion
200
200
  //#region src/seams/session-storage-pkce.ts
201
201
  const KEY_PREFIX = "poly-x.pkce.";
202
- function hasSessionStorage() {
202
+ function hasSessionStorage$1() {
203
203
  try {
204
204
  return typeof sessionStorage !== "undefined";
205
205
  } catch {
@@ -215,7 +215,7 @@ function hasSessionStorage() {
215
215
  var SessionStoragePkceStore = class {
216
216
  fallback;
217
217
  constructor() {
218
- if (!hasSessionStorage()) this.fallback = new InMemoryPkceStore();
218
+ if (!hasSessionStorage$1()) this.fallback = new InMemoryPkceStore();
219
219
  }
220
220
  save(entry) {
221
221
  if (this.fallback) return this.fallback.save(entry);
@@ -480,6 +480,198 @@ function getBffSessionStore() {
480
480
  return store;
481
481
  }
482
482
  //#endregion
483
+ //#region src/warm-hop.ts
484
+ /**
485
+ * Warm hop — the browser half (F027/F028 · FR-HOP-1…6, FR-FALL-1…3, FR-SWITCH-1…7).
486
+ *
487
+ * The probe is made **from here, by the browser**, and that is not a style choice. The
488
+ * platform resolves the ecosystem session from a cookie on its own domain; a server-side
489
+ * request carries no browser cookie, so it would be told "sign-in required" every time and
490
+ * the whole feature would look built while never working. `@poly-x/core` refuses the
491
+ * server-side call outright for that reason.
492
+ *
493
+ * The three steps, and why the credential stays safe throughout:
494
+ *
495
+ * 1. Ask our own BFF to prepare (`/api/polyx/hop`). It mints PKCE and keeps the verifier
496
+ * in an httpOnly cookie; we receive only a URL.
497
+ * 2. Call that URL with `credentials:"include"` so the browser attaches the ecosystem
498
+ * cookie. This is the one request that must not come from a server.
499
+ * 3. Hand the resulting `code` back to our BFF (`/api/polyx/hop-complete`), which does the
500
+ * exchange. The code is worthless to anyone who intercepts it — the PKCE verifier that
501
+ * redeems it never left the server — so a page holding it briefly is not a custody leak.
502
+ *
503
+ * Nothing here navigates. That matters beyond tidiness: this exact flow produced a redirect
504
+ * loop in a hand-rolled implementation once already, and a fetch cannot loop.
505
+ */
506
+ const HOP_ENDPOINT = "/api/polyx/hop";
507
+ const HOP_COMPLETE_ENDPOINT = "/api/polyx/hop-complete";
508
+ /**
509
+ * How long to wait for the silent attempt before giving up and showing the form.
510
+ *
511
+ * A guess, and labelled as one. The requirement it serves (FR-FALL-2) is that a slow or
512
+ * dead platform must never leave someone watching a spinner — and for that, *any* bound
513
+ * beats none. The person this protects is already signed out and gains nothing from
514
+ * waiting, so the bound is deliberately short. Override it if you have measured yours.
515
+ */
516
+ const WARM_HOP_TIMEOUT_MS = 5e3;
517
+ /**
518
+ * Survives navigation on purpose (FR-FALL-3).
519
+ *
520
+ * The fall-back from a failed attempt renders the sign-in page, which in most apps is a
521
+ * route change — so a guard held in React state or a module variable is reset exactly when
522
+ * it is needed. `sessionStorage` is scoped to the tab and cleared when it closes, which is
523
+ * the same lifetime as "this entry".
524
+ */
525
+ const ATTEMPTED_KEY = "polyx.warmHop.attempted";
526
+ function hasSessionStorage() {
527
+ try {
528
+ return typeof sessionStorage !== "undefined";
529
+ } catch {
530
+ return false;
531
+ }
532
+ }
533
+ /** Has an automatic attempt already run in this tab? */
534
+ function warmHopAttempted() {
535
+ if (!hasSessionStorage()) return false;
536
+ try {
537
+ return sessionStorage.getItem(ATTEMPTED_KEY) === "1";
538
+ } catch {
539
+ return false;
540
+ }
541
+ }
542
+ function markWarmHopAttempted() {
543
+ if (!hasSessionStorage()) return;
544
+ try {
545
+ sessionStorage.setItem(ATTEMPTED_KEY, "1");
546
+ } catch {}
547
+ }
548
+ /** Clear the once-per-entry marker. Exported for sign-out, so the next sign-in may try again. */
549
+ function resetWarmHop() {
550
+ if (!hasSessionStorage()) return;
551
+ try {
552
+ sessionStorage.removeItem(ATTEMPTED_KEY);
553
+ } catch {}
554
+ }
555
+ async function postJson(url, body, signal) {
556
+ return fetch(url, {
557
+ method: "POST",
558
+ headers: { "content-type": "application/json" },
559
+ body: JSON.stringify(body ?? {}),
560
+ signal,
561
+ credentials: "same-origin"
562
+ });
563
+ }
564
+ /**
565
+ * Run one silent attempt and report what the platform said.
566
+ *
567
+ * Never throws: every failure resolves to `failed`, because a caller in the middle of
568
+ * rendering a sign-in page has no better answer than "show the form" and an exception
569
+ * there is how a blank screen happens.
570
+ */
571
+ async function attemptWarmHop(options = {}) {
572
+ if (typeof window === "undefined") return {
573
+ status: "failed",
574
+ reason: "warm hop attempted outside a browser"
575
+ };
576
+ const controller = new AbortController();
577
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 5e3);
578
+ try {
579
+ const prepared = await postJson(HOP_ENDPOINT, options.workspace ? { tenantId: options.workspace } : {}, controller.signal);
580
+ if (!prepared.ok) return {
581
+ status: "failed",
582
+ reason: `hop preparation failed (${prepared.status})`
583
+ };
584
+ const { authorizeUrl } = await prepared.json();
585
+ if (!authorizeUrl) return {
586
+ status: "failed",
587
+ reason: "hop preparation returned no authorize URL"
588
+ };
589
+ const probe = await fetch(authorizeUrl, {
590
+ method: "GET",
591
+ credentials: "include",
592
+ headers: { accept: "application/json" },
593
+ signal: controller.signal
594
+ });
595
+ const body = await probe.json().catch(() => ({}));
596
+ const outcome = parseAuthorizeOutcome(probe.status, body);
597
+ switch (outcome.type) {
598
+ case "login_required": return { status: "sign_in_required" };
599
+ case "no_access": return { status: "no_access" };
600
+ case "select_tenant": return {
601
+ status: "select_workspace",
602
+ workspaces: outcome.tenants
603
+ };
604
+ case "authorized": {
605
+ const completed = await postJson(HOP_COMPLETE_ENDPOINT, {
606
+ code: outcome.code,
607
+ state: outcome.state
608
+ }, controller.signal);
609
+ const settled = await completed.json().catch(() => ({}));
610
+ if (completed.ok) {
611
+ if (settled.status === "workspace_mismatch" && settled.requested && settled.workspace) return {
612
+ status: "workspace_mismatch",
613
+ requested: settled.requested,
614
+ workspace: settled.workspace
615
+ };
616
+ return {
617
+ status: "signed_in",
618
+ ...settled.workspace ? { workspace: settled.workspace } : {}
619
+ };
620
+ }
621
+ return {
622
+ status: "failed",
623
+ reason: settled.status ?? `exchange failed (${completed.status})`
624
+ };
625
+ }
626
+ }
627
+ } catch (error) {
628
+ return {
629
+ status: "failed",
630
+ reason: error instanceof Error ? error.message : "warm hop failed"
631
+ };
632
+ } finally {
633
+ clearTimeout(timeout);
634
+ }
635
+ }
636
+ /**
637
+ * Move to another workspace (F028 / FR-SWITCH-1…6).
638
+ *
639
+ * The same mechanism as the entry attempt, aimed deliberately rather than on arrival — and
640
+ * deliberately **not** subject to the once-per-entry guard, because this one is a thing the
641
+ * person asked for.
642
+ *
643
+ * Eligibility is decided by the platform at this moment, not from any list held here, so a
644
+ * workspace revoked since the picker was drawn can never be entered (FR-SWITCH-4). Which
645
+ * refusal you get depends on what is left, verified against a live platform: still holding
646
+ * other workspaces gives `select_workspace` listing them; having lost them all gives
647
+ * `no_access`. Do not treat `no_access` as the only refusal.
648
+ *
649
+ * On `signed_in` the session cookie now names the new workspace. The caller decides what to
650
+ * do next — the SDK does not reload the page or discard anything on their behalf, because it
651
+ * cannot know what is unsaved on the screen (FR-SWITCH-7).
652
+ */
653
+ async function switchWorkspace(workspace, options = {}) {
654
+ if (!workspace) return {
655
+ status: "failed",
656
+ reason: "no workspace given"
657
+ };
658
+ return attemptWarmHop({
659
+ ...options,
660
+ workspace
661
+ });
662
+ }
663
+ /**
664
+ * The automatic entry attempt: at most once per tab, whatever the caller does (FR-HOP-2).
665
+ *
666
+ * Returns `null` when it declines to attempt — already tried this entry — so a caller can
667
+ * tell "we tried and they must sign in" from "we did not try".
668
+ */
669
+ async function attemptWarmHopOnce(options = {}) {
670
+ if (warmHopAttempted()) return null;
671
+ markWarmHopAttempted();
672
+ return attemptWarmHop(options);
673
+ }
674
+ //#endregion
483
675
  //#region src/hooks.ts
484
676
  function useSessionSource() {
485
677
  const ctx = useContext(PolyXContext);
@@ -549,6 +741,52 @@ function useUser() {
549
741
  const state = useSession();
550
742
  return state.status === "authenticated" || state.status === "refreshing" ? state.session.claims : null;
551
743
  }
744
+ /**
745
+ * Try a silent sign-in once when this component mounts (F027 / FR-HOP-2, FR-FALL-1…3).
746
+ *
747
+ * The contract that matters is D11's: **an attempt that fails must be invisible.** Someone who
748
+ * is genuinely signed out pays the whole cost of this feature and gets nothing from it, so they
749
+ * must not see a flicker, a spinner that outlives their patience, or — worst — a redirect loop.
750
+ * Hence: no navigation anywhere in the flow, a hard timeout, and a guard that survives the route
751
+ * change to the sign-in page (a guard in React state would reset exactly when it is needed).
752
+ *
753
+ * ```tsx
754
+ * const hop = useWarmHop();
755
+ * if (hop.status !== "settled" ) return null; // no half-drawn form
756
+ * if (hop.result.status === "signed_in") return <Redirect/>;
757
+ * return <SignIn />; // the ordinary path
758
+ * ```
759
+ *
760
+ * Strict Mode's double-mount does not produce two attempts: the marker is written before the
761
+ * first request goes out.
762
+ */
763
+ function useWarmHop(options = {}) {
764
+ const { enabled = true, workspace, timeoutMs } = options;
765
+ const [state, setState] = useState({ status: "idle" });
766
+ useEffect(() => {
767
+ if (!enabled) return;
768
+ let live = true;
769
+ setState({ status: "attempting" });
770
+ attemptWarmHopOnce({
771
+ workspace,
772
+ timeoutMs
773
+ }).then((result) => {
774
+ if (!live) return;
775
+ setState({
776
+ status: "settled",
777
+ result: result ?? { status: "sign_in_required" }
778
+ });
779
+ });
780
+ return () => {
781
+ live = false;
782
+ };
783
+ }, [
784
+ enabled,
785
+ workspace,
786
+ timeoutMs
787
+ ]);
788
+ return state;
789
+ }
552
790
  //#endregion
553
791
  //#region src/components/protected.tsx
554
792
  /** Gate content on authentication state without flashing the wrong branch. */
@@ -2680,6 +2918,6 @@ function useAuthzChanged(callback) {
2680
2918
  */
2681
2919
  const PACKAGE_NAME = "@poly-x/react";
2682
2920
  /** The published version of this package, injected at build time. */
2683
- const version = "0.2.0";
2921
+ const version = "0.3.0";
2684
2922
  //#endregion
2685
- export { AuthCallback, BroadcastAuthChannel, BroadcastChannelSessionChannel, ForgotPassword, PACKAGE_NAME, PolyXContext, PolyXProvider, PopupCancelledError, Protected, ResetPassword, SessionStoragePkceStore, SignIn, UserAvatar, UserButton, UserProfile, WebLocksLock, WindowBrowserBridge, capturePreciseLocation, consumeAuthEvent, createLoginController, initialsFrom, resolveSide, startLiveAuthz, useAuth, useAuthCallback, useAuthzChanged, useLiveAuthz, useSession, useUser, version };
2923
+ export { AuthCallback, BroadcastAuthChannel, BroadcastChannelSessionChannel, ForgotPassword, PACKAGE_NAME, PolyXContext, PolyXProvider, PopupCancelledError, Protected, ResetPassword, SessionStoragePkceStore, SignIn, UserAvatar, UserButton, UserProfile, WARM_HOP_TIMEOUT_MS, WebLocksLock, WindowBrowserBridge, attemptWarmHop, attemptWarmHopOnce, capturePreciseLocation, consumeAuthEvent, createLoginController, initialsFrom, resetWarmHop, resolveSide, startLiveAuthz, switchWorkspace, useAuth, useAuthCallback, useAuthzChanged, useLiveAuthz, useSession, useUser, useWarmHop, version, warmHopAttempted };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poly-x/react",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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.2.0"
30
+ "@poly-x/core": "0.3.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "react": ">=18",
@@ -56,6 +56,6 @@
56
56
  "scripts": {
57
57
  "build": "tsdown && node scripts/copy-styles.mjs",
58
58
  "test": "vitest run",
59
- "lint": "eslint src"
59
+ "lint": "eslint src && tsc --noEmit -p tsconfig.docs.json"
60
60
  }
61
61
  }