@cosmoops/connect-sdk 0.33.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +159 -0
  2. package/README.md +17 -5
  3. package/dist/cjs/client/components/access-gate.js +98 -2
  4. package/dist/cjs/client/components/subscription.js +6 -0
  5. package/dist/cjs/client/provider.js +5 -2
  6. package/dist/cjs/client/use-checkout.js +3 -1
  7. package/dist/cjs/helpers.js +46 -0
  8. package/dist/cjs/index.js +5 -1
  9. package/dist/cjs/next/index.js +10 -3
  10. package/dist/cjs/server/client.js +1 -1
  11. package/dist/cjs/server/handler.js +162 -28
  12. package/dist/cjs/server/resources/billing.js +5 -0
  13. package/dist/cjs/server/resources/entitlements.js +47 -0
  14. package/dist/esm/client/components/access-gate.js +99 -3
  15. package/dist/esm/client/components/subscription.js +6 -0
  16. package/dist/esm/client/provider.js +5 -2
  17. package/dist/esm/client/use-checkout.js +3 -1
  18. package/dist/esm/helpers.js +43 -0
  19. package/dist/esm/index.js +1 -0
  20. package/dist/esm/next/index.js +10 -3
  21. package/dist/esm/server/client.js +1 -1
  22. package/dist/esm/server/handler.js +162 -28
  23. package/dist/esm/server/resources/billing.js +5 -0
  24. package/dist/esm/server/resources/entitlements.js +47 -0
  25. package/dist/types/client/components/access-gate.d.ts +9 -0
  26. package/dist/types/client/components/subscription.d.ts +6 -0
  27. package/dist/types/client/provider.d.ts +155 -51
  28. package/dist/types/client/use-checkout.d.ts +18 -1
  29. package/dist/types/helpers.d.ts +36 -1
  30. package/dist/types/index.d.ts +1 -0
  31. package/dist/types/server/client.d.ts +1 -1
  32. package/dist/types/server/resources/billing.d.ts +4 -0
  33. package/dist/types/server/resources/entitlements.d.ts +40 -1
  34. package/dist/types/types.d.ts +164 -0
  35. 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 cookie so the callback
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 cookies = [stateCookie(`${state}:${next}`, secure)];
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 [cookieState, ...destinationParts] = (readCookie(request, stateCookieName) ?? "").split(":");
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
- if (!cookieState || !sameState(cookieState, returnedState)) {
994
- // Either a forged callback or a genuinely stale tab. Neither should
995
- // mint a session, and neither is worth an error page.
996
- return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=state_mismatch`, [
997
- clearState,
998
- silentCookie("", secure),
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, [clearState, clearSilent]);
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. `connect_retry` bounds
1023
- // it - a genuine loop must surface, not spin.
1024
- if (error === "login_required" && url.searchParams.get("connect_retry") !== "1") {
1025
- const retry = new URL(`${url.origin}${basePath}/signin`);
1026
- retry.searchParams.set("next", next);
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(`${next}${next.includes("?") ? "&" : "?"}connect_error=missing_code`, [clearState, clearSilent]);
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, [clearState, clearSilent]);
1180
+ const response = redirect(next, [settleState, clearSilent]);
1047
1181
  await session.write(request, response, result.sessionToken);
1048
1182
  return response;
1049
1183
  },
@@ -14,6 +14,10 @@ export class BillingResource {
14
14
  * independently of the instance it talks to. Without the coercion a partner
15
15
  * pointed at a Connect that has not upgraded yet renders a feature list of
16
16
  * blanks - which is exactly what happened. See `normalizePlanFeatures`.
17
+ *
18
+ * `limits` is defaulted for the same reason, one version later: an instance
19
+ * that predates limits sends no such field, and a pricing grid mapping over
20
+ * `plan.limits` would throw rather than simply show no limit rows.
17
21
  */
18
22
  async listPlans() {
19
23
  const response = await request(this.transport, { method: "GET", path: "/plans" });
@@ -21,6 +25,7 @@ export class BillingResource {
21
25
  plans: (response.plans ?? []).map((plan) => ({
22
26
  ...plan,
23
27
  features: normalizePlanFeatures(plan.features),
28
+ limits: plan.limits ?? [],
24
29
  })),
25
30
  };
26
31
  }
@@ -1,4 +1,5 @@
1
1
  import { pathSegment, request } from "../http.js";
2
+ import { NO_PLAN_LIMIT, planLimitAllows, planLimitFor } from "../../helpers.js";
2
3
  /**
3
4
  * The two checks worth making on every request: may this workspace be here at
4
5
  * all, and what is it paying for.
@@ -42,6 +43,52 @@ export class EntitlementsResource {
42
43
  const result = await this.getSubscription(workspaceId);
43
44
  return result.hasActiveSubscription && (result.plan?.features.includes(feature) ?? false);
44
45
  }
46
+ /**
47
+ * How much of a metered resource this workspace may have - the numeric
48
+ * counterpart of `hasFeature`, for the resources your application counts
49
+ * itself (sites, projects, pipelines).
50
+ *
51
+ * **This is the effective ceiling, not the plan's own number.** Connect has
52
+ * already folded in whatever the add-ons on the subscription raise it by, so
53
+ * "Starter includes 1 site, they bought two 5-site packs" arrives here as
54
+ * `{ model: "limited", value: 11 }`. You never join the plan against the
55
+ * add-on list yourself, and a customer who buys another pack needs no change
56
+ * on your side.
57
+ *
58
+ * Returns `{ model: "none" }` rather than throwing when there is no active
59
+ * subscription, when the plan carries no answer for this key, and when the
60
+ * Connect instance predates limits entirely - the same reasoning
61
+ * `hasFeature` gives for answering `false`: "not entitled" is the same answer
62
+ * either way, and a limit check should not need a try/catch around it.
63
+ *
64
+ * Connect does not enforce this. It cannot see what you are counting, so it
65
+ * hands you the number and your code refuses the next row - `canAdd` is that
66
+ * comparison if you already have the count.
67
+ */
68
+ async getLimit(workspaceId, key) {
69
+ const result = await this.getSubscription(workspaceId);
70
+ if (!result.hasActiveSubscription)
71
+ return NO_PLAN_LIMIT;
72
+ return planLimitFor(result.plan?.limits, key);
73
+ }
74
+ /**
75
+ * Whether this workspace may add `additional` more of a limited resource,
76
+ * given how many it already has.
77
+ *
78
+ * You supply the count, because only your application can produce it. This
79
+ * is `getLimit` plus `planLimitAllows`, which exists so the comparison is
80
+ * not rewritten per call site - see that helper for the three ways of
81
+ * getting it wrong.
82
+ *
83
+ * ```ts
84
+ * if (!(await connect.entitlements.canAdd(workspaceId, "sites", sites.length))) {
85
+ * return refuse("Your plan's site limit is reached.");
86
+ * }
87
+ * ```
88
+ */
89
+ async canAdd(workspaceId, key, currentCount, additional = 1) {
90
+ return planLimitAllows(await this.getLimit(workspaceId, key), currentCount, additional);
91
+ }
45
92
  /**
46
93
  * One page of the workspaces subscribed to your application, newest first.
47
94
  *
@@ -23,6 +23,15 @@ export type ConnectAccessGateState = "loading"
23
23
  | "clear";
24
24
  export interface ConnectAccessGateResult {
25
25
  state: ConnectAccessGateState;
26
+ /**
27
+ * Re-reads entitlements, evicting the cached answer first.
28
+ *
29
+ * Exposed because a blocker is almost always resolved **somewhere this tab
30
+ * cannot see** - on Connect, in another tab or on a phone - and nothing
31
+ * about that reaches this one. The gate wires it to the tab regaining
32
+ * focus; call it yourself if your app learns of a change some other way.
33
+ */
34
+ refresh(): void;
26
35
  /** The blocker driving a hard-block state (`suspended`/`no_subscription`/`onboarding_pending`/`blocked`). `null` otherwise. */
27
36
  blocker: ReadinessBlocker | null;
28
37
  /** The `payment_retry_pending` blocker, only when `state === "payment_retry"`. Carries `graceEndsAt`. */
@@ -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;
@@ -196,7 +196,21 @@ export interface ConnectProviderProps {
196
196
  * not a Connect URL - it is your backend that exchanges the code.
197
197
  */
198
198
  redirectUri?: string;
199
- /** Override for non-production Connect instances. */
199
+ /**
200
+ * Override for non-production Connect instances. Defaults to
201
+ * `https://connect.cosmoops.com`.
202
+ *
203
+ * The twin of `CONNECT_APP_URL`, and *not* derived from it - that variable
204
+ * is read on your server and never reaches the browser. This value is where
205
+ * the user menu's Profile, Billing and Invoices destinations open, where
206
+ * `ConnectAppsGrid` sends an unsubscribed application, where checkout
207
+ * opens, and - if you pass `redirectUri` and so build the authorize URL
208
+ * here rather than in your own `/signin` route - where sign-in goes. Point
209
+ * the server somewhere else and leave this alone and the two halves
210
+ * disagree, so pass both or neither. Your root layout is a Server
211
+ * Component, so `appUrl={process.env.CONNECT_APP_URL}` is enough and needs
212
+ * no browser-visible variable.
213
+ */
200
214
  appUrl?: string;
201
215
  /**
202
216
  * Replace the `fetch` used to reach your bridge. The browser-side twin of
@@ -327,11 +341,20 @@ export declare function useConnectEntitlements(): {
327
341
  isLoading: boolean;
328
342
  error: string | null;
329
343
  /**
330
- * Forces a fresh read - the provider's cache clears the mutated path's
331
- * entry on a successful `mutateResource` call, but nothing re-triggers
332
- * this hook's own effect on its own (none of its dependencies change from
333
- * a cache write nobody here observes). Call this right after a
334
- * `mutateResource` you made against the same `path` completes.
344
+ * Forces a fresh read - evicts this path's cache entry and re-runs the
345
+ * read.
346
+ *
347
+ * It used only to re-trigger the effect, on the assumption that the caller
348
+ * had just evicted the entry itself through `mutateResource`. That made it
349
+ * useless for the other reason to refetch: **something changed somewhere
350
+ * this tab cannot see.** The provider's cache is a `Map` of resolved
351
+ * promises with no expiry, so a `refetch()` without the eviction re-read
352
+ * the same settled promise and reported the same stale answer forever -
353
+ * which is what left `<ConnectAccessGate>` showing "Choose a plan" in a tab
354
+ * whose workspace had subscribed in a different one.
355
+ *
356
+ * Still safe after a `mutateResource` against the same path: evicting an
357
+ * entry that is already gone is a no-op.
335
358
  */
336
359
  refetch(): void;
337
360
  };
