@cosmoops/connect-sdk 0.2.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +284 -7
  3. package/dist/cjs/cli/doctor.js +209 -0
  4. package/dist/cjs/cli/init.js +20 -8
  5. package/dist/cjs/client/components/access-gate.js +162 -0
  6. package/dist/cjs/client/components/account-sessions.js +21 -19
  7. package/dist/cjs/client/components/app-shell.js +157 -0
  8. package/dist/cjs/client/components/apps-grid.js +2 -1
  9. package/dist/cjs/client/components/integrations-modal.js +95 -0
  10. package/dist/cjs/client/components/motion.js +27 -0
  11. package/dist/cjs/client/components/nav.js +97 -0
  12. package/dist/cjs/client/components/plans.js +39 -37
  13. package/dist/cjs/client/components/readiness.js +3 -1
  14. package/dist/cjs/client/components/side-nav.js +58 -0
  15. package/dist/cjs/client/components/subscription.js +2 -1
  16. package/dist/cjs/client/components/surfaces.js +54 -0
  17. package/dist/cjs/client/components/usage-meter.js +3 -2
  18. package/dist/cjs/client/components/user-menu.js +30 -3
  19. package/dist/cjs/client/index.js +29 -1
  20. package/dist/cjs/client/provider.js +54 -2
  21. package/dist/cjs/client/use-active-path.js +77 -0
  22. package/dist/cjs/errors.js +1 -1
  23. package/dist/cjs/next/index.js +96 -15
  24. package/dist/cjs/server/client.js +10 -2
  25. package/dist/cjs/server/handler.js +89 -6
  26. package/dist/cjs/server/index.js +8 -1
  27. package/dist/cjs/server/resources/analytics.js +66 -0
  28. package/dist/cjs/server/resources/identity.js +1 -1
  29. package/dist/cjs/server/resources/integrations.js +66 -0
  30. package/dist/cjs/server/resources/sessions.js +1 -1
  31. package/dist/cjs/server/webhooks.js +127 -0
  32. package/dist/cjs/this-entry-is-server-only.js +31 -0
  33. package/dist/esm/cli/doctor.js +206 -0
  34. package/dist/esm/cli/init.js +20 -8
  35. package/dist/esm/client/components/access-gate.js +158 -0
  36. package/dist/esm/client/components/account-sessions.js +21 -19
  37. package/dist/esm/client/components/app-shell.js +154 -0
  38. package/dist/esm/client/components/apps-grid.js +2 -1
  39. package/dist/esm/client/components/integrations-modal.js +92 -0
  40. package/dist/esm/client/components/motion.js +21 -0
  41. package/dist/esm/client/components/nav.js +89 -0
  42. package/dist/esm/client/components/plans.js +39 -37
  43. package/dist/esm/client/components/readiness.js +3 -1
  44. package/dist/esm/client/components/side-nav.js +53 -0
  45. package/dist/esm/client/components/subscription.js +2 -1
  46. package/dist/esm/client/components/surfaces.js +51 -0
  47. package/dist/esm/client/components/usage-meter.js +3 -2
  48. package/dist/esm/client/components/user-menu.js +31 -4
  49. package/dist/esm/client/index.js +15 -1
  50. package/dist/esm/client/provider.js +53 -2
  51. package/dist/esm/client/use-active-path.js +74 -0
  52. package/dist/esm/errors.js +1 -1
  53. package/dist/esm/next/index.js +95 -15
  54. package/dist/esm/server/client.js +10 -2
  55. package/dist/esm/server/handler.js +89 -6
  56. package/dist/esm/server/index.js +5 -0
  57. package/dist/esm/server/resources/analytics.js +62 -0
  58. package/dist/esm/server/resources/identity.js +1 -1
  59. package/dist/esm/server/resources/integrations.js +62 -0
  60. package/dist/esm/server/resources/sessions.js +1 -1
  61. package/dist/esm/server/webhooks.js +123 -0
  62. package/dist/esm/this-entry-is-server-only.js +30 -0
  63. package/dist/theme.css +28 -0
  64. package/dist/types/cli/doctor.d.ts +23 -0
  65. package/dist/types/cli/init.d.ts +2 -3
  66. package/dist/types/client/components/access-gate.d.ts +79 -0
  67. package/dist/types/client/components/app-shell.d.ts +115 -0
  68. package/dist/types/client/components/integrations-modal.d.ts +21 -0
  69. package/dist/types/client/components/motion.d.ts +22 -0
  70. package/dist/types/client/components/nav.d.ts +110 -0
  71. package/dist/types/client/components/side-nav.d.ts +31 -0
  72. package/dist/types/client/components/surfaces.d.ts +51 -0
  73. package/dist/types/client/index.d.ts +12 -1
  74. package/dist/types/client/provider.d.ts +38 -3
  75. package/dist/types/client/use-active-path.d.ts +8 -0
  76. package/dist/types/errors.d.ts +1 -1
  77. package/dist/types/next/index.d.ts +40 -0
  78. package/dist/types/server/client.d.ts +8 -2
  79. package/dist/types/server/index.d.ts +17 -2
  80. package/dist/types/server/resources/analytics.d.ts +31 -0
  81. package/dist/types/server/resources/identity.d.ts +12 -2
  82. package/dist/types/server/resources/integrations.d.ts +40 -0
  83. package/dist/types/server/webhooks.d.ts +67 -0
  84. package/dist/types/this-entry-is-server-only.d.ts +1 -0
  85. package/dist/types/types.d.ts +182 -1
  86. package/package.json +22 -4
