@cosmoops/connect-sdk 0.3.0 → 0.7.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 (137) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +215 -6
  3. package/dist/cjs/cli/doctor.js +3 -3
  4. package/dist/cjs/cli/init.js +3 -3
  5. package/dist/cjs/client/components/access-gate.js +162 -0
  6. package/dist/cjs/client/components/account-sessions.js +24 -22
  7. package/dist/cjs/client/components/app-shell.js +157 -0
  8. package/dist/cjs/client/components/apps-grid.js +4 -3
  9. package/dist/cjs/client/components/feature-gate.js +2 -2
  10. package/dist/cjs/client/components/integrations-modal.js +95 -0
  11. package/dist/cjs/client/components/logo.js +3 -3
  12. package/dist/cjs/client/components/motion.js +27 -0
  13. package/dist/cjs/client/components/nav.js +97 -0
  14. package/dist/cjs/client/components/plan-pricing.js +1 -1
  15. package/dist/cjs/client/components/plans.js +42 -40
  16. package/dist/cjs/client/components/readiness.js +6 -4
  17. package/dist/cjs/client/components/side-nav.js +58 -0
  18. package/dist/cjs/client/components/subscribe-dialog.js +2 -2
  19. package/dist/cjs/client/components/subscription.js +3 -2
  20. package/dist/cjs/client/components/surfaces.js +54 -0
  21. package/dist/cjs/client/components/usage-meter.js +4 -3
  22. package/dist/cjs/client/components/user-menu.js +33 -6
  23. package/dist/cjs/client/index.js +30 -2
  24. package/dist/cjs/client/mark.js +1 -1
  25. package/dist/cjs/client/provider.js +60 -8
  26. package/dist/cjs/client/use-active-path.js +77 -0
  27. package/dist/cjs/client/use-checkout.js +3 -3
  28. package/dist/cjs/errors.js +4 -4
  29. package/dist/cjs/helpers.js +1 -1
  30. package/dist/cjs/index.js +1 -1
  31. package/dist/cjs/next/index.js +96 -15
  32. package/dist/cjs/server/client.js +22 -14
  33. package/dist/cjs/server/handler.js +99 -16
  34. package/dist/cjs/server/http.js +1 -1
  35. package/dist/cjs/server/index.js +8 -1
  36. package/dist/cjs/server/resources/access.js +3 -3
  37. package/dist/cjs/server/resources/ai.js +1 -1
  38. package/dist/cjs/server/resources/analytics.js +66 -0
  39. package/dist/cjs/server/resources/apps.js +4 -4
  40. package/dist/cjs/server/resources/billing.js +5 -5
  41. package/dist/cjs/server/resources/drive.js +4 -4
  42. package/dist/cjs/server/resources/entitlements.js +7 -7
  43. package/dist/cjs/server/resources/identity.js +4 -4
  44. package/dist/cjs/server/resources/integrations.js +66 -0
  45. package/dist/cjs/server/resources/logs.js +3 -3
  46. package/dist/cjs/server/resources/sessions.js +6 -6
  47. package/dist/cjs/server/webhooks.js +127 -0
  48. package/dist/cjs/types.js +1 -1
  49. package/dist/esm/cli/doctor.js +3 -3
  50. package/dist/esm/cli/init.js +3 -3
  51. package/dist/esm/client/components/access-gate.js +158 -0
  52. package/dist/esm/client/components/account-sessions.js +24 -22
  53. package/dist/esm/client/components/app-shell.js +154 -0
  54. package/dist/esm/client/components/apps-grid.js +4 -3
  55. package/dist/esm/client/components/feature-gate.js +2 -2
  56. package/dist/esm/client/components/integrations-modal.js +92 -0
  57. package/dist/esm/client/components/logo.js +3 -3
  58. package/dist/esm/client/components/motion.js +21 -0
  59. package/dist/esm/client/components/nav.js +89 -0
  60. package/dist/esm/client/components/plan-pricing.js +1 -1
  61. package/dist/esm/client/components/plans.js +42 -40
  62. package/dist/esm/client/components/readiness.js +6 -4
  63. package/dist/esm/client/components/side-nav.js +53 -0
  64. package/dist/esm/client/components/subscribe-dialog.js +2 -2
  65. package/dist/esm/client/components/subscription.js +3 -2
  66. package/dist/esm/client/components/surfaces.js +51 -0
  67. package/dist/esm/client/components/usage-meter.js +4 -3
  68. package/dist/esm/client/components/user-menu.js +34 -7
  69. package/dist/esm/client/index.js +16 -2
  70. package/dist/esm/client/mark.js +1 -1
  71. package/dist/esm/client/provider.js +59 -8
  72. package/dist/esm/client/use-active-path.js +74 -0
  73. package/dist/esm/client/use-checkout.js +3 -3
  74. package/dist/esm/errors.js +4 -4
  75. package/dist/esm/helpers.js +1 -1
  76. package/dist/esm/index.js +1 -1
  77. package/dist/esm/next/index.js +95 -15
  78. package/dist/esm/server/client.js +22 -14
  79. package/dist/esm/server/handler.js +99 -16
  80. package/dist/esm/server/http.js +1 -1
  81. package/dist/esm/server/index.js +5 -0
  82. package/dist/esm/server/resources/access.js +3 -3
  83. package/dist/esm/server/resources/ai.js +1 -1
  84. package/dist/esm/server/resources/analytics.js +62 -0
  85. package/dist/esm/server/resources/apps.js +4 -4
  86. package/dist/esm/server/resources/billing.js +5 -5
  87. package/dist/esm/server/resources/drive.js +4 -4
  88. package/dist/esm/server/resources/entitlements.js +7 -7
  89. package/dist/esm/server/resources/identity.js +4 -4
  90. package/dist/esm/server/resources/integrations.js +62 -0
  91. package/dist/esm/server/resources/logs.js +3 -3
  92. package/dist/esm/server/resources/sessions.js +6 -6
  93. package/dist/esm/server/webhooks.js +123 -0
  94. package/dist/esm/types.js +1 -1
  95. package/dist/theme.css +28 -0
  96. package/dist/types/client/components/access-gate.d.ts +79 -0
  97. package/dist/types/client/components/account-sessions.d.ts +1 -1
  98. package/dist/types/client/components/app-shell.d.ts +115 -0
  99. package/dist/types/client/components/apps-grid.d.ts +1 -1
  100. package/dist/types/client/components/feature-gate.d.ts +1 -1
  101. package/dist/types/client/components/integrations-modal.d.ts +21 -0
  102. package/dist/types/client/components/logo.d.ts +5 -5
  103. package/dist/types/client/components/motion.d.ts +22 -0
  104. package/dist/types/client/components/nav.d.ts +110 -0
  105. package/dist/types/client/components/plan-pricing.d.ts +2 -2
  106. package/dist/types/client/components/readiness.d.ts +2 -2
  107. package/dist/types/client/components/side-nav.d.ts +31 -0
  108. package/dist/types/client/components/subscribe-dialog.d.ts +3 -3
  109. package/dist/types/client/components/surfaces.d.ts +51 -0
  110. package/dist/types/client/components/usage-meter.d.ts +1 -1
  111. package/dist/types/client/components/user-menu.d.ts +2 -2
  112. package/dist/types/client/index.d.ts +13 -2
  113. package/dist/types/client/mark.d.ts +1 -1
  114. package/dist/types/client/provider.d.ts +44 -9
  115. package/dist/types/client/use-active-path.d.ts +8 -0
  116. package/dist/types/client/use-checkout.d.ts +3 -3
  117. package/dist/types/errors.d.ts +4 -4
  118. package/dist/types/helpers.d.ts +1 -1
  119. package/dist/types/index.d.ts +1 -1
  120. package/dist/types/next/index.d.ts +40 -0
  121. package/dist/types/server/client.d.ts +17 -11
  122. package/dist/types/server/handler.d.ts +2 -2
  123. package/dist/types/server/index.d.ts +7 -0
  124. package/dist/types/server/resources/access.d.ts +3 -3
  125. package/dist/types/server/resources/ai.d.ts +2 -2
  126. package/dist/types/server/resources/analytics.d.ts +31 -0
  127. package/dist/types/server/resources/apps.d.ts +4 -4
  128. package/dist/types/server/resources/billing.d.ts +4 -4
  129. package/dist/types/server/resources/drive.d.ts +4 -4
  130. package/dist/types/server/resources/entitlements.d.ts +4 -4
  131. package/dist/types/server/resources/identity.d.ts +14 -4
  132. package/dist/types/server/resources/integrations.d.ts +40 -0
  133. package/dist/types/server/resources/logs.d.ts +3 -3
  134. package/dist/types/server/resources/sessions.d.ts +5 -5
  135. package/dist/types/server/webhooks.d.ts +67 -0
  136. package/dist/types/types.d.ts +220 -39
  137. package/package.json +10 -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.
