@cosmoops/connect-sdk
v0.27.0
Published
Official server-side SDK for the CosmoOps Connect platform API - Login with Connect, entitlements, billing and metering.
Readme
@cosmoops/connect-sdk
Official client for the CosmoOps Connect application-partner API (/api/v1/application/*).
Current version: 0.20.0 · Release notes · Full changelog
— follows semver with the 0.x convention that a minor version may carry a
breaking change (a patch never does). Read the release notes for your target
version before upgrading past a minor bump.
npm install @cosmoops/connect-sdk
npm install streamdown # optional — Ask AI's markdown renderer, see step 4Five entry points, and the split is a security boundary, not a preference:
| Import | Runs in | Holds the API key |
| --- | --- | --- |
| @cosmoops/connect-sdk/server | your backend | yes |
| @cosmoops/connect-sdk/client | the browser | never |
| @cosmoops/connect-sdk/next | your backend | yes (Next.js plug-and-play) |
| @cosmoops/connect-sdk/mcp | your backend | yes (store, as MCP tools) |
| @cosmoops/connect-sdk | anywhere | types + ConnectError only |
The browser half never sees a credential. It calls your own origin, and
createConnectHandler — mounted in your backend — is what talks to Connect.
Browser ──► your backend ──► Connect
(client) (createConnectHandler + server client, holds the key)Quickstart (Next.js)
The fast path — most integrations are this and nothing else. Not on Next.js? Skip to Other frameworks; the shape is the same, you just wire the session cookie yourself.
1. Install and get credentials. From your application's Connect client section in Connect, copy the API key and OAuth client id.
npm install @cosmoops/connect-sdk2. Set environment variables.
| Variable | Required | Purpose |
| --- | --- | --- |
| CONNECT_API_KEY | yes | server-side credential |
| CONNECT_CLIENT_ID | yes | OAuth client id |
| CONNECT_APP_URL | no | non-production Connect instance |
| CONNECT_SESSION_COOKIE | no | session cookie name |
3. Mount the bridge route. One file, no arguments — this is your entire backend integration:
// app/api/connect/[...connect]/route.ts
export { GET, POST } from "@cosmoops/connect-sdk/next";4. Wrap your layout and import the theme.
// app/layout.tsx
import { ConnectProvider } from "@cosmoops/connect-sdk/client";
<ConnectProvider>{children}</ConnectProvider>/* app/globals.css */
@import "tailwindcss";
@import "@heroui/styles";
@import "@cosmoops/connect-sdk/theme.css";
/* Ask AI's markdown renderer. Tailwind skips node_modules unless told, and
Streamdown's utility classes live inside its compiled bundle. Adjust the
`../` count to reach your own node_modules. */
@source "../node_modules/streamdown/dist/*.js";// app/layout.tsx — Streamdown's streaming animation keyframes
import "streamdown/styles.css";Skip the last two if you pass askAi={false} to AppShell. The colour tokens
Streamdown expects are already mapped onto HeroUI's in theme.css.
5. Register the redirect URI. In the application's Connect client section, add
{your origin}/api/connect/callback. This is the one step the SDK cannot do
for you.
6. Read the session wherever you need it.
// app/dashboard/page.tsx
import { requireConnectSession } from "@cosmoops/connect-sdk/next";
export default async function DashboardPage() {
const session = await requireConnectSession(); // UserResponse — redirects if signed out
return <h1>{session.user.workspaceName}</h1>;
}7. Verify the wiring.
npx @cosmoops/connect-sdk doctorThat's the whole round trip — /signin, /callback, an httpOnly session
cookie, /session, /entitlements, /plans, /addons, /apps,
/sessions, /logout — with no next.config.ts changes, including
transpilePackages: dist/ is already ES2022 with "use client" hoisted,
so Turbopack and webpack both consume it as-is.
npx @cosmoops/connect-sdk init writes step 3's route file for you, plus a
/plans page and an .env.local template for step 2 — then prints steps 4
and 5 as the ones it won't do on your behalf, since those touch your own
layout and Connect's dashboard rather than files it can safely write. See
Scaffolding below.
Two things most integrations add next:
- A shell —
AppShellfrom/clientis the rail and the access gate every application platform needs. See App shell. - Route protection before render — see Route protection and
proxy.tsbelow;requireConnectSession()above already redirects a signed-out visitor, so you only need this for visitors you want bounced before a page starts rendering.
The sections below this point cover the manual path — rolling the OAuth round
trip yourself for a non-Next backend, or understanding what /next is doing
for you under the hood.
Server setup
One credential does three jobs — the API key plaintext from your application's Connect client section is also your OAuth2 client_secret and the key your id_token is signed with. Revoking it revokes all three.
import { ConnectClient } from "@cosmoops/connect-sdk/server";
export const connect = new ConnectClient({
apiKey: process.env.CONNECT_API_KEY!,
clientId: process.env.CONNECT_CLIENT_ID, // only needed for identity.*
});An API key or a sessionToken in browser code is a leaked credential.
Login with Connect
1. Send the user out.
import { createState } from "@cosmoops/connect-sdk/server";
const state = createState();
await saveToSession({ state });
redirect(connect.identity.authorizeUrl({
redirectUri: "https://you.example/callback",
state,
// prompt: "create" ← opens registration instead of sign-in
}));2. Handle the callback.
if (query.state !== savedState) throw new Error("state mismatch");
if (query.error === "login_required") return retrySignIn(); // normal — not an error
if (query.error) return showCancelled();
const session = await connect.identity.exchangeCode({
code: query.code,
redirectUri: "https://you.example/callback",
});
await connect.identity.verifyIdToken(session.idToken);
await saveToSession({ sessionToken: session.sessionToken });3. On each later request.
const { user, serviceRole } = await connect.identity.getUser(sessionToken);
if (!serviceRole.granted.includes("invoice:write")) return forbidden();Know which kind of account signed in
user.workspaceType is "individual" (a one-seat personal account) or
"business" (a team). It is the one fact about the workspace you cannot look
up yourself — this API has no workspace read — and it decides whether half
your UI is real: an individual workspace has exactly one seat and can never
gain another, so a members table, an invite button and a seat meter can only
ever fail there.
audience answers the other half — what Connect's catalogue says your
application is sold to, and whether this account qualifies:
const { user, audience } = await connect.identity.getUser(sessionToken);
user.workspaceType; // "individual"
audience.application; // "b2b"
audience.matches; // false — readiness will block, subscribing is refusedBoth ship together so the check stays !audience.matches rather than a
hardcoded audience constant in your own code that goes stale the moment an
operator changes the catalogue entry. audience.enforcedAtSignIn says whether
Connect refuses the hand-off itself; when it does, a mismatch only ever reaches
you on a session minted before that setting was turned on.
A mismatch is not a reason to sign anyone out. They have a valid Connect
account — just the wrong kind of one for this application. <ConnectAccessGate>
already blocks the shell on it.
In the browser, the same two facts are useConnectWorkspaceType() and
useConnectAudience(), and <ConnectWorkspaceTypeGate> is the declarative form:
<ConnectWorkspaceTypeGate type="business">
<TeamMembersTable />
</ConnectWorkspaceTypeGate>That gate varies what each account type sees inside an application both
audiences can reach. It is not the audience gate and does not replace
<ConnectAccessGate>.
4. On logout.
await connect.identity.logout(sessionToken);Sign returning visitors in with no click
A visitor already signed into Connect should not have to press "Sign in"
again. OIDC calls this silent authentication, and on ./next it is one prop:
<ConnectProvider silentSignIn>On the first load of a signed-out visit the browser makes one round trip to
Connect with prompt=none. If they have a Connect session and have
authorised your app before, they come back signed in having seen nothing. If
not, they come back signed out — no error, no screen, your normal signed-out
UI a beat later.
Rolling it yourself is the same flow with prompt: "none":
redirect(connect.identity.authorizeUrl({ redirectUri, state, prompt: "none" }));Three things to get right, all handled for you by ./next:
prompt=nonenever shows a login screen — that is its contract. Use it to resume a session, never to start one. Sending a genuinely signed-out visitor through it in a loop is the classic way to build a redirect loop.login_requiredfrom a silent attempt is not thelogin_requiredabove. The retry in step 2 is right for an interactive sign-in and wrong here: it escalates a quiet "nobody home" into a forced login. Track which kind of attempt is in flight.- Attempt it at most once per tab. A visitor with no Connect session will answer the same way on every page load; without a marker, every navigation pays a full redirect.
First contact still shows Connect's consent screen once. That grant is
exactly what makes every later visit silent, and it is per user, per client —
so consent_required means "they have never authorised you", not "something
broke". Treat all three of login_required, consent_required and
interaction_required as "carry on signed out".
It costs a full-page redirect on that first load, which is why it is off by default: worth it for an app shell, less so for a marketing page.
Gate on entitlements
import { blockingBlockers } from "@cosmoops/connect-sdk"; // isomorphic
const readiness = await connect.entitlements.getReadiness(workspaceId);
if (!readiness.ready) {
const [blocker] = blockingBlockers(readiness);
return showBlocked(blocker.message, blocker.resolveUrl); // resolveUrl links into Connect
}
if (await connect.entitlements.hasFeature(workspaceId, "campaign-module")) {
// ...
}Branch on blocker.code, never on message. Treat an unrecognised code as blocking — the list grows.
Meter usage
const { balance } = await connect.billing.consumeAddon({
workspaceId,
addonId: "api-calls",
quantity: 1,
});An Idempotency-Key is sent on every consume, so a retry can't double-debit.
Errors
Everything throws ConnectError with a stable code.
import { isConnectError } from "@cosmoops/connect-sdk"; // isomorphic
try {
await connect.identity.getUser(sessionToken);
} catch (error) {
if (isConnectError(error) && error.isSessionExpired) return redirectToSignIn();
throw error;
}isRetryable covers throttling, 5xx and network failures. The SDK already retries those for safe requests; the flag is for deciding whether to queue your own.
Namespaces
| | |
| --- | --- |
| identity | authorizeUrl · exchangeCode · verifyIdToken · getUser · logout |
| access | getUserRoles · listRoles |
| entitlements | getReadiness · getSubscription · hasFeature |
| billing | listPlans · getAddonUsage · consumeAddon |
| drive | upload · getFileUrl · getPreviewUrl |
| contacts | lookupByPhone · get |
| channels | list · get · claim · setRouting · clearRouting |
| calls | list · get |
| scheduling | listEventTypes · findSlots · listBookings · getBooking · createBooking · rescheduleBooking · cancelBooking |
| ai | chat · extract |
| otp | send · verify |
| logs | write |
| apps | list |
| sessions | list · revoke · revokeOthers |
| integrations | list · initiateConnect · disconnect · execute |
The calendar, as a component
import { ConnectBookingCalendar } from "@cosmoops/connect-sdk/client";
<ConnectBookingCalendar timeZone="Asia/Kolkata" />A week grid on a desktop, a grouped agenda on a phone, with booking,
rescheduling and cancelling built in. It reads through the bridge as the
signed-in member, so Connect checks their own calendar:read /
calendar:manage - not your API key. canBook={false} renders the read-only
shape.
timeZone is the business's zone. Every instant on the grid is read
against it, so a workspace's working day does not shift because a member is
travelling.
Booking on a workspace's calendar
One calendar per workspace - the same one their team configures, and the same
one a voice agent books into. Needs calendar:read (plus calendar:write to
book) and a live subscription to your application in that workspace.
const eventTypes = await client.scheduling.listEventTypes(workspaceId);
const { slots, timezone } = await client.scheduling.findSlots({
workspaceId,
eventTypeId: eventTypes[0].id,
from: new Date(),
to: nextWeek,
});
try {
await client.scheduling.createBooking({
workspaceId,
eventTypeId: eventTypes[0].id,
startAt: slots[0].startAt, // exactly as findSlots gave it
attendee: { name, phone },
});
} catch (error) {
if (error instanceof ConnectError && error.code === "conflict") {
// Someone took it between the two calls. Re-read and offer the next one -
// this is an ordinary outcome on a shared calendar, not a failure.
}
}timezone is the business's zone, not your user's - render the wall time in
it, or you will offer an hour they are not open.
Keeping the booking id on a record of your own - a meeting on a deal, an appointment on a case - is how you get back to it later:
const booking = await client.scheduling.getBooking({ workspaceId, bookingId });
// Someone moved it on the workspace's own calendar. Follow the pointer.
if (booking.status === "rescheduled") {
const current = await client.scheduling.getBooking({
workspaceId,
bookingId: booking.rescheduledToId!,
});
}Use getBooking, not listBookings, for that. The list is a window over
startAt, and the startAt you cached is exactly what is wrong once the
appointment has moved - so the window you build from it comes back without the
row, in the one case worth re-reading for.
Attributing a log to a user
await client.logs.write({
message: "campaign sent",
metadata: { campaignId },
sessionToken, // that user's Connect session token
});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.
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.
Options
new ConnectClient({
apiKey,
clientId,
baseUrl, // default https://connect.cosmoops.com
timeoutMs, // default 20000, per attempt
maxRetries, // default 2, safe requests only
fetch, // bring your own instrumented fetch
});Next.js — reading the session
The setup is Quickstart above; this is the detail behind
its step 6 — getConnectSession() / requireConnectSession() beyond the
one-liner shown there.
requireConnectSession() is getConnectSession() plus the check-and-redirect.
Reach for the plain version when a missing session isn't an error — an
optional account badge in a header, say:
import { getConnectSession } from "@cosmoops/connect-sdk/next";
const session = await getConnectSession(); // UserResponse | nullPass a Request — getConnectSession(request) — anywhere next/headers does
not exist: a framework-agnostic handler, a custom server, a test.
Both are memoized per request when called with no arguments (React's
cache()), so a layout reading the session for a nav avatar and the page
beneath it reading it again for a role check share one round trip to Connect,
not two. An explicit request, client or session argument bypasses the
cache and always makes a live call.
It reads cookies() transitively, so under cacheComponents it belongs inside
a <Suspense> boundary like any other request-scoped read. A page that awaits
it above every boundary gives up its static shell.
Route protection and proxy.ts
Nothing here is auto-wired, and nothing needs to be: this SDK ships no proxy and
never writes one. Next 16 renamed Middleware to Proxy — a single proxy.ts
at the project root (or in src/, beside app/).
You only need one if you want unauthenticated visitors bounced before a page
renders. If you are happy redirecting inside the page, getConnectSession()
above already does the job and there is nothing to add.
If you do add one, keep it to a cookie presence check:
// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const COOKIE = process.env.CONNECT_SESSION_COOKIE ?? "connect_session";
export function proxy(request: NextRequest) {
if (request.cookies.has(COOKIE)) return NextResponse.next();
const signin = new URL("/api/connect/signin", request.url);
signin.searchParams.set("next", request.nextUrl.pathname);
return NextResponse.redirect(signin);
}
// Must not match /api/connect/* — the sign-in route has no session cookie yet,
// so matching it would redirect the sign-in to itself.
export const config = { matcher: ["/dashboard/:path*", "/settings/:path*"] };A cookie is not a session, and this check is not authorization. The cookie
holds an opaque Connect sessionToken; deciding whether it is still valid means
asking Connect, and proxy runs on every matched request — including
prefetched
ones — so a real check here would put a network round trip in front of far
more requests than the visitor actually makes.
This isn't just this SDK's opinion — it's what Next.js's own Authentication
guide
recommends, word for word (verified against the docs bundled with the Next
version in this repo's node_modules, since the hosted docs move faster than
any one install):
Since Proxy runs on every route, including prefetched routes, it's important to only read the session from the cookie (optimistic checks), and avoid database checks to prevent performance issues.
The snippet above is exactly that: a presence check, nothing more. Next is equally direct that this is as far as it goes — optimistic checks are "not [...] a full session management or authorization solution."
An expired or revoked token still arrives as a cookie and still passes the
snippet above. The authoritative check is getConnectSession() in the Server
Component, or client.entitlements.* on your server — treat the proxy purely as
a way to skip rendering a page the visitor cannot use.
The server/client boundary
@cosmoops/connect-sdk/server and /next build a client around
CONNECT_API_KEY; /client holds no credential and calls your own origin. That
split is enforced, not just documented — both credential-holding entries resolve
to a stub in a browser bundle, so importing one from a client component fails the
build:
No matching export in ".../dist/esm/this-entry-is-server-only.js" for import "ConnectClient"Import from /client instead, or use import type, which erases before any
bundler sees it. Server Components, Route Handlers, Server Actions and non-Next
backends are unaffected — the stub is wired to the browser export condition,
which only a client bundle resolves.
Other frameworks
Mount the bridge once in your backend:
// app/api/connect/[...route]/route.ts
import { createConnectHandler } from "@cosmoops/connect-sdk/server";
import { connect } from "@/lib/connect";
const handler = createConnectHandler({
client: connect,
session: {
read: async (req) => (await cookies()).get("connect_session")?.value ?? null,
clear: (req, res) => res.headers.append("set-cookie", "connect_session=; Max-Age=0; Path=/"),
},
});
export { handler as GET, handler as POST };Then wrap your app:
import { ConnectProvider } from "@cosmoops/connect-sdk/client";
<ConnectProvider>{children}</ConnectProvider>No clientId prop needed here either: the default sign-in flow bounces the
browser to your own /signin route, which builds the authorize URL
server-side. Pass clientId only if you also pass redirectUri, to build
that URL yourself instead.
Components
import {
AppShell,
ConnectLoginButton, ConnectRegisterButton,
ConnectUserButton, ConnectAppsGrid, ConnectPlans,
} from "@cosmoops/connect-sdk/client";
// The whole frame — the rail, the access gate, Ask AI. See "App shell" below.
<AppShell name="Dispatch" nav={nav} secondaryNav={settingsNav}>{children}</AppShell>
// Or the pieces, if you already have a layout. The pair swaps automatically
// on sign-in state.
<ConnectAppsGrid variant="icon" />
<ConnectLoginButton />
<ConnectUserButton variant="inline" />
// Pricing page
<ConnectPlans />| Component | What it does |
| --- | --- |
| AppShell | The whole application frame — one collapsible rail carrying the brand, workspace switcher, both navs, the launcher and the account row, with your pages beside it. No top bar. Mounts ConnectAccessGate for you. The one /client export without the Connect prefix; alias it on import if your own layout shares the name. |
| ConnectSidebar · ConnectSidebarHeader · ConnectSidebarBody · ConnectSidebarFooter · ConnectSidebarRow | The rail in pieces, for chrome the props cannot express. AppShell composes exactly these; they coordinate through ConnectShellProvider. |
| ConnectSidebarNav | The rail's nav column on its own — sections, one level of children, active-route highlight — for an app that already has its own chrome. (Was ConnectSideNav.) |
| ConnectLoginButton · ConnectRegisterButton | "Continue with Connect", in the shape people know from Google. Render nothing when signed in. |
| ConnectUserButton | The account row at the foot of the rail — circular avatar, name over email — opening a modal with identity, sessions and the Connect destinations. Profile, Manage subscription and Open Connect each open Connect in a new tab; Sign out acts in place. Renders nothing when signed out. (Was ConnectUserMenu.) |
| ConnectProfileModal | The same modal with no trigger, driven by isOpen/onOpenChange — for an app keeping its own account control. |
| ConnectWorkspaceSwitcher | Sits in the rail's header. Hides itself for an account with one workspace; shows unusable ones disabled, with the reason. |
| ConnectAppsGrid | Google-style launcher — grid icon opening a modal in two groups: Your apps (a live subscription, opened at the application's own site) and Explore (the rest of the catalogue, opened on Connect). Registered icons, filter field past eight tiles. |
| ConnectPlans | Monthly/annual toggle, features, add-ons, live discount, coupon field, and the subscribe / upgrade / downgrade / cancel flow. |
| ConnectPlansPage | A whole /plans page — readiness banner, current subscription, and the grid. What connect-sdk init scaffolds. |
| 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. |
| 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. |
| 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. |
| 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. |
| ConnectSubscriptionGate | Renders your module for a subscribed workspace, and ConnectNoSubscriptionCard for everyone else. Never flashes the upsell while loading. |
| ConnectNoSubscriptionCard · ConnectSubscriptionCard | The same slot, unsubscribed and subscribed. Plan, status, renewal date, seats, and one way into Connect. |
| ConnectReadinessBanner · ConnectReadinessGate | Every gate between this workspace and your application — KYC, payment method, seat headroom — each with the Connect screen that fixes it. |
| ConnectFeatureGate | Renders a module only when plan.features includes its key. |
| ConnectWorkspaceTypeGate | Renders a subtree only for an "individual" or "business" workspace — for dead controls, not access. |
| ConnectTrialBanner | A running trial and the date it converts. Renders nothing otherwise. |
| ConnectAccountSessions | Every device signed into this Connect account, with revoke. |
| ConnectUsageMeter | Remaining balance of each consumable add-on, with overage and carry-forward. |
| ConnectManageSubscriptionButton · ConnectInvoicesButton · ConnectProfileButton · ConnectHomeButton · ConnectSignOutButton | The menu items as standalone buttons — Connect's Billing, Invoices and Profile. |
| ConnectLogo | The shared lockup — your icon on the CosmoOps badge, cosmo·ops beside it, your name underneath. |
Hooks
useConnect · useConnectUser · useConnectWorkspaceType · useConnectAudience · useActivePath · useConnectAccessGate · useConnectEntitlements · useConnectPlans · useConnectApps · useConnectAddonUsage · useConnectSessions · useConnectResource · useConnectFeature · useConnectCheckout
Metering
await client.billing.consumeAddon({ workspaceId, addonId: "addon_sms", quantity: 1 }); // server
<ConnectUsageMeter /> // browserThe meter reads the same balance consumeAddon debits (addons:read, separate from addons:consume so a key that only renders a meter cannot spend the balance down). It shows balance against includedPerCycle and never a "used" figure: with carryForward a balance survives the cycle boundary, so included − balance can go negative — a meter running backwards.
Invoices stay a deep link (ConnectInvoicesButton). A GST tax invoice is a legal document emailed and downloaded from one place; a partner-rendered copy would be a second version of a record that must have exactly one.
Plan features: label vs key
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.
Gates
Most apps want one gate around everything, and finer ones inside it.
// Mount once, around your app's content — not around your header.
<ConnectAccessGate>
<YourApp />
</ConnectAccessGate>ConnectAccessGate answers "can this workspace use my app right now" in one
priority-ordered pass, so you are not left assembling the pieces and choosing
the order yourself:
| State | What renders |
| --- | --- |
| suspended | A hard stop. Nothing the customer does clears it — only a Connect admin reactivating the workspace. |
| no_subscription | "Choose a plan", linking to Connect's subscribe page for your application. |
| onboarding_pending | Waiting on a reviewer. No action offered, because there is none. |
| blocked | Any other blocking readiness code — verification, audience, seats — each with its own fix. |
| payment_retry | Your app still renders. A dismissible banner counts down to the dunning deadline. |
| clear | Your app, nothing else. |
Carve out your own plans/pricing routes. The no_subscription card sends
people to choose a plan; if the gate also covers the page they land on, they
are trapped. The SDK cannot do this for you — it imports nothing from next
and so cannot know your router:
const EXEMPT = new Set(["/plans", "/pricing"]);
const gated = !EXEMPT.has(usePathname());
<main>{gated ? <ConnectAccessGate>{children}</ConnectAccessGate> : children}</main>For per-state UI, call the hook instead of overriding fallback:
const { state, blocker, paymentRetry } = useConnectAccessGate();Signed-out renders children, deliberately — unlike the gates below. None of
those states describes a signed-out visitor, and a gate around your whole app
must not hide a public landing page.
The narrower gates still exist for gating within a cleared workspace:
<ConnectReadinessBanner /> {/* KYC, payment method, seats — each with a fix */}
<ConnectSubscriptionGate><Dashboard /></ConnectSubscriptionGate>
<ConnectFeatureGate feature="advanced_reports"><Reports /></ConnectFeatureGate>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.
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).
Scaffolding
npx @cosmoops/connect-sdk initWrites app/api/connect/[...connect]/route.ts, app/plans/page.tsx and an .env.local template, then prints the steps it will not do for you (wrapping your layout in <ConnectProvider>, importing the theme, and registering the redirect URI in Connect). It never overwrites, so re-running is safe.
npx @cosmoops/connect-sdk doctorChecks a wired-up project and names what is missing: the bridge route (wherever you mounted it) exporting both verbs, exactly one <ConnectProvider>, the theme imported after @heroui/styles, and both credentials actually set rather than declared empty. Exits non-zero on a real problem, so it works as a CI step. It is entirely offline — no credentials, no network — which is also its one limit: Connect exposes no endpoint listing a client's registered redirect URIs, so that step is reported as something only you can confirm.
Next has no plugin API that adds routes — next.config can rewrite and redirect, but both need a route to point at, and the App Router only discovers routes in your own app/ directory. So this is a scaffolder rather than a plugin: ConnectPlansPage makes the file that must exist one line, and init writes that line.
Subscribe, upgrade, downgrade, cancel
<ConnectPlans /> runs all three. Pressing a plan's action opens a review step — the plan, its GST-inclusive price, and what happens to the cycle the customer is part-way through — and confirming opens Connect on the confirmation screen for that exact plan, not a catalogue they have to search again. The page then watches /entitlements and repaints itself once the change lands, so nobody has to be told to refresh.
To drive it from your own pricing page instead, the two pieces are exported:
import { useConnectCheckout, buildCheckoutUrl, useConnect, useConnectEntitlements }
from "@cosmoops/connect-sdk/client";
const { appUrl } = useConnect();
const { data } = useConnectEntitlements();
const checkout = useConnectCheckout();
<button
onClick={() =>
checkout.start({
url: buildCheckoutUrl({
appUrl,
serviceId: data!.subscription.serviceId,
planId: "plan_growth_m",
// Present ⇒ change this subscription's plan; absent ⇒ first subscribe.
subscriptionId: data!.subscription.subscription?.id ?? null,
}),
isSettled: (next) => next.subscription.plan?.id === "plan_growth_m",
})
}
>
Upgrade
</button>checkout.status moves idle → waiting → completed | abandoned, plus blocked if the browser refused the window (render checkout.url as a link). A closed window is never read as success — only the entitlement changing is, because "paid and closed it" and "opened it and thought better of it" look identical from here.
Cancelling uses the same machinery via buildCancelUrl. There is no resume: Connect has no uncancel path, so a subscription already set to cancel gets a statement of when access ends, not a button that cannot work.
App shell
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { AppShell } from "@cosmoops/connect-sdk/client";
import { LayoutDashboard, Receipt, Settings, Truck } from "lucide-react";
export function Shell({ children }: { children: React.ReactNode }) {
return (
<AppShell
name="Dispatch"
icon={<Truck />}
activePath={usePathname()}
renderLink={(props) => <Link {...props} />}
nav={[
{ label: "Overview", href: "/", icon: <LayoutDashboard className="size-5" /> },
{ label: "Shipments", href: "/shipments", icon: <Truck className="size-5" /> },
]}
// Pinned to the foot of the rail: the routes you go to in order to
// change how the job works, not to do it.
secondaryNav={[
{
label: "Settings",
href: "/settings",
icon: <Settings className="size-5" />,
items: [
{ label: "Members", href: "/settings/members" },
{ label: "Billing", href: "/settings/billing" },
],
},
{ label: "Plans", href: "/plans", icon: <Receipt className="size-5" />, gateExempt: true },
]}
>
{children}
</AppShell>
);
}That is the entire layout of an application platform. Everything that should
look identical across the family — the lockup, the rail geometry, the
active-route treatment, the app launcher, the account row, the mobile drawer —
comes from here; only name, icon and the two navs differ per platform.
There is no top bar. The launcher and the account row live at the foot of
the rail, one press from wherever the pointer already is, and a page's own
heading belongs to the page — put it in ConnectPageShell from
@cosmoops/connect-sdk/ui. Below md a slim bar carries the drawer trigger
and nothing else.
Routing is a prop. The SDK ships into any React app and cannot import
next/link, so renderLink is how your router gets involved; the default is a
plain <a>, which works everywhere and costs a full page load. Pass
activePath from your router too (usePathname()) — without it the nav reads
window.location and patches history.pushState to hear about client-side
navigations, which is guesswork your router never has to do.
The access gate is on by default. AppShell is exactly the "wrap
your app content once" place ConnectAccessGate asks for, so it mounts one
around children — an app that ships a shell and forgets the gate is an app
that lets a suspended workspace keep working. The chrome is never gated: the
sidebar, the lockup, the account menu and sign-out stay reachable in every
blocked state. Routes that resolve a block rather than needing one — your
pricing page — are flagged where they are declared, in either nav:
{ label: "Plans", href: "/plans", gateExempt: true }Gate a route any other way and a customer with no subscription reaches the
page that sells them one, and is told to choose a plan. Pass gate={false} to
opt out entirely, or gateExemptRoutes for a path that is not in the sidebar
at all.
Ask AI is on by default too, for the same reason: a family of application
platforms should answer questions the same way, and a feature every partner has
to remember to switch on is a feature most of them do not have. It stays inert
until it can work — nothing renders for a signed-out visitor, and a workspace
whose Context Index is off gets a message in the panel rather than a broken
answer. Pass askAi={false} to leave it out, or an object to override its
heading, copy or markdown plugins.
It needs two things you control: the workspace's Context Index turned on in
Connect, and ai:chat on the API key behind your bridge.
Other things worth knowing:
- The rail starts collapsed and remembers the choice per browser
(
localStorage). The icon rail is the resting state; expanding is the deliberate act. Expanded, the collapse control sits at the header's trailing edge; collapsed, hovering the lockup swaps it for the expand glyph — so the rail spends no row on a permanent control, at the cost of the lockup no longer being a link home while collapsed. PassdefaultCollapsed={false}to open by default, drive it withcollapsed+onCollapsedChange, or pin it open withcollapsible={false}. - The collapse animates width and nothing else. Every row is laid out so its glyph sits on the same horizontal centre in both states, and labels are clipped rather than re-measured — so no row reflows and nothing drifts sideways mid-transition.
- Below
mdthere is no rail, only a drawer behind the mobile bar's menu button — a 64px icon strip on a phone is a worse drawer, not a smaller one. - Children unfold only on the branch you are on. A sidebar that opens every branch at once is a sitemap.
- No
isSignedInbranch, in here or in yours. The auth buttons render nothing once a session exists, the account row renders nothing without one, and the launcher renders nothing when signed out. secondaryNav,sidebarFooterandfooterare slots;contentClassNamereplaces the content pane's default max-width and padding. (titleandheaderActionsare gone with the top bar — see the 0.12.0 changelog entry.)theme/onThemeChangepass straight through toConnectUserButton— the SDK has no theme provider of its own, deliberately.
ConnectSidebarNav is the nav column alone, for an app with chrome it already
likes, and ConnectSidebar / ConnectSidebarHeader / ConnectSidebarBody /
ConnectSidebarFooter / ConnectSidebarRow are the rest of the rail — the
exact pieces AppShell composes, so chrome the props cannot express is a
rearrangement rather than a fork. resolveActivePath and isNavItemActive are
the same pure functions the rail highlights with, exported so a breadcrumb or a
document title derived from your nav cannot disagree with it.
Logo
import { ConnectLogo } from "@cosmoops/connect-sdk/client";
import { Truck } from "lucide-react";
<ConnectLogo name="Dispatch" icon={<Truck />} />Each platform passes only what differs — name and icon. Mark geometry, wordmark, separator and accent come from one place, so the family reads as one product. Sizes are em-based: className="text-xl" scales the whole lockup, which is why there's no size prop. collapsed gives the mark alone.
Theming
@import "tailwindcss";
@import "@heroui/styles";
@import "@cosmoops/connect-sdk/theme.css";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.
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.
Why payment happens on Connect
The flow is in your app; the payment is not. A plan change is not one write — it re-runs the readiness gates, prices proration against accumulated credit, re-checks the RBI mandate ceiling for the new amount, and issues a GST invoice. Connect owns that sequence end to end, and a partner-side implementation would be a second copy of it that drifts. It would also mean a browser holding your credentials could move money, which is the one thing the bridge exists to prevent.
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.
Release notes
Full history: CHANGELOG.md. Latest first:
0.20.0
The app launcher shows the whole catalogue. <ConnectAppsGrid> renders two
groups — Your apps (a live subscription, opened at the application's own
site) and Explore (the rest of the platform's active applications, opened
on Connect). GET /apps answers accordingly, and WorkspaceApp gains
subscribed and category.
The "Browse all applications" footer link is gone: it pointed at a Connect
route that has never existed. If you render your own grid from
connect.apps.list(), filter on subscribed to get the previous set back.
0.12.0 — breaking
The application shell is one column. The top bar is gone; the rail holds everything it used to, and starts collapsed.
titleandheaderActionsare removed with the bar that held them. A page's heading goes inConnectPageShell(from/ui), next to that page's own breadcrumbs and actions; an application-level control goes insidebarFooteras aConnectSidebarRow.defaultCollapsednow defaults totrue. Passfalsefor 0.11's behaviour.ConnectSideNav→ConnectSidebarNav,ConnectUserMenu→ConnectUserButton. Both old names remain as deprecated aliases.AppShellkeeps its name — the one export here without theConnectprefix, as decided in 0.8.0.- New:
secondaryNav(a second nav group pinned to the foot of the rail),ConnectProfileModal(the account modal with no trigger), and the rail's own pieces —ConnectSidebar,ConnectSidebarHeader,ConnectSidebarBody,ConnectSidebarFooter,ConnectSidebarRow,ConnectShellProvider,useConnectShell,sidebarRowClass. - The workspace switcher moved into the rail's header and is collapse-aware; the account row sits at the foot with a circular avatar.
- Fixed: the closed Ask AI panel occupied 400px at
lgand up on every page withaskAion; sidebar contrast (the active row was below WCAG AA in both schemes); the launcher and account rows looked permanently selected.
0.11.0 — breaking
ConnectUser.workspaceId,workspaceNameandworkspaceTypeare nullable. They arenullfor exactly one thing: a Connect platform staff session, which belongs to no workspace. If you never enable staff access — it is off by default — this is a type narrow and nothing else.?? ""is the wrong fix: decide what your tenanting does with a session that has no tenant.- Connect platform staff sign-in,
platformAccessonexchangeCode()andgetUser(),<ConnectPlatformGate>anduseConnectPlatformAccess().
0.10.0
@cosmoops/connect-sdk/ui—ConnectPageShell,ConnectSection,ConnectEmptyState,ConnectStatusChip,ConnectSettingRow. Deliberately not a"use client"module, so a page shell does not become a client boundary.ConnectLinkButton,ConnectConfirmDialog,ConnectCursorPagination,ConnectFilterTabs,ConnectAccountSessions— built since 0.8 and reachable from no entry point until now.ConnectWorkspaceSwitcher,useConnectWorkspaces(),useConnectWorkspaceMembers(), and theworkspacesresource on the server client.
0.9.0
- Ask AI, mounted by
AppShellby default — a chat panel grounded in the workspace's Context Index, streamed through a newPOST /ai/chatroute on your own bridge. The workspace comes from the caller's session, never the browser. - Answers draw as well as write. Connect's model calls presentation tools and the panel renders them natively: a sortable table with CSV export, a chart, metric tiles, timelines, fact sheets, procedures, citations. Values arrive raw with a format tag, so they are formatted in the viewer's locale and a column still sorts numerically. The seven widgets are exported.
streamdownis a new optional peer dependency — see step 1 and step 4 of the quickstart. Two lines of host setup; skip both withaskAi={false}.user.workspaceType,audience,<ConnectWorkspaceTypeGate>,useConnectWorkspaceType(),useConnectAudience().streamResource()onuseConnect()— POST through the bridge and get the raw streamingResponseback.- Fixed:
ai.chat()no longer has its stream cut off mid-answer by the transport's 20s timeout.
0.8.0 was never published; its one breaking change is in this release.
0.8.0 — breaking (folded into 0.9.0)
ConnectAppShellrenamed toAppShell. Same props, same behaviour — only the name changed. Every other/clientexport keeps itsConnectprefix; this is the one deliberate exception.- import { ConnectAppShell, type ConnectAppShellProps } from "@cosmoops/connect-sdk/client"; + import { AppShell, type AppShellProps } from "@cosmoops/connect-sdk/client";If your own layout component is also named
AppShell, alias the import:import { AppShell as ConnectAppShell } from "...".
0.6.0
AppShell(thenConnectAppShell) — the application frame every platform was otherwise writing by hand: collapsible sidebar, top bar with the app launcher and account menu, mobile drawer,ConnectAccessGatemounted around your content by default.ConnectSideNav— the sidebar's nav column alone, for an app with chrome it already likes.- The nav model as data —
ConnectNav/ConnectNavItem/ConnectNavSection— plus the pure functions the rail resolves the active route with.
See CHANGELOG.md for 0.5.0 and earlier.
Upgrading past a minor version? Read that version's entry first — per the 0.x convention at the top of this file, a minor bump here is allowed to break something, and the changelog says what and how to migrate.
Development
bun test # server suite - hermetic, no network
bun run storybook # client components against a mock bridge, port 6106
bun run build # dual CJS/ESM + typesServer tests drive the injectable fetch seam, so nothing reaches Connect.
Storybook renders the real ConnectProvider and substitutes only fetch — the
same seam the production bridge sits behind — so loading, error and empty
states are exercised for real. .storybook/theme.css is a sample host theme:
change it and every story should move together. If one doesn't, it has a
hardcoded colour.
License
Proprietary. See LICENSE — no rights are granted to anyone outside CosmoOps and its authorised integrators.