package/CHANGELOG.md ADDED
@@ -0,0 +1,125 @@
1
+ # Changelog
2
+
3
+ `@cosmoops/connect-sdk` follows semver, with the 0.x convention that **minor
4
+ versions may carry breaking changes** and patch versions never do.
5
+
6
+ ## 0.6.0
7
+
8
+ ### Added
9
+
10
+ - **`ConnectAppShell`** - the application frame every service platform was
11
+ otherwise writing by hand: a collapsible sidebar, a top bar carrying the app
12
+ launcher and the account menu, a mobile drawer below `md`, and your pages in
13
+ the middle. Each platform passes `name`, `icon` and `nav`; the lockup, the
14
+ rail geometry, the active-route treatment and the chrome come from one
15
+ place, which is what keeps a family of platforms looking like one product.
16
+
17
+ It mounts `ConnectAccessGate` around `children` by default. That is a
18
+ deliberate default rather than a convenience: the shell is exactly the "wrap
19
+ your app content once" place that gate asks for, and a platform that ships a
20
+ shell and forgets the gate is a platform where a suspended workspace keeps
21
+ working. The chrome is never gated, and routes that *resolve* a block -
22
+ your pricing page - are flagged `gateExempt: true` on the nav item itself,
23
+ so the exemption lives next to the route rather than in a second list that
24
+ has to be kept in step.
25
+
26
+ - **`ConnectSideNav`** - the sidebar's nav column on its own, for an app that
27
+ already has chrome it likes. Sections, one level of children, and the same
28
+ active-route resolution the shell uses.
29
+
30
+ - **The nav model as data**: `ConnectNav` / `ConnectNavItem` /
31
+ `ConnectNavSection` types plus `normalizeNav`, `flattenNav`, `normalizePath`,
32
+ `resolveActivePath`, `isNavItemActive` and `gateExemptPaths`. Pure functions
33
+ over plain data, exported because a breadcrumb or a page title derived from
34
+ your own nav should not be able to disagree with what the rail highlights.
35
+
36
+ - **`useActivePath`** - the current path, from your router when you pass one
37
+ and from the browser when you don't.
38
+
39
+ Routing is a prop throughout (`renderLink`), because this package ships into
40
+ any React app and cannot import `next/link`. The default is a plain `<a>`.
41
+ Uncontrolled, `useActivePath` patches `history.pushState`/`replaceState` to
42
+ dispatch an event - neither fires one, so a client-side router moves the
43
+ address bar and tells nothing outside itself, and a sidebar reading
44
+ `location.pathname` would highlight the route the page first loaded on
45
+ forever. Pass `activePath` (Next: `usePathname()`) and none of that runs.
46
+
47
+ ## 0.5.0
48
+
49
+ ### Breaking
50
+
51
+ - **`react` and `react-dom` peers raised to `>=19`** (from `>=18`). Both stay
52
+ `optional` - a `/server`-only consumer still needs neither - but `/next`'s
53
+ `getConnectSession()` now imports `react`'s `cache()` unconditionally,
54
+ which React 18 doesn't export. Every current App Router project is already
55
+ on 19; this only bites a project still pinned to 18.
56
+
57
+ ### Added
58
+
59
+ - **`getConnectSession()` is now memoized per request.** The plain,
60
+ zero-argument call - the one every page, layout and Server Action actually
61
+ uses - is wrapped in React's `cache()`, so calling it from a layout *and*
62
+ the page beneath it costs one round trip to Connect, not two. Passing an
63
+ explicit `request`, `client` or `session` still bypasses the cache and
64
+ always makes a live call, as before.
65
+ - **`requireConnectSession()`** - `getConnectSession()` plus the
66
+ `if (!session) redirect(...)` every integration was writing by hand.
67
+ Redirects to `/api/connect/signin` (or `signInPath`) when signed out,
68
+ optionally appending `next`. See "Authentication & authorization" in the
69
+ docs for where role/entitlement checks still belong - this only covers the
70
+ "is anyone signed in" half.
71
+
72
+ ## 0.4.0
73
+
74
+ ### Breaking
75
+
76
+ - **`ConnectUser.photoUrl` is a new required field.** Additive for anything
77
+ *reading* a `ConnectUser`, breaking for anything *constructing* one - a test
78
+ fixture or a hand-rolled mock will stop type-checking until it sets
79
+ `photoUrl: string | null`. Nothing else about the type changed.
80
+
81
+ Connect has always stored a profile picture; it was simply never carried on
82
+ the wire, so `<ConnectUserMenu>` showed initials for every account no matter
83
+ what the member had set. It now flows through `/sso/token` and `/sso/user`.
84
+
85
+ ### Added
86
+
87
+ - **`<ConnectAccessGate>` and `useConnectAccessGate()`** - one gate composing
88
+ the four states a partner actually needs, in priority order: workspace
89
+ suspended, no subscription, onboarding approval pending, and a dismissible
90
+ payment-due banner during a dunning retry. Replaces hand-assembling
91
+ `ConnectReadinessGate` + `ConnectSubscriptionGate` and deciding the ordering
92
+ yourself.
93
+
94
+ Mount it once around your app's content, but **carve out your own plans and
95
+ pricing routes** - its "Choose a plan" state is where blocked customers are
96
+ sent, so gating that page too traps them. See the component's doc comment.
97
+
98
+ - **`<ConnectProvider silentSignIn>`** - OIDC silent authentication. A visitor
99
+ already signed into Connect who has authorised your app before is signed in
100
+ with no click and no screen. Off by default: it costs one full-page redirect
101
+ on the first load of a signed-out visit, and is attempted at most once per
102
+ tab.
103
+
104
+ First contact still shows Connect's consent screen once - that grant is
105
+ exactly what makes every later visit silent.
106
+
107
+ - `ReadinessBlockerCode` gains `organization_suspended` and
108
+ `payment_retry_pending`; `ReadinessBlocker` gains an optional `graceEndsAt`
109
+ carrying the dunning-suspension deadline, so a payment-due warning can show a
110
+ real countdown instead of a bare "something failed".
111
+
112
+ - `identity.authorizeUrl({ prompt })` accepts `"none"` alongside `"create"`.
113
+
114
+ ### Fixed
115
+
116
+ - `ConnectUserMenu` renders the member's actual profile picture, falling back
117
+ to initials while it loads or if they have none.
118
+ - Panels inside the account modal shared one elevation and one icon-medallion
119
+ spec; the destination tiles and session list previously used a fill that is
120
+ the *same lightness* as the modal itself in both light and dark, so they read
121
+ as hairline outlines beside a solid identity card.
122
+ - `theme.css` now defines `--default` and `--surface-tertiary`. Without them,
123
+ every `secondary`/`tertiary` Button and default Chip fell back to
124
+ `@heroui/styles`' stock cool-grey ramp while the rest of the palette sat on a
125
+ warm one - close enough to read as a mistake rather than a choice.
package/README.md CHANGED
@@ -25,7 +25,7 @@ Browser ──► your backend ──► Connect
25
25
 
