@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
package/CHANGELOG.md CHANGED
@@ -3,6 +3,165 @@
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
+
54
+ ## 0.34.0
55
+
56
+ Getting a blocked customer unblocked, and getting them back afterwards - plus
57
+ the field the deletion events were missing. No new resources: two of these are
58
+ the places the subscription journey dead-ended, and the third is a number your
59
+ webhook handler had no way to read.
60
+
61
+ ### `<ConnectAccessGate>` no longer strands people in a second tab
62
+
63
+ The `subscription_missing` card's button used to `window.open(resolveUrl,
64
+ "_blank")`. The customer subscribed over there, was left on Connect's billing
65
+ page with nothing pointing home, and the tab they started in still showed
66
+ "Choose a plan to continue" - unchanged, because nothing re-reads entitlements
67
+ on its own. The usual ending was two open tabs and the belief that the payment
68
+ had not gone through.
69
+
70
+ Now it is a **same-tab navigation carrying `?next=`** back to the page they
71
+ were standing on. Connect honours it after the subscribe when the origin
72
+ matches one registered for your Connect client, so the customer lands back
73
+ exactly where they were, subscribed.
74
+
75
+ Register your app's origin in Console if you have not - an unregistered one
76
+ costs the return trip, nothing more. They still reach Connect, still resolve
77
+ the blocker, and:
78
+
79
+ **A blocked gate now re-checks when the tab regains focus.** Covers everything
80
+ the return trip cannot: a second tab, a colleague with the admin rights, a
81
+ payment settled on a phone. Only while blocked - a `clear` gate does not put a
82
+ request on the critical path of alt-tabbing back into a working app.
83
+
84
+ `useConnectAccessGate()` gained a `refresh()` for the cases your app learns of
85
+ a change some other way.
86
+
87
+ ### `buildCheckoutUrl({ next })`
88
+
89
+ Where Connect sends the customer once the subscription is live:
90
+
91
+ ```ts
92
+ buildCheckoutUrl({
93
+ appUrl,
94
+ applicationId,
95
+ planId,
96
+ next: window.location.href, // back to your pricing page, subscribed
97
+ });
98
+ ```
99
+
100
+ Without it the default destination is the new subscription's own billing page
101
+ - right for someone who started in Connect, wrong for someone who started on
102
+ your pricing page. Connect validates the origin against your registered
103
+ redirect URIs and ignores anything else, so an unrecognised value costs the
104
+ return trip and can never redirect your customer somewhere you did not
105
+ register.
106
+
107
+ ### `useConnectResource().refetch()` actually refetches
108
+
109
+ It only re-ran the effect, on the assumption the caller had just evicted the
110
+ cache entry itself through `mutateResource`. The provider's cache is a `Map`
111
+ of resolved promises with no expiry, so a `refetch()` on its own re-read the
112
+ same settled promise and reported the same stale answer forever. It now evicts
113
+ first. Still safe after a `mutateResource` against the same path.
114
+
115
+ ### Note for applications that cannot be used without a plan
116
+
117
+ If your application is unusable without a subscription, the better fix is on
118
+ Connect's side and needs no SDK change: a platform admin turns on **"Require a
119
+ subscription to sign in"** on your Connect client. Plan selection then happens
120
+ *inside* the sign-in handshake - the customer picks a plan, free tier first,
121
+ and is handed back to you automatically - instead of them arriving signed-in
122
+ and hitting this gate at all. The gate stays as the backstop for a
123
+ subscription that lapses mid-use, which is what the changes above are for.
124
+
125
+
126
+ ### The deletion events carry your retention policy
127
+
128
+ `workspace.deletion_scheduled` and `workspace.deleted` now include `policy`:
129
+ your application's own data policy, frozen as it stood when the customer
130
+ confirmed the deletion.
131
+
132
+ ```ts
133
+ "workspace.deleted": async ({ workspaceId, policy }) => {
134
+ for (const dataClass of policy?.dataClasses ?? []) {
135
+ if (dataClass.disposition === "retain") {
136
+ await archive(workspaceId, dataClass.key, dataClass.retainDays);
137
+ }
138
+ }
139
+ }
140
+ ```
141
+
142
+ Before this, the two events carried `purgeAfter` and `graceDays` — Connect's
143
+ clock — and nothing about yours. The number a handler actually acts on is
144
+ `retainDays` per data class (and `quarantineDays` per held identifier), and
145
+ that was the one number never on the wire: every integration had to re-declare
146
+ in code what it had already declared in Console, where the two drift the first
147
+ time somebody edits the policy and nothing fails.
148
+
149
+ `policy` is per recipient — a deletion fans out to every application the
150
+ workspace subscribed to, and each receives only its own declaration. It is the
151
+ *clamped*, *versioned* snapshot: `version` names the policy the customer was
152
+ shown, and the numbers are the ones Connect promised them rather than whatever
153
+ your stored policy says today. `null` only for a deletion confirmed before
154
+ policies existed; an application that has declared nothing gets empty arrays,
155
+ never a missing key.
156
+
157
+ New types: `ConnectDeletionPolicyNotice`, `ConnectDataClassNotice`,
158
+ `ConnectResourceReleaseNotice`, `ConnectDataDisposition`,
159
+ `ConnectFiduciaryRole`.
160
+
161
+ Also corrected: `requestedBy` on `workspace.deletion_scheduled` is
162
+ `"owner" | "platform_admin"` — which path the request came in on. The payload
163
+ example in Console had been showing a user id, which is not on the wire.
164
+
6
165
  ## 0.33.0
7
166
 
8
167
  Two additions: the public visitor assistant, and `connect.enrichment.presence()`.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Official client for the **CosmoOps Connect application-partner API** (`/api/v1/application/*`).
4
4
 
5
- **Current version: `0.20.0`** · [Release notes](#release-notes) · [Full changelog](./CHANGELOG.md)
5
+ **Current version: `0.33.0`** · [Release notes](#release-notes) · [Full changelog](./CHANGELOG.md)
6
6
  — follows semver with the 0.x convention that a **minor** version may carry a
7
7
  breaking change (a patch never does). Read the release notes for your target
8
8
  version before upgrading past a minor bump.
@@ -12,7 +12,7 @@ npm install @cosmoops/connect-sdk
12
12
  npm install streamdown # optional — Ask AI's markdown renderer, see step 4
13
13
  ```
14
14
 
15
- Five entry points, and the split is a security boundary, not a preference:
15
+ Six entry points, and the split is a security boundary, not a preference:
16
16
 
17
17
  | Import | Runs in | Holds the API key |
18
18
  | --- | --- | --- |
@@ -20,8 +20,12 @@ Five entry points, and the split is a security boundary, not a preference:
20
20
  | `@cosmoops/connect-sdk/client` | the browser | never |
21
21
  | `@cosmoops/connect-sdk/next` | your backend | **yes** (Next.js plug-and-play) |
22
22
  | `@cosmoops/connect-sdk/mcp` | your backend | **yes** (store, as MCP tools) |
23
+ | `@cosmoops/connect-sdk/ui` | either | never (presentational, no `"use client"`) |
23
24
  | `@cosmoops/connect-sdk` | anywhere | types + `ConnectError` only |
24
25
 
26
+ Plus `@cosmoops/connect-sdk/theme.css`, which is not code: the one shared
27
+ HeroUI theme the components are drawn against.
28
+
25
29
  The browser half never sees a credential. It calls your own origin, and
26
30
  `createConnectHandler` — mounted in your backend — is what talks to Connect.
27
31
 
@@ -49,8 +53,14 @@ npm install @cosmoops/connect-sdk
49
53
  | --- | --- | --- |
50
54
  | `CONNECT_API_KEY` | yes | server-side credential |
51
55
  | `CONNECT_CLIENT_ID` | yes | OAuth client id |
52
- | `CONNECT_APP_URL` | no | non-production Connect instance |
53
- | `CONNECT_SESSION_COOKIE` | no | session cookie name |
56
+ | `CONNECT_APP_URL` | no | a non-production Connect instance; unset means `https://connect.cosmoops.com` |
57
+ | `CONNECT_SESSION_COOKIE` | no | session cookie name; unset means `connect_session` |
58
+
59
+ Production is the absence of configuration — set `CONNECT_APP_URL` only when
60
+ you mean somewhere else. Note that it is **server-side**: `ConnectProvider`
61
+ defaults `appUrl` to the production origin on its own, so an app pointed at a
62
+ local instance must pass `appUrl` too or its portal links and its sign-in
63
+ redirect will go to production. See [Environments](https://connect.cosmoops.com/docs/environments).
54
64
 
55
65
  **3. Mount the bridge route.** One file, no arguments — this is your entire backend integration:
56
66
 
@@ -919,7 +929,9 @@ So the hand-off is a URL, and Connect renders it as a modal over the relevant pa
919
929
 
920
930
  ## Release notes
921
931
 
922
- Full history: [CHANGELOG.md](./CHANGELOG.md). Latest first:
932
+ **[CHANGELOG.md](./CHANGELOG.md) is the current and complete history**, through
933
+ `0.33.0`. What follows is an older excerpt, kept for the migration notes in it, and
934
+ it stops at `0.20.0` — read the changelog for anything newer.
923
935
 
924
936
  ### 0.20.0
925
937
 
@@ -40,7 +40,19 @@ function formatDate(iso) {
40
40
  */
