@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 +85 -0
- package/dist/cjs/client/components/confirm-dialog.js +14 -6
- package/dist/cjs/client/provider.js +28 -7
- package/dist/cjs/client/use-active-path.js +12 -1
- package/dist/cjs/next/index.js +41 -3
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/http.js +3 -1
- package/dist/cjs/server/resources/identity.js +12 -2
- package/dist/esm/client/components/confirm-dialog.js +15 -7
- package/dist/esm/client/provider.js +28 -7
- package/dist/esm/client/use-active-path.js +12 -1
- package/dist/esm/next/index.js +41 -3
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/http.js +3 -1
- package/dist/esm/server/resources/identity.js +12 -2
- package/dist/types/client/components/confirm-dialog.d.ts +21 -6
- package/dist/types/next/index.d.ts +12 -2
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/server/http.d.ts +22 -0
- package/package.json +1 -1
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
|
|
23
|
-
* `Modal.Container` the sizing, `Modal.Dialog` the focus trap. The trigger
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* and the dialog
|
|
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)(
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
}
|
package/dist/cjs/next/index.js
CHANGED
|
@@ -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`
|
|
288
|
-
*
|
|
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()`.
|
package/dist/cjs/server/http.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
|
20
|
-
* `Modal.Container` the sizing, `Modal.Dialog` the focus trap. The trigger
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* and the dialog
|
|
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(
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
}
|
package/dist/esm/next/index.js
CHANGED
|
@@ -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`
|
|
258
|
-
*
|
|
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()`.
|
package/dist/esm/server/http.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
29
|
-
* `Modal.Container` the sizing, `Modal.Dialog` the focus trap. The trigger
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* and the dialog
|
|
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`
|
|
97
|
-
*
|
|
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()`.
|
|
@@ -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.
|
|
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",
|