@cosmoops/connect-sdk 0.24.0 → 0.25.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/CHANGELOG.md CHANGED
@@ -3,6 +3,91 @@
3
3
  `@cosmoops/connect-sdk` follows semver, with the 0.x convention that **minor
4
4
  versions may carry breaking changes** and patch versions never do.
5
5
 
6
+ ## 0.25.0
7
+
8
+ ### Changed
9
+
10
+ - **`getConnectSession()` no longer reports a failed read as "signed out".**
11
+ It returned `null` for *every* failure, and `null` is what makes
12
+ `requireConnectSession()` redirect to sign-in - so a rate-limited or
13
+ unreachable Connect logged a signed-in user out, and the OAuth round trip
14
+ that followed spent more calls against the same exhausted budget and failed
15
+ again on the way back. A login loop that could only end when the rate-limit
16
+ window rolled. Two developers hit it; production logs showed 120 429s on
17
+ `/sso/user` in a day, arriving in bursts of fifteen inside one second,
18
+ because a partner layout that reads the session is called once per
19
+ prefetched route shell.
20
+
21
+ `null` now means **there is no session** - no cookie, or one Connect reports
22
+ as expired, revoked, or issued to another application, which are exactly the
23
+ cases where sending someone back through sign-in is right. Everything else
24
+ throws. This is the breaking part: a guard written as
25
+ `const s = await getConnectSession(); if (!s) redirect(…)` will now surface
26
+ a transient failure to the nearest error boundary rather than silently
27
+ signing the visitor out. That is the intended behaviour - an error boundary
28
+ is recoverable and truthful, a spurious sign-out is neither - but it is a
29
+ behaviour change, which is why this is a minor rather than a patch.
30
+
31
+ - **`<ConnectProvider>` keeps the session it already has when a refresh
32
+ fails.** It used to replace whatever was known with a signed-out session on
33
+ any error. On a first load that is right - something has to render rather
34
+ than skeletons forever - but on a refresh it meant one dropped or throttled
35
+ request logged the user out of the UI, and the host app's own guards then
36
+ acted on it. `error` is still set either way.
37
+
38
+ ### Fixed
39
+
40
+ - **`POST /sso/user` never retried a 429.** The transport has honoured
41
+ `Retry-After` with exponential backoff all along, but only for requests it
42
+ considered idempotent - and that test keyed on the verb, so it excluded
43
+ every `POST`. `/sso/user` is a `POST` because its input is a session token
44
+ that must not sit in a URL; it writes nothing, and it is the most-called
45
+ endpoint on the platform. It was therefore the one endpoint that could never
46
+ ride out a throttle. `RequestSpec` gains `idempotent` for reads shaped this
47
+ way. `POST /sso/token` deliberately does not set it: it consumes a one-time
48
+ authorization code, and a replay is an exchange of a code that no longer
49
+ exists.
50
+
51
+ - **"useInsertionEffect must not schedule updates." on every navigation.**
52
+ `useActivePath`'s uncontrolled fallback patches
53
+ `history.pushState`/`replaceState` so a shell with no `activePath` can see a
54
+ client-side navigation, and it dispatched its `connect:locationchange`
55
+ event synchronously from inside the patched method. Next's App Router
56
+ commits its history entry from a `useInsertionEffect` (`HistoryUpdater`),
57
+ so the listener - and the re-render `useSyncExternalStore` forces from it -
58
+ landed in the middle of that phase, which React refuses: a console error on
59
+ every navigation in any app that lets the rail resolve its own active path.
60
+
61
+ The dispatch is now deferred to a microtask, which runs once the commit's
62
+ stack has unwound. `location.pathname` already holds the new value by then,
63
+ so the snapshot is unchanged and the highlight still moves on the same
64
+ commit; only the scheduling moved. A shell that passes `activePath` never
65
+ ran any of this and was never affected.
66
+
67
+ ## 0.24.1
68
+
69
+ ### Fixed
70
+
71
+ - **`<ConnectConfirmDialog />` never opened.** Its trigger was wrapped in
72
+ `Modal.Trigger`, which renders a `div role="button"` inside React Aria's
73
+ `Pressable` and depends on a plain `onClick` bubbling up to it. A React Aria
74
+ button nested inside - which every real trigger is, including HeroUI's
75
+ `<Button>` - calls `e.stopPropagation()` in its own `usePress` click
76
+ handler, so the click never arrived. Nothing threw and nothing warned; the
77
+ press was simply eaten, and every confirmation in every app built on this
78
+ SDK was inert. Twelve of them across the two reference apps: each delete
79
+ confirmation in the CRM, and each in the club app.
80
+
81
+ The trigger is now a direct child of the `Modal` root, which is React Aria's
82
+ `DialogTrigger`. Its `PressResponder` picks the trigger up directly, so
83
+ there is no nested pressable and no propagation to lose.
84
+
85
+ No call site changes. The prop's contract is now stated the right way round:
86
+ the trigger **must** be a pressable - a HeroUI `Button`, or anything else
87
+ built on React Aria's `useButton`. A non-pressable element (a bare `<span>`)
88
+ used to be the only thing that worked and now does not; nothing shipped
89
+ passes one.
90
+
6
91
  ## 0.24.0
