@cosmoops/connect-sdk 0.34.0 → 0.35.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 +146 -0
- package/dist/cjs/cli/doctor.js +39 -5
- package/dist/cjs/client/components/shell-context.js +35 -6
- package/dist/cjs/client/components/sidebar.js +6 -1
- package/dist/cjs/client/components/subscription.js +6 -0
- package/dist/cjs/next/index.js +56 -1
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/handler.js +162 -28
- package/dist/esm/cli/doctor.js +38 -5
- package/dist/esm/client/components/shell-context.js +35 -6
- package/dist/esm/client/components/sidebar.js +6 -1
- package/dist/esm/client/components/subscription.js +6 -0
- package/dist/esm/next/index.js +56 -2
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/handler.js +162 -28
- package/dist/types/client/components/shell-context.d.ts +39 -7
- package/dist/types/client/components/sidebar.d.ts +2 -2
- package/dist/types/client/components/subscription.d.ts +6 -0
- package/dist/types/next/index.d.ts +49 -0
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/types.d.ts +15 -0
- package/package.json +1 -1
|
@@ -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
|
},
|
|
@@ -25,22 +25,51 @@ export declare const RAIL_GUTTER = 12;
|
|
|
25
25
|
* The row shape every sidebar control shares - nav items, the workspace tile,
|
|
26
26
|
* the launcher, the user button.
|
|
27
27
|
*
|
|
28
|
+
* **The height is not here.** It rides on `SIDEBAR_ROW_ICON` /
|
|
29
|
+
* `SIDEBAR_ROW_TILE` instead, because the two glyph sizes want different
|
|
30
|
+
* heights and only one of them is in the region that overflows - see those
|
|
31
|
+
* two constants.
|
|
32
|
+
*
|
|
28
33
|
* `overflow-hidden` + `whitespace-nowrap` is what makes the collapse smooth:
|
|
29
34
|
* the label is *clipped* by the shrinking rail rather than re-wrapped or
|
|
30
35
|
* re-measured, so a width animation costs no reflow. Fading the label with
|
|
31
36
|
* `SidebarLabel` on top of that hides the clip itself.
|
|
32
37
|
*/
|
|
33
|
-
export declare const SIDEBAR_ROW = "group/row relative flex
|
|
38
|
+
export declare const SIDEBAR_ROW = "group/row relative flex w-full shrink-0 items-center justify-start gap-3 overflow-hidden whitespace-nowrap rounded-lg pe-2 text-sm font-normal transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus";
|
|
34
39
|
/**
|
|
35
|
-
* Leading inset for a row whose glyph is 20px wide (a nav icon,
|
|
40
|
+
* Leading inset *and height* for a row whose glyph is 20px wide (a nav icon,
|
|
41
|
+
* the launcher, a secondary-nav row).
|
|
42
|
+
*
|
|
36
43
|
* `10 + 10 = 20`, and `RAIL_GUTTER + 20 = 32`. See `RAIL_COLLAPSED`.
|
|
44
|
+
*
|
|
45
|
+
* **36px, not 40px, and the height belongs on this axis rather than on
|
|
46
|
+
* `SIDEBAR_ROW`.** The nav is the only region of the rail that grows with the
|
|
47
|
+
* host's route count, and `ConnectSidebarBody` is the only region that
|
|
48
|
+
* scrolls - so a rail that overflows overflows here. Ten destinations at 40px
|
|
49
|
+
* came to 436px of nav against 273px of fixed chrome (header, switcher, two
|
|
50
|
+
* footers), which needs a 709px viewport to sit still; a 1366x768 screen has
|
|
51
|
+
* about 678px of it. Four pixels a row is 48px back across the twelve rows a
|
|
52
|
+
* real integration draws, and 36px is still comfortably past the 24px
|
|
53
|
+
* WCAG 2.5.8 target minimum with the row's full width as the hit area.
|
|
54
|
+
*
|
|
55
|
+
* It is deliberately *not* a smaller glyph. The 20px box is a flex centring
|
|
56
|
+
* container for an icon the **host** supplies at its own size, so shrinking
|
|
57
|
+
* the box buys no vertical space and only pushes the icon off the 32px centre
|
|
58
|
+
* the collapsed rail is built on.
|
|
37
59
|
*/
|
|
38
|
-
export declare const SIDEBAR_ROW_ICON = "ps-[10px]";
|
|
60
|
+
export declare const SIDEBAR_ROW_ICON = "h-9 ps-[10px]";
|
|
39
61
|
/**
|
|
40
|
-
* Leading inset for a row whose glyph is 32px wide (the avatar, the
|
|
41
|
-
* tile). `4 + 16 = 20`, landing on the same 32px centre as
|
|
62
|
+
* Leading inset and height for a row whose glyph is 32px wide (the avatar, the
|
|
63
|
+
* workspace tile). `4 + 16 = 20`, landing on the same 32px centre as
|
|
64
|
+
* `SIDEBAR_ROW_ICON`.
|
|
65
|
+
*
|
|
66
|
+
* **These stay at 40px.** There are exactly two of them and neither scales
|
|
67
|
+
* with the host's nav, so they are not what overflows - and both need the
|
|
68
|
+
* height: a 32px tile in a 36px row has 2px of air, and the account row's
|
|
69
|
+
* label is two lines (name over email, `leading-tight`) which measures ~32px
|
|
70
|
+
* on its own.
|
|
42
71
|
*/
|
|
43
|
-
export declare const SIDEBAR_ROW_TILE = "ps-[4px]";
|
|
72
|
+
export declare const SIDEBAR_ROW_TILE = "h-10 ps-[4px]";
|
|
44
73
|
/**
|
|
45
74
|
* The class list for one sidebar row, in one place.
|
|
46
75
|
*
|
|
@@ -57,7 +86,10 @@ export declare const SIDEBAR_ROW_TILE = "ps-[4px]";
|
|
|
57
86
|
* lets a `w-fit`, centred, `px-`-padded button take this shape at all.
|
|
58
87
|
*/
|
|
59
88
|
export declare function sidebarRowClass(options?: Readonly<{
|
|
60
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* The glyph is 32px (an avatar, a workspace tile) rather than 20px - which
|
|
91
|
+
* also makes the row 40px rather than 36px. See `SIDEBAR_ROW_TILE`.
|
|
92
|
+
*/
|
|
61
93
|
tile?: boolean;
|
|
62
94
|
/** Paint the active treatment. */
|
|
63
95
|
active?: boolean;
|
|
@@ -73,13 +73,13 @@ export declare function ConnectSidebarFooter({ children, separated, }: Readonly<
|
|
|
73
73
|
separated?: boolean;
|
|
74
74
|
}>): import("react").JSX.Element;
|
|
75
75
|
export type ConnectSidebarRowProps = Readonly<{
|
|
76
|
-
/** The glyph. 20px by default; 32px when `tile`. */
|
|
76
|
+
/** The glyph. 20px in a 36px row by default; 32px in a 40px row when `tile`. */
|
|
77
77
|
icon: ReactNode;
|
|
78
78
|
/** The row's text, and - when it is a string - its accessible name. */
|
|
79
79
|
label: ReactNode;
|
|
80
80
|
/** Overrides the accessible name. Required when `label` is not a string. */
|
|
81
81
|
"aria-label"?: string;
|
|
82
|
-
/** The glyph is 32px (an avatar, a workspace tile) rather than 20px. */
|
|
82
|
+
/** The glyph is 32px (an avatar, a workspace tile) rather than 20px, in a 40px row. */
|
|
83
83
|
tile?: boolean;
|
|
84
84
|
/** Trailing content, e.g. a chevron. Clipped with the label while collapsed. */
|
|
85
85
|
trailing?: ReactNode;
|
|
@@ -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;
|
|
@@ -9,6 +9,55 @@ import type { UserResponse } from "../types.js";
|
|
|
9
9
|
* are frequently absent.
|
|
10
10
|
*/
|
|
11
11
|
export declare function connectClient(overrides?: Partial<ConnectClientOptions>): ConnectClient;
|
|
12
|
+
/**
|
|
13
|
+
* The `ConnectProvider` props that come from the environment.
|
|
14
|
+
*
|
|
15
|
+
* ```tsx
|
|
16
|
+
* // app/layout.tsx - a Server Component
|
|
17
|
+
* import { connectProviderProps } from "@cosmoops/connect-sdk/next";
|
|
18
|
+
* import { ConnectProvider } from "@cosmoops/connect-sdk/client";
|
|
19
|
+
*
|
|
20
|
+
* <ConnectProvider {...connectProviderProps()}>{children}</ConnectProvider>
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* ## What it is for
|
|
24
|
+
*
|
|
25
|
+
* `CONNECT_APP_URL` points the *server* at a non-production Connect. It is not
|
|
26
|
+
* `NEXT_PUBLIC_`, so it never reaches the browser, and `ConnectProvider`
|
|
27
|
+
* defaults `appUrl` to the production origin independently - so pointing the
|
|
28
|
+
* server somewhere else and leaving the prop off splits the integration in
|
|
29
|
+
* half. The server talks to dev and every destination the browser builds opens
|
|
30
|
+
* production: the user menu's Profile and Billing rows, the app launcher's link
|
|
31
|
+
* for an unsubscribed application, `ConnectAccessGate`'s subscribe target, and
|
|
32
|
+
* checkout. Nothing errors. The customer is simply asked to buy a second
|
|
33
|
+
* subscription in an instance where they have none.
|
|
34
|
+
*
|
|
35
|
+
* Both apps built on this SDK shipped that bug before anyone noticed, from
|
|
36
|
+
* reading "the provider takes no props" as a rule rather than as a remark about
|
|
37
|
+
* `clientId`. A root layout is a Server Component, so one spread fixes it with
|
|
38
|
+
* no browser-visible copy of the variable.
|
|
39
|
+
*
|
|
40
|
+
* ## What it deliberately leaves out
|
|
41
|
+
*
|
|
42
|
+
* `clientId` and `basePath`. The first is only needed alongside an explicit
|
|
43
|
+
* `redirectUri`, when you build the authorize URL in the browser rather than
|
|
44
|
+
* letting `signIn()` bounce through your own `/signin` route - and handing the
|
|
45
|
+
* browser a value it does not need is surface for nothing. The second is where
|
|
46
|
+
* *you* mounted the handler, which the environment does not know. Pass either
|
|
47
|
+
* yourself; a prop you pass always wins over this spread, whichever order you
|
|
48
|
+
* write them in, as long as the spread comes first.
|
|
49
|
+
*
|
|
50
|
+
* ## If you already have an origin helper
|
|
51
|
+
*
|
|
52
|
+
* Use it instead. An app that builds its own links into Connect (a "Manage
|
|
53
|
+
* team" button, a pricing page's subscribe target) needs that origin on the
|
|
54
|
+
* server too, and one function owning the read is better than this helper
|
|
55
|
+
* beside it - two readers of one variable is the shape that caused this. This
|
|
56
|
+
* exists for the integration that has no such helper, which is most of them.
|
|
57
|
+
*/
|
|
58
|
+
export declare function connectProviderProps(): {
|
|
59
|
+
appUrl: string;
|
|
60
|
+
};
|
|
12
61
|
/**
|
|
13
62
|
* An httpOnly cookie holding the opaque `sessionToken`.
|
|
14
63
|
*
|
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.
|
|
3
|
+
"version": "0.35.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",
|