@@ -347,11 +370,20 @@ export declare function useConnectAddonUsage(): {
347
370
  isLoading: boolean;
348
371
  error: string | null;
349
372
  /**
350
- * Forces a fresh read - the provider's cache clears the mutated path's
351
- * entry on a successful `mutateResource` call, but nothing re-triggers
352
- * this hook's own effect on its own (none of its dependencies change from
353
- * a cache write nobody here observes). Call this right after a
354
- * `mutateResource` you made against the same `path` completes.
373
+ * Forces a fresh read - evicts this path's cache entry and re-runs the
374
+ * read.
375
+ *
376
+ * It used only to re-trigger the effect, on the assumption that the caller
377
+ * had just evicted the entry itself through `mutateResource`. That made it
378
+ * useless for the other reason to refetch: **something changed somewhere
379
+ * this tab cannot see.** The provider's cache is a `Map` of resolved
380
+ * promises with no expiry, so a `refetch()` without the eviction re-read
381
+ * the same settled promise and reported the same stale answer forever -
382
+ * which is what left `<ConnectAccessGate>` showing "Choose a plan" in a tab
383
+ * whose workspace had subscribed in a different one.
384
+ *
385
+ * Still safe after a `mutateResource` against the same path: evicting an
386
+ * entry that is already gone is a no-op.
355
387
  */
356
388
  refetch(): void;
357
389
  };