7
92
 
8
93
  ### Fixed
@@ -19,11 +19,19 @@ const react_2 = require("@heroui/react");
19
19
  * rejection is caught here rather than escaping - an error thrown out of an
20
20
  * async transition reaches the nearest error boundary and takes the page down.
21
21
  *
22
- * The anatomy is HeroUI's: `Modal.Backdrop` owns the open state,
23
- * `Modal.Container` the sizing, `Modal.Dialog` the focus trap. The trigger
24
- * goes inside `Modal.Trigger`, which *is* a React Aria button - so pass a
25
- * plain element, never a `<Button>`, or the nested button swallows the press
26
- * and the dialog silently never opens.
22
+ * The anatomy is HeroUI's: the `Modal` root owns the open state,
23
+ * `Modal.Container` the sizing, `Modal.Dialog` the focus trap. **The trigger
24
+ * is a direct child of the root**, never wrapped in `Modal.Trigger`.
25
+ *
26
+ * That wrapping is what this component used to do, and it meant the dialog
27
+ * never opened for any caller passing a `<Button>` - which was all of them.
28
+ * `Modal.Trigger` renders a `div role="button"` inside React Aria's
29
+ * `Pressable` and relies on a plain `onClick` bubbling up to it; a React Aria
30
+ * button nested inside calls `e.stopPropagation()` in its own `usePress`
31
+ * click handler, so the click never arrives. Nothing failed loudly - the
32
+ * press was simply eaten. The root is `DialogTrigger`, whose `PressResponder`
33
+ * the trigger registers with directly, so there is no nesting and no
34
+ * propagation to lose.
27
35
  */
28
36
  function ConnectConfirmDialog({ trigger, title, description, confirmLabel = "Confirm", cancelLabel = "Cancel", tone = "danger", onConfirm, }) {
29
37
  const [isOpen, setIsOpen] = (0, react_1.useState)(false);
@@ -36,5 +44,5 @@ function ConnectConfirmDialog({ trigger, title, description, confirmLabel = "Con
36
44
  // caller, which owns the toast and the rollback.
37
45
  });
38
46
  };
39
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_2.Modal.Trigger, { onClick: () => setIsOpen(true), children: trigger }), (0, jsx_runtime_1.jsx)(react_2.Modal.Backdrop, { isOpen: isOpen, onOpenChange: setIsOpen, children: (0, jsx_runtime_1.jsx)(react_2.Modal.Container, { size: "sm", children: (0, jsx_runtime_1.jsxs)(react_2.Modal.Dialog, { children: [(0, jsx_runtime_1.jsx)(react_2.Modal.CloseTrigger, { onPress: () => setIsOpen(false) }), (0, jsx_runtime_1.jsx)(react_2.Modal.Header, { children: (0, jsx_runtime_1.jsx)(react_2.Modal.Heading, { children: title }) }), description && ((0, jsx_runtime_1.jsx)(react_2.Modal.Body, { children: (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: description }) })), (0, jsx_runtime_1.jsxs)(react_2.Modal.Footer, { children: [(0, jsx_runtime_1.jsx)(react_2.Button, { variant: "secondary", onPress: () => setIsOpen(false), children: cancelLabel }), (0, jsx_runtime_1.jsx)(react_2.Button, { variant: tone === "danger" ? "danger" : "primary", onPress: confirm, children: confirmLabel })] })] }) }) })] }));
47
+ return ((0, jsx_runtime_1.jsxs)(react_2.Modal, { isOpen: isOpen, onOpenChange: setIsOpen, children: [trigger, (0, jsx_runtime_1.jsx)(react_2.Modal.Backdrop, { children: (0, jsx_runtime_1.jsx)(react_2.Modal.Container, { size: "sm", children: (0, jsx_runtime_1.jsxs)(react_2.Modal.Dialog, { children: [(0, jsx_runtime_1.jsx)(react_2.Modal.CloseTrigger, { onPress: () => setIsOpen(false) }), (0, jsx_runtime_1.jsx)(react_2.Modal.Header, { children: (0, jsx_runtime_1.jsx)(react_2.Modal.Heading, { children: title }) }), description && ((0, jsx_runtime_1.jsx)(react_2.Modal.Body, { children: (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: description }) })), (0, jsx_runtime_1.jsxs)(react_2.Modal.Footer, { children: [(0, jsx_runtime_1.jsx)(react_2.Button, { variant: "secondary", onPress: () => setIsOpen(false), children: cancelLabel }), (0, jsx_runtime_1.jsx)(react_2.Button, { variant: tone === "danger" ? "danger" : "primary", onPress: confirm, children: confirmLabel })] })] }) }) })] }));
40
48
  }