26
26
  ## Server setup
27
27
 
28
- One credential does three jobs — the API key plaintext from your service's Setup tab is also your OAuth2 `client_secret` and the key your `id_token` is signed with. Revoking it revokes all three.
28
+ One credential does three jobs — the API key plaintext from your service's Integration tab is also your OAuth2 `client_secret` and the key your `id_token` is signed with. Revoking it revokes all three.
29
29
 
30
30
  ```ts
31
31
  import { ConnectClient } from "@cosmoops/connect-sdk/server";
@@ -87,6 +87,49 @@ if (!serviceRole.granted.includes("invoice:write")) return forbidden();
87
87
  await connect.identity.logout(sessionToken);
88
88
  ```
89
89
 
90
+ ## Sign returning visitors in with no click
91
+
92
+ A visitor already signed into Connect should not have to press "Sign in"
93
+ again. OIDC calls this silent authentication, and on `./next` it is one prop:
94
+
95
+ ```tsx
96
+ <ConnectProvider silentSignIn>
97
+ ```
98
+
99
+ On the first load of a signed-out visit the browser makes one round trip to
100
+ Connect with `prompt=none`. If they have a Connect session **and** have
101
+ authorised your app before, they come back signed in having seen nothing. If
102
+ not, they come back signed out — no error, no screen, your normal signed-out
103
+ UI a beat later.
104
+
105
+ Rolling it yourself is the same flow with `prompt: "none"`:
106
+
107
+ ```ts
108
+ redirect(connect.identity.authorizeUrl({ redirectUri, state, prompt: "none" }));
109
+ ```
110
+
111
+ Three things to get right, all handled for you by `./next`:
112
+
113
+ - **`prompt=none` never shows a login screen — that is its contract.** Use it
114
+ to *resume* a session, never to start one. Sending a genuinely signed-out
115
+ visitor through it in a loop is the classic way to build a redirect loop.
116
+ - **`login_required` from a silent attempt is not the `login_required` above.**
117
+ The retry in step 2 is right for an interactive sign-in and wrong here: it
118
+ escalates a quiet "nobody home" into a forced login. Track which kind of
119
+ attempt is in flight.
120
+ - **Attempt it at most once per tab.** A visitor with no Connect session will
121
+ answer the same way on every page load; without a marker, every navigation
122
+ pays a full redirect.
123
+
124
+ **First contact still shows Connect's consent screen once.** That grant is
125
+ exactly what makes every later visit silent, and it is per user, per client —
126
+ so `consent_required` means "they have never authorised you", not "something
127
+ broke". Treat all three of `login_required`, `consent_required` and
128
+ `interaction_required` as "carry on signed out".
129
+
130
+ It costs a full-page redirect on that first load, which is why it is off by
131
+ default: worth it for an app shell, less so for a marketing page.
132
+
90
133
  ## Gate on entitlements