41
41
  function useConnectAccessGate() {
42
42
  const { session, isLoading: sessionLoading } = (0, provider_js_1.useConnect)();
43
- const { data, isLoading: entitlementsLoading } = (0, provider_js_1.useConnectEntitlements)();
43
+ const { data, isLoading: entitlementsLoading, refetch } = (0, provider_js_1.useConnectEntitlements)();
44
+ return { ...decideAccess(session, sessionLoading, data, entitlementsLoading), refresh: refetch };
45
+ }
46
+ /**
47
+ * The priority ordering, with the reads lifted out.
48
+ *
49
+ * Split from the hook so the twelve verdicts stay one `return` each and none
50
+ * of them has to carry `refresh` - a field that is the same function in every
51
+ * branch and says nothing about which branch it is. Pure, like
52
+ * `rbac/policy.ts` and `evaluateSignInRisk`, and for the same reason: a
53
+ * decision spread across its inputs cannot be tested.
54
+ */
55
+ function decideAccess(session, sessionLoading, data, entitlementsLoading) {
44
56
  if (sessionLoading) {
45
57
  return { state: "loading", blocker: null, paymentRetry: null };
46
58
  }
@@ -151,6 +163,49 @@ function useConnectAccessGate() {
151
163
  */
152
164
  function ConnectAccessGate({ children, fallback, loading, className }) {
153
165
  const gate = useConnectAccessGate();
166
+ const isBlocked = gate.state !== "loading" &&
167
+ gate.state !== "signed_out" &&
168
+ gate.state !== "clear" &&
169
+ gate.state !== "payment_retry";
170
+ /**
171
+ * A blocked gate re-checks when the tab comes back.
172
+ *
173
+ * Every blocker this component draws is resolved **somewhere else** - on
174
+ * Connect, in another tab, by an admin, on a phone - and none of that
175
+ * reaches this tab. The resolve button used to open Connect in a new tab,
176
+ * so the overwhelmingly common path was: subscribe over there, switch back,
177
+ * and find the same "Choose a plan to continue" card, with a reload the
178
+ * only way out. It read as the purchase not having worked.
179
+ *
180
+ * The button is a same-tab navigation now (see `GateCard`), which handles
181
+ * the common case on its own. This covers the rest: a second tab, a
182
+ * colleague with the admin rights, a payment settled on a phone.
183
+ *
184
+ * Only while blocked, deliberately. A `clear` gate re-reading entitlements
185
+ * on every tab focus would put a request on the critical path of
186
+ * alt-tabbing back into an application that is working fine, for every
187
+ * workspace, all day. Nothing is gained by it: the gate has nothing to
188
+ * show, and losing access mid-session is what the server-side check before
189
+ * each action is for.
190
+ */
191
+ (0, react_1.useEffect)(() => {
192
+ if (!isBlocked || typeof document === "undefined")
193
+ return;
194
+ const recheck = () => {
195
+ if (document.visibilityState === "visible")
196
+ gate.refresh();
197
+ };
198
+ document.addEventListener("visibilitychange", recheck);
199
+ window.addEventListener("focus", recheck);
200
+ return () => {
201
+ document.removeEventListener("visibilitychange", recheck);
202
+ window.removeEventListener("focus", recheck);
203
+ };
204
+ // `gate.refresh` is stable per render of the provider and re-subscribing
205
+ // on it would churn the listeners; `isBlocked` is the only thing that
206
+ // should add or remove them.
207
+ // eslint-disable-next-line react-hooks/exhaustive-deps
208
+ }, [isBlocked]);
154
209
  // `children`, unless the host explicitly asked for a placeholder. See the
155
210
  // doc comment - this is the branch that decides whether a partner's page
156
211
  // can stream.
@@ -219,9 +274,27 @@ function AccessGateFallback({ state, blocker, className, }) {
219
274
  * so "see plans" landing back on the same blocked screen never gets anyone
220
275
  * to a choice they can act on. Sending them to Connect's own subscribe page
221
276
  * always resolves.
277
+ *
278
+ * ### Same tab, and it carries the way back
279
+ *
280
+ * It used to `window.open(resolveUrl, "_blank")`. A new tab is the wrong
281
+ * shape for a step you have to finish before you can continue: the customer
282
+ * subscribes over there, is left on Connect's billing page with nothing
283
+ * pointing home, and the tab they started in is still showing this card. The
284
+ * most common ending was two open tabs and the belief that the payment had
285
+ * not worked.
286
+ *
287
+ * So: a same-tab navigation carrying `?next=` back to the page they are
288
+ * standing on, which Connect honours after a subscribe once the origin
289
+ * matches one registered for this application's client
290
+ * (`resolveApplicationReturnUrl`). An unregistered origin costs the return
291
+ * trip and nothing else - they still reach Connect, still resolve the
292
+ * blocker, and `ConnectAccessGate`'s focus re-check catches them on the way
293
+ * back. Partners should register their app's origin; the fallback is a
294
+ * degraded path, not a broken one.
222
295
  */
223
296
  function GateCard({ icon: Icon, iconClassName, title, message, actionLabel, resolveUrl, className, }) {
224
- return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsx)(motion_js_1.m.div, { initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: motion_js_1.ENTER, children: (0, jsx_runtime_1.jsxs)(react_2.Card, { className: className, children: [(0, jsx_runtime_1.jsxs)(react_2.Card.Header, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(Icon, { className: `size-5 shrink-0 ${iconClassName}`, "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)(react_2.Card.Title, { children: title })] }), (0, jsx_runtime_1.jsx)(react_2.Card.Description, { children: message })] }), resolveUrl && ((0, jsx_runtime_1.jsx)(react_2.Card.Footer, { children: (0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "primary", onPress: () => window.open(resolveUrl, "_blank", "noopener,noreferrer"), children: [actionLabel ?? "Resolve on Connect", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] }) }))] }) }) }));
297
+ return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsx)(motion_js_1.m.div, { initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: motion_js_1.ENTER, children: (0, jsx_runtime_1.jsxs)(react_2.Card, { className: className, children: [(0, jsx_runtime_1.jsxs)(react_2.Card.Header, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(Icon, { className: `size-5 shrink-0 ${iconClassName}`, "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)(react_2.Card.Title, { children: title })] }), (0, jsx_runtime_1.jsx)(react_2.Card.Description, { children: message })] }), resolveUrl && ((0, jsx_runtime_1.jsx)(react_2.Card.Footer, { children: (0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "primary", onPress: () => window.location.assign(withReturnTo(resolveUrl)), children: [actionLabel ?? "Resolve on Connect", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] }) }))] }) }) }));
225
298
  }
226
299
  /**
227
300
  * The skippable payment-due warning. Dismissal is scoped to this specific
@@ -240,3 +313,26 @@ function PaymentRetryBanner({ blocker, className, }) {
240
313
  window.localStorage.setItem(dismissKey, "1");
241
314
  }, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4" }) })] })] }) }));
242
315
  }
316
+ /**
317
+ * `resolveUrl` with `?next=` pointing at the page the customer is standing
318
+ * on, so Connect can hand them back once the blocker is gone.
319
+ *
320
+ * Returns the URL untouched if anything is off - no `window`, an unparseable
321
+ * `resolveUrl`, a `next` already set by the server. Getting back is a
322
+ * courtesy on top of resolving the blocker, and it must never be the reason
323
+ * somebody cannot reach the screen that resolves it.
324
+ */
325
+ function withReturnTo(resolveUrl) {
326
+ if (typeof window === "undefined")
327
+ return resolveUrl;
328
+ try {
329
+ const url = new URL(resolveUrl);
330
+ if (url.searchParams.has("next"))
331
+ return resolveUrl;
332
+ url.searchParams.set("next", window.location.href);
333
+ return url.toString();
334
+ }
335
+ catch {
336
+ return resolveUrl;
337
+ }
338
+ }
@@ -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")
@@ -503,7 +503,7 @@ function useConnectSessions() {
503
503
  */
504
504
  function useConnectResource(path, options) {
505
505
  const requiresSession = options?.requiresSession ?? true;
506
- const { fetchResource, session, isLoading: sessionLoading } = useConnect();
506
+ const { fetchResource, invalidateResource, session, isLoading: sessionLoading } = useConnect();
507
507
  // Stamped with the `path` it belongs to, so switching paths reports loading
508
508
  // without a synchronous `setIsLoading(true)` - and, more usefully, so a
509
509
  // resolved response for a previous path can never be read as this one's.
@@ -543,6 +543,9 @@ function useConnectResource(path, options) {
543
543
  // should render its signed-out state, not skeletons forever.
544
544
  isLoading: !signedOut && !loaded,
545
545
  error: loaded?.error ?? null,
546
- refetch: () => setVersion((v) => v + 1),
546
+ refetch: () => {
547
+ invalidateResource(path);
548
+ setVersion((v) => v + 1);
549
+ },
547
550
  };
548
551
  }
@@ -166,12 +166,14 @@ function buildCancelUrl({ appUrl, subscriptionId }) {
166
166
  * prefix that has never served this page. Building the other one is what
167
167
  * sent every first-time subscriber from a partner's pricing page to a 404.
168
168
  */
169
- function buildCheckoutUrl({ appUrl, applicationId, planId, subscriptionId, coupon, }) {
169
+ function buildCheckoutUrl({ appUrl, applicationId, planId, subscriptionId, coupon, next, }) {
170
170
  const path = subscriptionId
171
171
  ? `/billing/subscriptions/${encodeURIComponent(subscriptionId)}/change-plan/${encodeURIComponent(planId)}`
172
172
  : `/apps/${encodeURIComponent(applicationId)}/subscribe/${encodeURIComponent(planId)}`;
173
173
  const url = new URL(path, appUrl);
174
174
  if (coupon?.trim())
175
175
  url.searchParams.set("coupon", coupon.trim());
176
+ if (next?.trim())
177
+ url.searchParams.set("next", next.trim());
176
178
  return url.toString();
177
179
  }
@@ -1,7 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NO_PLAN_LIMIT = void 0;
3
4
  exports.blockingBlockers = blockingBlockers;
4
5
  exports.normalizePlanFeatures = normalizePlanFeatures;
6
+ exports.planLimitFor = planLimitFor;
7
+ exports.planLimitAllows = planLimitAllows;
5
8
  /**
6
9
  * The blockers that actually deny access, in the order to show them.
7
10
  *
@@ -28,3 +31,46 @@ function blockingBlockers(readiness) {
28
31
  function normalizePlanFeatures(features) {
29
32
  return (features ?? []).map((feature) => typeof feature === "string" ? { key: feature, name: feature, description: "" } : feature);
30
33
  }
34
+ /**
35
+ * What a key the plan carries no answer for reads as - see
36
+ * `SubscribedPlan.limits`, which is sparse.
37
+ *
38
+ * `"none"` rather than `"unlimited"`: a plan that was never asked about a
39
+ * limit has not sold it, and defaulting the other way would hand an unbounded
40
+ * allowance to every tier that predates the question.
41
+ */
42
+ exports.NO_PLAN_LIMIT = { model: "none", value: null };
43
+ /**
44
+ * One plan's answer for one limit key, with the sparse-map default applied.
45
+ *
46
+ * Indexing `plan.limits` directly is what makes a missing key `undefined`,
47
+ * which then reads as unlimited through the first `?.` somebody writes. This
48
+ * returns a `PlanLimit` either way.
49
+ */
50
+ function planLimitFor(limits, key) {
51
+ return limits?.[key] ?? exports.NO_PLAN_LIMIT;
52
+ }
53
+ /**
54
+ * Whether `additional` more of the resource fit under this limit, given how
55
+ * many the subscriber already has. The comparison, written once.
56
+ *
57
+ * Every way of writing it at a call site is wrong in a different direction:
58
+ * `count < limit.value` is off by one as soon as you add more than one row,
59
+ * `if (!limit.value)` refuses the unlimited tier, and
60
+ * `limit.value ?? Infinity` grants unlimited to a plan that allows none. This
61
+ * is the same implementation Connect's own server uses to validate a plan.
62
+ *
63
+ * ```ts
64
+ * const limit = await connect.entitlements.getLimit(workspaceId, "sites");
65
+ * if (!planLimitAllows(limit, await countSites(workspaceId))) {
66
+ * throw new Error("Your plan's site limit is reached.");
67
+ * }
68
+ * ```
69
+ */
70
+ function planLimitAllows(limit, currentCount, additional = 1) {
71
+ if (limit.model === "unlimited")
72
+ return true;
73
+ if (limit.model === "none")
74
+ return false;
75
+ return currentCount + additional <= (limit.value ?? 0);
76
+ }
package/dist/cjs/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.storeIndexCovers = exports.describeQueryRejection = exports.unchangedSince = exports.storeIndexSignature = exports.storeIndexFor = exports.serverTimestamp = exports.increment = exports.documentExists = exports.deleteField = exports.arrayUnion = exports.arrayRemove = exports.normalizePlanFeatures = exports.blockingBlockers = exports.isConnectError = exports.ConnectError = void 0;
3
+ exports.storeIndexCovers = exports.describeQueryRejection = exports.unchangedSince = exports.storeIndexSignature = exports.storeIndexFor = exports.serverTimestamp = exports.increment = exports.documentExists = exports.deleteField = exports.arrayUnion = exports.arrayRemove = exports.planLimitFor = exports.planLimitAllows = exports.NO_PLAN_LIMIT = exports.normalizePlanFeatures = exports.blockingBlockers = exports.isConnectError = exports.ConnectError = void 0;
4
4
  /**
5
5
  * `@cosmoops/connect-sdk` - the official client for the CosmoOps Connect
6
6
  * **application-partner API** (`/api/v1/application/*`).
@@ -22,6 +22,10 @@ Object.defineProperty(exports, "isConnectError", { enumerable: true, get: functi
22
22
  var helpers_js_1 = require("./helpers.js");
23
23
  Object.defineProperty(exports, "blockingBlockers", { enumerable: true, get: function () { return helpers_js_1.blockingBlockers; } });
24
24
  Object.defineProperty(exports, "normalizePlanFeatures", { enumerable: true, get: function () { return helpers_js_1.normalizePlanFeatures; } });
25
+ var helpers_js_2 = require("./helpers.js");
26
+ Object.defineProperty(exports, "NO_PLAN_LIMIT", { enumerable: true, get: function () { return helpers_js_2.NO_PLAN_LIMIT; } });
27
+ Object.defineProperty(exports, "planLimitAllows", { enumerable: true, get: function () { return helpers_js_2.planLimitAllows; } });
28
+ Object.defineProperty(exports, "planLimitFor", { enumerable: true, get: function () { return helpers_js_2.planLimitFor; } });
25
29
  var store_js_1 = require("./store.js");
26
30
  Object.defineProperty(exports, "arrayRemove", { enumerable: true, get: function () { return store_js_1.arrayRemove; } });
27
31
  Object.defineProperty(exports, "arrayUnion", { enumerable: true, get: function () { return store_js_1.arrayUnion; } });
@@ -79,11 +79,18 @@ const errors_js_1 = require("../errors.js");
79
79
  * | --- | --- | --- |
80
80
  * | `CONNECT_API_KEY` | yes | server-side credential |
81
81
  * | `CONNECT_CLIENT_ID` | yes | OAuth client id |
82
- * | `CONNECT_APP_URL` | no | non-production Connect instance |
83
- * | `CONNECT_SESSION_COOKIE` | no | session cookie name |
82
+ * | `CONNECT_APP_URL` | no | non-production Connect instance; unset means production |
83
+ * | `CONNECT_SESSION_COOKIE` | no | session cookie name; unset means `connect_session` |
84
+ *
85
+ * `CONNECT_APP_URL` is server-side only and does not reach the browser:
86
+ * `ConnectProvider` defaults `appUrl` to the production origin independently,
87
+ * so an app pointed at another instance must pass that prop as well or its
88
+ * portal links and its browser-built authorize URL still go to production.
84
89
  *
85
90
  * Register `{your origin}/api/connect/callback` as a redirect URI in the
86
- * application's Connect client section. That is the only step this cannot do for you.
91
+ * application's Connect client section. That is the only step this cannot do
92
+ * for you, and Connect matches it exactly - every origin you run from needs
93
+ * its own entry, localhost included.
87
94
  *
88
95
  * No `next.config.ts` changes needed either, including `transpilePackages`:
89
96
  * `dist/` is already ES2022 with `"use client"` hoisted to the top of every
@@ -179,4 +179,4 @@ class ConnectClient {
179
179
  }
180
180
  exports.ConnectClient = ConnectClient;
181
181
  /** Kept in step with `package.json` by `sdk.test.ts`. */
182
- exports.SDK_VERSION = "0.32.0";
182
+ exports.SDK_VERSION = "0.34.0";