@@ -152,13 +152,34 @@ function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri
152
152
  setResult({ session, error: null });
153
153
  }
154
154
  catch (cause) {
155
- // A bridge that cannot be reached is reported as signed out *with* an
156
- // error, so a Login button still renders rather than the UI hanging on
157
- // skeletons forever.
158
- setResult({
159
- session: { user: null, subscription: null, role: null, permissions: [], staff: false, audience: null },
160
- error: cause instanceof Error ? cause.message : "Could not reach Connect",
161
- });
155
+ const message = cause instanceof Error ? cause.message : "Could not reach Connect";
156
+ /*
157
+ * **A failed read never downgrades a session we already have.**
158
+ *
159
+ * This used to replace whatever was known with a signed-out session on
160
+ * every failure. On a first load that is right - something has to render
161
+ * rather than skeletons forever - but on a *refresh* it meant one
162
+ * throttled or dropped request logged the user out of the UI, and the
163
+ * host app's own guards then acted on that.
164
+ *
165
+ * Keeping the last known session is the honest reading: the bridge could
166
+ * not answer, so what we knew a moment ago is still the best answer we
167
+ * have. `error` is set either way, so an app that wants to surface
168
+ * "we've lost touch with Connect" still can.
169
+ */
170
+ setResult((previous) => previous?.session.user
171
+ ? { session: previous.session, error: message }
172
+ : {
173
+ session: {
174
+ user: null,
175
+ subscription: null,
176
+ role: null,
177
+ permissions: [],
178
+ staff: false,
179
+ audience: null,
180
+ },
181
+ error: message,
182
+ });
162
183
  }
163
184
  }, [fetchResource]);
