@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 +111 -0
- package/README.md +17 -5
- package/dist/cjs/client/components/access-gate.js +98 -2
- package/dist/cjs/client/provider.js +5 -2
- package/dist/cjs/client/use-checkout.js +3 -1
- package/dist/cjs/helpers.js +46 -0
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/next/index.js +10 -3
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/resources/billing.js +5 -0
- package/dist/cjs/server/resources/entitlements.js +47 -0
- package/dist/esm/client/components/access-gate.js +99 -3
- package/dist/esm/client/provider.js +5 -2
- package/dist/esm/client/use-checkout.js +3 -1
- package/dist/esm/helpers.js +43 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/next/index.js +10 -3
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/resources/billing.js +5 -0
- package/dist/esm/server/resources/entitlements.js +47 -0
- package/dist/types/client/components/access-gate.d.ts +9 -0
- package/dist/types/client/provider.d.ts +155 -51
- package/dist/types/client/use-checkout.d.ts +18 -1
- package/dist/types/helpers.d.ts +36 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/server/resources/billing.d.ts +4 -0
- package/dist/types/server/resources/entitlements.d.ts +40 -1
- package/dist/types/types.d.ts +149 -0
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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: () =>
|
|
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
|
}
|
package/dist/cjs/helpers.js
CHANGED
|
@@ -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; } });
|
package/dist/cjs/next/index.js
CHANGED
|
@@ -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
|
|
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
|
|
@@ -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
|
*
|