91
134
 
92
135
  ```ts
@@ -149,6 +192,7 @@ try {
149
192
  | `logs` | `write` |
150
193
  | `apps` | `list` |
151
194
  | `sessions` | `list` · `revoke` · `revokeOthers` |
195
+ | `integrations` | `list` · `initiateConnect` · `disconnect` · `execute` |
152
196
 
153
197
  ### Attributing a log to a user
154
198
 
@@ -215,7 +259,7 @@ cookie, `/session`, `/entitlements`, `/plans`, `/addons`, `/apps`, `/sessions`,
215
259
  | `CONNECT_SESSION_COOKIE` | no | session cookie name |
216
260
 
217
261
  Register `{your origin}/api/connect/callback` as a redirect URI in the service's
218
- Setup tab — the one step this cannot do for you.
262
+ Integration tab — the one step this cannot do for you.
219
263
 
220
264
  No `next.config.ts` changes needed, including `transpilePackages`: `dist/` is
221
265
  already ES2022 with `"use client"` hoisted, so Turbopack and webpack both
@@ -235,9 +279,26 @@ export default async function DashboardPage() {
235
279
  }
236
280
  ```
237
281
 
282
+ Or skip the check-and-redirect entirely — `requireConnectSession()` does both:
283
+
284
+ ```tsx
285
+ import { requireConnectSession } from "@cosmoops/connect-sdk/next";
286
+
287
+ export default async function DashboardPage() {
288
+ const session = await requireConnectSession(); // UserResponse — redirects if signed out
289
+ return <h1>{session.user.organizationName}</h1>;
290
+ }
291
+ ```
292
+
238
293
  Pass a `Request` — `getConnectSession(request)` — anywhere `next/headers` does