164
185
  (0, react_1.useEffect)(() => {
@@ -20,6 +20,17 @@ let patched = false;
20
20
  * actually mounted without an `activePath`. Pass `activePath` (Next:
21
21
  * `usePathname()`) and none of this runs at all - which is the recommended
22
22
  * path for any app that has a router to ask.
23
+ *
24
+ * The notification is deferred to a microtask, and that is load-bearing
25
+ * rather than tidiness. Next's App Router commits its history entry from
26
+ * inside a `useInsertionEffect` (`HistoryUpdater` calls
27
+ * `history.pushState`/`replaceState` there), so a synchronous
28
+ * `dispatchEvent` reaches `useSyncExternalStore`'s listener *during* that
29
+ * phase and the re-render it forces is refused: React logs
30
+ * "useInsertionEffect must not schedule updates" on every navigation. A
31
+ * microtask runs once the commit's stack has unwound, by which point
32
+ * `location.pathname` already holds the new value, so the snapshot the store
33
+ * reads is the same one - only the scheduling moves.
23
34
  */
24
35
  function patchHistory() {
25
36
  if (patched || typeof window === "undefined" || typeof window.history === "undefined")
@@ -29,7 +40,7 @@ function patchHistory() {
29
40
  const original = window.history[method];
30
41
  window.history[method] = function patchedHistoryMethod(...args) {
31
42
  const result = original.apply(this, args);
32
- window.dispatchEvent(new Event(LOCATION_CHANGE));
43
+ queueMicrotask(() => window.dispatchEvent(new Event(LOCATION_CHANGE)));
33
44
  return result;
34
45
  };
35
46
  }
@@ -45,6 +45,7 @@ exports.requireConnectSession = requireConnectSession;
45
45
  const react_1 = require("react");
46
46
  const client_js_1 = require("../server/client.js");
47
47
  const handler_js_1 = require("../server/handler.js");
48
+ const errors_js_1 = require("../errors.js");
48
49
  /**
49
50
  * Plug-and-play wiring for Next.js.
50
51
  *
@@ -241,7 +242,34 @@ async function readSession(request, options) {
241
242
  try {
242
243
  return await (options.client ?? connectClient()).identity.getUser(token);
243
244
  }
244
- catch {
245
+ catch (cause) {
246
+ /*
247
+ * `null` means **not signed in**, and nothing else.
248
+ *
249
+ * This used to be a bare `catch { return null }`, so every failure - a
250
+ * throttled read, a five-hundred, a network blip - was reported to the
251
+ * caller as "there is no session here". That is the worst possible lie for
252
+ * this particular function, because `requireConnectSession` and every
253
+ * app guard built on it answer "not signed in" by redirecting to sign-in.
254
+ *
255
+ * The result was a login loop nobody could escape: a 429 on the session
256
+ * read bounced a signed-in user to `/signin`, the OAuth round trip spent
257
+ * *more* calls against the same exhausted bucket, and the app 429'd again
258
+ * on the way back. It could only clear when the rate-limit window rolled.
259
+ * Both of the screenshots that reported this show exactly that sequence.
260
+ *
261
+ * So only a genuine session failure answers `null`. `isSessionExpired`
262
+ * covers the whole family the API deliberately collapses into
263
+ * `invalid_token` - unknown, expired, revoked, or issued to another
264
+ * application - which are the cases where signing in again is the correct
265
+ * and only response.
266
+ *
267
+ * Everything else throws. A page that cannot reach Connect gets an error
268
+ * boundary, which is recoverable and truthful; being quietly signed out is
269
+ * neither.
270
+ */
271
+ if (cause instanceof errors_js_1.ConnectError && !cause.isSessionExpired)
272
+ throw cause;
245
273
  return null;
246
274
  }
247
275
  }
@@ -284,8 +312,18 @@ const cachedAmbientSession = (0, react_1.cache)(async () => {
284
312
  * any other request-scoped read - a page that awaits it above every boundary
285
313
  * gives up its static shell.
286
314
  *
287
- * Returns `null` for every failure - an expired session and a missing one both
288
- * mean "not signed in", and a page guard should not need a try/catch.
315
+ * Returns `null` when there is **no session** - no cookie, or one Connect
316
+ * reports as expired, revoked or issued to another application. A page guard
317
+ * needs no try/catch for those, which are the cases where sending someone
318
+ * back through sign-in is the right answer.
319
+ *
320
+ * **It throws on anything else**, and that is deliberate. It previously
321
+ * swallowed every failure into `null`, so a rate-limited or unreachable
322
+ * Connect read as "signed out" - and `requireConnectSession` then redirected a
323
+ * signed-in user to sign-in, whose OAuth round trip spent more of the same
324
+ * exhausted budget and 429'd again on return. A login loop that could only end
325
+ * when the rate-limit window rolled. An error boundary is the honest outcome
326
+ * for "could not ask", and unlike a spurious sign-out it is recoverable.
289
327
  *
290
328
  * **Memoized per request for the plain, zero-argument call** - the shape
291
329
  * every page, layout and Server Action actually uses - via React's `cache()`.
@@ -161,4 +161,4 @@ class ConnectClient {
161
161
  }
162
162
  exports.ConnectClient = ConnectClient;
163
163
  /** Kept in step with `package.json` by `sdk.test.ts`. */
164
- exports.SDK_VERSION = "0.24.0";
164
+ exports.SDK_VERSION = "0.25.0";
@@ -122,7 +122,9 @@ async function request(options, spec) {
122
122
  // `PUT` and `DELETE` are idempotent by definition - replaying one lands the
123
123
  // same end state - so they retry like `GET`. `POST` still needs an
124
124
  // `Idempotency-Key` to earn it, because a replayed create is a second row.
125
- const idempotent = spec.method !== "POST" || headers["idempotency-key"] !== undefined;
125
+ const idempotent = spec.idempotent === true ||
126
+ spec.method !== "POST" ||
127
+ headers["idempotency-key"] !== undefined;
126
128
  const attempts = idempotent ? options.maxRetries + 1 : 1;
127
129
  let lastError;
128
130
  for (let attempt = 0; attempt < attempts; attempt++) {
@@ -179,7 +179,14 @@ class IdentityResource {
179
179
  * `authorizeUrl()`.
180
180
  */
181
181
  async getUser(sessionToken) {
182
- const wire = await this.post("/sso/user", { session_token: sessionToken });
182
+ /*
183
+ * `idempotent` because it is: this asks who a session token belongs to and
184
+ * writes nothing. Without it a 429 here reached the caller on the first
185
+ * attempt, and the transport's `Retry-After` backoff - which exists
186
+ * exactly for this - never ran, because the rule keyed on the verb rather
187
+ * than on the effect.
188
+ */
189
+ const wire = await this.post("/sso/user", { session_token: sessionToken }, { idempotent: true });
183
190
  return {
184
191
  user: wire.user,
185
192
  subscription: wire.subscription,
@@ -209,8 +216,11 @@ class IdentityResource {
209
216
  scope: options.scope,
210
217
  });
211
218
  }
212
- post(path, form) {
219
+ post(path, form,
220
+ /** Only `idempotent` today - for the `/sso/*` reads that are POSTs. */
221
+ extra) {
213
222
  const spec = {
223
+ ...extra,
214
224
  method: "POST",
215
225
  path,
216
226
  auth: "none",
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState } from "react";
4
4
  import { Button, Modal } from "@heroui/react";
5
5
  /**
@@ -16,11 +16,19 @@ import { Button, Modal } from "@heroui/react";
16
16
  * rejection is caught here rather than escaping - an error thrown out of an
17
17
  * async transition reaches the nearest error boundary and takes the page down.
18
18
  *
19
- * The anatomy is HeroUI's: `Modal.Backdrop` owns the open state,
20
- * `Modal.Container` the sizing, `Modal.Dialog` the focus trap. The trigger
21
- * goes inside `Modal.Trigger`, which *is* a React Aria button - so pass a
22
- * plain element, never a `<Button>`, or the nested button swallows the press
23
- * and the dialog silently never opens.
19
+ * The anatomy is HeroUI's: the `Modal` root owns the open state,
20
+ * `Modal.Container` the sizing, `Modal.Dialog` the focus trap. **The trigger
21
+ * is a direct child of the root**, never wrapped in `Modal.Trigger`.
22
+ *
23
+ * That wrapping is what this component used to do, and it meant the dialog
24
+ * never opened for any caller passing a `<Button>` - which was all of them.
25
+ * `Modal.Trigger` renders a `div role="button"` inside React Aria's
26
+ * `Pressable` and relies on a plain `onClick` bubbling up to it; a React Aria
27
+ * button nested inside calls `e.stopPropagation()` in its own `usePress`
28
+ * click handler, so the click never arrives. Nothing failed loudly - the
29
+ * press was simply eaten. The root is `DialogTrigger`, whose `PressResponder`
30
+ * the trigger registers with directly, so there is no nesting and no
31
+ * propagation to lose.
24
32
  */
25
33
  export function ConnectConfirmDialog({ trigger, title, description, confirmLabel = "Confirm", cancelLabel = "Cancel", tone = "danger", onConfirm, }) {
26
34
  const [isOpen, setIsOpen] = useState(false);
@@ -33,5 +41,5 @@ export function ConnectConfirmDialog({ trigger, title, description, confirmLabel
33
41
  // caller, which owns the toast and the rollback.
34
42
  });
35
43
  };
36
- return (_jsxs(_Fragment, { children: [_jsx(Modal.Trigger, { onClick: () => setIsOpen(true), children: trigger }), _jsx(Modal.Backdrop, { isOpen: isOpen, onOpenChange: setIsOpen, children: _jsx(Modal.Container, { size: "sm", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, { onPress: () => setIsOpen(false) }), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: title }) }), description && (_jsx(Modal.Body, { children: _jsx("p", { className: "text-sm text-muted", children: description }) })), _jsxs(Modal.Footer, { children: [_jsx(Button, { variant: "secondary", onPress: () => setIsOpen(false), children: cancelLabel }), _jsx(Button, { variant: tone === "danger" ? "danger" : "primary", onPress: confirm, children: confirmLabel })] })] }) }) })] }));
44
+ return (_jsxs(Modal, { isOpen: isOpen, onOpenChange: setIsOpen, children: [trigger, _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { size: "sm", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, { onPress: () => setIsOpen(false) }), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: title }) }), description && (_jsx(Modal.Body, { children: _jsx("p", { className: "text-sm text-muted", children: description }) })), _jsxs(Modal.Footer, { children: [_jsx(Button, { variant: "secondary", onPress: () => setIsOpen(false), children: cancelLabel }), _jsx(Button, { variant: tone === "danger" ? "danger" : "primary", onPress: confirm, children: confirmLabel })] })] }) }) })] }));
37
45
  }