@@ -361,11 +393,20 @@ export declare function useConnectPlans(): {
361
393
  isLoading: boolean;
362
394
  error: string | null;
363
395
  /**
364
- * Forces a fresh read - the provider's cache clears the mutated path's
365
- * entry on a successful `mutateResource` call, but nothing re-triggers
366
- * this hook's own effect on its own (none of its dependencies change from
367
- * a cache write nobody here observes). Call this right after a
368
- * `mutateResource` you made against the same `path` completes.
396
+ * Forces a fresh read - evicts this path's cache entry and re-runs the
397
+ * read.
398
+ *
399
+ * It used only to re-trigger the effect, on the assumption that the caller
400
+ * had just evicted the entry itself through `mutateResource`. That made it
401
+ * useless for the other reason to refetch: **something changed somewhere
402
+ * this tab cannot see.** The provider's cache is a `Map` of resolved
403
+ * promises with no expiry, so a `refetch()` without the eviction re-read
404
+ * the same settled promise and reported the same stale answer forever -
405
+ * which is what left `<ConnectAccessGate>` showing "Choose a plan" in a tab
406
+ * whose workspace had subscribed in a different one.
407
+ *
408
+ * Still safe after a `mutateResource` against the same path: evicting an
409
+ * entry that is already gone is a no-op.
369
410
  */
370
411
  refetch(): void;
371
412
  };