239
294
  not exist: a framework-agnostic handler, a custom server, a test.
240
295
 
296
+ Both are memoized per request when called with no arguments (React's
297
+ `cache()`), so a layout reading the session for a nav avatar and the page
298
+ beneath it reading it again for a role check share one round trip to Connect,
299
+ not two. An explicit `request`, `client` or `session` argument bypasses the
300
+ cache and always makes a live call.
301
+
241
302
  It reads `cookies()` transitively, so under `cacheComponents` it belongs inside
242
303
  a `<Suspense>` boundary like any other request-scoped read. A page that awaits
243
304
  it above every boundary gives up its static shell.
@@ -245,6 +306,68 @@ it above every boundary gives up its static shell.
245
306
  You do **not** need `transpilePackages`. `dist/` is ES2022 with `"use client"`
246
307
  already hoisted, so Turbopack and webpack consume it as-is.
247
308
 
309
+ ## Route protection and `proxy.ts`
310
+
311
+ Nothing here is auto-wired, and nothing needs to be: this SDK ships no proxy and
312
+ never writes one. Next 16 renamed Middleware to **Proxy** — a single `proxy.ts`
313
+ at the project root (or in `src/`, beside `app/`).
314
+
315
+ You only need one if you want unauthenticated visitors bounced *before* a page
316
+ renders. If you are happy redirecting inside the page, `getConnectSession()`
317
+ above already does the job and there is nothing to add.
318
+
319
+ If you do add one, keep it to a cookie **presence** check:
320
+
321
+ ```ts
322
+ // proxy.ts
323
+ import { NextResponse } from "next/server";
324
+ import type { NextRequest } from "next/server";
325
+
326
+ const COOKIE = process.env.CONNECT_SESSION_COOKIE ?? "connect_session";
327
+
328
+ export function proxy(request: NextRequest) {
329
+ if (request.cookies.has(COOKIE)) return NextResponse.next();
330
+
331
+ const signin = new URL("/api/connect/signin", request.url);
332
+ signin.searchParams.set("next", request.nextUrl.pathname);
333
+ return NextResponse.redirect(signin);
334
+ }
335
+
336
+ // Must not match /api/connect/* — the sign-in route has no session cookie yet,
337
+ // so matching it would redirect the sign-in to itself.
338
+ export const config = { matcher: ["/dashboard/:path*", "/settings/:path*"] };
339
+ ```
340
+
341
+ **A cookie is not a session, and this check is not authorization.** The cookie
342
+ holds an opaque Connect `sessionToken`; deciding whether it is still valid means
343
+ asking Connect, and proxy runs on every matched request — so a real check here
344
+ would put a network round trip in front of each one. Next's own guidance says
345
+ the same thing: proxy is for [optimistic
346
+ checks](https://nextjs.org/docs/app/guides/authentication#optimistic-checks-with-proxy-optional),
347
+ "not ... a full session management or authorization solution."
348
+
349
+ An expired or revoked token still arrives as a cookie and still passes the
350
+ snippet above. The authoritative check is `getConnectSession()` in the Server
351
+ Component, or `client.entitlements.*` on your server — treat the proxy purely as
352
+ a way to skip rendering a page the visitor cannot use.
353
+
354
+ ### The server/client boundary
355
+
356
+ `@cosmoops/connect-sdk/server` and `/next` build a client around
357
+ `CONNECT_API_KEY`; `/client` holds no credential and calls your own origin. That
358
+ split is enforced, not just documented — both credential-holding entries resolve
359
+ to a stub in a browser bundle, so importing one from a client component fails the
360
+ build:
361
+
362
+ ```
363
+ No matching export in ".../dist/esm/this-entry-is-server-only.js" for import "ConnectClient"
364
+ ```
365
+
366
+ Import from `/client` instead, or use `import type`, which erases before any
367
+ bundler sees it. Server Components, Route Handlers, Server Actions and non-Next
368
+ backends are unaffected — the stub is wired to the `browser` export condition,
369
+ which only a client bundle resolves.
370
+
248
371
  ## Other frameworks
249
372
 
250
373
  Mount the bridge once in your backend:
@@ -282,11 +405,16 @@ that URL yourself instead.
282
405
 
283
406
  ```tsx
284
407
  import {
408
+ ConnectAppShell,
285
409
  ConnectLoginButton, ConnectRegisterButton,
286
410
  ConnectUserMenu, ConnectAppsGrid, ConnectPlans,
287
411
  } from "@cosmoops/connect-sdk/client";
288
412
 
289
- // Top bar — the pair swaps automatically on sign-in state
413
+ // The whole frame — sidebar, top bar, access gate. See "App shell" below.
414
+ <ConnectAppShell name="Dispatch" nav={nav}>{children}</ConnectAppShell>
415
+
416
+ // Or the pieces, if you already have a layout. The pair swaps automatically
417
+ // on sign-in state.
290
418
  <ConnectAppsGrid />
291
419
  <ConnectLoginButton />
292
420
  <ConnectUserMenu />
@@ -297,12 +425,15 @@ import {
297
425
 
298
426
  | Component | What it does |
299
427
  | --- | --- |
428
+ | `ConnectAppShell` | **The whole application frame** — collapsible sidebar, top bar with the launcher and account menu, and your pages in the middle. Mounts `ConnectAccessGate` for you. |
429
+ | `ConnectSideNav` | The sidebar's nav column on its own — sections, one level of children, active-route highlight — for an app that already has its own chrome. |
300
430
  | `ConnectLoginButton` · `ConnectRegisterButton` | "Continue with Connect", in the shape people know from Google. Render nothing when signed in. |
301
431
  | `ConnectUserMenu` | Avatar, name, workspace. Profile, Manage subscription and Open Connect each open Connect in a new tab; Sign out acts in place. Renders nothing when signed out. |
302
432
  | `ConnectAppsGrid` | Google-style launcher — grid icon opening a modal of every app this workspace can open. |
303
433
  | `ConnectPlans` | Monthly/annual toggle, features, add-ons, live discount, coupon field, and the subscribe / upgrade / downgrade / cancel flow. |
304
434
  | `ConnectPlansPage` | A whole `/plans` page — readiness banner, current subscription, and the grid. What `connect-sdk init` scaffolds. |
305
435
  | `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. |
436
+ | `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. |
306
437
  | `ConnectSubscriptionGate` | Renders your module for a subscribed workspace, and `ConnectNoSubscriptionCard` for everyone else. Never flashes the upsell while loading. |
307
438
  | `ConnectNoSubscriptionCard` · `ConnectSubscriptionCard` | The same slot, unsubscribed and subscribed. Plan, status, renewal date, seats, and one way into Connect. |
308
439
  | `ConnectReadinessBanner` · `ConnectReadinessGate` | Every gate between this workspace and your service — KYC, payment method, seat headroom — each with the Connect screen that fixes it. |
@@ -315,7 +446,7 @@ import {
315
446
 
316
447
  ### Hooks
317
448
 
318
- `useConnect` · `useConnectUser` · `useConnectEntitlements` · `useConnectPlans` · `useConnectApps` · `useConnectAddonUsage` · `useConnectSessions` · `useConnectResource` · `useConnectFeature` · `useConnectCheckout`
449
+ `useConnect` · `useConnectUser` · `useActivePath` · `useConnectAccessGate` · `useConnectEntitlements` · `useConnectPlans` · `useConnectApps` · `useConnectAddonUsage` · `useConnectSessions` · `useConnectResource` · `useConnectFeature` · `useConnectCheckout`
319
450
 
320
451
  ### Metering
321
452
 
@@ -334,13 +465,59 @@ The catalogue read (`useConnectPlans`, `<ConnectPlans />`) reports `features` as
334
465
 
335
466
  ### Gates
336
467
 
468
+ Most apps want **one** gate around everything, and finer ones inside it.
469
+
470
+ ```tsx
471
+ // Mount once, around your app's content — not around your header.
472
+ <ConnectAccessGate>
473
+ <YourApp />
474
+ </ConnectAccessGate>
475
+ ```
476
+
477
+ `ConnectAccessGate` answers "can this workspace use my app right now" in one
478
+ priority-ordered pass, so you are not left assembling the pieces and choosing
479
+ the order yourself:
480
+
481
+ | State | What renders |
482
+ | --- | --- |
483
+ | `suspended` | A hard stop. Nothing the customer does clears it — only a Connect admin reactivating the workspace. |
484
+ | `no_subscription` | "Choose a plan", linking to Connect's subscribe page for your service. |
485
+ | `onboarding_pending` | Waiting on a reviewer. No action offered, because there is none. |
486
+ | `blocked` | Any other blocking readiness code — verification, audience, seats — each with its own fix. |
487
+ | `payment_retry` | **Your app still renders.** A dismissible banner counts down to the dunning deadline. |
488
+ | `clear` | Your app, nothing else. |
489
+
490
+ **Carve out your own plans/pricing routes.** The `no_subscription` card sends
491
+ people to choose a plan; if the gate also covers the page they land on, they
492
+ are trapped. The SDK cannot do this for you — it imports nothing from `next`
493
+ and so cannot know your router:
494
+
495
+ ```tsx
496
+ const EXEMPT = new Set(["/plans", "/pricing"]);
497
+ const gated = !EXEMPT.has(usePathname());
498
+
499
+ <main>{gated ? <ConnectAccessGate>{children}</ConnectAccessGate> : children}</main>
500
+ ```
501
+
502
+ For per-state UI, call the hook instead of overriding `fallback`:
503
+
504
+ ```tsx
505
+ const { state, blocker, paymentRetry } = useConnectAccessGate();
506
+ ```
507
+
508
+ Signed-out renders `children`, deliberately — unlike the gates below. None of
509
+ those states describes a signed-out visitor, and a gate around your whole app
510
+ must not hide a public landing page.
511
+
512
+ The narrower gates still exist for gating *within* a cleared workspace:
513
+
337
514
  ```tsx
338
515
  <ConnectReadinessBanner /> {/* KYC, payment method, seats — each with a fix */}
339
516
  <ConnectSubscriptionGate><Dashboard /></ConnectSubscriptionGate>
340
517
  <ConnectFeatureGate feature="advanced_reports"><Reports /></ConnectFeatureGate>
341
518
  ```
342
519
 
343
- All three render `loading` — never the fallback — while the answer is unknown, so a paying customer is never flashed an upsell for something they already have.
520
+ 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.
344
521
 
345
522
  **They are UX gates, not security boundaries.** Everything they read reaches your own origin and a browser can ignore it. The authoritative check is on your server: `client.entitlements.getReadiness(orgId)` and `client.entitlements.hasFeature(orgId, feature)`.
346
523
 
@@ -350,7 +527,13 @@ All three render `loading` — never the fallback — while the answer is unknow
350
527
  npx @cosmoops/connect-sdk init
351
528
  ```
352
529
 
353
- Writes `app/api/connect/[...connect]/route.ts`, `app/plans/page.tsx` and an `.env.local` template, then prints the two steps it will not do for you (wrapping your layout in `<ConnectProvider>`, and registering the redirect URI in Connect). It never overwrites, so re-running is safe.
530
+ 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.
531
+
532
+ ```bash
533
+ npx @cosmoops/connect-sdk doctor
534
+ ```
535
+
536
+ 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.
354
537
 
355
538
  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.
356
539
 
@@ -390,6 +573,100 @@ const checkout = useConnectCheckout();
390
573
 
391
574
  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.
392
575
 
576
+ ### App shell
577
+
578
+ ```tsx
579
+ "use client";
580
+ import Link from "next/link";
581
+ import { usePathname } from "next/navigation";
582
+ import { ConnectAppShell } from "@cosmoops/connect-sdk/client";
583
+ import { LayoutDashboard, Receipt, Settings, Truck } from "lucide-react";
584
+
585
+ export function Shell({ children }: { children: React.ReactNode }) {
586
+ return (
587
+ <ConnectAppShell
588
+ name="Dispatch"
589
+ icon={<Truck />}
590
+ activePath={usePathname()}
591
+ renderLink={(props) => <Link {...props} />}
592
+ nav={[
593
+ { label: "Overview", href: "/", icon: <LayoutDashboard className="size-5" /> },
594
+ { label: "Shipments", href: "/shipments", icon: <Truck className="size-5" /> },
595
+ {
596
+ title: "Workspace",
597
+ items: [
598
+ {
599
+ label: "Settings",
600
+ href: "/settings",
601
+ icon: <Settings className="size-5" />,
602
+ items: [
603
+ { label: "Members", href: "/settings/members" },
604
+ { label: "Billing", href: "/settings/billing" },
605
+ ],
606
+ },
607
+ { label: "Plans", href: "/plans", icon: <Receipt className="size-5" />, gateExempt: true },
608
+ ],
609
+ },
610
+ ]}
611
+ >
612
+ {children}
613
+ </ConnectAppShell>
614
+ );
615
+ }
616
+ ```
617
+
618
+ That is the entire layout of a service platform. Everything that should look
619
+ identical across the family — the lockup, the rail geometry, the active-route
620
+ treatment, the app launcher, the account menu, the mobile drawer — comes from
621
+ here; only `name`, `icon` and `nav` differ per platform.
622
+
623
+ **Routing is a prop.** The SDK ships into any React app and cannot import
624
+ `next/link`, so `renderLink` is how your router gets involved; the default is a
625
+ plain `<a>`, which works everywhere and costs a full page load. Pass
626
+ `activePath` from your router too (`usePathname()`) — without it the nav reads
627
+ `window.location` and patches `history.pushState` to hear about client-side
628
+ navigations, which is guesswork your router never has to do.
629
+
630
+ **The access gate is on by default.** `ConnectAppShell` is exactly the "wrap
631
+ your app content once" place `ConnectAccessGate` asks for, so it mounts one
632
+ around `children` — an app that ships a shell and forgets the gate is an app
633
+ that lets a suspended workspace keep working. The chrome is never gated: the
634
+ sidebar, the lockup, the account menu and sign-out stay reachable in every
635
+ blocked state. Routes that resolve a block rather than needing one — your
636
+ pricing page — are flagged where they are declared:
637
+
638
+ ```tsx
639
+ { label: "Plans", href: "/plans", gateExempt: true }
640
+ ```
641
+
642
+ Gate a route any other way and a customer with no subscription reaches the
643
+ page that sells them one, and is told to choose a plan. Pass `gate={false}` to
644
+ opt out entirely, or `gateExemptRoutes` for a path that is not in the sidebar
645
+ at all.
646
+
647
+ Other things worth knowing:
648
+
649
+ - **The rail collapses to icons** and remembers the choice per browser
650
+ (`localStorage`). Drive it yourself with `collapsed` + `onCollapsedChange`,
651
+ or pin it open with `collapsible={false}`.
652
+ - **Below `md` there is no rail**, only a drawer behind the top bar's menu
653
+ button — a 64px icon strip on a phone is a worse drawer, not a smaller one.
654
+ - **Children unfold only on the branch you are on.** A sidebar that opens
655
+ every branch at once is a sitemap.
656
+ - **No `isSignedIn` branch, in here or in yours.** The auth buttons render
657
+ nothing once a session exists, the account menu renders nothing without one,
658
+ and the launcher renders nothing when signed out.
659
+ - `title`, `headerActions`, `sidebarFooter` and `footer` are slots;
660
+ `contentClassName` replaces the content pane's default max-width and
661
+ padding.
662
+ - `theme` / `onThemeChange` pass straight through to `ConnectUserMenu` — the
663
+ SDK has no theme provider of its own, deliberately.
664
+
665
+ `ConnectSideNav` is the nav column alone, for an app with chrome it already
666
+ likes. `resolveActivePath` and `isNavItemActive` are the same pure functions
667
+ the rail highlights with, exported so a breadcrumb or a document title derived
668
+ from your nav cannot disagree with it.
669
+
393
670
  ### Logo
394
671
 
395
672
  ```tsx
@@ -411,7 +688,7 @@ Each platform passes only what differs — `name` and `icon`. Mark geometry, wor
411
688
 
412
689
  One shared **HeroUI v3** theme for every service 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.
413
690
 
414
- Requires `@heroui/react` and `lucide-react` as peers (optional — server-only consumers need neither).
691
+ 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`.
415
692
 
416
693
  ### Why payment happens on Connect
417
694