@@ -133,13 +133,34 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
133
133
  setResult({ session, error: null });
134
134
  }
135
135
  catch (cause) {
136
- // A bridge that cannot be reached is reported as signed out *with* an
137
- // error, so a Login button still renders rather than the UI hanging on
138
- // skeletons forever.
139
- setResult({
140
- session: { user: null, subscription: null, role: null, permissions: [], staff: false, audience: null },
141
- error: cause instanceof Error ? cause.message : "Could not reach Connect",
142
- });
136
+ const message = cause instanceof Error ? cause.message : "Could not reach Connect";
137
+ /*
138
+ * **A failed read never downgrades a session we already have.**
139
+ *
140
+ * This used to replace whatever was known with a signed-out session on
141
+ * every failure. On a first load that is right - something has to render
142
+ * rather than skeletons forever - but on a *refresh* it meant one
143
+ * throttled or dropped request logged the user out of the UI, and the
144
+ * host app's own guards then acted on that.
145
+ *
146
+ * Keeping the last known session is the honest reading: the bridge could
147
+ * not answer, so what we knew a moment ago is still the best answer we
148
+ * have. `error` is set either way, so an app that wants to surface
149
+ * "we've lost touch with Connect" still can.
150
+ */
151
+ setResult((previous) => previous?.session.user
152
+ ? { session: previous.session, error: message }
153
+ : {
154
+ session: {
155
+ user: null,
156
+ subscription: null,
157
+ role: null,
158
+ permissions: [],
159
+ staff: false,
160
+ audience: null,
161
+ },
162
+ error: message,
163
+ });
143
164
  }
