@cosmoops/connect-sdk 0.34.0 → 0.34.1
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 +48 -0
- package/dist/cjs/client/components/subscription.js +6 -0
- package/dist/cjs/server/handler.js +162 -28
- package/dist/esm/client/components/subscription.js +6 -0
- package/dist/esm/server/handler.js +162 -28
- package/dist/types/client/components/subscription.d.ts +6 -0
- package/dist/types/types.d.ts +15 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,54 @@
|
|
|
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.34.1
|
|
7
|
+
|
|
8
|
+
Two tabs, or one abandoned sign-in and one fresh one, could not both work -
|
|
9
|
+
and when the older one came back it was bounced into a loop with nothing on
|
|
10
|
+
screen explaining it. If a customer has told you "I signed in and it showed me
|
|
11
|
+
the login page again", this is it.
|
|
12
|
+
|
|
13
|
+
### One browser may have several sign-ins in flight
|
|
14
|
+
|
|
15
|
+
`createConnectHandler`'s state cookie held a single `state:next` pair, so the
|
|
16
|
+
second `GET /signin` overwrote the first. The older round trip then came back
|
|
17
|
+
to a cookie naming a state the handler had never heard of, was answered with
|
|
18
|
+
`connect_error=state_mismatch`, and - because that sends the visitor to a page
|
|
19
|
+
your own session gate guards - got silently re-signed-in by your app, landing
|
|
20
|
+
them back on Connect's sign-in screen.
|
|
21
|
+
|
|
22
|
+
The cookie now holds up to **five** pending round trips, newest first, and a
|
|
23
|
+
callback consumes only its own entry. So finishing in one tab leaves the other
|
|
24
|
+
able to finish too, and each returns to the destination *it* asked for rather
|
|
25
|
+
than whichever one was started last.
|
|
26
|
+
|
|
27
|
+
Nothing in your code changes. The cookie's internal format did change -
|
|
28
|
+
entries are `state:flags:next` with the destination percent-encoded, joined by
|
|
29
|
+
`|` - and the handler still reads the old single-pair format, so sign-ins in
|
|
30
|
+
flight across your deploy complete rather than failing.
|
|
31
|
+
|
|
32
|
+
### Both retry bounds work for the first time
|
|
33
|
+
|
|
34
|
+
A `state_mismatch` had no bound at all, and `login_required`'s was
|
|
35
|
+
unreachable: the callback looked for `connect_retry=1` on its own URL, but
|
|
36
|
+
that parameter is set on `/signin`, and the `redirect_uri` handed to Connect is
|
|
37
|
+
a registered exact-match value that cannot carry extra query parameters. It
|
|
38
|
+
never arrived, so `login_required` could loop indefinitely too.
|
|
39
|
+
|
|
40
|
+
The marker now travels on the flow's own cookie entry, stamped at `/signin`
|
|
41
|
+
where it can actually be read. Each case gets one retry - the usual cause is a
|
|
42
|
+
lost entry for someone Connect has already signed in, which completes in a
|
|
43
|
+
single hop - and then surfaces `connect_error` instead of going round again.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- A `state_mismatch` no longer replays the state cookie's destination. With no
|
|
48
|
+
matching entry there is no destination that flow owns, so it falls back to
|
|
49
|
+
`defaultRedirect` rather than one belonging to a different round trip.
|
|
50
|
+
- A `prompt=none` probe whose state no longer matches still returns quietly to
|
|
51
|
+
`next`. The silent check now happens before the state check, so the new
|
|
52
|
+
retry can never escalate a silent probe into a visible sign-in.
|
|
53
|
+
|
|
6
54
|
## 0.34.0
|
|
7
55
|
|
|
8
56
|
Getting a blocked customer unblocked, and getting them back afterwards - plus
|
|
@@ -127,6 +127,12 @@ function NextEvent({ detail, state, }) {
|
|
|
127
127
|
* count against, so it renders nothing rather than a meter that is always
|
|
128
128
|
* empty. Under `"per-seat"` going over is billable rather than blocked, so it
|
|
129
129
|
* says what the extra costs instead of warning.
|
|
130
|
+
*
|
|
131
|
+
* `detail.seatCount` is a live count of the members holding a role for this
|
|
132
|
+
* application. It used to be the subscription's last-billed snapshot, which
|
|
133
|
+
* only ever moved on a `"per-seat"` plan - so a `"fixed"` single-seat plan
|
|
134
|
+
* read "0 of 1 seat used" permanently, including when its one seat was taken
|
|
135
|
+
* and Connect was already refusing the next assignment.
|
|
130
136
|
*/
|
|
131
137
|
function ConnectSeatUsage({ plan, detail, className, }) {
|
|
132
138
|
if (plan.seats.model === "unlimited")
|
|
@@ -58,6 +58,99 @@ function sameState(a, b) {
|
|
|
58
58
|
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
59
59
|
return diff === 0;
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* How many sign-ins one browser may have in flight at once.
|
|
63
|
+
*
|
|
64
|
+
* The state cookie used to hold a *single* `state:next` pair, so a second
|
|
65
|
+
* `GET /signin` overwrote the first. Two tabs - or one abandoned attempt and
|
|
66
|
+
* one fresh one - and the older round trip came back to a cookie naming a
|
|
67
|
+
* state it had never heard of: `connect_error=state_mismatch`, which the host
|
|
68
|
+
* app's own session gate answers by silently firing another `/signin`, putting
|
|
69
|
+
* the visitor back on the sign-in screen with nothing on the page saying why.
|
|
70
|
+
*
|
|
71
|
+
* Five is far past what anyone opens by hand and still a cookie nobody
|
|
72
|
+
* notices - each entry is a 43-character state plus a short path. Entries are
|
|
73
|
+
* newest-first and the oldest fall off the end, so the tab a visitor is
|
|
74
|
+
* actually looking at is the last one to be forgotten.
|
|
75
|
+
*
|
|
76
|
+
* It does not weaken the CSRF check: a callback must still present one of
|
|
77
|
+
* these exact 256-bit values, and each still expires with the cookie.
|
|
78
|
+
*/
|
|
79
|
+
const MAX_PENDING_STATES = 5;
|
|
80
|
+
/** `-` and `r` are the only flag fields this format writes - see `decodePendingState`. */
|
|
81
|
+
const RETRIED_FLAG = "r";
|
|
82
|
+
const NO_FLAGS = "-";
|
|
83
|
+
/**
|
|
84
|
+
* The state cookie's wire format: one entry per in-flight sign-in, newest
|
|
85
|
+
* first, joined by `|`.
|
|
86
|
+
*
|
|
87
|
+
* An entry is `state:flags:next` with the destination **percent-encoded**
|
|
88
|
+
* rather than written raw, and that is what makes splitting on a delimiter
|
|
89
|
+
* safe: `state` is base64url so it can hold neither, and an encoded path
|
|
90
|
+
* cannot be read as a field boundary however many colons or pipes it
|
|
91
|
+
* contains. The raw single-pair format this replaces is exactly why `retried`
|
|
92
|
+
* and the silent marker had to live outside the cookie - the split was too
|
|
93
|
+
* fragile to carry a third field.
|
|
94
|
+
*/
|
|
95
|
+
function encodePendingStates(entries) {
|
|
96
|
+
return entries
|
|
97
|
+
.map((entry) => `${entry.state}:${entry.retried ? RETRIED_FLAG : NO_FLAGS}:${encodeURIComponent(entry.next)}`)
|
|
98
|
+
.join("|");
|
|
99
|
+
}
|
|
100
|
+
function decodePendingStates(raw) {
|
|
101
|
+
if (!raw)
|
|
102
|
+
return [];
|
|
103
|
+
const entries = [];
|
|
104
|
+
for (const part of raw.split("|")) {
|
|
105
|
+
const entry = decodePendingState(part);
|
|
106
|
+
if (entry)
|
|
107
|
+
entries.push(entry);
|
|
108
|
+
}
|
|
109
|
+
return entries;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* One entry, tolerating the format this replaced.
|
|
113
|
+
*
|
|
114
|
+
* A cookie minted by the previous version is a single raw `state:next` pair,
|
|
115
|
+
* and sign-ins in flight across a deploy carry one. The flag field is a known
|
|
116
|
+
* one-character set, so anything else in that position means the remainder is
|
|
117
|
+
* a legacy destination - including one that itself contains a colon. Dropping
|
|
118
|
+
* those instead would answer every mid-deploy callback with
|
|
119
|
+
* `state_mismatch`.
|
|
120
|
+
*/
|
|
121
|
+
function decodePendingState(part) {
|
|
122
|
+
const firstColon = part.indexOf(":");
|
|
123
|
+
if (firstColon <= 0)
|
|
124
|
+
return null;
|
|
125
|
+
const state = part.slice(0, firstColon);
|
|
126
|
+
const remainder = part.slice(firstColon + 1);
|
|
127
|
+
const secondColon = remainder.indexOf(":");
|
|
128
|
+
const flags = secondColon === -1 ? "" : remainder.slice(0, secondColon);
|
|
129
|
+
const hasFlags = flags === NO_FLAGS || flags === RETRIED_FLAG;
|
|
130
|
+
return {
|
|
131
|
+
state,
|
|
132
|
+
next: decodeDestination(hasFlags ? remainder.slice(secondColon + 1) : remainder),
|
|
133
|
+
retried: flags === RETRIED_FLAG,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* `decodeURIComponent` throws on a lone `%`, which a legacy raw destination
|
|
138
|
+
* may well hold. The undecoded string then goes through `safeRedirect` like
|
|
139
|
+
* any other candidate, so a malformed one costs the default destination
|
|
140
|
+
* rather than a 500.
|
|
141
|
+
*/
|
|
142
|
+
function decodeDestination(value) {
|
|
143
|
+
try {
|
|
144
|
+
return decodeURIComponent(value);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Appends `connect_error=<code>` to a destination that may already carry a query. */
|
|
151
|
+
function withError(next, code) {
|
|
152
|
+
return `${next}${next.includes("?") ? "&" : "?"}connect_error=${encodeURIComponent(code)}`;
|
|
153
|
+
}
|
|
61
154
|
function readCookie(request, name) {
|
|
62
155
|
const header = request.headers.get("cookie");
|
|
63
156
|
if (!header)
|
|
@@ -130,6 +223,10 @@ function createConnectHandler(options) {
|
|
|
130
223
|
* the cross-site redirect back from Connect, which is the one request that
|
|
131
224
|
* has to read it. Scoped to `basePath` so it is never attached to ordinary
|
|
132
225
|
* page loads.
|
|
226
|
+
*
|
|
227
|
+
* Holds every in-flight round trip rather than the latest one - see
|
|
228
|
+
* `MAX_PENDING_STATES` and `encodePendingStates`. An empty value clears it,
|
|
229
|
+
* which is what an exchange that consumed the last pending entry wants.
|
|
133
230
|
*/
|
|
134
231
|
function stateCookie(value, secure) {
|
|
135
232
|
const cleared = value === "";
|
|
@@ -959,6 +1056,17 @@ function createConnectHandler(options) {
|
|
|
959
1056
|
* alongside the post-login destination, and bounces the browser to
|
|
960
1057
|
* Connect.
|
|
961
1058
|
*
|
|
1059
|
+
* The new entry is **prepended to whatever is already pending** rather
|
|
1060
|
+
* than replacing it, so opening the app in a second tab no longer
|
|
1061
|
+
* invalidates the first - see `MAX_PENDING_STATES`.
|
|
1062
|
+
*
|
|
1063
|
+
* `?connect_retry=1` is stamped onto the entry here, which is the only
|
|
1064
|
+
* place that marker can be read: it is a query parameter on *this* URL,
|
|
1065
|
+
* and the `redirect_uri` handed to Connect is a registered, exact-match
|
|
1066
|
+
* value that cannot carry it. The callback used to look for it on its own
|
|
1067
|
+
* URL, where it could never arrive - so both retry bounds below were
|
|
1068
|
+
* unreachable and `login_required` could loop indefinitely.
|
|
1069
|
+
*
|
|
962
1070
|
* Registered only when the session store can `write` - without that, the
|
|
963
1071
|
* callback would have nowhere to put the session it just earned.
|
|
964
1072
|
*/
|
|
@@ -974,9 +1082,13 @@ function createConnectHandler(options) {
|
|
|
974
1082
|
state,
|
|
975
1083
|
...(prompt ? { prompt } : {}),
|
|
976
1084
|
});
|
|
977
|
-
// State and destination travel together in one
|
|
1085
|
+
// State and destination travel together in one entry so the callback
|
|
978
1086
|
// cannot end up with one and not the other.
|
|
979
|
-
const
|
|
1087
|
+
const pending = [
|
|
1088
|
+
{ state, next, retried: url.searchParams.get("connect_retry") === "1" },
|
|
1089
|
+
...decodePendingStates(readCookie(request, stateCookieName)),
|
|
1090
|
+
].slice(0, MAX_PENDING_STATES);
|
|
1091
|
+
const cookies = [stateCookie(encodePendingStates(pending), secure)];
|
|
980
1092
|
if (prompt === "none")
|
|
981
1093
|
cookies.push(silentCookie("1", secure));
|
|
982
1094
|
return redirect(authorizeUrl, cookies);
|
|
@@ -989,20 +1101,49 @@ function createConnectHandler(options) {
|
|
|
989
1101
|
"GET /callback": async (request) => {
|
|
990
1102
|
const url = new URL(request.url);
|
|
991
1103
|
const secure = url.protocol === "https:";
|
|
992
|
-
const
|
|
993
|
-
const next = safeRedirect(destinationParts.join(":") || null, defaultRedirect);
|
|
994
|
-
const clearState = stateCookie("", secure);
|
|
1104
|
+
const pending = decodePendingStates(readCookie(request, stateCookieName));
|
|
995
1105
|
const returnedState = url.searchParams.get("state") ?? "";
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1106
|
+
const matched = pending.find((entry) => sameState(entry.state, returnedState)) ?? null;
|
|
1107
|
+
// Every *other* in-flight sign-in survives this one. Clearing the whole
|
|
1108
|
+
// cookie is what made a second tab collateral damage: finishing in one
|
|
1109
|
+
// tab left the other holding a state the server had just forgotten.
|
|
1110
|
+
const settleState = stateCookie(encodePendingStates(pending.filter((entry) => entry !== matched)), secure);
|
|
1111
|
+
// Only ever this flow's own destination. With no match there is no
|
|
1112
|
+
// destination to trust - a `next` belonging to a different round trip is
|
|
1113
|
+
// somebody else's business, so that case takes the default.
|
|
1114
|
+
const next = safeRedirect(matched?.next ?? null, defaultRedirect);
|
|
1115
|
+
// Read before the state check, not after. A `prompt=none` probe must
|
|
1116
|
+
// never escalate into a visible sign-in - including the retry below -
|
|
1117
|
+
// and the mismatch branch is reachable for a silent probe whose cookie
|
|
1118
|
+
// has expired.
|
|
1004
1119
|
const wasSilent = readCookie(request, silentCookieName) === "1";
|
|
1005
1120
|
const clearSilent = silentCookie("", secure);
|
|
1121
|
+
/** One more interactive attempt, marked so the next pass can tell. */
|
|
1122
|
+
const retrySignIn = () => {
|
|
1123
|
+
const retry = new URL(`${url.origin}${basePath}/signin`);
|
|
1124
|
+
retry.searchParams.set("next", next);
|
|
1125
|
+
retry.searchParams.set("connect_retry", "1");
|
|
1126
|
+
return redirect(retry.toString(), [settleState, clearSilent]);
|
|
1127
|
+
};
|
|
1128
|
+
if (!matched) {
|
|
1129
|
+
// A stale tab whose cookie has expired, more entries in flight than
|
|
1130
|
+
// `MAX_PENDING_STATES` holds, or a forged callback. None of them mints
|
|
1131
|
+
// a session and none is worth an error page.
|
|
1132
|
+
//
|
|
1133
|
+
// One retry, because the overwhelmingly common cause is a round trip
|
|
1134
|
+
// whose own entry is simply gone and whose visitor Connect has already
|
|
1135
|
+
// signed in - that completes in a single hop. The bound reads *any*
|
|
1136
|
+
// pending entry's marker rather than the matched one, there being
|
|
1137
|
+
// none: a browser that has already been bounced once gets the error
|
|
1138
|
+
// instead of another lap. Without it this is the silent infinite loop,
|
|
1139
|
+
// since the host app's session gate answers `/dashboard` with no
|
|
1140
|
+
// session by firing `/signin` again.
|
|
1141
|
+
if (wasSilent)
|
|
1142
|
+
return redirect(next, [settleState, clearSilent]);
|
|
1143
|
+
if (!pending.some((entry) => entry.retried))
|
|
1144
|
+
return retrySignIn();
|
|
1145
|
+
return redirect(withError(next, "state_mismatch"), [settleState, clearSilent]);
|
|
1146
|
+
}
|
|
1006
1147
|
const error = url.searchParams.get("error");
|
|
1007
1148
|
if (error) {
|
|
1008
1149
|
// A silent (`prompt=none`) probe that came back empty-handed. All
|
|
@@ -1017,27 +1158,20 @@ function createConnectHandler(options) {
|
|
|
1017
1158
|
// if they're already here" into "force everyone to log in", which is
|
|
1018
1159
|
// the precise opposite of the feature.
|
|
1019
1160
|
if (wasSilent) {
|
|
1020
|
-
return redirect(next, [
|
|
1161
|
+
return redirect(next, [settleState, clearSilent]);
|
|
1021
1162
|
}
|
|
1022
1163
|
// `login_required` is normal traffic, not a fault: a new account gets
|
|
1023
1164
|
// pulled out to their inbox to verify, and the ~15 minute request
|
|
1024
1165
|
// expires before they return. Connect has already signed them in, so
|
|
1025
|
-
// one silent retry completes in a single hop.
|
|
1026
|
-
// it - a genuine loop must surface, not spin.
|
|
1027
|
-
if (error === "login_required" &&
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
retry.searchParams.set("connect_retry", "1");
|
|
1031
|
-
return redirect(retry.toString(), [clearState, clearSilent]);
|
|
1032
|
-
}
|
|
1033
|
-
return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=${encodeURIComponent(error)}`, [
|
|
1034
|
-
clearState,
|
|
1035
|
-
clearSilent,
|
|
1036
|
-
]);
|
|
1166
|
+
// one silent retry completes in a single hop. The marker on this
|
|
1167
|
+
// flow's own entry bounds it - a genuine loop must surface, not spin.
|
|
1168
|
+
if (error === "login_required" && !matched.retried)
|
|
1169
|
+
return retrySignIn();
|
|
1170
|
+
return redirect(withError(next, error), [settleState, clearSilent]);
|
|
1037
1171
|
}
|
|
1038
1172
|
const code = url.searchParams.get("code");
|
|
1039
1173
|
if (!code) {
|
|
1040
|
-
return redirect(
|
|
1174
|
+
return redirect(withError(next, "missing_code"), [settleState, clearSilent]);
|
|
1041
1175
|
}
|
|
1042
1176
|
const result = await client.identity.exchangeCode({
|
|
1043
1177
|
code,
|
|
@@ -1046,7 +1180,7 @@ function createConnectHandler(options) {
|
|
|
1046
1180
|
// Verified before the session is stored, not after - an unverifiable
|
|
1047
1181
|
// token must never become a signed-in user.
|
|
1048
1182
|
await client.identity.verifyIdToken(result.idToken);
|
|
1049
|
-
const response = redirect(next, [
|
|
1183
|
+
const response = redirect(next, [settleState, clearSilent]);
|
|
1050
1184
|
await session.write(request, response, result.sessionToken);
|
|
1051
1185
|
return response;
|
|
1052
1186
|
},
|
|
@@ -119,6 +119,12 @@ function NextEvent({ detail, state, }) {
|
|
|
119
119
|
* count against, so it renders nothing rather than a meter that is always
|
|
120
120
|
* empty. Under `"per-seat"` going over is billable rather than blocked, so it
|
|
121
121
|
* says what the extra costs instead of warning.
|
|
122
|
+
*
|
|
123
|
+
* `detail.seatCount` is a live count of the members holding a role for this
|
|
124
|
+
* application. It used to be the subscription's last-billed snapshot, which
|
|
125
|
+
* only ever moved on a `"per-seat"` plan - so a `"fixed"` single-seat plan
|
|
126
|
+
* read "0 of 1 seat used" permanently, including when its one seat was taken
|
|
127
|
+
* and Connect was already refusing the next assignment.
|
|
122
128
|
*/
|
|
123
129
|
export function ConnectSeatUsage({ plan, detail, className, }) {
|
|
124
130
|
if (plan.seats.model === "unlimited")
|
|
@@ -55,6 +55,99 @@ function sameState(a, b) {
|
|
|
55
55
|
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
56
56
|
return diff === 0;
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* How many sign-ins one browser may have in flight at once.
|
|
60
|
+
*
|
|
61
|
+
* The state cookie used to hold a *single* `state:next` pair, so a second
|
|
62
|
+
* `GET /signin` overwrote the first. Two tabs - or one abandoned attempt and
|
|
63
|
+
* one fresh one - and the older round trip came back to a cookie naming a
|
|
64
|
+
* state it had never heard of: `connect_error=state_mismatch`, which the host
|
|
65
|
+
* app's own session gate answers by silently firing another `/signin`, putting
|
|
66
|
+
* the visitor back on the sign-in screen with nothing on the page saying why.
|
|
67
|
+
*
|
|
68
|
+
* Five is far past what anyone opens by hand and still a cookie nobody
|
|
69
|
+
* notices - each entry is a 43-character state plus a short path. Entries are
|
|
70
|
+
* newest-first and the oldest fall off the end, so the tab a visitor is
|
|
71
|
+
* actually looking at is the last one to be forgotten.
|
|
72
|
+
*
|
|
73
|
+
* It does not weaken the CSRF check: a callback must still present one of
|
|
74
|
+
* these exact 256-bit values, and each still expires with the cookie.
|
|
75
|
+
*/
|
|
76
|
+
const MAX_PENDING_STATES = 5;
|
|
77
|
+
/** `-` and `r` are the only flag fields this format writes - see `decodePendingState`. */
|
|
78
|
+
const RETRIED_FLAG = "r";
|
|
79
|
+
const NO_FLAGS = "-";
|
|
80
|
+
/**
|
|
81
|
+
* The state cookie's wire format: one entry per in-flight sign-in, newest
|
|
82
|
+
* first, joined by `|`.
|
|
83
|
+
*
|
|
84
|
+
* An entry is `state:flags:next` with the destination **percent-encoded**
|
|
85
|
+
* rather than written raw, and that is what makes splitting on a delimiter
|
|
86
|
+
* safe: `state` is base64url so it can hold neither, and an encoded path
|
|
87
|
+
* cannot be read as a field boundary however many colons or pipes it
|
|
88
|
+
* contains. The raw single-pair format this replaces is exactly why `retried`
|
|
89
|
+
* and the silent marker had to live outside the cookie - the split was too
|
|
90
|
+
* fragile to carry a third field.
|
|
91
|
+
*/
|
|
92
|
+
function encodePendingStates(entries) {
|
|
93
|
+
return entries
|
|
94
|
+
.map((entry) => `${entry.state}:${entry.retried ? RETRIED_FLAG : NO_FLAGS}:${encodeURIComponent(entry.next)}`)
|
|
95
|
+
.join("|");
|
|
96
|
+
}
|
|
97
|
+
function decodePendingStates(raw) {
|
|
98
|
+
if (!raw)
|
|
99
|
+
return [];
|
|
100
|
+
const entries = [];
|
|
101
|
+
for (const part of raw.split("|")) {
|
|
102
|
+
const entry = decodePendingState(part);
|
|
103
|
+
if (entry)
|
|
104
|
+
entries.push(entry);
|
|
105
|
+
}
|
|
106
|
+
return entries;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* One entry, tolerating the format this replaced.
|
|
110
|
+
*
|
|
111
|
+
* A cookie minted by the previous version is a single raw `state:next` pair,
|
|
112
|
+
* and sign-ins in flight across a deploy carry one. The flag field is a known
|
|
113
|
+
* one-character set, so anything else in that position means the remainder is
|
|
114
|
+
* a legacy destination - including one that itself contains a colon. Dropping
|
|
115
|
+
* those instead would answer every mid-deploy callback with
|
|
116
|
+
* `state_mismatch`.
|
|
117
|
+
*/
|
|
118
|
+
function decodePendingState(part) {
|
|
119
|
+
const firstColon = part.indexOf(":");
|
|
120
|
+
if (firstColon <= 0)
|
|
121
|
+
return null;
|
|
122
|
+
const state = part.slice(0, firstColon);
|
|
123
|
+
const remainder = part.slice(firstColon + 1);
|
|
124
|
+
const secondColon = remainder.indexOf(":");
|
|
125
|
+
const flags = secondColon === -1 ? "" : remainder.slice(0, secondColon);
|
|
126
|
+
const hasFlags = flags === NO_FLAGS || flags === RETRIED_FLAG;
|
|
127
|
+
return {
|
|
128
|
+
state,
|
|
129
|
+
next: decodeDestination(hasFlags ? remainder.slice(secondColon + 1) : remainder),
|
|
130
|
+
retried: flags === RETRIED_FLAG,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* `decodeURIComponent` throws on a lone `%`, which a legacy raw destination
|
|
135
|
+
* may well hold. The undecoded string then goes through `safeRedirect` like
|
|
136
|
+
* any other candidate, so a malformed one costs the default destination
|
|
137
|
+
* rather than a 500.
|
|
138
|
+
*/
|
|
139
|
+
function decodeDestination(value) {
|
|
140
|
+
try {
|
|
141
|
+
return decodeURIComponent(value);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Appends `connect_error=<code>` to a destination that may already carry a query. */
|
|
148
|
+
function withError(next, code) {
|
|
149
|
+
return `${next}${next.includes("?") ? "&" : "?"}connect_error=${encodeURIComponent(code)}`;
|
|
150
|
+
}
|
|
58
151
|
function readCookie(request, name) {
|
|
59
152
|
const header = request.headers.get("cookie");
|
|
60
153
|
if (!header)
|
|
@@ -127,6 +220,10 @@ export function createConnectHandler(options) {
|
|
|
127
220
|
* the cross-site redirect back from Connect, which is the one request that
|
|
128
221
|
* has to read it. Scoped to `basePath` so it is never attached to ordinary
|
|
129
222
|
* page loads.
|
|
223
|
+
*
|
|
224
|
+
* Holds every in-flight round trip rather than the latest one - see
|
|
225
|
+
* `MAX_PENDING_STATES` and `encodePendingStates`. An empty value clears it,
|
|
226
|
+
* which is what an exchange that consumed the last pending entry wants.
|
|
130
227
|
*/
|
|
131
228
|
function stateCookie(value, secure) {
|
|
132
229
|
const cleared = value === "";
|
|
@@ -956,6 +1053,17 @@ export function createConnectHandler(options) {
|
|
|
956
1053
|
* alongside the post-login destination, and bounces the browser to
|
|
957
1054
|
* Connect.
|
|
958
1055
|
*
|
|
1056
|
+
* The new entry is **prepended to whatever is already pending** rather
|
|
1057
|
+
* than replacing it, so opening the app in a second tab no longer
|
|
1058
|
+
* invalidates the first - see `MAX_PENDING_STATES`.
|
|
1059
|
+
*
|
|
1060
|
+
* `?connect_retry=1` is stamped onto the entry here, which is the only
|
|
1061
|
+
* place that marker can be read: it is a query parameter on *this* URL,
|
|
1062
|
+
* and the `redirect_uri` handed to Connect is a registered, exact-match
|
|
1063
|
+
* value that cannot carry it. The callback used to look for it on its own
|
|
1064
|
+
* URL, where it could never arrive - so both retry bounds below were
|
|
1065
|
+
* unreachable and `login_required` could loop indefinitely.
|
|
1066
|
+
*
|
|
959
1067
|
* Registered only when the session store can `write` - without that, the
|
|
960
1068
|
* callback would have nowhere to put the session it just earned.
|
|
961
1069
|
*/
|
|
@@ -971,9 +1079,13 @@ export function createConnectHandler(options) {
|
|
|
971
1079
|
state,
|
|
972
1080
|
...(prompt ? { prompt } : {}),
|
|
973
1081
|
});
|
|
974
|
-
// State and destination travel together in one
|
|
1082
|
+
// State and destination travel together in one entry so the callback
|
|
975
1083
|
// cannot end up with one and not the other.
|
|
976
|
-
const
|
|
1084
|
+
const pending = [
|
|
1085
|
+
{ state, next, retried: url.searchParams.get("connect_retry") === "1" },
|
|
1086
|
+
...decodePendingStates(readCookie(request, stateCookieName)),
|
|
1087
|
+
].slice(0, MAX_PENDING_STATES);
|
|
1088
|
+
const cookies = [stateCookie(encodePendingStates(pending), secure)];
|
|
977
1089
|
if (prompt === "none")
|
|
978
1090
|
cookies.push(silentCookie("1", secure));
|
|
979
1091
|
return redirect(authorizeUrl, cookies);
|
|
@@ -986,20 +1098,49 @@ export function createConnectHandler(options) {
|
|
|
986
1098
|
"GET /callback": async (request) => {
|
|
987
1099
|
const url = new URL(request.url);
|
|
988
1100
|
const secure = url.protocol === "https:";
|
|
989
|
-
const
|
|
990
|
-
const next = safeRedirect(destinationParts.join(":") || null, defaultRedirect);
|
|
991
|
-
const clearState = stateCookie("", secure);
|
|
1101
|
+
const pending = decodePendingStates(readCookie(request, stateCookieName));
|
|
992
1102
|
const returnedState = url.searchParams.get("state") ?? "";
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1103
|
+
const matched = pending.find((entry) => sameState(entry.state, returnedState)) ?? null;
|
|
1104
|
+
// Every *other* in-flight sign-in survives this one. Clearing the whole
|
|
1105
|
+
// cookie is what made a second tab collateral damage: finishing in one
|
|
1106
|
+
// tab left the other holding a state the server had just forgotten.
|
|
1107
|
+
const settleState = stateCookie(encodePendingStates(pending.filter((entry) => entry !== matched)), secure);
|
|
1108
|
+
// Only ever this flow's own destination. With no match there is no
|
|
1109
|
+
// destination to trust - a `next` belonging to a different round trip is
|
|
1110
|
+
// somebody else's business, so that case takes the default.
|
|
1111
|
+
const next = safeRedirect(matched?.next ?? null, defaultRedirect);
|
|
1112
|
+
// Read before the state check, not after. A `prompt=none` probe must
|
|
1113
|
+
// never escalate into a visible sign-in - including the retry below -
|
|
1114
|
+
// and the mismatch branch is reachable for a silent probe whose cookie
|
|
1115
|
+
// has expired.
|
|
1001
1116
|
const wasSilent = readCookie(request, silentCookieName) === "1";
|
|
1002
1117
|
const clearSilent = silentCookie("", secure);
|
|
1118
|
+
/** One more interactive attempt, marked so the next pass can tell. */
|
|
1119
|
+
const retrySignIn = () => {
|
|
1120
|
+
const retry = new URL(`${url.origin}${basePath}/signin`);
|
|
1121
|
+
retry.searchParams.set("next", next);
|
|
1122
|
+
retry.searchParams.set("connect_retry", "1");
|
|
1123
|
+
return redirect(retry.toString(), [settleState, clearSilent]);
|
|
1124
|
+
};
|
|
1125
|
+
if (!matched) {
|
|
1126
|
+
// A stale tab whose cookie has expired, more entries in flight than
|
|
1127
|
+
// `MAX_PENDING_STATES` holds, or a forged callback. None of them mints
|
|
1128
|
+
// a session and none is worth an error page.
|
|
1129
|
+
//
|
|
1130
|
+
// One retry, because the overwhelmingly common cause is a round trip
|
|
1131
|
+
// whose own entry is simply gone and whose visitor Connect has already
|
|
1132
|
+
// signed in - that completes in a single hop. The bound reads *any*
|
|
1133
|
+
// pending entry's marker rather than the matched one, there being
|
|
1134
|
+
// none: a browser that has already been bounced once gets the error
|
|
1135
|
+
// instead of another lap. Without it this is the silent infinite loop,
|
|
1136
|
+
// since the host app's session gate answers `/dashboard` with no
|
|
1137
|
+
// session by firing `/signin` again.
|
|
1138
|
+
if (wasSilent)
|
|
1139
|
+
return redirect(next, [settleState, clearSilent]);
|
|
1140
|
+
if (!pending.some((entry) => entry.retried))
|
|
1141
|
+
return retrySignIn();
|
|
1142
|
+
return redirect(withError(next, "state_mismatch"), [settleState, clearSilent]);
|
|
1143
|
+
}
|
|
1003
1144
|
const error = url.searchParams.get("error");
|
|
1004
1145
|
if (error) {
|
|
1005
1146
|
// A silent (`prompt=none`) probe that came back empty-handed. All
|
|
@@ -1014,27 +1155,20 @@ export function createConnectHandler(options) {
|
|
|
1014
1155
|
// if they're already here" into "force everyone to log in", which is
|
|
1015
1156
|
// the precise opposite of the feature.
|
|
1016
1157
|
if (wasSilent) {
|
|
1017
|
-
return redirect(next, [
|
|
1158
|
+
return redirect(next, [settleState, clearSilent]);
|
|
1018
1159
|
}
|
|
1019
1160
|
// `login_required` is normal traffic, not a fault: a new account gets
|
|
1020
1161
|
// pulled out to their inbox to verify, and the ~15 minute request
|
|
1021
1162
|
// expires before they return. Connect has already signed them in, so
|
|
1022
|
-
// one silent retry completes in a single hop.
|
|
1023
|
-
// it - a genuine loop must surface, not spin.
|
|
1024
|
-
if (error === "login_required" &&
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
retry.searchParams.set("connect_retry", "1");
|
|
1028
|
-
return redirect(retry.toString(), [clearState, clearSilent]);
|
|
1029
|
-
}
|
|
1030
|
-
return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=${encodeURIComponent(error)}`, [
|
|
1031
|
-
clearState,
|
|
1032
|
-
clearSilent,
|
|
1033
|
-
]);
|
|
1163
|
+
// one silent retry completes in a single hop. The marker on this
|
|
1164
|
+
// flow's own entry bounds it - a genuine loop must surface, not spin.
|
|
1165
|
+
if (error === "login_required" && !matched.retried)
|
|
1166
|
+
return retrySignIn();
|
|
1167
|
+
return redirect(withError(next, error), [settleState, clearSilent]);
|
|
1034
1168
|
}
|
|
1035
1169
|
const code = url.searchParams.get("code");
|
|
1036
1170
|
if (!code) {
|
|
1037
|
-
return redirect(
|
|
1171
|
+
return redirect(withError(next, "missing_code"), [settleState, clearSilent]);
|
|
1038
1172
|
}
|
|
1039
1173
|
const result = await client.identity.exchangeCode({
|
|
1040
1174
|
code,
|
|
@@ -1043,7 +1177,7 @@ export function createConnectHandler(options) {
|
|
|
1043
1177
|
// Verified before the session is stored, not after - an unverifiable
|
|
1044
1178
|
// token must never become a signed-in user.
|
|
1045
1179
|
await client.identity.verifyIdToken(result.idToken);
|
|
1046
|
-
const response = redirect(next, [
|
|
1180
|
+
const response = redirect(next, [settleState, clearSilent]);
|
|
1047
1181
|
await session.write(request, response, result.sessionToken);
|
|
1048
1182
|
return response;
|
|
1049
1183
|
},
|
|
@@ -61,6 +61,12 @@ export declare function ConnectSubscriptionCard({ plansHref, className }: Connec
|
|
|
61
61
|
* count against, so it renders nothing rather than a meter that is always
|
|
62
62
|
* empty. Under `"per-seat"` going over is billable rather than blocked, so it
|
|
63
63
|
* says what the extra costs instead of warning.
|
|
64
|
+
*
|
|
65
|
+
* `detail.seatCount` is a live count of the members holding a role for this
|
|
66
|
+
* application. It used to be the subscription's last-billed snapshot, which
|
|
67
|
+
* only ever moved on a `"per-seat"` plan - so a `"fixed"` single-seat plan
|
|
68
|
+
* read "0 of 1 seat used" permanently, including when its one seat was taken
|
|
69
|
+
* and Connect was already refusing the next assignment.
|
|
64
70
|
*/
|
|
65
71
|
export declare function ConnectSeatUsage({ plan, detail, className, }: Readonly<{
|
|
66
72
|
plan: SubscribedPlan;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -442,6 +442,14 @@ export interface SubscriptionDetail {
|
|
|
442
442
|
id: string;
|
|
443
443
|
status: SubscriptionStatus;
|
|
444
444
|
cancelAtPeriodEnd: boolean;
|
|
445
|
+
/**
|
|
446
|
+
* How many workspace members currently hold a role for your application,
|
|
447
|
+
* counted when you read it. Compare it against `plan.seats.included`.
|
|
448
|
+
*
|
|
449
|
+
* `0` on an `"unlimited"` plan - there is no ceiling to count against, so
|
|
450
|
+
* Connect does not pay for the count. `ConnectSeatUsage` renders nothing in
|
|
451
|
+
* that case for the same reason.
|
|
452
|
+
*/
|
|
445
453
|
seatCount: number;
|
|
446
454
|
addons: string[];
|
|
447
455
|
currentBillingCycle: {
|
|
@@ -474,6 +482,13 @@ export interface ApplicationSubscriber {
|
|
|
474
482
|
/** `null` if the plan was hard-deleted rather than archived. */
|
|
475
483
|
planName: string | null;
|
|
476
484
|
status: "created" | "authenticated" | "active" | "paused" | "cancelled";
|
|
485
|
+
/**
|
|
486
|
+
* The last-billed seat snapshot, not the live count `SubscriptionDetail`
|
|
487
|
+
* carries: a page of subscribers cannot afford one member scan per row. It
|
|
488
|
+
* is `0` unless the plan is `"per-seat"`, which is the only model that
|
|
489
|
+
* bills by seat. Read the single workspace's subscription for "who is
|
|
490
|
+
* assigned right now".
|
|
491
|
+
*/
|
|
477
492
|
seatCount: number;
|
|
478
493
|
/** ISO 8601. */
|
|
479
494
|
subscribedAt: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmoops/connect-sdk",
|
|
3
|
-
"version": "0.34.
|
|
3
|
+
"version": "0.34.1",
|
|
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",
|