npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 4

Five 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-sdk

2. 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 doctor

That'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 shellAppShell from /client is the rail and the access gate every application platform needs. See App shell.
  • Route protection before render — see Route protection and proxy.ts below; 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 refused

Both 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=none never 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_required from a silent attempt is not the login_required above. 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 | null

Pass a RequestgetConnectSession(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 />                                                                     // browser

The 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 init

Writes 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 doctor

Checks 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. Pass defaultCollapsed={false} to open by default, drive it with collapsed + onCollapsedChange, or pin it open with collapsible={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 md there 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 isSignedIn branch, 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, sidebarFooter and footer are slots; contentClassName replaces the content pane's default max-width and padding. (title and headerActions are gone with the top bar — see the 0.12.0 changelog entry.)
  • theme / onThemeChange pass straight through to ConnectUserButton — 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.

  • title and headerActions are removed with the bar that held them. A page's heading goes in ConnectPageShell (from /ui), next to that page's own breadcrumbs and actions; an application-level control goes in sidebarFooter as a ConnectSidebarRow.
  • defaultCollapsed now defaults to true. Pass false for 0.11's behaviour.
  • ConnectSideNavConnectSidebarNav, ConnectUserMenuConnectUserButton. Both old names remain as deprecated aliases. AppShell keeps its name — the one export here without the Connect prefix, 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 lg and up on every page with askAi on; 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, workspaceName and workspaceType are nullable. They are null for 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, platformAccess on exchangeCode() and getUser(), <ConnectPlatformGate> and useConnectPlatformAccess().

0.10.0

  • @cosmoops/connect-sdk/uiConnectPageShell, 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 the workspaces resource on the server client.

0.9.0

  • Ask AI, mounted by AppShell by default — a chat panel grounded in the workspace's Context Index, streamed through a new POST /ai/chat route 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.
  • streamdown is a new optional peer dependency — see step 1 and step 4 of the quickstart. Two lines of host setup; skip both with askAi={false}.
  • user.workspaceType, audience, <ConnectWorkspaceTypeGate>, useConnectWorkspaceType(), useConnectAudience().
  • streamResource() on useConnect() — POST through the bridge and get the raw streaming Response back.
  • 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)

  • ConnectAppShell renamed to AppShell. Same props, same behaviour — only the name changed. Every other /client export keeps its Connect prefix; 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 (then ConnectAppShell) — the application frame every platform was otherwise writing by hand: collapsible sidebar, top bar with the app launcher and account menu, mobile drawer, ConnectAccessGate mounted 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 + types

Server 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.