144
165
  }, [fetchResource]);
145
166
  useEffect(() => {
@@ -17,6 +17,17 @@ let patched = false;
17
17
  * actually mounted without an `activePath`. Pass `activePath` (Next:
18
18
  * `usePathname()`) and none of this runs at all - which is the recommended
19
19
  * path for any app that has a router to ask.
20
+ *
21
+ * The notification is deferred to a microtask, and that is load-bearing
22
+ * rather than tidiness. Next's App Router commits its history entry from
23
+ * inside a `useInsertionEffect` (`HistoryUpdater` calls
24
+ * `history.pushState`/`replaceState` there), so a synchronous
25
+ * `dispatchEvent` reaches `useSyncExternalStore`'s listener *during* that
26
+ * phase and the re-render it forces is refused: React logs
27
+ * "useInsertionEffect must not schedule updates" on every navigation. A
28
+ * microtask runs once the commit's stack has unwound, by which point
29
+ * `location.pathname` already holds the new value, so the snapshot the store
30
+ * reads is the same one - only the scheduling moves.
20
31
  */
21
32
  function patchHistory() {
22
33
  if (patched || typeof window === "undefined" || typeof window.history === "undefined")
@@ -26,7 +37,7 @@ function patchHistory() {
26
37
  const original = window.history[method];
27
38
  window.history[method] = function patchedHistoryMethod(...args) {
28
39
  const result = original.apply(this, args);
29
- window.dispatchEvent(new Event(LOCATION_CHANGE));
40
+ queueMicrotask(() => window.dispatchEvent(new Event(LOCATION_CHANGE)));
30
41
  return result;
31
42
  };
32
43
  }
@@ -1,6 +1,7 @@
1
1
  import { cache } from "react";
2
2
  import { ConnectClient } from "../server/client.js";
3
3
  import { createConnectHandler } from "../server/handler.js";
4
+ import { ConnectError } from "../errors.js";
4
5
  /**
5
6
  * Plug-and-play wiring for Next.js.
6
7
  *
@@ -211,7 +212,34 @@ async function readSession(request, options) {
211
212
  try {
212
213
  return await (options.client ?? connectClient()).identity.getUser(token);
213
214
  }
214
- catch {
215
+ catch (cause) {
216
+ /*
217
+ * `null` means **not signed in**, and nothing else.
218
+ *
219
+ * This used to be a bare `catch { return null }`, so every failure - a
220
+ * throttled read, a five-hundred, a network blip - was reported to the
221
+ * caller as "there is no session here". That is the worst possible lie for
222
+ * this particular function, because `requireConnectSession` and every
223
+ * app guard built on it answer "not signed in" by redirecting to sign-in.
224
+ *
225
+ * The result was a login loop nobody could escape: a 429 on the session
226
+ * read bounced a signed-in user to `/signin`, the OAuth round trip spent
227
+ * *more* calls against the same exhausted bucket, and the app 429'd again
228
+ * on the way back. It could only clear when the rate-limit window rolled.
229
+ * Both of the screenshots that reported this show exactly that sequence.
230
+ *
231
+ * So only a genuine session failure answers `null`. `isSessionExpired`
232
+ * covers the whole family the API deliberately collapses into
233
+ * `invalid_token` - unknown, expired, revoked, or issued to another
234
+ * application - which are the cases where signing in again is the correct
235
+ * and only response.
236
+ *
237
+ * Everything else throws. A page that cannot reach Connect gets an error
238
+ * boundary, which is recoverable and truthful; being quietly signed out is
239
+ * neither.
240
+ */
241
+ if (cause instanceof ConnectError && !cause.isSessionExpired)
242
+ throw cause;
215
243
  return null;
216
244
  }
217
245
  }
@@ -254,8 +282,18 @@ const cachedAmbientSession = cache(async () => {
254
282
  * any other request-scoped read - a page that awaits it above every boundary
255
283
  * gives up its static shell.
256
284
  *
257
- * Returns `null` for every failure - an expired session and a missing one both
258
- * mean "not signed in", and a page guard should not need a try/catch.
285
+ * Returns `null` when there is **no session** - no cookie, or one Connect
286
+ * reports as expired, revoked or issued to another application. A page guard
287
+ * needs no try/catch for those, which are the cases where sending someone
288
+ * back through sign-in is the right answer.
289
+ *
290
+ * **It throws on anything else**, and that is deliberate. It previously
291
+ * swallowed every failure into `null`, so a rate-limited or unreachable
292
+ * Connect read as "signed out" - and `requireConnectSession` then redirected a
293
+ * signed-in user to sign-in, whose OAuth round trip spent more of the same
294
+ * exhausted budget and 429'd again on return. A login loop that could only end
295
+ * when the rate-limit window rolled. An error boundary is the honest outcome
296
+ * for "could not ask", and unlike a spurious sign-out it is recoverable.
259
297
  *
260
298
  * **Memoized per request for the plain, zero-argument call** - the shape
261
299
  * every page, layout and Server Action actually uses - via React's `cache()`.
@@ -157,4 +157,4 @@ export class ConnectClient {
157
157
  }
158
158
  }
159
159
  /** Kept in step with `package.json` by `sdk.test.ts`. */
160
- export const SDK_VERSION = "0.24.0";
160
+ export const SDK_VERSION = "0.25.0";
@@ -118,7 +118,9 @@ export async function request(options, spec) {
118
118
  // `PUT` and `DELETE` are idempotent by definition - replaying one lands the
119
119
  // same end state - so they retry like `GET`. `POST` still needs an
120
120
  // `Idempotency-Key` to earn it, because a replayed create is a second row.
121
- const idempotent = spec.method !== "POST" || headers["idempotency-key"] !== undefined;
121
+ const idempotent = spec.idempotent === true ||
122
+ spec.method !== "POST" ||
123
+ headers["idempotency-key"] !== undefined;
122
124
  const attempts = idempotent ? options.maxRetries + 1 : 1;
123
125
  let lastError;
124
126
  for (let attempt = 0; attempt < attempts; attempt++) {
@@ -174,7 +174,14 @@ export class IdentityResource {
174
174
  * `authorizeUrl()`.
175
175
  */
176
176
  async getUser(sessionToken) {
177
- const wire = await this.post("/sso/user", { session_token: sessionToken });
177
+ /*
178
+ * `idempotent` because it is: this asks who a session token belongs to and
179
+ * writes nothing. Without it a 429 here reached the caller on the first
180
+ * attempt, and the transport's `Retry-After` backoff - which exists
181
+ * exactly for this - never ran, because the rule keyed on the verb rather
182
+ * than on the effect.
183
+ */
184
+ const wire = await this.post("/sso/user", { session_token: sessionToken }, { idempotent: true });
178
185
  return {
179
186
  user: wire.user,
180
187
  subscription: wire.subscription,
@@ -204,8 +211,11 @@ export class IdentityResource {
204
211
  scope: options.scope,
205
212
  });
206
213
  }
207
- post(path, form) {
214
+ post(path, form,
215
+ /** Only `idempotent` today - for the `/sso/*` reads that are POSTs. */
216
+ extra) {
208
217
  const spec = {
218
+ ...extra,
209
219
  method: "POST",
210
220
  path,
211
221
  auth: "none",
@@ -1,6 +1,13 @@
1
1
  import { type ReactNode } from "react";
2
2
  export interface ConnectConfirmDialogProps {
3
- /** The control that opens it. Rendered inside `Modal.Trigger`. */
3
+ /**
4
+ * The control that opens it, and it **must be a pressable** - a HeroUI
5
+ * `Button`, or anything else built on React Aria's `useButton`. It is
6
+ * rendered as a direct child of the `Modal` root, which is React Aria's
7
+ * `DialogTrigger`: that puts a `PressResponder` around its children and the
8
+ * trigger registers with it. A plain `<span>` never registers, and React
9
+ * Aria warns that the responder has no pressable child.
10
+ */
4
11
  trigger: ReactNode;
5
12
  title: ReactNode;
6
13
  /** What will happen, in the user's words. Name the thing being acted on. */
@@ -25,10 +32,18 @@ export interface ConnectConfirmDialogProps {
25
32
  * rejection is caught here rather than escaping - an error thrown out of an
26
33
  * async transition reaches the nearest error boundary and takes the page down.
27
34
  *
28
- * The anatomy is HeroUI's: `Modal.Backdrop` owns the open state,
29
- * `Modal.Container` the sizing, `Modal.Dialog` the focus trap. The trigger
30
- * goes inside `Modal.Trigger`, which *is* a React Aria button - so pass a
31
- * plain element, never a `<Button>`, or the nested button swallows the press
32
- * and the dialog silently never opens.
35
+ * The anatomy is HeroUI's: the `Modal` root owns the open state,
36
+ * `Modal.Container` the sizing, `Modal.Dialog` the focus trap. **The trigger
37
+ * is a direct child of the root**, never wrapped in `Modal.Trigger`.
38
+ *
39
+ * That wrapping is what this component used to do, and it meant the dialog
40
+ * never opened for any caller passing a `<Button>` - which was all of them.
41
+ * `Modal.Trigger` renders a `div role="button"` inside React Aria's
42
+ * `Pressable` and relies on a plain `onClick` bubbling up to it; a React Aria
43
+ * button nested inside calls `e.stopPropagation()` in its own `usePress`
44
+ * click handler, so the click never arrives. Nothing failed loudly - the
45
+ * press was simply eaten. The root is `DialogTrigger`, whose `PressResponder`
46
+ * the trigger registers with directly, so there is no nesting and no
47
+ * propagation to lose.
33
48
  */
34
49
  export declare function ConnectConfirmDialog({ trigger, title, description, confirmLabel, cancelLabel, tone, onConfirm, }: Readonly<ConnectConfirmDialogProps>): import("react").JSX.Element;
@@ -93,8 +93,18 @@ export { handle as GET, handle as POST };
93
93
  * any other request-scoped read - a page that awaits it above every boundary
94
94
  * gives up its static shell.
95
95
  *
96
- * Returns `null` for every failure - an expired session and a missing one both
97
- * mean "not signed in", and a page guard should not need a try/catch.
96
+ * Returns `null` when there is **no session** - no cookie, or one Connect
97
+ * reports as expired, revoked or issued to another application. A page guard
98
+ * needs no try/catch for those, which are the cases where sending someone
99
+ * back through sign-in is the right answer.
100
+ *
101
+ * **It throws on anything else**, and that is deliberate. It previously
102
+ * swallowed every failure into `null`, so a rate-limited or unreachable
103
+ * Connect read as "signed out" - and `requireConnectSession` then redirected a
104
+ * signed-in user to sign-in, whose OAuth round trip spent more of the same
105
+ * exhausted budget and 429'd again on return. A login loop that could only end
106
+ * when the rate-limit window rolled. An error boundary is the honest outcome
107
+ * for "could not ask", and unlike a spurious sign-out it is recoverable.
98
108
  *
99
109
  * **Memoized per request for the plain, zero-argument call** - the shape
100
110
  * every page, layout and Server Action actually uses - via React's `cache()`.
@@ -123,4 +123,4 @@ export declare class ConnectClient {
123
123
  constructor(options: ConnectClientOptions);
124
124
  }
125
125
  /** Kept in step with `package.json` by `sdk.test.ts`. */
126
- export declare const SDK_VERSION = "0.24.0";
126
+ export declare const SDK_VERSION = "0.25.0";
@@ -25,6 +25,28 @@ export interface RequestSpec {
25
25
  */
26
26
  raw?: boolean;
27
27
  signal?: AbortSignal;
28
+ /**
29
+ * Force this request into the retry path even though it is a `POST`.
30
+ *
31
+ * The default rule below - retry anything that is not a `POST`, plus a
32
+ * `POST` carrying an `Idempotency-Key` - is right about writes and wrong
33
+ * about the handful of endpoints that are reads wearing a `POST` because
34
+ * their input is a credential that must not sit in a URL. `POST /sso/user`
35
+ * is the one that matters: it takes a session token and answers who that
36
+ * is, replaying it changes nothing, and it is the **most-called endpoint on
37
+ * the platform** because the SDK asks it on every request a partner serves.
38
+ *
39
+ * It was therefore also the only one that could never retry a 429 - the
40
+ * transport had a correct `Retry-After` backoff that this endpoint was
41
+ * excluded from by the shape of its verb. A throttled read went straight
42
+ * back to the caller as a failure, and both session helpers turned that
43
+ * into "signed out".
44
+ *
45
+ * Set it **only** where replaying genuinely cannot change server state.
46
+ * `POST /sso/token` must never set it: it consumes a one-time authorization
47
+ * code, and a replay is a second exchange of a code that no longer exists.
48
+ */
49
+ idempotent?: boolean;
28
50
  }
29
51
  export interface TransportOptions {
30
52
  baseUrl: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmoops/connect-sdk",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Official server-side SDK for the CosmoOps Connect platform API - Login with Connect, entitlements, billing and metering.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "commonjs",