@@ -344,11 +405,16 @@ that URL yourself instead.
344
405
 
345
406
  ```tsx
346
407
  import {
408
+ ConnectAppShell,
347
409
  ConnectLoginButton, ConnectRegisterButton,
348
410
  ConnectUserMenu, ConnectAppsGrid, ConnectPlans,
349
411
  } from "@cosmoops/connect-sdk/client";
350
412
 
351
- // 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.
352
418
  <ConnectAppsGrid />
353
419
  <ConnectLoginButton />
354
420
  <ConnectUserMenu />
@@ -359,12 +425,15 @@ import {
359
425
 
360
426
  | Component | What it does |
361
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. |
362
430
  | `ConnectLoginButton` · `ConnectRegisterButton` | "Continue with Connect", in the shape people know from Google. Render nothing when signed in. |
363
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. |
364
432
  | `ConnectAppsGrid` | Google-style launcher — grid icon opening a modal of every app this workspace can open. |
365
433
  | `ConnectPlans` | Monthly/annual toggle, features, add-ons, live discount, coupon field, and the subscribe / upgrade / downgrade / cancel flow. |
366
434
  | `ConnectPlansPage` | A whole `/plans` page — readiness banner, current subscription, and the grid. What `connect-sdk init` scaffolds. |
367
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. |
368
437
  | `ConnectSubscriptionGate` | Renders your module for a subscribed workspace, and `ConnectNoSubscriptionCard` for everyone else. Never flashes the upsell while loading. |
369
438
  | `ConnectNoSubscriptionCard` · `ConnectSubscriptionCard` | The same slot, unsubscribed and subscribed. Plan, status, renewal date, seats, and one way into Connect. |
370
439
  | `ConnectReadinessBanner` · `ConnectReadinessGate` | Every gate between this workspace and your service — KYC, payment method, seat headroom — each with the Connect screen that fixes it. |
@@ -377,7 +446,7 @@ import {
377
446
 
378
447
  ### Hooks
379
448
 
380
- `useConnect` · `useConnectUser` · `useConnectEntitlements` · `useConnectPlans` · `useConnectApps` · `useConnectAddonUsage` · `useConnectSessions` · `useConnectResource` · `useConnectFeature` · `useConnectCheckout`
449
+ `useConnect` · `useConnectUser` · `useActivePath` · `useConnectAccessGate` · `useConnectEntitlements` · `useConnectPlans` · `useConnectApps` · `useConnectAddonUsage` · `useConnectSessions` · `useConnectResource` · `useConnectFeature` · `useConnectCheckout`
381
450
 
382
451
  ### Metering
383
452
 
@@ -396,13 +465,59 @@ The catalogue read (`useConnectPlans`, `<ConnectPlans />`) reports `features` as
396
465
 
397
466
  ### Gates
398
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
+
399
514
  ```tsx
