@cosmoops/connect-sdk 0.7.0 → 0.9.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 +165 -0
- package/README.md +273 -91
- package/dist/cjs/client/components/access-gate.js +39 -4
- package/dist/cjs/client/components/ai-format.js +135 -0
- package/dist/cjs/client/components/ai-widgets.js +232 -0
- package/dist/cjs/client/components/app-shell.js +7 -6
- package/dist/cjs/client/components/ask-ai.js +334 -0
- package/dist/cjs/client/components/nav.js +1 -1
- package/dist/cjs/client/components/side-nav.js +1 -1
- package/dist/cjs/client/components/user-menu.js +28 -5
- package/dist/cjs/client/components/workspace-type-gate.js +48 -0
- package/dist/cjs/client/index.js +31 -3
- package/dist/cjs/client/provider.js +45 -2
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/handler.js +43 -0
- package/dist/cjs/server/http.js +46 -24
- package/dist/cjs/server/resources/entitlements.js +41 -0
- package/dist/cjs/server/resources/identity.js +2 -0
- package/dist/esm/client/components/access-gate.js +40 -5
- package/dist/esm/client/components/ai-format.js +129 -0
- package/dist/esm/client/components/ai-widgets.js +223 -0
- package/dist/esm/client/components/app-shell.js +6 -5
- package/dist/esm/client/components/ask-ai.js +331 -0
- package/dist/esm/client/components/nav.js +1 -1
- package/dist/esm/client/components/side-nav.js +1 -1
- package/dist/esm/client/components/user-menu.js +28 -5
- package/dist/esm/client/components/workspace-type-gate.js +45 -0
- package/dist/esm/client/index.js +13 -2
- package/dist/esm/client/provider.js +43 -2
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/handler.js +43 -0
- package/dist/esm/server/http.js +46 -24
- package/dist/esm/server/resources/entitlements.js +41 -0
- package/dist/esm/server/resources/identity.js +2 -0
- package/dist/theme.css +87 -0
- package/dist/types/client/components/access-gate.d.ts +17 -3
- package/dist/types/client/components/ai-format.d.ts +24 -0
- package/dist/types/client/components/ai-widgets.d.ts +42 -0
- package/dist/types/client/components/app-shell.d.ts +21 -5
- package/dist/types/client/components/ask-ai.d.ts +73 -0
- package/dist/types/client/components/nav.d.ts +3 -3
- package/dist/types/client/components/side-nav.d.ts +1 -1
- package/dist/types/client/components/user-menu.d.ts +2 -1
- package/dist/types/client/components/workspace-type-gate.d.ts +48 -0
- package/dist/types/client/index.d.ts +10 -3
- package/dist/types/client/provider.d.ts +44 -1
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/server/http.d.ts +6 -1
- package/dist/types/server/resources/entitlements.d.ts +33 -1
- package/dist/types/types.d.ts +174 -0
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
# @cosmoops/connect-sdk
|
|
2
2
|
|
|
3
|
-
Official client for the **CosmoOps Connect
|
|
3
|
+
Official client for the **CosmoOps Connect application-partner API** (`/api/v1/application/*`).
|
|
4
|
+
|
|
5
|
+
**Current version: `0.8.0`** · [Release notes](#release-notes) · [Full changelog](./CHANGELOG.md)
|
|
6
|
+
— follows semver with the 0.x convention that a **minor** version may carry a
|
|
7
|
+
breaking change (a patch never does). Read the release notes for your target
|
|
8
|
+
version before upgrading past a minor bump.
|
|
4
9
|
|
|
5
10
|
```bash
|
|
6
11
|
npm install @cosmoops/connect-sdk
|
|
12
|
+
npm install streamdown # optional — Ask AI's markdown renderer, see step 4
|
|
7
13
|
```
|
|
8
14
|
|
|
9
15
|
Two entry points, and the split is a security boundary, not a preference:
|
|
@@ -23,9 +29,114 @@ Browser ──► your backend ──► Connect
|
|
|
23
29
|
(client) (createConnectHandler + server client, holds the key)
|
|
24
30
|
```
|
|
25
31
|
|
|
32
|
+
## Quickstart (Next.js)
|
|
33
|
+
|
|
34
|
+
The fast path — most integrations are this and nothing else. Not on Next.js?
|
|
35
|
+
Skip to [Other frameworks](#other-frameworks); the shape is the same, you
|
|
36
|
+
just wire the session cookie yourself.
|
|
37
|
+
|
|
38
|
+
**1. Install and get credentials.** From your application's Integration tab
|
|
39
|
+
in Connect, copy the API key and OAuth client id.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install @cosmoops/connect-sdk
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**2. Set environment variables.**
|
|
46
|
+
|
|
47
|
+
| Variable | Required | Purpose |
|
|
48
|
+
| --- | --- | --- |
|
|
49
|
+
| `CONNECT_API_KEY` | yes | server-side credential |
|
|
50
|
+
| `CONNECT_CLIENT_ID` | yes | OAuth client id |
|
|
51
|
+
| `CONNECT_APP_URL` | no | non-production Connect instance |
|
|
52
|
+
| `CONNECT_SESSION_COOKIE` | no | session cookie name |
|
|
53
|
+
|
|
54
|
+
**3. Mount the bridge route.** One file, no arguments — this is your entire backend integration:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// app/api/connect/[...connect]/route.ts
|
|
58
|
+
export { GET, POST } from "@cosmoops/connect-sdk/next";
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**4. Wrap your layout and import the theme.**
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
// app/layout.tsx
|
|
65
|
+
import { ConnectProvider } from "@cosmoops/connect-sdk/client";
|
|
66
|
+
|
|
67
|
+
<ConnectProvider>{children}</ConnectProvider>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```css
|
|
71
|
+
/* app/globals.css */
|
|
72
|
+
@import "tailwindcss";
|
|
73
|
+
@import "@heroui/styles";
|
|
74
|
+
@import "@cosmoops/connect-sdk/theme.css";
|
|
75
|
+
|
|
76
|
+
/* Ask AI's markdown renderer. Tailwind skips node_modules unless told, and
|
|
77
|
+
Streamdown's utility classes live inside its compiled bundle. Adjust the
|
|
78
|
+
`../` count to reach your own node_modules. */
|
|
79
|
+
@source "../node_modules/streamdown/dist/*.js";
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
// app/layout.tsx — Streamdown's streaming animation keyframes
|
|
84
|
+
import "streamdown/styles.css";
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Skip the last two if you pass `askAi={false}` to `AppShell`. The colour tokens
|
|
88
|
+
Streamdown expects are already mapped onto HeroUI's in `theme.css`.
|
|
89
|
+
|
|
90
|
+
**5. Register the redirect URI.** In the application's Integration tab, add
|
|
91
|
+
`{your origin}/api/connect/callback`. This is the one step the SDK cannot do
|
|
92
|
+
for you.
|
|
93
|
+
|
|
94
|
+
**6. Read the session wherever you need it.**
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
// app/dashboard/page.tsx
|
|
98
|
+
import { requireConnectSession } from "@cosmoops/connect-sdk/next";
|
|
99
|
+
|
|
100
|
+
export default async function DashboardPage() {
|
|
101
|
+
const session = await requireConnectSession(); // UserResponse — redirects if signed out
|
|
102
|
+
return <h1>{session.user.workspaceName}</h1>;
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**7. Verify the wiring.**
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
npx @cosmoops/connect-sdk doctor
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
That's the whole round trip — `/signin`, `/callback`, an httpOnly session
|
|
113
|
+
cookie, `/session`, `/entitlements`, `/plans`, `/addons`, `/apps`,
|
|
114
|
+
`/sessions`, `/logout` — with no `next.config.ts` changes, including
|
|
115
|
+
`transpilePackages`: `dist/` is already ES2022 with `"use client"` hoisted,
|
|
116
|
+
so Turbopack and webpack both consume it as-is.
|
|
117
|
+
|
|
118
|
+
`npx @cosmoops/connect-sdk init` writes step 3's route file for you, plus a
|
|
119
|
+
`/plans` page and an `.env.local` template for step 2 — then prints steps 4
|
|
120
|
+
and 5 as the ones it won't do on your behalf, since those touch your own
|
|
121
|
+
layout and Connect's dashboard rather than files it can safely write. See
|
|
122
|
+
[Scaffolding](#scaffolding) below.
|
|
123
|
+
|
|
124
|
+
Two things most integrations add next:
|
|
125
|
+
|
|
126
|
+
- **A shell** — `AppShell` from `/client` is the sidebar, top bar and access
|
|
127
|
+
gate every application platform needs. See [App shell](#app-shell).
|
|
128
|
+
- **Route protection before render** — see [Route protection and
|
|
129
|
+
`proxy.ts`](#route-protection-and-proxyts) below; `requireConnectSession()`
|
|
130
|
+
above already redirects a signed-out visitor, so you only need this for
|
|
131
|
+
visitors you want bounced *before* a page starts rendering.
|
|
132
|
+
|
|
133
|
+
The sections below this point cover the manual path — rolling the OAuth round
|
|
134
|
+
trip yourself for a non-Next backend, or understanding what `/next` is doing
|
|
135
|
+
for you under the hood.
|
|
136
|
+
|
|
26
137
|
## Server setup
|
|
27
138
|
|
|
28
|
-
One credential does three jobs — the API key plaintext from your
|
|
139
|
+
One credential does three jobs — the API key plaintext from your application's Integration tab is also your OAuth2 `client_secret` and the key your `id_token` is signed with. Revoking it revokes all three.
|
|
29
140
|
|
|
30
141
|
```ts
|
|
31
142
|
import { ConnectClient } from "@cosmoops/connect-sdk/server";
|
|
@@ -81,6 +192,49 @@ const { user, serviceRole } = await connect.identity.getUser(sessionToken);
|
|
|
81
192
|
if (!serviceRole.granted.includes("invoice:write")) return forbidden();
|
|
82
193
|
```
|
|
83
194
|
|
|
195
|
+
## Know which kind of account signed in
|
|
196
|
+
|
|
197
|
+
`user.workspaceType` is `"individual"` (a one-seat personal account) or
|
|
198
|
+
`"business"` (a team). It is the one fact about the workspace you cannot look
|
|
199
|
+
up yourself — this API has no workspace read — and it decides whether half
|
|
200
|
+
your UI is real: an individual workspace has exactly one seat and can never
|
|
201
|
+
gain another, so a members table, an invite button and a seat meter can only
|
|
202
|
+
ever fail there.
|
|
203
|
+
|
|
204
|
+
`audience` answers the other half — what Connect's catalogue says your
|
|
205
|
+
application is *sold* to, and whether this account qualifies:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
const { user, audience } = await connect.identity.getUser(sessionToken);
|
|
209
|
+
|
|
210
|
+
user.workspaceType; // "individual"
|
|
211
|
+
audience.application; // "b2b"
|
|
212
|
+
audience.matches; // false — readiness will block, subscribing is refused
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Both ship together so the check stays `!audience.matches` rather than a
|
|
216
|
+
hardcoded audience constant in your own code that goes stale the moment an
|
|
217
|
+
operator changes the catalogue entry. `audience.enforcedAtSignIn` says whether
|
|
218
|
+
Connect refuses the hand-off itself; when it does, a mismatch only ever reaches
|
|
219
|
+
you on a session minted before that setting was turned on.
|
|
220
|
+
|
|
221
|
+
A mismatch is not a reason to sign anyone out. They have a valid Connect
|
|
222
|
+
account — just the wrong kind of one for this application. `<ConnectAccessGate>`
|
|
223
|
+
already blocks the shell on it.
|
|
224
|
+
|
|
225
|
+
In the browser, the same two facts are `useConnectWorkspaceType()` and
|
|
226
|
+
`useConnectAudience()`, and `<ConnectWorkspaceTypeGate>` is the declarative form:
|
|
227
|
+
|
|
228
|
+
```tsx
|
|
229
|
+
<ConnectWorkspaceTypeGate type="business">
|
|
230
|
+
<TeamMembersTable />
|
|
231
|
+
</ConnectWorkspaceTypeGate>
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
That gate varies what each account type sees *inside* an application both
|
|
235
|
+
audiences can reach. It is not the audience gate and does not replace
|
|
236
|
+
`<ConnectAccessGate>`.
|
|
237
|
+
|
|
84
238
|
**4. On logout.**
|
|
85
239
|
|
|
86
240
|
```ts
|
|
@@ -135,14 +289,14 @@ default: worth it for an app shell, less so for a marketing page.
|
|
|
135
289
|
```ts
|
|
136
290
|
import { blockingBlockers } from "@cosmoops/connect-sdk"; // isomorphic
|
|
137
291
|
|
|
138
|
-
const readiness = await connect.entitlements.getReadiness(
|
|
292
|
+
const readiness = await connect.entitlements.getReadiness(workspaceId);
|
|
139
293
|
|
|
140
294
|
if (!readiness.ready) {
|
|
141
295
|
const [blocker] = blockingBlockers(readiness);
|
|
142
296
|
return showBlocked(blocker.message, blocker.resolveUrl); // resolveUrl links into Connect
|
|
143
297
|
}
|
|
144
298
|
|
|
145
|
-
if (await connect.entitlements.hasFeature(
|
|
299
|
+
if (await connect.entitlements.hasFeature(workspaceId, "campaign-module")) {
|
|
146
300
|
// ...
|
|
147
301
|
}
|
|
148
302
|
```
|
|
@@ -153,7 +307,7 @@ Branch on `blocker.code`, never on `message`. Treat an unrecognised code as bloc
|
|
|
153
307
|
|
|
154
308
|
```ts
|
|
155
309
|
const { balance } = await connect.billing.consumeAddon({
|
|
156
|
-
|
|
310
|
+
workspaceId,
|
|
157
311
|
addonId: "api-calls",
|
|
158
312
|
quantity: 1,
|
|
159
313
|
});
|
|
@@ -204,7 +358,7 @@ await client.logs.write({
|
|
|
204
358
|
});
|
|
205
359
|
```
|
|
206
360
|
|
|
207
|
-
Both scopes on the stored entry come from something verified, never from your payload: `
|
|
361
|
+
Both scopes on the stored entry come from something verified, never from your payload: `applicationId` from your API key, and `userId` from this token — which Connect checks is unrevoked, unexpired, and **minted for your application** before attributing anything. **There is no `userId` field to send.** An actor a caller can type is not an actor worth recording, and a conformance test fails the build if one ever appears in the server schema.
|
|
208
362
|
|
|
209
363
|
`write` returns `{ delivered, attributed }` and never throws. `attributed: false` with `delivered: true` means the token had expired (~15 days) or been revoked and the line was stored *without* a user — deliberately kept rather than rejected, because losing telemetry over a failed attribution is the worse of the two outcomes.
|
|
210
364
|
|
|
@@ -221,73 +375,20 @@ new ConnectClient({
|
|
|
221
375
|
});
|
|
222
376
|
```
|
|
223
377
|
|
|
224
|
-
## Next.js —
|
|
378
|
+
## Next.js — reading the session
|
|
225
379
|
|
|
226
|
-
|
|
380
|
+
The setup is [Quickstart](#quickstart-nextjs) above; this is the detail behind
|
|
381
|
+
its step 6 — `getConnectSession()` / `requireConnectSession()` beyond the
|
|
382
|
+
one-liner shown there.
|
|
227
383
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
```
|
|
384
|
+
`requireConnectSession()` is `getConnectSession()` plus the check-and-redirect.
|
|
385
|
+
Reach for the plain version when a missing session isn't an error — an
|
|
386
|
+
optional account badge in a header, say:
|
|
232
387
|
|
|
233
388
|
```tsx
|
|
234
|
-
// app/layout.tsx
|
|
235
|
-
import { ConnectProvider } from "@cosmoops/connect-sdk/client";
|
|
236
|
-
|
|
237
|
-
<ConnectProvider>{children}</ConnectProvider>
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
```css
|
|
241
|
-
/* app/globals.css */
|
|
242
|
-
@import "tailwindcss";
|
|
243
|
-
@import "@heroui/styles";
|
|
244
|
-
@import "@cosmoops/connect-sdk/theme.css";
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
`npx @cosmoops/connect-sdk init` writes the route file and a `/plans` page for
|
|
248
|
-
you; the provider and the stylesheet are the two it won't edit on your behalf.
|
|
249
|
-
|
|
250
|
-
That gives you the whole round trip — `/signin`, `/callback`, an httpOnly session
|
|
251
|
-
cookie, `/session`, `/entitlements`, `/plans`, `/addons`, `/apps`, `/sessions`,
|
|
252
|
-
`/logout`. Config comes from the environment:
|
|
253
|
-
|
|
254
|
-
| Variable | Required | Purpose |
|
|
255
|
-
| --- | --- | --- |
|
|
256
|
-
| `CONNECT_API_KEY` | yes | server-side credential |
|
|
257
|
-
| `CONNECT_CLIENT_ID` | yes | OAuth client id |
|
|
258
|
-
| `CONNECT_APP_URL` | no | non-production Connect instance |
|
|
259
|
-
| `CONNECT_SESSION_COOKIE` | no | session cookie name |
|
|
260
|
-
|
|
261
|
-
Register `{your origin}/api/connect/callback` as a redirect URI in the service's
|
|
262
|
-
Integration tab — the one step this cannot do for you.
|
|
263
|
-
|
|
264
|
-
No `next.config.ts` changes needed, including `transpilePackages`: `dist/` is
|
|
265
|
-
already ES2022 with `"use client"` hoisted, so Turbopack and webpack both
|
|
266
|
-
consume it as-is.
|
|
267
|
-
|
|
268
|
-
Reading the session server-side — no argument, no `headers()` dance:
|
|
269
|
-
|
|
270
|
-
```tsx
|
|
271
|
-
// app/dashboard/page.tsx
|
|
272
389
|
import { getConnectSession } from "@cosmoops/connect-sdk/next";
|
|
273
|
-
import { redirect } from "next/navigation";
|
|
274
390
|
|
|
275
|
-
|
|
276
|
-
const session = await getConnectSession(); // UserResponse | null
|
|
277
|
-
if (!session) redirect("/api/connect/signin?next=/dashboard");
|
|
278
|
-
return <h1>{session.user.organizationName}</h1>;
|
|
279
|
-
}
|
|
280
|
-
```
|
|
281
|
-
|
|
282
|
-
Or skip the check-and-redirect entirely — `requireConnectSession()` does both:
|
|
283
|
-
|
|
284
|
-
```tsx
|
|
285
|
-
import { requireConnectSession } from "@cosmoops/connect-sdk/next";
|
|
286
|
-
|
|
287
|
-
export default async function DashboardPage() {
|
|
288
|
-
const session = await requireConnectSession(); // UserResponse — redirects if signed out
|
|
289
|
-
return <h1>{session.user.organizationName}</h1>;
|
|
290
|
-
}
|
|
391
|
+
const session = await getConnectSession(); // UserResponse | null
|
|
291
392
|
```
|
|
292
393
|
|
|
293
394
|
Pass a `Request` — `getConnectSession(request)` — anywhere `next/headers` does
|
|
@@ -303,9 +404,6 @@ It reads `cookies()` transitively, so under `cacheComponents` it belongs inside
|
|
|
303
404
|
a `<Suspense>` boundary like any other request-scoped read. A page that awaits
|
|
304
405
|
it above every boundary gives up its static shell.
|
|
305
406
|
|
|
306
|
-
You do **not** need `transpilePackages`. `dist/` is ES2022 with `"use client"`
|
|
307
|
-
already hoisted, so Turbopack and webpack consume it as-is.
|
|
308
|
-
|
|
309
407
|
## Route protection and `proxy.ts`
|
|
310
408
|
|
|
311
409
|
Nothing here is auto-wired, and nothing needs to be: this SDK ships no proxy and
|
|
@@ -340,11 +438,24 @@ export const config = { matcher: ["/dashboard/:path*", "/settings/:path*"] };
|
|
|
340
438
|
|
|
341
439
|
**A cookie is not a session, and this check is not authorization.** The cookie
|
|
342
440
|
holds an opaque Connect `sessionToken`; deciding whether it is still valid means
|
|
343
|
-
asking Connect, and proxy runs on every matched request —
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
441
|
+
asking Connect, and proxy runs on every matched request — including
|
|
442
|
+
[prefetched](https://nextjs.org/docs/app/getting-started/linking-and-navigating#prefetching)
|
|
443
|
+
ones — so a real check here would put a network round trip in front of far
|
|
444
|
+
more requests than the visitor actually makes.
|
|
445
|
+
|
|
446
|
+
This isn't just this SDK's opinion — it's what Next.js's own [Authentication
|
|
447
|
+
guide](https://nextjs.org/docs/app/guides/authentication#optimistic-checks-with-proxy-optional)
|
|
448
|
+
recommends, word for word (verified against the docs bundled with the Next
|
|
449
|
+
version in this repo's `node_modules`, since the hosted docs move faster than
|
|
450
|
+
any one install):
|
|
451
|
+
|
|
452
|
+
> Since Proxy runs on every route, including prefetched routes, it's important
|
|
453
|
+
> to only read the session from the cookie (optimistic checks), and avoid
|
|
454
|
+
> database checks to prevent performance issues.
|
|
455
|
+
|
|
456
|
+
The snippet above is exactly that: a presence check, nothing more. Next is
|
|
457
|
+
equally direct that this is as far as it goes — optimistic checks are "not
|
|
458
|
+
\[...\] a full session management or authorization solution."
|
|
348
459
|
|
|
349
460
|
An expired or revoked token still arrives as a cookie and still passes the
|
|
350
461
|
snippet above. The authoritative check is `getConnectSession()` in the Server
|
|
@@ -405,13 +516,13 @@ that URL yourself instead.
|
|
|
405
516
|
|
|
406
517
|
```tsx
|
|
407
518
|
import {
|
|
408
|
-
|
|
519
|
+
AppShell,
|
|
409
520
|
ConnectLoginButton, ConnectRegisterButton,
|
|
410
521
|
ConnectUserMenu, ConnectAppsGrid, ConnectPlans,
|
|
411
522
|
} from "@cosmoops/connect-sdk/client";
|
|
412
523
|
|
|
413
|
-
// The whole frame — sidebar, top bar, access gate. See "App shell" below.
|
|
414
|
-
<
|
|
524
|
+
// The whole frame — sidebar, top bar, access gate, Ask AI. See "App shell" below.
|
|
525
|
+
<AppShell name="Dispatch" nav={nav}>{children}</AppShell>
|
|
415
526
|
|
|
416
527
|
// Or the pieces, if you already have a layout. The pair swaps automatically
|
|
417
528
|
// on sign-in state.
|
|
@@ -425,7 +536,7 @@ import {
|
|
|
425
536
|
|
|
426
537
|
| Component | What it does |
|
|
427
538
|
| --- | --- |
|
|
428
|
-
| `
|
|
539
|
+
| `AppShell` | **The whole application frame** — collapsible sidebar, top bar with the launcher and account menu, and your pages in the middle. Mounts `ConnectAccessGate` for you. |
|
|
429
540
|
| `ConnectSideNav` | The sidebar's nav column on its own — sections, one level of children, active-route highlight — for an app that already has its own chrome. |
|
|
430
541
|
| `ConnectLoginButton` · `ConnectRegisterButton` | "Continue with Connect", in the shape people know from Google. Render nothing when signed in. |
|
|
431
542
|
| `ConnectUserMenu` | Avatar, name, workspace. Profile, Manage subscription and Open Connect each open Connect in a new tab; Sign out acts in place. Renders nothing when signed out. |
|
|
@@ -433,11 +544,14 @@ import {
|
|
|
433
544
|
| `ConnectPlans` | Monthly/annual toggle, features, add-ons, live discount, coupon field, and the subscribe / upgrade / downgrade / cancel flow. |
|
|
434
545
|
| `ConnectPlansPage` | A whole `/plans` page — readiness banner, current subscription, and the grid. What `connect-sdk init` scaffolds. |
|
|
435
546
|
| `ConnectSubscribeDialog` | The review step `ConnectPlans` opens — plan, GST, what happens to the current cycle. Exported so you can drive the flow from your own pricing page. |
|
|
547
|
+
| `ConnectAskAi` | **Ask AI** — a chat panel grounded in the workspace's Context Index, streamed through your own bridge. Answers draw as well as write: tables, charts, metric tiles. Mounted by `AppShell` by default. |
|
|
548
|
+
| `AiTable` · `AiChart` · `AiMetrics` · `AiTimeline` · `AiDetails` · `AiSteps` · `AiSources` | The seven widgets an Ask AI answer can draw, exported for rendering Connect's chat somewhere other than the panel. |
|
|
436
549
|
| `ConnectAccessGate` | **The one gate to mount if you mount one.** Composes suspended / no-plan / onboarding-pending / payment-due into a single priority-ordered decision. Wrap your app content once. |
|
|
437
550
|
| `ConnectSubscriptionGate` | Renders your module for a subscribed workspace, and `ConnectNoSubscriptionCard` for everyone else. Never flashes the upsell while loading. |
|
|
438
551
|
| `ConnectNoSubscriptionCard` · `ConnectSubscriptionCard` | The same slot, unsubscribed and subscribed. Plan, status, renewal date, seats, and one way into Connect. |
|
|
439
|
-
| `ConnectReadinessBanner` · `ConnectReadinessGate` | Every gate between this workspace and your
|
|
552
|
+
| `ConnectReadinessBanner` · `ConnectReadinessGate` | Every gate between this workspace and your application — KYC, payment method, seat headroom — each with the Connect screen that fixes it. |
|
|
440
553
|
| `ConnectFeatureGate` | Renders a module only when `plan.features` includes its key. |
|
|
554
|
+
| `ConnectWorkspaceTypeGate` | Renders a subtree only for an `"individual"` or `"business"` workspace — for dead controls, not access. |
|
|
441
555
|
| `ConnectTrialBanner` | A running trial and the date it converts. Renders nothing otherwise. |
|
|
442
556
|
| `ConnectAccountSessions` | Every device signed into this Connect account, with revoke. |
|
|
443
557
|
| `ConnectUsageMeter` | Remaining balance of each consumable add-on, with overage and carry-forward. |
|
|
@@ -446,12 +560,12 @@ import {
|
|
|
446
560
|
|
|
447
561
|
### Hooks
|
|
448
562
|
|
|
449
|
-
`useConnect` · `useConnectUser` · `useActivePath` · `useConnectAccessGate` · `useConnectEntitlements` · `useConnectPlans` · `useConnectApps` · `useConnectAddonUsage` · `useConnectSessions` · `useConnectResource` · `useConnectFeature` · `useConnectCheckout`
|
|
563
|
+
`useConnect` · `useConnectUser` · `useConnectWorkspaceType` · `useConnectAudience` · `useActivePath` · `useConnectAccessGate` · `useConnectEntitlements` · `useConnectPlans` · `useConnectApps` · `useConnectAddonUsage` · `useConnectSessions` · `useConnectResource` · `useConnectFeature` · `useConnectCheckout`
|
|
450
564
|
|
|
451
565
|
### Metering
|
|
452
566
|
|
|
453
567
|
```tsx
|
|
454
|
-
await client.billing.consumeAddon({
|
|
568
|
+
await client.billing.consumeAddon({ workspaceId, addonId: "addon_sms", quantity: 1 }); // server
|
|
455
569
|
<ConnectUsageMeter /> // browser
|
|
456
570
|
```
|
|
457
571
|
|
|
@@ -461,7 +575,7 @@ Invoices stay a deep link (`ConnectInvoicesButton`). A GST tax invoice is a lega
|
|
|
461
575
|
|
|
462
576
|
### Plan features: label vs key
|
|
463
577
|
|
|
464
|
-
The catalogue read (`useConnectPlans`, `<ConnectPlans />`) reports `features` as `{ key, name, description }` — **render `name`, gate on `key`**. The subscription read (`useConnectEntitlements`, `hasFeature`, `<ConnectFeatureGate>`) reports `features` as a bare `string[]` of keys, and stays that way: that one is a contract your backend compares against, and a display label is neither stable nor comparable. `name` falls back to the key for a plan older than its
|
|
578
|
+
The catalogue read (`useConnectPlans`, `<ConnectPlans />`) reports `features` as `{ key, name, description }` — **render `name`, gate on `key`**. The subscription read (`useConnectEntitlements`, `hasFeature`, `<ConnectFeatureGate>`) reports `features` as a bare `string[]` of keys, and stays that way: that one is a contract your backend compares against, and a display label is neither stable nor comparable. `name` falls back to the key for a plan older than its application's feature catalog.
|
|
465
579
|
|
|
466
580
|
### Gates
|
|
467
581
|
|
|
@@ -481,7 +595,7 @@ the order yourself:
|
|
|
481
595
|
| State | What renders |
|
|
482
596
|
| --- | --- |
|
|
483
597
|
| `suspended` | A hard stop. Nothing the customer does clears it — only a Connect admin reactivating the workspace. |
|
|
484
|
-
| `no_subscription` | "Choose a plan", linking to Connect's subscribe page for your
|
|
598
|
+
| `no_subscription` | "Choose a plan", linking to Connect's subscribe page for your application. |
|
|
485
599
|
| `onboarding_pending` | Waiting on a reviewer. No action offered, because there is none. |
|
|
486
600
|
| `blocked` | Any other blocking readiness code — verification, audience, seats — each with its own fix. |
|
|
487
601
|
| `payment_retry` | **Your app still renders.** A dismissible banner counts down to the dunning deadline. |
|
|
@@ -519,7 +633,7 @@ The narrower gates still exist for gating *within* a cleared workspace:
|
|
|
519
633
|
|
|
520
634
|
All of them render `loading` — never the fallback — while the answer is unknown, so a paying customer is never flashed an upsell for something they already have.
|
|
521
635
|
|
|
522
|
-
**They are UX gates, not security boundaries.** Everything they read reaches your own origin and a browser can ignore it. The authoritative check is on your server: `client.entitlements.getReadiness(
|
|
636
|
+
**They are UX gates, not security boundaries.** Everything they read reaches your own origin and a browser can ignore it. The authoritative check is on your server: `client.entitlements.getReadiness(workspaceId)` and `client.entitlements.hasFeature(workspaceId, feature)`.
|
|
523
637
|
|
|
524
638
|
### Scaffolding
|
|
525
639
|
|
|
@@ -579,12 +693,12 @@ Cancelling uses the same machinery via `buildCancelUrl`. There is no *resume*: C
|
|
|
579
693
|
"use client";
|
|
580
694
|
import Link from "next/link";
|
|
581
695
|
import { usePathname } from "next/navigation";
|
|
582
|
-
import {
|
|
696
|
+
import { AppShell } from "@cosmoops/connect-sdk/client";
|
|
583
697
|
import { LayoutDashboard, Receipt, Settings, Truck } from "lucide-react";
|
|
584
698
|
|
|
585
699
|
export function Shell({ children }: { children: React.ReactNode }) {
|
|
586
700
|
return (
|
|
587
|
-
<
|
|
701
|
+
<AppShell
|
|
588
702
|
name="Dispatch"
|
|
589
703
|
icon={<Truck />}
|
|
590
704
|
activePath={usePathname()}
|
|
@@ -610,12 +724,12 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
|
|
610
724
|
]}
|
|
611
725
|
>
|
|
612
726
|
{children}
|
|
613
|
-
</
|
|
727
|
+
</AppShell>
|
|
614
728
|
);
|
|
615
729
|
}
|
|
616
730
|
```
|
|
617
731
|
|
|
618
|
-
That is the entire layout of a
|
|
732
|
+
That is the entire layout of a application platform. Everything that should look
|
|
619
733
|
identical across the family — the lockup, the rail geometry, the active-route
|
|
620
734
|
treatment, the app launcher, the account menu, the mobile drawer — comes from
|
|
621
735
|
here; only `name`, `icon` and `nav` differ per platform.
|
|
@@ -627,7 +741,7 @@ plain `<a>`, which works everywhere and costs a full page load. Pass
|
|
|
627
741
|
`window.location` and patches `history.pushState` to hear about client-side
|
|
628
742
|
navigations, which is guesswork your router never has to do.
|
|
629
743
|
|
|
630
|
-
**The access gate is on by default.** `
|
|
744
|
+
**The access gate is on by default.** `AppShell` is exactly the "wrap
|
|
631
745
|
your app content once" place `ConnectAccessGate` asks for, so it mounts one
|
|
632
746
|
around `children` — an app that ships a shell and forgets the gate is an app
|
|
633
747
|
that lets a suspended workspace keep working. The chrome is never gated: the
|
|
@@ -644,6 +758,17 @@ page that sells them one, and is told to choose a plan. Pass `gate={false}` to
|
|
|
644
758
|
opt out entirely, or `gateExemptRoutes` for a path that is not in the sidebar
|
|
645
759
|
at all.
|
|
646
760
|
|
|
761
|
+
**Ask AI is on by default too**, for the same reason: a family of application
|
|
762
|
+
platforms should answer questions the same way, and a feature every partner has
|
|
763
|
+
to remember to switch on is a feature most of them do not have. It stays inert
|
|
764
|
+
until it can work — nothing renders for a signed-out visitor, and a workspace
|
|
765
|
+
whose Context Index is off gets a message in the panel rather than a broken
|
|
766
|
+
answer. Pass `askAi={false}` to leave it out, or an object to override its
|
|
767
|
+
heading, copy or markdown plugins.
|
|
768
|
+
|
|
769
|
+
It needs two things you control: the workspace's Context Index turned on in
|
|
770
|
+
Connect, and `ai:chat` on the API key behind your bridge.
|
|
771
|
+
|
|
647
772
|
Other things worth knowing:
|
|
648
773
|
|
|
649
774
|
- **The rail collapses to icons** and remembers the choice per browser
|
|
@@ -686,7 +811,7 @@ Each platform passes only what differs — `name` and `icon`. Mark geometry, wor
|
|
|
686
811
|
@import "@cosmoops/connect-sdk/theme.css";
|
|
687
812
|
```
|
|
688
813
|
|
|
689
|
-
One shared **HeroUI v3** theme for every
|
|
814
|
+
One shared **HeroUI v3** theme for every application platform. The components paint with semantic tokens only — `accent`, `muted`, `surface`, `separator`, `danger` — never a hardcoded colour, so one edit to the theme restyles all of them everywhere at once. To diverge on brand, redefine individual tokens *after* the import rather than forking the file.
|
|
690
815
|
|
|
691
816
|
Requires `@heroui/react`, `lucide-react` and `motion` as peers (optional — server-only consumers need none of them). `motion` drives the components' entrance and state transitions, and respects `prefers-reduced-motion`.
|
|
692
817
|
|
|
@@ -696,6 +821,63 @@ The *flow* is in your app; the *payment* is not. A plan change is not one write
|
|
|
696
821
|
|
|
697
822
|
So the hand-off is a URL, and Connect renders it as a modal over the relevant page (an intercepted parallel route) — the customer lands on the confirmation for the plan they picked, and returns to a subscription that is already correct.
|
|
698
823
|
|
|
824
|
+
## Release notes
|
|
825
|
+
|
|
826
|
+
Full history: [CHANGELOG.md](./CHANGELOG.md). Latest first:
|
|
827
|
+
|
|
828
|
+
### 0.9.0
|
|
829
|
+
|
|
830
|
+
- **Ask AI**, mounted by `AppShell` by default — a chat panel grounded in the
|
|
831
|
+
workspace's Context Index, streamed through a new `POST /ai/chat` route on
|
|
832
|
+
your own bridge. The workspace comes from the caller's session, never the
|
|
833
|
+
browser.
|
|
834
|
+
- **Answers draw as well as write.** Connect's model calls presentation tools
|
|
835
|
+
and the panel renders them natively: a sortable table with CSV export, a
|
|
836
|
+
chart, metric tiles, timelines, fact sheets, procedures, citations. Values
|
|
837
|
+
arrive raw with a format tag, so they are formatted in the viewer's locale
|
|
838
|
+
and a column still sorts numerically. The seven widgets are exported.
|
|
839
|
+
- **`streamdown` is a new optional peer dependency** — see step 1 and step 4 of
|
|
840
|
+
the quickstart. Two lines of host setup; skip both with `askAi={false}`.
|
|
841
|
+
- **`user.workspaceType`, `audience`**, `<ConnectWorkspaceTypeGate>`,
|
|
842
|
+
`useConnectWorkspaceType()`, `useConnectAudience()`.
|
|
843
|
+
- **`streamResource()`** on `useConnect()` — POST through the bridge and get
|
|
844
|
+
the raw streaming `Response` back.
|
|
845
|
+
- **Fixed:** `ai.chat()` no longer has its stream cut off mid-answer by the
|
|
846
|
+
transport's 20s timeout.
|
|
847
|
+
|
|
848
|
+
*0.8.0 was never published; its one breaking change is in this release.*
|
|
849
|
+
|
|
850
|
+
### 0.8.0 — breaking (folded into 0.9.0)
|
|
851
|
+
|
|
852
|
+
- **`ConnectAppShell` renamed to `AppShell`.** Same props, same behaviour —
|
|
853
|
+
only the name changed. Every other `/client` export keeps its `Connect`
|
|
854
|
+
prefix; this is the one deliberate exception.
|
|
855
|
+
|
|
856
|
+
```diff
|
|
857
|
+
- import { ConnectAppShell, type ConnectAppShellProps } from "@cosmoops/connect-sdk/client";
|
|
858
|
+
+ import { AppShell, type AppShellProps } from "@cosmoops/connect-sdk/client";
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
If your own layout component is also named `AppShell`, alias the import:
|
|
862
|
+
`import { AppShell as ConnectAppShell } from "..."`.
|
|
863
|
+
|
|
864
|
+
### 0.6.0
|
|
865
|
+
|
|
866
|
+
- **`AppShell`** (then `ConnectAppShell`) — the application frame every
|
|
867
|
+
platform was otherwise writing by hand: collapsible sidebar, top bar with
|
|
868
|
+
the app launcher and account menu, mobile drawer, `ConnectAccessGate`
|
|
869
|
+
mounted around your content by default.
|
|
870
|
+
- **`ConnectSideNav`** — the sidebar's nav column alone, for an app with
|
|
871
|
+
chrome it already likes.
|
|
872
|
+
- The nav model as data — `ConnectNav` / `ConnectNavItem` / `ConnectNavSection`
|
|
873
|
+
— plus the pure functions the rail resolves the active route with.
|
|
874
|
+
|
|
875
|
+
See [CHANGELOG.md](./CHANGELOG.md) for 0.5.0 and earlier.
|
|
876
|
+
|
|
877
|
+
Upgrading past a minor version? Read that version's entry first — per the 0.x
|
|
878
|
+
convention at the top of this file, a minor bump here is allowed to break
|
|
879
|
+
something, and the changelog says what and how to migrate.
|
|
880
|
+
|
|
699
881
|
## Development
|
|
700
882
|
|
|
701
883
|
```bash
|
|
@@ -25,9 +25,17 @@ function formatDate(iso) {
|
|
|
25
25
|
* `ConnectReadinessGate`/`ConnectReadinessBanner` already use. `ConnectAccessGate`
|
|
26
26
|
* is a priority ordering over the same data, not a second opinion about it.
|
|
27
27
|
*
|
|
28
|
-
* Priority, first match wins: `suspended` > `
|
|
29
|
-
* `onboarding_pending` > any other blocking code
|
|
30
|
-
* `payment_retry` (advisory - `children` still render) >
|
|
28
|
+
* Priority, first match wins: `suspended` > `audience_mismatch` >
|
|
29
|
+
* `no_subscription` > `onboarding_pending` > any other blocking code
|
|
30
|
+
* (`blocked`) > `payment_retry` (advisory - `children` still render) >
|
|
31
|
+
* `clear`.
|
|
32
|
+
*
|
|
33
|
+
* `audience_mismatch` outranks `no_subscription` deliberately, and the
|
|
34
|
+
* ordering is the whole point of it: a workspace of the wrong account type
|
|
35
|
+
* carries BOTH blockers at once, and showing the subscription one first sent
|
|
36
|
+
* the customer to a plan picker whose subscribe call
|
|
37
|
+
* (`ActivateSubscriptionUseCase`) rejects them for the audience anyway. The
|
|
38
|
+
* account type is the reason; the missing subscription is a consequence.
|
|
31
39
|
*/
|
|
32
40
|
function useConnectAccessGate() {
|
|
33
41
|
const { session, isLoading: sessionLoading } = (0, provider_js_1.useConnect)();
|
|
@@ -50,6 +58,10 @@ function useConnectAccessGate() {
|
|
|
50
58
|
const suspended = blocking.find((b) => b.code === "workspace_suspended");
|
|
51
59
|
if (suspended)
|
|
52
60
|
return { state: "suspended", blocker: suspended, paymentRetry: null };
|
|
61
|
+
const audienceMismatch = blocking.find((b) => b.code === "audience_mismatch");
|
|
62
|
+
if (audienceMismatch) {
|
|
63
|
+
return { state: "audience_mismatch", blocker: audienceMismatch, paymentRetry: null };
|
|
64
|
+
}
|
|
53
65
|
const noSubscription = blocking.find((b) => b.code === "subscription_missing");
|
|
54
66
|
if (noSubscription)
|
|
55
67
|
return { state: "no_subscription", blocker: noSubscription, paymentRetry: null };
|
|
@@ -109,6 +121,29 @@ function ConnectAccessGate({ children, fallback, loading, className }) {
|
|
|
109
121
|
return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: fallback ?? (0, jsx_runtime_1.jsx)(AccessGateFallback, { state: gate.state, blocker: gate.blocker, className: className }) }));
|
|
110
122
|
}
|
|
111
123
|
function AccessGateFallback({ state, blocker, className, }) {
|
|
124
|
+
if (state === "audience_mismatch") {
|
|
125
|
+
// `requiredWorkspaceType` rather than the message, which is documented as
|
|
126
|
+
// human-facing and subject to rewording - the title has to name the right
|
|
127
|
+
// account type even after someone rephrases the sentence below it.
|
|
128
|
+
const required = blocker?.requiredWorkspaceType ?? null;
|
|
129
|
+
return ((0, jsx_runtime_1.jsx)(GateCard, { icon: required === "individual" ? lucide_react_1.User : lucide_react_1.Building2, iconClassName: "text-muted", title: required === "business"
|
|
130
|
+
? "Business account required"
|
|
131
|
+
: required === "individual"
|
|
132
|
+
? "Individual account required"
|
|
133
|
+
: "Not available for this account type",
|
|
134
|
+
// The server names the application in its own message, which reads
|
|
135
|
+
// better than anything composable here; the fallback covers a blocker
|
|
136
|
+
// that arrived without one.
|
|
137
|
+
message: blocker?.message ??
|
|
138
|
+
(required
|
|
139
|
+
? `This application is only available to ${required} accounts.`
|
|
140
|
+
: "This application isn't available to this account's type."),
|
|
141
|
+
// Deliberately no action, even if one were sent: `Workspace.type` is
|
|
142
|
+
// set at sign-up and no screen changes it, so any button here would
|
|
143
|
+
// only move the dead end somewhere else. Switching means a different
|
|
144
|
+
// account, which is a conversation with support, not a link.
|
|
145
|
+
resolveUrl: null, className: className }));
|
|
146
|
+
}
|
|
112
147
|
if (state === "no_subscription") {
|
|
113
148
|
return ((0, jsx_runtime_1.jsx)(GateCard, { icon: lucide_react_1.CreditCard, iconClassName: "text-accent", title: "Choose a plan to continue", message: blocker?.message ?? "This workspace isn't subscribed to this application yet.", actionLabel: "See plans", resolveUrl: blocker?.resolveUrl ?? null, className: className }));
|
|
114
149
|
}
|
|
@@ -118,7 +153,7 @@ function AccessGateFallback({ state, blocker, className, }) {
|
|
|
118
153
|
if (state === "onboarding_pending") {
|
|
119
154
|
return ((0, jsx_runtime_1.jsx)(GateCard, { icon: lucide_react_1.Clock, iconClassName: "text-muted", title: "Waiting on approval", message: blocker?.message ?? "This workspace is waiting on approval before it can use this application.", resolveUrl: null, className: className }));
|
|
120
155
|
}
|
|
121
|
-
// "blocked" - any other blocking code (verification,
|
|
156
|
+
// "blocked" - any other blocking code (verification, seats, ...).
|
|
122
157
|
// Full fidelity for a code this component doesn't special-case: the same
|
|
123
158
|
// banner-in-a-card `ConnectReadinessGate` falls back to, rather than
|
|
124
159
|
// `GateCard`'s single-blocker shape - there can be more than one of these
|