@@ -384,11 +425,20 @@ export declare function useConnectCompanyProfile(): {
384
425
  isLoading: boolean;
385
426
  error: string | null;
386
427
  /**
387
- * Forces a fresh read - the provider's cache clears the mutated path's
388
- * entry on a successful `mutateResource` call, but nothing re-triggers
389
- * this hook's own effect on its own (none of its dependencies change from
390
- * a cache write nobody here observes). Call this right after a
391
- * `mutateResource` you made against the same `path` completes.
428
+ * Forces a fresh read - evicts this path's cache entry and re-runs the
429
+ * read.
430
+ *
431
+ * It used only to re-trigger the effect, on the assumption that the caller
432
+ * had just evicted the entry itself through `mutateResource`. That made it
433
+ * useless for the other reason to refetch: **something changed somewhere
434
+ * this tab cannot see.** The provider's cache is a `Map` of resolved
435
+ * promises with no expiry, so a `refetch()` without the eviction re-read
436
+ * the same settled promise and reported the same stale answer forever -
437
+ * which is what left `<ConnectAccessGate>` showing "Choose a plan" in a tab
438
+ * whose workspace had subscribed in a different one.
439
+ *
440
+ * Still safe after a `mutateResource` against the same path: evicting an
441
+ * entry that is already gone is a no-op.
392
442
  */
393
443
  refetch(): void;
394
444
  };