400
515
  <ConnectReadinessBanner /> {/* KYC, payment method, seats — each with a fix */}
401
516
  <ConnectSubscriptionGate><Dashboard /></ConnectSubscriptionGate>
402
517
  <ConnectFeatureGate feature="advanced_reports"><Reports /></ConnectFeatureGate>
403
518
  ```
404
519
 
405
- 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.
406
521
 
407
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)`.
408
523
 
@@ -458,6 +573,100 @@ const checkout = useConnectCheckout();
458
573
 
459
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.
460
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
+
461
670
  ### Logo
462
671
 
463
672
  ```tsx
@@ -479,7 +688,7 @@ Each platform passes only what differs — `name` and `icon`. Mark geometry, wor
479
688
 
480
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.
481
690
 
482
- 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`.
483
692
 
484
693
  ### Why payment happens on Connect
485
694
 
@@ -162,8 +162,8 @@ function checkEnv(root) {
162
162
  status: "fail",
163
163
  title: `${name} set`,
164
164
  detail: declaredEmpty
165
- ? `Declared but empty in .env.local. The value is in your service's Setup tab in Connect.`
166
- : `Not found in .env.local or the environment. Both values are in your service's Setup tab in Connect.`,
165
+ ? `Declared but empty in .env.local. The value is in your application's Integration tab in Connect.`
166
+ : `Not found in .env.local or the environment. Both values are in your application's Integration tab in Connect.`,
167
167
  };
