@cosmoops/connect-sdk 0.33.0 → 0.34.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 CHANGED
@@ -3,6 +3,117 @@
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.0
7
+
8
+ Getting a blocked customer unblocked, and getting them back afterwards - plus
9
+ the field the deletion events were missing. No new resources: two of these are
10
+ the places the subscription journey dead-ended, and the third is a number your
11
+ webhook handler had no way to read.
12
+
13
+ ### `<ConnectAccessGate>` no longer strands people in a second tab
14
+
15
+ The `subscription_missing` card's button used to `window.open(resolveUrl,
16
+ "_blank")`. The customer subscribed over there, was left on Connect's billing
17
+ page with nothing pointing home, and the tab they started in still showed
18
+ "Choose a plan to continue" - unchanged, because nothing re-reads entitlements
19
+ on its own. The usual ending was two open tabs and the belief that the payment
20
+ had not gone through.
21
+
22
+ Now it is a **same-tab navigation carrying `?next=`** back to the page they
23
+ were standing on. Connect honours it after the subscribe when the origin
24
+ matches one registered for your Connect client, so the customer lands back
25
+ exactly where they were, subscribed.
26
+
27
+ Register your app's origin in Console if you have not - an unregistered one
28
+ costs the return trip, nothing more. They still reach Connect, still resolve
29
+ the blocker, and:
30
+
31
+ **A blocked gate now re-checks when the tab regains focus.** Covers everything
32
+ the return trip cannot: a second tab, a colleague with the admin rights, a
33
+ payment settled on a phone. Only while blocked - a `clear` gate does not put a
34
+ request on the critical path of alt-tabbing back into a working app.
35
+
36
+ `useConnectAccessGate()` gained a `refresh()` for the cases your app learns of
37
+ a change some other way.
38
+
39
+ ### `buildCheckoutUrl({ next })`
40
+
41
+ Where Connect sends the customer once the subscription is live:
42
+
43
+ ```ts
44
+ buildCheckoutUrl({
45
+ appUrl,
46
+ applicationId,
47
+ planId,
48
+ next: window.location.href, // back to your pricing page, subscribed
49
+ });
50
+ ```
51
+
52
+ Without it the default destination is the new subscription's own billing page
53
+ - right for someone who started in Connect, wrong for someone who started on
54
+ your pricing page. Connect validates the origin against your registered
55
+ redirect URIs and ignores anything else, so an unrecognised value costs the
56
+ return trip and can never redirect your customer somewhere you did not
57
+ register.
58
+
59
+ ### `useConnectResource().refetch()` actually refetches
60
+
61
+ It only re-ran the effect, on the assumption the caller had just evicted the
62
+ cache entry itself through `mutateResource`. The provider's cache is a `Map`
63
+ of resolved promises with no expiry, so a `refetch()` on its own re-read the
64
+ same settled promise and reported the same stale answer forever. It now evicts
65
+ first. Still safe after a `mutateResource` against the same path.
66
+
67
+ ### Note for applications that cannot be used without a plan
68
+
69
+ If your application is unusable without a subscription, the better fix is on
70
+ Connect's side and needs no SDK change: a platform admin turns on **"Require a
71
+ subscription to sign in"** on your Connect client. Plan selection then happens
72
+ *inside* the sign-in handshake - the customer picks a plan, free tier first,
73
+ and is handed back to you automatically - instead of them arriving signed-in
74
+ and hitting this gate at all. The gate stays as the backstop for a
75
+ subscription that lapses mid-use, which is what the changes above are for.
76
+
77
+
78
+ ### The deletion events carry your retention policy
79
+
80
+ `workspace.deletion_scheduled` and `workspace.deleted` now include `policy`:
81
+ your application's own data policy, frozen as it stood when the customer
82
+ confirmed the deletion.
83
+
84
+ ```ts
85
+ "workspace.deleted": async ({ workspaceId, policy }) => {
86
+ for (const dataClass of policy?.dataClasses ?? []) {
87
+ if (dataClass.disposition === "retain") {
88
+ await archive(workspaceId, dataClass.key, dataClass.retainDays);
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ Before this, the two events carried `purgeAfter` and `graceDays` — Connect's
95
+ clock — and nothing about yours. The number a handler actually acts on is
96
+ `retainDays` per data class (and `quarantineDays` per held identifier), and
97
+ that was the one number never on the wire: every integration had to re-declare
98
+ in code what it had already declared in Console, where the two drift the first
99
+ time somebody edits the policy and nothing fails.
100
+
101
+ `policy` is per recipient — a deletion fans out to every application the
102
+ workspace subscribed to, and each receives only its own declaration. It is the
103
+ *clamped*, *versioned* snapshot: `version` names the policy the customer was
104
+ shown, and the numbers are the ones Connect promised them rather than whatever
105
+ your stored policy says today. `null` only for a deletion confirmed before
106
+ policies existed; an application that has declared nothing gets empty arrays,
107
+ never a missing key.
108
+
109
+ New types: `ConnectDeletionPolicyNotice`, `ConnectDataClassNotice`,
110
+ `ConnectResourceReleaseNotice`, `ConnectDataDisposition`,
111
+ `ConnectFiduciaryRole`.
112
+
113
+ Also corrected: `requestedBy` on `workspace.deletion_scheduled` is
114
+ `"owner" | "platform_admin"` — which path the request came in on. The payload
115
+ example in Console had been showing a user id, which is not on the wire.
116
+
6
117
  ## 0.33.0
7
118
 
8
119
  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
+ }
@@ -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";
@@ -17,6 +17,10 @@ class BillingResource {
17
17
  * independently of the instance it talks to. Without the coercion a partner
18
18
  * pointed at a Connect that has not upgraded yet renders a feature list of
19
19
  * blanks - which is exactly what happened. See `normalizePlanFeatures`.
20
+ *
21
+ * `limits` is defaulted for the same reason, one version later: an instance
22
+ * that predates limits sends no such field, and a pricing grid mapping over
23
+ * `plan.limits` would throw rather than simply show no limit rows.
20
24
  */
21
25
  async listPlans() {
22
26
  const response = await (0, http_js_1.request)(this.transport, { method: "GET", path: "/plans" });
@@ -24,6 +28,7 @@ class BillingResource {
24
28
  plans: (response.plans ?? []).map((plan) => ({
25
29
  ...plan,
26
30
  features: (0, helpers_js_1.normalizePlanFeatures)(plan.features),
31
+ limits: plan.limits ?? [],
27
32
  })),
28
33
  };
29
34
  }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.EntitlementsResource = void 0;
4
4
  const http_js_1 = require("../http.js");
5
+ const helpers_js_1 = require("../../helpers.js");
5
6
  /**
6
7
  * The two checks worth making on every request: may this workspace be here at
7
8
  * all, and what is it paying for.
@@ -45,6 +46,52 @@ class EntitlementsResource {
45
46
  const result = await this.getSubscription(workspaceId);
46
47
  return result.hasActiveSubscription && (result.plan?.features.includes(feature) ?? false);
47
48
  }
49
+ /**
50
+ * How much of a metered resource this workspace may have - the numeric
51
+ * counterpart of `hasFeature`, for the resources your application counts
52
+ * itself (sites, projects, pipelines).
53
+ *
54
+ * **This is the effective ceiling, not the plan's own number.** Connect has
55
+ * already folded in whatever the add-ons on the subscription raise it by, so
56
+ * "Starter includes 1 site, they bought two 5-site packs" arrives here as
57
+ * `{ model: "limited", value: 11 }`. You never join the plan against the
58
+ * add-on list yourself, and a customer who buys another pack needs no change
59
+ * on your side.
60
+ *
61
+ * Returns `{ model: "none" }` rather than throwing when there is no active
62
+ * subscription, when the plan carries no answer for this key, and when the
63
+ * Connect instance predates limits entirely - the same reasoning
64
+ * `hasFeature` gives for answering `false`: "not entitled" is the same answer
65
+ * either way, and a limit check should not need a try/catch around it.
66
+ *
67
+ * Connect does not enforce this. It cannot see what you are counting, so it
68
+ * hands you the number and your code refuses the next row - `canAdd` is that
69
+ * comparison if you already have the count.
70
+ */
71
+ async getLimit(workspaceId, key) {
72
+ const result = await this.getSubscription(workspaceId);
73
+ if (!result.hasActiveSubscription)
74
+ return helpers_js_1.NO_PLAN_LIMIT;
75
+ return (0, helpers_js_1.planLimitFor)(result.plan?.limits, key);
76
+ }
77
+ /**
78
+ * Whether this workspace may add `additional` more of a limited resource,
79
+ * given how many it already has.
80
+ *
81
+ * You supply the count, because only your application can produce it. This
82
+ * is `getLimit` plus `planLimitAllows`, which exists so the comparison is
83
+ * not rewritten per call site - see that helper for the three ways of
84
+ * getting it wrong.
85
+ *
86
+ * ```ts
87
+ * if (!(await connect.entitlements.canAdd(workspaceId, "sites", sites.length))) {
88
+ * return refuse("Your plan's site limit is reached.");
89
+ * }
90
+ * ```
91
+ */
92
+ async canAdd(workspaceId, key, currentCount, additional = 1) {
93
+ return (0, helpers_js_1.planLimitAllows)(await this.getLimit(workspaceId, key), currentCount, additional);
94
+ }
48
95
  /**
49
96
  * One page of the workspaces subscribed to your application, newest first.
50
97
  *