@@ -400,11 +450,20 @@ export declare function useConnectApps(): {
400
450
  isLoading: boolean;
401
451
  error: string | null;
402
452
  /**
403
- * Forces a fresh read - the provider's cache clears the mutated path's
404
- * entry on a successful `mutateResource` call, but nothing re-triggers
405
- * this hook's own effect on its own (none of its dependencies change from
406
- * a cache write nobody here observes). Call this right after a
407
- * `mutateResource` you made against the same `path` completes.
453
+ * Forces a fresh read - evicts this path's cache entry and re-runs the
454
+ * read.
455
+ *
456
+ * It used only to re-trigger the effect, on the assumption that the caller
457
+ * had just evicted the entry itself through `mutateResource`. That made it
458
+ * useless for the other reason to refetch: **something changed somewhere
459
+ * this tab cannot see.** The provider's cache is a `Map` of resolved
460
+ * promises with no expiry, so a `refetch()` without the eviction re-read
461
+ * the same settled promise and reported the same stale answer forever -
462
+ * which is what left `<ConnectAccessGate>` showing "Choose a plan" in a tab
463
+ * whose workspace had subscribed in a different one.
464
+ *
465
+ * Still safe after a `mutateResource` against the same path: evicting an
466
+ * entry that is already gone is a no-op.
408
467
  */
409
468
  refetch(): void;
410
469
  };
@@ -423,11 +482,20 @@ export declare function useConnectWorkspaces(): {
423
482
  isLoading: boolean;
424
483
  error: string | null;
425
484
  /**
426
- * Forces a fresh read - the provider's cache clears the mutated path's
427
- * entry on a successful `mutateResource` call, but nothing re-triggers
428
- * this hook's own effect on its own (none of its dependencies change from
429
- * a cache write nobody here observes). Call this right after a
430
- * `mutateResource` you made against the same `path` completes.
485
+ * Forces a fresh read - evicts this path's cache entry and re-runs the
486
+ * read.
487
+ *
488
+ * It used only to re-trigger the effect, on the assumption that the caller
489
+ * had just evicted the entry itself through `mutateResource`. That made it
490
+ * useless for the other reason to refetch: **something changed somewhere
491
+ * this tab cannot see.** The provider's cache is a `Map` of resolved
492
+ * promises with no expiry, so a `refetch()` without the eviction re-read
493
+ * the same settled promise and reported the same stale answer forever -
494
+ * which is what left `<ConnectAccessGate>` showing "Choose a plan" in a tab
495
+ * whose workspace had subscribed in a different one.
496
+ *
497
+ * Still safe after a `mutateResource` against the same path: evicting an
498
+ * entry that is already gone is a no-op.
431
499
  */
432
500
  refetch(): void;
433
501
  };
@@ -442,11 +510,20 @@ export declare function useConnectWorkspaceMembers(): {
442
510
  isLoading: boolean;
443
511
  error: string | null;
444
512
  /**
445
- * Forces a fresh read - the provider's cache clears the mutated path's
446
- * entry on a successful `mutateResource` call, but nothing re-triggers
447
- * this hook's own effect on its own (none of its dependencies change from
448
- * a cache write nobody here observes). Call this right after a
449
- * `mutateResource` you made against the same `path` completes.
513
+ * Forces a fresh read - evicts this path's cache entry and re-runs the
514
+ * read.
515
+ *
516
+ * It used only to re-trigger the effect, on the assumption that the caller
517
+ * had just evicted the entry itself through `mutateResource`. That made it
518
+ * useless for the other reason to refetch: **something changed somewhere
519
+ * this tab cannot see.** The provider's cache is a `Map` of resolved
520
+ * promises with no expiry, so a `refetch()` without the eviction re-read
521
+ * the same settled promise and reported the same stale answer forever -
522
+ * which is what left `<ConnectAccessGate>` showing "Choose a plan" in a tab
523
+ * whose workspace had subscribed in a different one.
524
+ *
525
+ * Still safe after a `mutateResource` against the same path: evicting an
526
+ * entry that is already gone is a no-op.
450
527
  */
451
528
  refetch(): void;
452
529
  };