168
168
  });
169
169
  }
@@ -176,7 +176,7 @@ function checkRedirectUri() {
176
176
  return {
177
177
  status: "warn",
178
178
  title: "Redirect URI registered in Connect",
179
- detail: "Not checkable from here. Confirm {your origin}/api/connect/callback is listed as a redirect URI in your service's Setup tab - including the deployed origin, not just http://localhost:3000.",
179
+ detail: "Not checkable from here. Confirm {your origin}/api/connect/callback is listed as a redirect URI in your application's Integration tab - including the deployed origin, not just http://localhost:3000.",
180
180
  };
181
181
  }
182
182
  const MARKER = { pass: " ok ", warn: " note ", fail: " FAIL " };
@@ -44,7 +44,7 @@ const PLANS_PAGE = `"use client";
44
44
  // with the other exports) whenever you want your own copy or layout.
45
45
  export { ConnectPlansPage as default } from "@cosmoops/connect-sdk/client";
46
46
  `;
47
- const ENV_TEMPLATE = `# CosmoOps Connect - values come from your service's Setup tab.
47
+ const ENV_TEMPLATE = `# CosmoOps Connect - values come from your application's Integration tab.
48
48
  CONNECT_API_KEY=
49
49
  CONNECT_CLIENT_ID=
50
50
  `;
@@ -111,7 +111,7 @@ function runInit(argv = []) {
111
111
  console.log(` ${marker} ${(0, node_path_1.relative)(root, target)} - ${scaffold.description}`);
112
112
  }
113
113
  const env = ensureEnv(root);
114
- console.log(` ${env === "skipped" ? "exists " : env === "appended" ? "updated" : "created"} .env.local - fill in the two values from your service's Setup tab`);
114
+ console.log(` ${env === "skipped" ? "exists " : env === "appended" ? "updated" : "created"} .env.local - fill in the two values from your application's Integration tab`);
115
115
  // The two steps a scaffolder must not do for you: editing an existing layout
116
116
  // means parsing and rewriting JSX someone else wrote, and the redirect URI
117
117
  // lives in Connect, not in this repo.
@@ -137,7 +137,7 @@ Three things left, all one-time:
137
137
  Tailwind roots its content scan inside node_modules and skips your app.
138
138
 
139
139
  3. Register {your origin}/api/connect/callback as a redirect URI in your
140
- service's Setup tab in Connect.
140
+ application's Integration tab in Connect.
141
141
 
142
142
  No next.config.ts changes needed - not even transpilePackages: dist/ is
143
143
  already ES2022 with "use client" hoisted, so Turbopack and webpack consume it
@@ -0,0 +1,162 @@
1
+ "use client";
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.useConnectAccessGate = useConnectAccessGate;
5
+ exports.ConnectAccessGate = ConnectAccessGate;
6
+ const jsx_runtime_1 = require("react/jsx-runtime");
7
+ const react_1 = require("react");
8
+ const lucide_react_1 = require("lucide-react");
9
+ const react_2 = require("@heroui/react");
10
+ const helpers_js_1 = require("../../helpers.js");
11
+ const provider_js_1 = require("../provider.js");
12
+ const motion_js_1 = require("./motion.js");
13
+ const readiness_js_1 = require("./readiness.js");
14
+ function formatDate(iso) {
15
+ return new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
16
+ }
17
+ /**
18
+ * The single composed answer to "can this workspace use my app right now,
19
+ * and if not, which of the four things is it": choose a plan, wait on
20
+ * approval, resolve an overdue payment, or - a suspended workspace - none of
21
+ * the above, because nothing short of a platform admin fixes that one.
22
+ *
23
+ * Reuses the existing single sources of truth rather than re-deriving them:
24
+ * `blockingBlockers()` (`helpers.ts`) for severity, the same readiness read
25
+ * `ConnectReadinessGate`/`ConnectReadinessBanner` already use. `ConnectAccessGate`
26
+ * is a priority ordering over the same data, not a second opinion about it.
27
+ *
28
+ * Priority, first match wins: `suspended` > `no_subscription` >
29
+ * `onboarding_pending` > any other blocking code (`blocked`) >
30
+ * `payment_retry` (advisory - `children` still render) > `clear`.
31
+ */
32
+ function useConnectAccessGate() {
33
+ const { session, isLoading: sessionLoading } = (0, provider_js_1.useConnect)();
34
+ const { data, isLoading: entitlementsLoading } = (0, provider_js_1.useConnectEntitlements)();
35
+ if (sessionLoading || (session?.user && entitlementsLoading)) {
36
+ return { state: "loading", blocker: null, paymentRetry: null };
37
+ }
38
+ if (!session?.user) {
39
+ return { state: "signed_out", blocker: null, paymentRetry: null };
40
+ }
41
+ if (!data) {
42
+ // Signed in, not loading, yet nothing came back - a failed read (see
43
+ // `error` on `useConnectEntitlements`). Fail open rather than hard-block
44
+ // the whole app on a network hiccup; `ConnectReadinessBanner` makes the
45
+ // same call for its own "no data" branch.
46
+ return { state: "clear", blocker: null, paymentRetry: null };
47
+ }
48
+ const readiness = data.readiness;
49
+ const blocking = (0, helpers_js_1.blockingBlockers)(readiness);
50
+ const suspended = blocking.find((b) => b.code === "workspace_suspended");
51
+ if (suspended)
52
+ return { state: "suspended", blocker: suspended, paymentRetry: null };
53
+ const noSubscription = blocking.find((b) => b.code === "subscription_missing");
54
+ if (noSubscription)
55
+ return { state: "no_subscription", blocker: noSubscription, paymentRetry: null };
56
+ const onboardingPending = blocking.find((b) => b.code === "onboarding_approval_pending");
57
+ if (onboardingPending) {
58
+ return { state: "onboarding_pending", blocker: onboardingPending, paymentRetry: null };
59
+ }
60
+ if (blocking.length > 0) {
61
+ return { state: "blocked", blocker: blocking[0] ?? null, paymentRetry: null };
62
+ }
63
+ const paymentRetry = readiness.readiness.blockers.find((b) => b.code === "payment_retry_pending");
64
+ if (paymentRetry)
65
+ return { state: "payment_retry", blocker: null, paymentRetry };
66
+ return { state: "clear", blocker: null, paymentRetry: null };
67
+ }
68
+ /**
69
+ * Wrap your app's content once - typically in your root layout, alongside
70
+ * (not instead of) your own header/nav - and it renders exactly one of: the
71
+ * hard-block card for a suspended workspace, one with no plan, or one
72
+ * waiting on approval; your `children` plus a dismissible payment-due banner
73
+ * during an automatic dunning retry; or just `children` once everything is
74
+ * clear.
75
+ *
76
+ * **Do not wrap your own plans/pricing page with this.** The `no_subscription`
77
+ * card's button sends someone to Connect's subscribe page directly (see
78
+ * `GateCard`'s doc comment on why - it used to link to your in-app `/plans`
79
+ * instead, which is a dead end once this gate hides that very page too), but
80
+ * a customer can still navigate to your plans page on their own (a nav link,
81
+ * a bookmark) and must actually see it there. Since the gate is mounted once
82
+ * around every route, carving out that exception is your router's job, not
83
+ * a prop on this component: check the current path in your layout/shell and
84
+ * skip wrapping `children` in `<ConnectAccessGate>` for that one route.
85
+ *
86
+ * **Signed-out renders `children`, not a fallback** - deliberately different
87
+ * from `ConnectReadinessGate`/`ConnectSubscriptionGate`, which show nothing
88
+ * until signed in. None of the four states this component composes is a
89
+ * property of a signed-out visitor, and a component mounted around your
90
+ * entire app must not hide a public landing page behind an empty gate. Your
91
+ * own sign-in flow decides what a signed-out visitor sees.
92
+ *
93
+ * **This is a UX gate, not a security boundary** - same caveat as every
94
+ * other gate in this package. The authoritative check is
95
+ * `client.entitlements.getReadiness` on your server, before you do the
96
+ * thing.
97
+ */
98
+ function ConnectAccessGate({ children, fallback, loading, className }) {
99
+ const gate = useConnectAccessGate();
100
+ if (gate.state === "loading") {
101
+ return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: loading ?? (0, jsx_runtime_1.jsx)(react_2.Skeleton, { className: `h-32 rounded-xl ${className ?? ""}` }) });
102
+ }
103
+ if (gate.state === "signed_out" || gate.state === "clear") {
104
+ return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: children });
105
+ }
106
+ if (gate.state === "payment_retry") {
107
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [gate.paymentRetry && (0, jsx_runtime_1.jsx)(PaymentRetryBanner, { blocker: gate.paymentRetry, className: className }), children] }));
108
+ }
109
+ return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: fallback ?? (0, jsx_runtime_1.jsx)(AccessGateFallback, { state: gate.state, blocker: gate.blocker, className: className }) }));
110
+ }
111
+ function AccessGateFallback({ state, blocker, className, }) {
112
+ if (state === "no_subscription") {
113
+ return ((0, jsx_runtime_1.jsx)(GateCard, { icon: lucide_react_1.CreditCard, iconClassName: "text-accent", title: "Choose a plan to continue", message: blocker?.message ?? "This workspace isn't subscribed to this application yet.", actionLabel: "See plans", resolveUrl: blocker?.resolveUrl ?? null, className: className }));
114
+ }
115
+ if (state === "suspended") {
116
+ return ((0, jsx_runtime_1.jsx)(GateCard, { icon: lucide_react_1.Ban, iconClassName: "text-danger", title: "Workspace suspended", message: blocker?.message ?? "This workspace has been suspended.", resolveUrl: null, className: className }));
117
+ }
118
+ if (state === "onboarding_pending") {
119
+ return ((0, jsx_runtime_1.jsx)(GateCard, { icon: lucide_react_1.Clock, iconClassName: "text-muted", title: "Waiting on approval", message: blocker?.message ?? "This workspace is waiting on approval before it can use this application.", resolveUrl: null, className: className }));
120
+ }
121
+ // "blocked" - any other blocking code (verification, audience, seats, ...).
122
+ // Full fidelity for a code this component doesn't special-case: the same
123
+ // banner-in-a-card `ConnectReadinessGate` falls back to, rather than
124
+ // `GateCard`'s single-blocker shape - there can be more than one of these
125
+ // at once, and each already carries its own action.
126
+ return ((0, jsx_runtime_1.jsx)(react_2.Card, { className: className, children: (0, jsx_runtime_1.jsx)(react_2.Card.Content, { className: "py-6", children: (0, jsx_runtime_1.jsx)(readiness_js_1.ConnectReadinessBanner, {}) }) }));
127
+ }
128
+ /**
129
+ * One card shape for every hard-block state: an icon, what's wrong, and -
130
+ * when the server sent a `resolveUrl` - the one button that fixes it,
131
+ * opening Connect directly in a new tab. The same pattern every other
132
+ * resolvable blocker in this package uses (`BlockerRow`, `PaymentRetryBanner`
133
+ * below) - `no_subscription` used to be the one exception, sending someone
134
+ * to the host app's own `/plans` page instead. That was a deliberate choice
135
+ * on `ConnectNoSubscriptionCard` (browsing plans belongs in your product),
136
+ * but inside a gate mounted around your *entire* app it is a dead end: the
137
+ * card replaces whatever page it is rendered on, including your own plans
138
+ * page if you did not carve it out (see `ConnectAccessGate`'s doc comment),
139
+ * so "see plans" landing back on the same blocked screen never gets anyone
140
+ * to a choice they can act on. Sending them to Connect's own subscribe page
141
+ * always resolves.
142
+ */
143
+ function GateCard({ icon: Icon, iconClassName, title, message, actionLabel, resolveUrl, className, }) {
144
+ return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsx)(motion_js_1.m.div, { initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: motion_js_1.ENTER, children: (0, jsx_runtime_1.jsxs)(react_2.Card, { className: className, children: [(0, jsx_runtime_1.jsxs)(react_2.Card.Header, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(Icon, { className: `size-5 shrink-0 ${iconClassName}`, "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)(react_2.Card.Title, { children: title })] }), (0, jsx_runtime_1.jsx)(react_2.Card.Description, { children: message })] }), resolveUrl && ((0, jsx_runtime_1.jsx)(react_2.Card.Footer, { children: (0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "primary", onPress: () => window.open(resolveUrl, "_blank", "noopener,noreferrer"), children: [actionLabel ?? "Resolve on Connect", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] }) }))] }) }) }));
145
+ }
146
+ /**
147
+ * The skippable payment-due warning. Dismissal is scoped to this specific
148
+ * dunning cycle - keyed by `graceEndsAt`, so a dismissed banner reappears on
149
+ * its own if a *new* retry cycle starts (a different `graceEndsAt`) even
150
+ * though an old dismissal is still on record.
151
+ */
152
+ function PaymentRetryBanner({ blocker, className, }) {
153
+ const dismissKey = blocker.graceEndsAt ? `connect_dismissed_payment_retry_pending_${blocker.graceEndsAt}` : null;
154
+ const [dismissed, setDismissed] = (0, react_1.useState)(() => dismissKey !== null && typeof window !== "undefined" && window.localStorage.getItem(dismissKey) === "1");
155
+ if (dismissed)
156
+ return null;
157
+ return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsxs)(motion_js_1.m.div, { className: `flex flex-wrap items-center justify-between gap-3 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent ${className ?? ""}`, role: "status", initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: motion_js_1.ENTER, children: [(0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" }), blocker.message, blocker.graceEndsAt && ` Resolve by ${formatDate(blocker.graceEndsAt)} to avoid losing access.`] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [blocker.resolveUrl && ((0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "secondary", onPress: () => window.open(blocker.resolveUrl, "_blank", "noopener,noreferrer"), children: ["Resolve payment", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] })), (0, jsx_runtime_1.jsx)(react_2.Button, { variant: "tertiary", isIconOnly: true, "aria-label": "Dismiss", onPress: () => {
158
+ setDismissed(true);
159
+ if (dismissKey && typeof window !== "undefined")
160
+ window.localStorage.setItem(dismissKey, "1");
161
+ }, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4" }) })] })] }) }));
162
+ }