@@ -456,11 +533,20 @@ export declare function useConnectIntegrations(): {
456
533
  isLoading: boolean;
457
534
  error: string | null;
458
535
  /**
459
- * Forces a fresh read - the provider's cache clears the mutated path's
460
- * entry on a successful `mutateResource` call, but nothing re-triggers
461
- * this hook's own effect on its own (none of its dependencies change from
462
- * a cache write nobody here observes). Call this right after a
463
- * `mutateResource` you made against the same `path` completes.
536
+ * Forces a fresh read - evicts this path's cache entry and re-runs the
537
+ * read.
538
+ *
539
+ * It used only to re-trigger the effect, on the assumption that the caller
540
+ * had just evicted the entry itself through `mutateResource`. That made it
541
+ * useless for the other reason to refetch: **something changed somewhere
542
+ * this tab cannot see.** The provider's cache is a `Map` of resolved
543
+ * promises with no expiry, so a `refetch()` without the eviction re-read
544
+ * the same settled promise and reported the same stale answer forever -
545
+ * which is what left `<ConnectAccessGate>` showing "Choose a plan" in a tab
546
+ * whose workspace had subscribed in a different one.
547
+ *
548
+ * Still safe after a `mutateResource` against the same path: evicting an
549
+ * entry that is already gone is a no-op.
464
550
  */
465
551
  refetch(): void;
466
552
  };
@@ -476,11 +562,20 @@ export declare function useConnectSessions(): {
476
562
  isLoading: boolean;
477
563
  error: string | null;
478
564
  /**
479
- * Forces a fresh read - the provider's cache clears the mutated path's
480
- * entry on a successful `mutateResource` call, but nothing re-triggers
481
- * this hook's own effect on its own (none of its dependencies change from
482
- * a cache write nobody here observes). Call this right after a
483
- * `mutateResource` you made against the same `path` completes.
565
+ * Forces a fresh read - evicts this path's cache entry and re-runs the
566
+ * read.
567
+ *
568
+ * It used only to re-trigger the effect, on the assumption that the caller
569
+ * had just evicted the entry itself through `mutateResource`. That made it
570
+ * useless for the other reason to refetch: **something changed somewhere
571
+ * this tab cannot see.** The provider's cache is a `Map` of resolved
572
+ * promises with no expiry, so a `refetch()` without the eviction re-read
573
+ * the same settled promise and reported the same stale answer forever -
574
+ * which is what left `<ConnectAccessGate>` showing "Choose a plan" in a tab
575
+ * whose workspace had subscribed in a different one.
576
+ *
577
+ * Still safe after a `mutateResource` against the same path: evicting an
578
+ * entry that is already gone is a no-op.
484
579
  */
485
580
  refetch(): void;
486
581
  };
@@ -512,11 +607,20 @@ export declare function useConnectResource<T>(path: string, options?: Readonly<U
512
607
  isLoading: boolean;
513
608
  error: string | null;
514
609
  /**
515
- * Forces a fresh read - the provider's cache clears the mutated path's
516
- * entry on a successful `mutateResource` call, but nothing re-triggers
517
- * this hook's own effect on its own (none of its dependencies change from
518
- * a cache write nobody here observes). Call this right after a
519
- * `mutateResource` you made against the same `path` completes.
610
+ * Forces a fresh read - evicts this path's cache entry and re-runs the
611
+ * read.
612
+ *
613
+ * It used only to re-trigger the effect, on the assumption that the caller
614
+ * had just evicted the entry itself through `mutateResource`. That made it
615
+ * useless for the other reason to refetch: **something changed somewhere
616
+ * this tab cannot see.** The provider's cache is a `Map` of resolved
617
+ * promises with no expiry, so a `refetch()` without the eviction re-read
618
+ * the same settled promise and reported the same stale answer forever -
619
+ * which is what left `<ConnectAccessGate>` showing "Choose a plan" in a tab
620
+ * whose workspace had subscribed in a different one.
621
+ *
622
+ * Still safe after a `mutateResource` against the same path: evicting an
623
+ * entry that is already gone is a no-op.
520
624
  */
521
625
  refetch(): void;
522
626
  };